diff --git a/commands/core/lookup.go b/commands/core/lookup.go index 69cef2c..2e9e9de 100644 --- a/commands/core/lookup.go +++ b/commands/core/lookup.go @@ -19,6 +19,16 @@ func GetAlias(name string) (string, bool) { return val, ok } +func GetAliasesCopy() map[string]string { + shellAliasesMu.RLock() + defer shellAliasesMu.RUnlock() + cp := make(map[string]string, len(ShellAliases)) + for k, v := range ShellAliases { + cp[k] = v + } + return cp +} + // Lookup finds matching suggestions for your input by looking at how many words you typed // it changes aliases to real commands, finds subcommands inside others, and runs generators for more suggestions // e.g. Lookup("git che") -> suggests "git checkout" @@ -131,6 +141,10 @@ func Lookup(input string) []Suggestion { depth++ continue } + // invalid subcommand word typed + if len(currentSubs) > 0 { + return nil + } break } @@ -237,7 +251,7 @@ func Lookup(input string) []Suggestion { } } - if partial == "" || (len(partial) > 0 && partial[0] == '-') { + if len(partial) > 0 && partial[0] == '-' { usedOpts := make(map[string]bool) for _, t := range tokens { if strings.HasPrefix(t, "-") { @@ -247,7 +261,7 @@ func Lookup(input string) []Suggestion { for _, opt := range currentOpts { if !usedOpts[opt.Name] && (partial == "" || HasPrefix(opt.Name, partial)) { results = append(results, Suggestion{ - Cmd: prefix + " " + opt.Name, Desc: opt.Description, Icon: rootCmdName, + Cmd: linePrefix + " " + opt.Name, Desc: opt.Description, Icon: rootCmdName, }) } } diff --git a/commands/git/git.go b/commands/git/git.go index 3ebb22e..c169789 100644 --- a/commands/git/git.go +++ b/commands/git/git.go @@ -218,6 +218,9 @@ func GitPushPullGenerator(tokens []string, prefix string, partial string) []core } // remote is set, suggest local branches only (no duplicates with origin/xxx) + if len(pArgs) >= 2 { + return nil + } return gitLocalBranchGenerator(tokens, prefix, partial) } diff --git a/integration/history.go b/integration/history.go index b4776c4..7501e99 100644 --- a/integration/history.go +++ b/integration/history.go @@ -30,7 +30,7 @@ func init() { idMapCache = make(map[string]int) } -func SearchHistory(query string) ([]HistResult, error) { +func SearchHistory(query string, aliases map[string]string) ([]HistResult, error) { mu.Lock() defer mu.Unlock() @@ -143,68 +143,119 @@ func SearchHistory(query string) ([]HistResult, error) { return results, nil } - matches := searcherCache.SearchWithScores(query, &fuzzy.SearchOptions{Limit: 200}) + var alternativeQueries []string + for name, target := range aliases { + if target != "" { + qLow := strings.ToLower(query) + tLow := strings.ToLower(target) + nLow := strings.ToLower(name) - // when query has a clear first word, only keep history entries that share the same first word - // this prevents e.g. curl commands from showing up when typing "git ..." - queryFirstWord := "" - if strings.IndexByte(query, ' ') != -1 { - if fields := strings.Fields(query); len(fields) > 0 { - queryFirstWord = strings.ToLower(fields[0]) + if qLow == tLow { + alternativeQueries = append(alternativeQueries, name) + } else if strings.HasPrefix(qLow, tLow+" ") { + suffix := query[len(target):] + alternativeQueries = append(alternativeQueries, name+suffix) + } + + if qLow == nLow { + alternativeQueries = append(alternativeQueries, target) + } else if strings.HasPrefix(qLow, nLow+" ") { + suffix := query[len(name):] + alternativeQueries = append(alternativeQueries, target+suffix) + } } } var results []HistResult - for _, m := range matches { - if queryFirstWord != "" { + seenCmds := make(map[string]bool) + + addMatches := func(q string) { + qLow := strings.ToLower(q) + queryFirstWord := "" + if strings.IndexByte(qLow, ' ') != -1 { + if fields := strings.Fields(qLow); len(fields) > 0 { + queryFirstWord = fields[0] + } + } + + matches := searcherCache.SearchWithScores(q, &fuzzy.SearchOptions{Limit: 200}) + for _, m := range matches { + if seenCmds[m.Str] { + continue + } + + // filter results by command name match firstWord := m.Str if idx := strings.IndexByte(m.Str, ' '); idx != -1 { firstWord = m.Str[:idx] } - if !strings.EqualFold(firstWord, queryFirstWord) { - continue + firstWordLow := strings.ToLower(firstWord) + + if queryFirstWord != "" { + if firstWordLow != queryFirstWord { + continue + } + } else { + if !strings.HasPrefix(firstWordLow, qLow) { + continue + } } + + seenCmds[m.Str] = true + results = append(results, HistResult{ + ID: idMapCache[m.Str], + Cmd: m.Str, + FuzzyScore: m.Score, + }) } - results = append(results, HistResult{ - ID: idMapCache[m.Str], - Cmd: m.Str, - FuzzyScore: m.Score, - }) } - // within the same tier, we sort by ID - // tier 1: exact match - // tier 2: prefix match - // tier 3: substring match - // tier 4: fuzzy match + addMatches(query) + + for _, altQ := range alternativeQueries { + addMatches(altQ) + } + getTier := func(cmd, q string) int { - cmdLow := strings.ToLower(cmd) - qLow := strings.ToLower(q) - if cmdLow == qLow { - return 1 + bestTier := 4 + check := func(ql string) { + cmdLow := strings.ToLower(cmd) + qlLow := strings.ToLower(ql) + tier := 4 + if cmdLow == qlLow { + tier = 1 + } else if strings.HasPrefix(cmdLow, qlLow) { + tier = 2 + } else if strings.Contains(cmdLow, qlLow) { + tier = 3 + } + if tier < bestTier { + bestTier = tier + } } - if strings.HasPrefix(cmdLow, qLow) { - return 2 + check(q) + for _, altQ := range alternativeQueries { + check(altQ) } - if strings.Contains(cmdLow, qLow) { - return 3 - } - return 4 + return bestTier + } + + tiers := make([]int, len(results)) + for i, r := range results { + tiers[i] = getTier(r.Cmd, query) } sort.SliceStable(results, func(i, j int) bool { - tI := getTier(results[i].Cmd, query) - tJ := getTier(results[j].Cmd, query) + tI := tiers[i] + tJ := tiers[j] if tI != tJ { - return tI < tJ // lower tier is better + return tI < tJ } - // If both are fuzzy matches (Tier 4), prioritize fuzzy score first! if tI == 4 && results[i].FuzzyScore != results[j].FuzzyScore { return results[i].FuzzyScore > results[j].FuzzyScore } - // if same tier (and same fuzzy score), sort by ID descending (most recent first) return results[i].ID > results[j].ID }) diff --git a/integration/overlay.go b/integration/overlay.go index b24a406..fc2f0c4 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -2,25 +2,29 @@ package integration import ( "fmt" + "os" "strings" "sync" "github.com/charmbracelet/lipgloss" "github.com/versenilvis/iris/commands/core" + "golang.org/x/term" ) const ( boxWidth = 72 - maxItems = 6 // max items showing in the menu preview + maxItems = 6 ) type Overlay struct { - mu sync.Mutex - Visible bool - Items []core.Suggestion - Cursor int - StartIdx int - LastGhostLen int + mu sync.Mutex + Visible bool + Items []core.Suggestion + Cursor int + StartIdx int + LastGhostLen int + TypedQuery string + UserNavigated bool } var ( @@ -53,11 +57,10 @@ func (o *Overlay) UpdateItems(items []core.Suggestion) { o.Items = items o.Visible = len(o.Items) > 0 - o.Cursor = 0 // reset to top result on update + o.Cursor = 0 o.StartIdx = 0 } -// fixedWidth pads or truncates a string to exact rune width func fixedWidth(s string, width int) string { runes := []rune(s) if len(runes) > width { @@ -93,11 +96,11 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool) string { } // add extra padding to erase any stray characters left by fast backspaces - // before the debounce timer fired. 10 spaces is safe and won't hit right prompts + // before the debounce timer fired, 10 spaces is safe and won't hit right prompts padLen += 10 if ghostText != "" || padLen > 0 { - s.WriteString("\0337") // SAVE CURSOR at prompt + s.WriteString("\0337") // save cursor at prompt if ghostText != "" { s.WriteString("\033[90m") s.WriteString(ghostText) @@ -106,7 +109,7 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool) string { if padLen > 0 { s.WriteString(strings.Repeat(" ", padLen)) } - s.WriteString("\0338") // RESTORE CURSOR back to prompt + s.WriteString("\0338") // restore cursor back to prompt o.LastGhostLen = len(ghostText) } @@ -123,16 +126,32 @@ func (o *Overlay) Render() string { var s strings.Builder s.WriteString("\033[?7l") - s.WriteString("\0337") // DEC save cursor for menu drawing + + var offset int + if o.UserNavigated && len(o.Items) > 0 && o.Cursor >= 0 && o.Cursor < len(o.Items) { + currentCmd := o.Items[o.Cursor].Cmd + typedLen := len([]rune(o.TypedQuery)) + currentLen := len([]rune(currentCmd)) + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + width = 120 + } + if currentLen+4 < width { + offset = currentLen - typedLen + } else { + curCol := (currentLen + 2) % width + typedCol := (typedLen + 2) % width + offset = curCol - typedCol + } + } + + s.WriteString("\0337") windowSize := maxItems if len(o.Items) < windowSize { windowSize = len(o.Items) } - // when you use up arrow key, the selection bar will stick with the second item - // it stays still at second position until you reach the limit of the list - // but not apply the same with down arrow key scrolloffUp := 1 scrolloffDown := 0 if windowSize <= 3 { @@ -159,22 +178,23 @@ func (o *Overlay) Render() string { start := o.StartIdx end := start + windowSize - totalLines := windowSize + 2 // top border + items + bottom border + totalLines := windowSize + 2 - // if we reach the last lines of terminal, it will auto expand space to have space for the menu for range totalLines { s.WriteByte('\n') } fmt.Fprintf(&s, "\033[%dA", totalLines) - // re-save after scroll up - // it means when you scroll up, then scroll down to the prompt, the menu is still be there s.WriteString("\0337") - // top border with scroll indicator s.WriteString("\0338") fmt.Fprintf(&s, "\033[%dB", 1) s.WriteString("\033[2K") + if offset > 0 { + fmt.Fprintf(&s, "\033[%dD", offset) + } else if offset < 0 { + fmt.Fprintf(&s, "\033[%dC", -offset) + } scrollInfo := "" if len(o.Items) > windowSize { @@ -193,6 +213,11 @@ func (o *Overlay) Render() string { s.WriteString("\0338") fmt.Fprintf(&s, "\033[%dB", (i-start)+2) s.WriteString("\033[2K") + if offset > 0 { + fmt.Fprintf(&s, "\033[%dD", offset) + } else if offset < 0 { + fmt.Fprintf(&s, "\033[%dC", -offset) + } it := o.Items[i] rawIcon := fixedWidth(it.Icon, iconW) @@ -216,14 +241,18 @@ func (o *Overlay) Render() string { } } - // Bottom border s.WriteString("\0338") fmt.Fprintf(&s, "\033[%dB", windowSize+2) s.WriteString("\033[2K") + if offset > 0 { + fmt.Fprintf(&s, "\033[%dD", offset) + } else if offset < 0 { + fmt.Fprintf(&s, "\033[%dC", -offset) + } bottomBorder := "╰" + strings.Repeat("─", boxWidth) + "╯" s.WriteString(borderStyle.Render(bottomBorder)) - s.WriteString("\0338") // restore to prompt + s.WriteString("\0338") s.WriteString("\033[?7h") return s.String() } @@ -251,20 +280,22 @@ func (o *Overlay) ClearAndDisable() string { o.mu.Lock() defer o.mu.Unlock() - if !o.Visible { + if !o.Visible && len(o.Items) == 0 && o.LastGhostLen == 0 { return "" } o.Visible = false o.Items = nil + o.TypedQuery = "" + o.UserNavigated = false var s strings.Builder s.WriteString("\033[?7l") if o.LastGhostLen > 0 { - s.WriteString("\0337") // save + s.WriteString("\0337") s.WriteString(strings.Repeat(" ", o.LastGhostLen+10)) - s.WriteString("\0338") // restore + s.WriteString("\0338") o.LastGhostLen = 0 } diff --git a/root/init.go b/root/init.go index 286e39d..93a81d8 100644 --- a/root/init.go +++ b/root/init.go @@ -21,7 +21,7 @@ For example, add this to your ~/.zshrc: Run: func(cmd *cobra.Command, args []string) { shell := args[0] switch shell { - case "bash", "zsh": + case "zsh": fmt.Printf(` # Iris Autostart Hook if [ -n "$TMUX" ] && [ -n "$IRIS_PID" ]; then @@ -31,10 +31,41 @@ if [ -n "$TMUX" ] && [ -n "$IRIS_PID" ]; then fi if [ -z "$IRIS_PID" ]; then - export IRIS_ACTIVE_SHELL="%s" + export IRIS_ACTIVE_SHELL="zsh" exec iris fi -`, shell) + +# Iris Autocomplete Hook +if [ -n "$IRIS_PID" ] && [ -n "$IRIS_FD" ]; then + _iris_send_lbuffer() { + print -u $IRIS_FD -N -r -- "$LBUFFER" 2>/dev/null + } + + _iris_precmd() { + print -u $IRIS_FD -N -r -- "IRIS_CMD_STOP" 2>/dev/null + } + + autoload -Uz add-zle-hook-widget + autoload -Uz add-zsh-hook + + add-zle-hook-widget line-pre-redraw _iris_send_lbuffer + add-zsh-hook precmd _iris_precmd +fi +`) + case "bash": + fmt.Printf(` +# Iris Autostart Hook +if [ -n "$TMUX" ] && [ -n "$IRIS_PID" ]; then + if ps -o comm= -p $PPID 2>/dev/null | grep -q "tmux"; then + unset IRIS_PID IRIS_IS_CHILD IRIS_FD + fi +fi + +if [ -z "$IRIS_PID" ]; then + export IRIS_ACTIVE_SHELL="bash" + exec iris +fi +`) case "fish": fmt.Printf(` # Iris Autostart Hook diff --git a/root/root.go b/root/root.go index dd08e76..97038fe 100644 --- a/root/root.go +++ b/root/root.go @@ -57,9 +57,7 @@ func init() { if shellFlag != "" { config.Get().Core.Shell = shellFlag } - if debugMode { - config.Get().Core.Debug = true - } + config.Get().Core.Debug = true if config.Get().Core.Debug { logDir, err := config.CachePath() if err == nil { diff --git a/root/suggestions.go b/root/suggestions.go index 4243c46..923ae45 100644 --- a/root/suggestions.go +++ b/root/suggestions.go @@ -12,53 +12,61 @@ import ( // mergeResults collects and dedupes suggestions for a query and mode // example: mergeResults("git ", "spec") func MergeResults(query string, mode string) []core.Suggestion { - if query == "" && mode != "history" { - debugLog("[Merge] Query empty, returning nil") - return nil - } - maxSugg := config.Get().UI.MaxSuggestions - normalizedQuery := strings.TrimSpace(query) seen := make(map[string]bool) deduped := []core.Suggestion{} - if mode == "history" { - histResults, _ := integration.SearchHistory(query) - for _, h := range histResults { - normalizedCmd := strings.TrimSpace(h.Cmd) - if seen[normalizedCmd] { - continue - } - seen[normalizedCmd] = true - deduped = append(deduped, core.Suggestion{ - Cmd: h.Cmd, - Desc: " history", - Icon: fmt.Sprintf("%d", h.ID), - }) - if len(deduped) >= maxSugg { - break - } - } - debugLog("[Merge] History mode found %d items", len(deduped)) - return deduped + // always call lookup to scan aliases and get spec suggestions + var cmdResults []core.Suggestion + if query != "" { + debugLog("[Merge] Calling Lookup for '%s'", query) + cmdResults = core.Lookup(query) } - debugLog("[Merge] Calling Lookup for '%s'", query) - cmdResults := core.Lookup(query) - debugLog("[Merge] Lookup returned %d raw items", len(cmdResults)) + // search history if in history mode + var histResults []integration.HistResult + if mode == "history" { + aliases := core.GetAliasesCopy() + histResults, _ = integration.SearchHistory(query, aliases) + } - for _, s := range cmdResults { + normalizedQuery := strings.TrimSpace(query) + + // add suggestion helper to deduplicate + addSuggestion := func(s core.Suggestion) { normalizedCmd := strings.TrimSpace(s.Cmd) - if normalizedCmd == normalizedQuery { // filter exact matches to avoid loops - debugLog("[Merge] Filtered EXACT MATCH: '%s'", normalizedCmd) - continue + if normalizedCmd == "" { + return + } + // filter exact match to avoid loops and redundant suggestions + if normalizedCmd == normalizedQuery { + return } - if !seen[s.Cmd] { seen[s.Cmd] = true deduped = append(deduped, s) } } + + if mode == "history" { + // history mode: history first, then spec/alias + for _, h := range histResults { + addSuggestion(core.Suggestion{ + Cmd: h.Cmd, + Desc: " history", + Icon: fmt.Sprintf("%d", h.ID), + }) + } + for _, s := range cmdResults { + addSuggestion(s) + } + } else { + // spec mode: spec/alias only + for _, s := range cmdResults { + addSuggestion(s) + } + } + if len(deduped) > maxSugg { deduped = deduped[:maxSugg] } diff --git a/root/wrapper.go b/root/wrapper.go index e20b775..58c344b 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -46,10 +46,22 @@ func saveMode(mode string) { } var ( - oldState *term.State - oldStateMu sync.Mutex + oldState *term.State + oldStateMu sync.Mutex + activeMode string + activeModeMu sync.RWMutex + stdoutMu sync.Mutex ) +func writeStdout(data []byte) { + if len(data) == 0 { + return + } + stdoutMu.Lock() + defer stdoutMu.Unlock() + _, _ = os.Stdout.Write(data) +} + // restoreTerminal restores the terminal state if needed func restoreTerminal() { oldStateMu.Lock() @@ -65,6 +77,10 @@ func restoreTerminal() { // it handles raw terminal mode to intercept keystrokes and // coordinates between the shell process and the suggestion overlay func runWrapper() { + var naiveBuffer string + cursorOffset := 0 + var bufferMu sync.Mutex + r, w, err := os.Pipe() // pipe for ipc communication from shell to iris if err != nil { return @@ -86,9 +102,9 @@ func runWrapper() { ctx := context.Background() c := exec.CommandContext(ctx, adapter.GetShellPath()) c.ExtraFiles = make([]*os.File, 11) - // pass write end of pipe to shell as fd 10 (I chose 10 just because it won't conflict with other file descriptors) + // pass write end of pipe to shell as fd 13 (since index 10 maps to 13) c.ExtraFiles[10] = w - c.Env = adapter.GetEnv(10, os.Getpid()) + c.Env = adapter.GetEnv(13, os.Getpid()) ptmx, err := pty.Start(c) if err != nil { @@ -194,7 +210,7 @@ func runWrapper() { } continue } - _, _ = os.Stdout.Write(buf[:n]) + writeStdout(buf[:n]) } }() @@ -258,115 +274,163 @@ func runWrapper() { continue } - if isExecuting() { + if overlay.UserNavigated { continue } - results := MergeResults(query, "spec") + // sync local buffer with actual command line + bufferMu.Lock() + naiveBuffer = query + cursorOffset = 0 + bufferMu.Unlock() + + activeModeMu.RLock() + currentMode := activeMode + activeModeMu.RUnlock() + + results := MergeResults(query, currentMode) if len(results) == 0 { - _, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable())) + writeStdout([]byte(overlay.ClearAndDisable())) continue } - _, _ = os.Stdout.Write([]byte(overlay.Clear())) + writeStdout([]byte(overlay.Clear())) + overlay.TypedQuery = query + overlay.UserNavigated = false overlay.UpdateItems(results) var rBuf strings.Builder if !disableGhostText.Load() { rBuf.WriteString(overlay.RenderGhostText(query, false)) } rBuf.WriteString(overlay.Render()) - _, _ = os.Stdout.Write([]byte(rBuf.String())) + writeStdout([]byte(rBuf.String())) } if err := scanner.Err(); err != nil { debugLog("[IPC] scanner error: %v", err) } }() - var naiveBuffer string suggestionsEnabled := true - mode := loadMode() + activeModeMu.Lock() + activeMode = loadMode() + activeModeMu.Unlock() + + writeStdout([]byte(overlay.Clear())) var renderTimer *time.Timer var renderMu sync.Mutex - // renderOverlay decides whether to draw the suggestion menu based on current state + renderMenuNow := func() { + if isExecuting() { + return + } + + // copy state safely inside timer + bufferMu.Lock() + bufCopy := naiveBuffer + offsetCopy := cursorOffset + bufferMu.Unlock() + + activeModeMu.RLock() + modeCopy := activeMode + activeModeMu.RUnlock() + + navCopy := userNavigated + + runes := []rune(bufCopy) + if offsetCopy > 0 && offsetCopy <= len(runes) { + bufCopy = string(runes[:len(runes)-offsetCopy]) + } + + var b strings.Builder + if !navCopy { + if bufCopy == "" { + writeStdout([]byte(overlay.ClearAndDisable())) + return + } + debugLog("[Render] query: '%s', mode: %s", bufCopy, modeCopy) + results := MergeResults(bufCopy, modeCopy) + debugLog("[Render] results found: %d", len(results)) + + if len(results) == 0 || (len(results) == 1 && strings.TrimSpace(results[0].Cmd) == strings.TrimSpace(bufCopy) && !strings.HasSuffix(bufCopy, " ")) { + b.WriteString(overlay.ClearAndDisable()) + writeStdout([]byte(b.String())) + return + } + + if overlay.Visible { + b.WriteString(overlay.Clear()) + } + overlay.TypedQuery = bufCopy + overlay.UpdateItems(results) + } else { + if overlay.Visible { + b.WriteString(overlay.Clear()) + } + } + + overlay.UserNavigated = navCopy + if !disableGhostText.Load() { + b.WriteString(overlay.RenderGhostText(bufCopy, navCopy)) + } + currentCmd := "" + if len(overlay.Items) > 0 && overlay.Cursor >= 0 && overlay.Cursor < len(overlay.Items) { + currentCmd = overlay.Items[overlay.Cursor].Cmd + } + debugLog("[RenderOverlay] nav: %v, cursor: %d, typedQuery: '%s', currentCmd: '%s'", navCopy, overlay.Cursor, overlay.TypedQuery, currentCmd) + b.WriteString(overlay.Render()) + writeStdout([]byte(b.String())) + } + + var isNavTimerRunning bool + renderOverlay = func() { renderMu.Lock() defer renderMu.Unlock() - if renderTimer != nil { - renderTimer.Stop() - } - if !suggestionsEnabled || isExecuting() { + if renderTimer != nil { + renderTimer.Stop() + renderTimer = nil + } + isNavTimerRunning = false return } - bufCopy := naiveBuffer - modeCopy := mode navCopy := userNavigated - if bufCopy == "" && !navCopy { - _, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable())) - return - } - - // Debounce for 15ms to allow PTY to process the keystroke and update the terminal cursor. - // This completely prevents the asynchronous ghost text race condition where the PTY echo - // overwrites the first letter of our ghost text! - renderTimer = time.AfterFunc(15*time.Millisecond, func() { - if isExecuting() { + if navCopy { + if isNavTimerRunning { return } - - var b strings.Builder - if !navCopy { - debugLog("[Render] query: '%s', mode: %s", bufCopy, modeCopy) - results := MergeResults(bufCopy, modeCopy) - debugLog("[Render] results found: %d", len(results)) - - if len(results) == 0 { - b.WriteString(overlay.ClearAndDisable()) - _, _ = os.Stdout.Write([]byte(b.String())) - return - } - - if overlay.Visible { - b.WriteString(overlay.Clear()) - } - overlay.UpdateItems(results) - } else { - if overlay.Visible { - b.WriteString(overlay.Clear()) - } + if renderTimer != nil { + renderTimer.Stop() } - - if !disableGhostText.Load() { - b.WriteString(overlay.RenderGhostText(bufCopy, navCopy)) + isNavTimerRunning = true + // IMPORTANT: please dont change this to above 24ms or under 19ms + // I still can get the reason why it only works stably between 20-23ms + renderTimer = time.AfterFunc(23*time.Millisecond, func() { + renderMu.Lock() + renderTimer = nil + isNavTimerRunning = false + renderMu.Unlock() + renderMenuNow() + }) + } else { + if renderTimer != nil { + renderTimer.Stop() } - b.WriteString(overlay.Render()) - _, _ = os.Stdout.Write([]byte(b.String())) - }) + isNavTimerRunning = false + renderTimer = time.AfterFunc(25*time.Millisecond, func() { + renderMu.Lock() + renderTimer = nil + renderMu.Unlock() + renderMenuNow() + }) + } } renderOverlay() - renderNow := func() { - renderMu.Lock() - if renderTimer != nil { - renderTimer.Stop() - } - renderMu.Unlock() - - var b strings.Builder - if overlay.Visible { - if !disableGhostText.Load() { - b.WriteString(overlay.RenderGhostText(naiveBuffer, userNavigated)) - } - b.WriteString(overlay.Render()) - } - _, _ = os.Stdout.Write([]byte(b.String())) - } - // reads from stdin and decides what to forward or intercept // for most cases, I just handle the already have terminal shortcuts // for some shortcuts like tab, enter, shift tab, ctrl r, @@ -390,6 +454,15 @@ func runWrapper() { intercepted := false if b == '\033' { + // check for bracketed paste start/end + if i+5 < n && inputSlice[i+1] == '[' && inputSlice[i+2] == '2' && inputSlice[i+3] == '0' { + if (inputSlice[i+4] == '0' || inputSlice[i+4] == '1') && inputSlice[i+5] == '~' { + intercepted = true + _, _ = ptmx.Write(inputSlice[i : i+6]) + i += 5 + continue + } + } // handle escape sequences like arrow keys and functional shortcuts if i+2 < n && (inputSlice[i+1] == '[' || inputSlice[i+1] == 'O') { // shift tab: hide/unhide menu dropdown @@ -397,7 +470,7 @@ func runWrapper() { intercepted = true suggestionsEnabled = !suggestionsEnabled if !suggestionsEnabled { - _, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable())) + writeStdout([]byte(overlay.ClearAndDisable())) } else { shouldOverlayDraw = true } @@ -408,9 +481,19 @@ func runWrapper() { if overlay.Visible && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { intercepted = true userNavigated = true + overlay.UserNavigated = true - _, _ = os.Stdout.Write([]byte(overlay.Clear())) // clear old menu + // clear ghost text synchronously + if overlay.LastGhostLen > 0 { + var gs strings.Builder + gs.WriteString("\0337") + gs.WriteString(strings.Repeat(" ", overlay.LastGhostLen+10)) + gs.WriteString("\0338") + writeStdout([]byte(gs.String())) + overlay.LastGhostLen = 0 + } + oldCursor := overlay.Cursor if inputSlice[i+2] == 'A' { // up arrow overlay.Cursor-- if overlay.Cursor < 0 { @@ -423,20 +506,36 @@ func runWrapper() { } } - selected := overlay.Items[overlay.Cursor].Cmd - _, _ = ptmx.Write([]byte{0x15}) // ctrl+u to clear line - _, _ = ptmx.Write([]byte(selected)) - naiveBuffer = selected + // boundary hit - ignore redundant write to avoid PTY flooding + if overlay.Cursor == oldCursor { + i += 2 + continue + } - renderNow() + selected := overlay.Items[overlay.Cursor].Cmd + bufferMu.Lock() + naiveBuffer = selected + cursorOffset = 0 + bufferMu.Unlock() + + _, _ = ptmx.Write(append([]byte{0x15}, selected...)) + + userNavigated = true + overlay.UserNavigated = true + renderOverlay() i += 2 continue } else if !overlay.Visible && naiveBuffer == "" && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { // up/down arrow on empty prompt intercepted = true - mode = "history" - saveMode(mode) + activeModeMu.Lock() + activeMode = "history" + saveMode(activeMode) + activeModeMu.Unlock() - results := MergeResults("", "history") + activeModeMu.RLock() + currentMode := activeMode + activeModeMu.RUnlock() + results := MergeResults("", currentMode) if len(results) > 0 { limit := 100 if len(results) < limit { @@ -445,12 +544,12 @@ func runWrapper() { var historyList []core.Suggestion if inputSlice[i+2] == 'A' { - // Up arrow: Reverse the list so newest is at the bottom + // up arrow: reverse the list so newest is at the bottom for j := limit - 1; j >= 0; j-- { historyList = append(historyList, results[j]) } } else { - // Down arrow: Normal order, newest is at the top + // down arrow: normal order, newest is at the top for j := 0; j < limit; j++ { historyList = append(historyList, results[j]) } @@ -459,42 +558,104 @@ func runWrapper() { overlay.UpdateItems(historyList) if inputSlice[i+2] == 'A' { - overlay.Cursor = len(historyList) - 1 // Up arrow: Start at the bottom + overlay.Cursor = len(historyList) - 1 // up arrow: start at the bottom } else { - overlay.Cursor = 0 // Down arrow: Start at the top + overlay.Cursor = 0 // down arrow: start at the top } selected := overlay.Items[overlay.Cursor].Cmd - _, _ = ptmx.Write([]byte{0x15}) // ctrl+u to clear line - _, _ = ptmx.Write([]byte(selected)) + bufferMu.Lock() naiveBuffer = selected + cursorOffset = 0 + bufferMu.Unlock() + + _, _ = ptmx.Write(append([]byte{0x15}, selected...)) userNavigated = true - renderNow() + overlay.UserNavigated = true + renderOverlay() } i += 2 continue } else if overlay.Visible && !disableGhostText.Load() && inputSlice[i+2] == 'C' { // right arrow + bufferMu.Lock() topCmd := overlay.Items[0].Cmd - if strings.HasPrefix(strings.ToLower(topCmd), strings.ToLower(naiveBuffer)) { - ghostText := topCmd[len(naiveBuffer):] - if len(ghostText) > 0 { - intercepted = true - naiveBuffer += ghostText - _, _ = ptmx.Write([]byte(ghostText)) - shouldOverlayDraw = true - i += 2 - continue - } + hasMatch := strings.HasPrefix(strings.ToLower(topCmd), strings.ToLower(naiveBuffer)) + var ghostText string + if hasMatch { + ghostText = topCmd[len(naiveBuffer):] + } + bufferMu.Unlock() + + if hasMatch && len(ghostText) > 0 { + intercepted = true + bufferMu.Lock() + naiveBuffer += ghostText + cursorOffset = 0 + bufferMu.Unlock() + _, _ = ptmx.Write([]byte(ghostText)) + shouldOverlayDraw = true + i += 2 + continue } } } - // forward escape sequence to pty if not intercepted + // left/right arrow cursor tracking + isLeftRightArrow := false + if i+2 < n && (inputSlice[i+1] == '[' || inputSlice[i+1] == 'O') { + if inputSlice[i+2] == 'D' { + bufferMu.Lock() + isEmptyQuery := (overlay.Visible && overlay.TypedQuery == "") || (!overlay.Visible && naiveBuffer == "") + bufferMu.Unlock() + if isEmptyQuery { + intercepted = true + i += 2 + continue + } + bufferMu.Lock() + if naiveBuffer != "" || overlay.Visible { + cursorOffset++ + if cursorOffset > len(naiveBuffer) { + cursorOffset = len(naiveBuffer) + } + shouldOverlayDraw = true + userNavigated = false + } + bufferMu.Unlock() + isLeftRightArrow = true + } else if inputSlice[i+2] == 'C' { + bufferMu.Lock() + isEmptyQuery := (overlay.Visible && overlay.TypedQuery == "") || (!overlay.Visible && naiveBuffer == "") + bufferMu.Unlock() + if isEmptyQuery { + intercepted = true + i += 2 + continue + } + bufferMu.Lock() + if naiveBuffer != "" || overlay.Visible { + cursorOffset-- + if cursorOffset < 0 { + cursorOffset = 0 + } + shouldOverlayDraw = true + userNavigated = false + } + bufferMu.Unlock() + isLeftRightArrow = true + } + } + if !intercepted { - _, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable())) + writeStdout([]byte(overlay.ClearAndDisable())) disableGhostText.Store(true) - naiveBuffer = "" + if !isLeftRightArrow { + bufferMu.Lock() + naiveBuffer = "" + cursorOffset = 0 + bufferMu.Unlock() + } _, _ = ptmx.Write([]byte{b}) // skip remaining bytes of the escape sequence to avoid misinterpretation @@ -512,12 +673,14 @@ func runWrapper() { if b == 0x12 { // ctrl+r: toggle between command specs and command history intercepted = true - if mode == "spec" { - mode = "history" + activeModeMu.Lock() + if activeMode == "spec" { + activeMode = "history" } else { - mode = "spec" + activeMode = "spec" } - saveMode(mode) + saveMode(activeMode) + activeModeMu.Unlock() shouldOverlayDraw = true // enter: enter behavior is a bit different from tab suggestions in code editor // I want it to execute the command anyway and ignore the suggestions @@ -525,10 +688,23 @@ func runWrapper() { // enter is not used to select suggestions } else if overlay.Visible && (b == 0x0d || b == 0x0a) { intercepted = true - _, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable())) + if overlay.UserNavigated && len(overlay.Items) > 0 && overlay.Cursor >= 0 && overlay.Cursor < len(overlay.Items) { + selected := overlay.Items[overlay.Cursor].Cmd + activeModeMu.RLock() + currentMode := activeMode + activeModeMu.RUnlock() + if currentMode == "spec" { + selected = strings.TrimSpace(selected) + " " + } + _, _ = ptmx.Write(append([]byte{0x15}, selected...)) + } + writeStdout([]byte(overlay.ClearAndDisable())) _, _ = ptmx.Write([]byte{0x0d}) + bufferMu.Lock() naiveBuffer = "" + cursorOffset = 0 + bufferMu.Unlock() disableGhostText.Store(false) shouldOverlayDraw = false userNavigated = false @@ -539,16 +715,21 @@ func runWrapper() { shouldOverlayDraw = true } else { selected := overlay.Items[overlay.Cursor].Cmd - _, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable())) + writeStdout([]byte(overlay.ClearAndDisable())) - if mode == "spec" { + activeModeMu.RLock() + currentMode := activeMode + activeModeMu.RUnlock() + if currentMode == "spec" { selected = strings.TrimSpace(selected) + " " } + bufferMu.Lock() naiveBuffer = selected + cursorOffset = 0 + bufferMu.Unlock() - _, _ = ptmx.Write([]byte{0x15}) // ctrl+u to clear line - _, _ = ptmx.Write([]byte(selected)) + _, _ = ptmx.Write(append([]byte{0x15}, selected...)) overlay.Cursor = 0 // this prevents when you tab, it switches between suggestions non-stop @@ -565,13 +746,47 @@ func runWrapper() { // we handle line editing keys manually to keep naiveBuffer in sync // since terminal is in raw mode, we must update our state for every change switch b { - case 127, 0x08: // backspace: remove last character from buffer - if len(naiveBuffer) > 0 { - naiveBuffer = naiveBuffer[:len(naiveBuffer)-1] + case 0x01: // ctrl+a: move to beginning of line + bufferMu.Lock() + cursorOffset = len(naiveBuffer) + if naiveBuffer != "" || overlay.Visible { shouldOverlayDraw = true - userNavigated = false } + bufferMu.Unlock() + userNavigated = false + case 0x05: // ctrl+e: move to end of line + bufferMu.Lock() + cursorOffset = 0 + if naiveBuffer != "" || overlay.Visible { + shouldOverlayDraw = true + } + bufferMu.Unlock() + userNavigated = false + + case 127, 0x08: // backspace: remove character + bufferMu.Lock() + if len(naiveBuffer) > 0 { + runes := []rune(naiveBuffer) + if cursorOffset <= 0 { + if len(runes) > 0 { + naiveBuffer = string(runes[:len(runes)-1]) + } + cursorOffset = 0 + } else { + if cursorOffset > len(runes) { + cursorOffset = len(runes) + } + pos := len(runes) - cursorOffset + if pos > 0 && pos <= len(runes) { + naiveBuffer = string(append(runes[:pos-1], runes[pos:]...)) + } + } + } + bufferMu.Unlock() + shouldOverlayDraw = true + userNavigated = false case 0x17: // ctrl+w: delete the last word in the buffer + bufferMu.Lock() trimBuf := strings.TrimRight(naiveBuffer, " ") lastSpace := strings.LastIndex(trimBuf, " ") if lastSpace >= 0 { @@ -579,29 +794,66 @@ func runWrapper() { } else { naiveBuffer = "" } + cursorOffset = 0 + bufferMu.Unlock() shouldOverlayDraw = true userNavigated = false - case '\r', '\n', 0x03, 0x15, 0x0C: // enter, ctrl+c, ctrl+u, ctrl+l: clear buffer on line reset + case 0x0c: // ctrl+l: clear screen but keep buffer and redraw menu + shouldOverlayDraw = true + userNavigated = false + case '\r', '\n', 0x03, 0x15: // enter, ctrl+c, ctrl+u: clear buffer on line reset + bufferMu.Lock() naiveBuffer = "" + cursorOffset = 0 + bufferMu.Unlock() + activeModeMu.Lock() + activeMode = loadMode() + activeModeMu.Unlock() disableGhostText.Store(false) - _, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable())) + writeStdout([]byte(overlay.ClearAndDisable())) userNavigated = false default: // track normal printable characters in the buffer for matching if b >= 32 && b <= 126 { // if user presses space, check if the current word is an alias - if b == ' ' && naiveBuffer != "" && !strings.Contains(naiveBuffer, " ") { - if target, ok := core.GetAlias(naiveBuffer); ok { - // clear the current alias and replace it with the full command - _, _ = ptmx.Write([]byte{0x15}) // ctrl+u to clear the current input line - _, _ = ptmx.Write([]byte(target + " ")) - naiveBuffer = target + " " - shouldOverlayDraw = true - continue + bufferMu.Lock() + isSpaceAlias := b == ' ' && naiveBuffer != "" && !strings.Contains(naiveBuffer, " ") + var target string + var ok bool + if isSpaceAlias { + target, ok = core.GetAlias(naiveBuffer) + } + bufferMu.Unlock() + + if isSpaceAlias && ok { + // clear the current alias and replace it with the full command + _, _ = ptmx.Write(append([]byte{0x15}, target+" "...)) + bufferMu.Lock() + naiveBuffer = target + " " + cursorOffset = 0 + bufferMu.Unlock() + shouldOverlayDraw = true + continue + } + bufferMu.Lock() + if cursorOffset == 0 { + naiveBuffer += string(b) + } else { + if cursorOffset > len(naiveBuffer) { + cursorOffset = len(naiveBuffer) + } + pos := len(naiveBuffer) - cursorOffset + if pos >= 0 && pos <= len(naiveBuffer) { + naiveBuffer = naiveBuffer[:pos] + string(b) + naiveBuffer[pos:] + } else { + naiveBuffer += string(b) + cursorOffset = 0 } } - naiveBuffer += string(b) + bufferMu.Unlock() shouldOverlayDraw = true + userNavigated = false + overlay.UserNavigated = false } } }