From ca9d30af9c0f9361cb5515d46afa36fdd12140d4 Mon Sep 17 00:00:00 2001 From: VERSE Date: Fri, 3 Jul 2026 12:29:42 +0700 Subject: [PATCH] fix: data race and overlay bugs (#20) - redundant ghost text - prompt deletion bug on overlay - overlay bug on the non-exists commands image image image --- commands/core/lookup.go | 1 + integration/overlay.go | 196 +++++++++++++++++++++++++++++----- root/init.go | 5 + root/wrapper.go | 225 +++++++++++++++++++--------------------- scripts/iris.zsh | 5 + 5 files changed, 285 insertions(+), 147 deletions(-) diff --git a/commands/core/lookup.go b/commands/core/lookup.go index 5291855..a7eb941 100644 --- a/commands/core/lookup.go +++ b/commands/core/lookup.go @@ -272,6 +272,7 @@ func Lookup(input string) []Suggestion { } func topLevelSuggestions(query string, aliases map[string]string) []Suggestion { + scanExternalCommands() results, seen := []Suggestion{}, make(map[string]bool) for name, target := range aliases { diff --git a/integration/overlay.go b/integration/overlay.go index 46f3cfd..b1820b6 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -195,21 +195,152 @@ func (o *Overlay) UpdateItems(items []core.Suggestion) { o.StartIdx = 0 } +func (o *Overlay) IsVisible() bool { + o.mu.Lock() + defer o.mu.Unlock() + return o.Visible +} + +func (o *Overlay) GetUserNavigated() bool { + o.mu.Lock() + defer o.mu.Unlock() + return o.UserNavigated +} + +func (o *Overlay) SetUserNavigated(v bool) { + o.mu.Lock() + defer o.mu.Unlock() + o.UserNavigated = v +} + +func (o *Overlay) GetTypedQuery() string { + o.mu.Lock() + defer o.mu.Unlock() + return o.TypedQuery +} + +func (o *Overlay) GetCurrentCmd() string { + o.mu.Lock() + defer o.mu.Unlock() + if len(o.Items) > 0 && o.Cursor >= 0 && o.Cursor < len(o.Items) { + return o.Items[o.Cursor].Cmd + } + return "" +} + +func (o *Overlay) GetTopCmd() string { + o.mu.Lock() + defer o.mu.Unlock() + if len(o.Items) > 0 { + return o.Items[0].Cmd + } + return "" +} + +func (o *Overlay) Show() { + o.mu.Lock() + defer o.mu.Unlock() + o.UserNavigated = false + o.Visible = true +} + +func (o *Overlay) ResetCursor() { + o.mu.Lock() + defer o.mu.Unlock() + o.Cursor = 0 +} + +func (o *Overlay) SetQueryAndItems(query string, items []core.Suggestion) { + o.mu.Lock() + defer o.mu.Unlock() + o.TypedQuery = query + o.UserNavigated = false + o.Items = items + o.Visible = len(o.Items) > 0 + o.Cursor = 0 + o.StartIdx = 0 +} + +func (o *Overlay) ClearGhostLen() int { + o.mu.Lock() + defer o.mu.Unlock() + l := o.LastGhostLen + o.LastGhostLen = 0 + return l +} + +func (o *Overlay) MoveCursor(dir string) (moved bool, selectedCmd string) { + o.mu.Lock() + defer o.mu.Unlock() + if !o.Visible || len(o.Items) == 0 { + return false, "" + } + o.UserNavigated = true + oldCursor := o.Cursor + if dir == "up" { + o.Cursor-- + if o.Cursor < 0 { + o.Cursor = 0 + } + } else { + o.Cursor++ + if o.Cursor >= len(o.Items) { + o.Cursor = len(o.Items) - 1 + } + } + if o.Cursor == oldCursor { + return false, "" + } + return true, o.Items[o.Cursor].Cmd +} + +func (o *Overlay) SetHistoryList(items []core.Suggestion, startAtBottom bool) string { + o.mu.Lock() + defer o.mu.Unlock() + o.TypedQuery = "" + o.UserNavigated = true + o.Items = items + o.Visible = len(o.Items) > 0 + if startAtBottom && len(o.Items) > 0 { + o.Cursor = len(o.Items) - 1 + } else { + o.Cursor = 0 + } + o.StartIdx = 0 + if len(o.Items) > 0 && o.Cursor >= 0 && o.Cursor < len(o.Items) { + return o.Items[o.Cursor].Cmd + } + return "" +} + + func fixedWidth(s string, width int) string { if width <= 0 { return "" } - runes := []rune(s) - if len(runes) > width { - if width == 1 { - return "…" + visualWidth := lipgloss.Width(s) + if visualWidth == width { + return s + } + if visualWidth < width { + return s + strings.Repeat(" ", width-visualWidth) + } + var sb strings.Builder + currentWidth := 0 + for _, r := range s { + rw := lipgloss.Width(string(r)) + if currentWidth+rw > width-1 { + break } - return string(runes[:width-1]) + "…" + sb.WriteRune(r) + currentWidth += rw } - if len(runes) < width { - return s + strings.Repeat(" ", width-len(runes)) + sb.WriteString("…") + rem := width - lipgloss.Width(sb.String()) + if rem > 0 { + sb.WriteString(strings.Repeat(" ", rem)) } - return s + return sb.String() } func (o *Overlay) RenderGhostText(buffer string, userNavigated bool) string { @@ -229,26 +360,31 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool) string { } } - padLen := o.LastGhostLen - len(ghostText) + if ghostText == "" && o.LastGhostLen == 0 { + return "" + } + + ghostWidth := lipgloss.Width(ghostText) + padLen := o.LastGhostLen - ghostWidth if padLen < 0 { padLen = 0 } - padLen += 10 - - if ghostText != "" || padLen > 0 { - s.WriteString("\0337") - if ghostText != "" { - s.WriteString("\033[90m") - s.WriteString(ghostText) - s.WriteString("\033[0m") - } - if padLen > 0 { - s.WriteString(strings.Repeat(" ", padLen)) - } - s.WriteString("\0338") - o.LastGhostLen = len(ghostText) + if o.LastGhostLen > 0 { + padLen += 4 } + s.WriteString("\0337") + if ghostText != "" { + s.WriteString("\033[90m") + s.WriteString(ghostText) + s.WriteString("\033[0m") + } + if padLen > 0 { + s.WriteString(strings.Repeat(" ", padLen)) + } + s.WriteString("\0338") + o.LastGhostLen = ghostWidth + return s.String() } @@ -272,11 +408,13 @@ func renderMatchedTitle(title, typed string, selected bool, w int) string { return base.Render(display) } - matchLen := len(typed) - if matchLen > len(display) { - matchLen = len(display) + typedRunes := []rune(typed) + displayRunes := []rune(display) + matchLen := len(typedRunes) + if matchLen > len(displayRunes) { + matchLen = len(displayRunes) } - return match.Render(display[:matchLen]) + base.Render(display[matchLen:]) + return match.Render(string(displayRunes[:matchLen])) + base.Render(string(displayRunes[matchLen:])) } func (o *Overlay) Render() string { @@ -396,7 +534,7 @@ func (o *Overlay) draw() string { padGap := 2 markerW := 1 iconW := 2 - if isClassic { + if isClassic || !config.Get().UI.NerdFonts { iconW = 0 } sidePad := 1 @@ -583,6 +721,8 @@ func (o *Overlay) ClearAndDisable() string { o.Items = nil o.TypedQuery = "" o.UserNavigated = false + o.Cursor = 0 + o.StartIdx = 0 var s strings.Builder s.WriteString("\033[?7l") diff --git a/root/init.go b/root/init.go index 386126d..c79c5b1 100644 --- a/root/init.go +++ b/root/init.go @@ -45,11 +45,16 @@ if [ -n "$IRIS_PID" ] && [ -n "$IRIS_FD" ]; then print -u $IRIS_FD -N -r -- "IRIS_CMD_STOP" 2>/dev/null } + _iris_preexec() { + print -u $IRIS_FD -N -r -- "IRIS_CMD_START" 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 + add-zsh-hook preexec _iris_preexec fi `) case "bash": diff --git a/root/wrapper.go b/root/wrapper.go index 15e6f94..b373344 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -220,7 +220,11 @@ func runWrapper() { if err != nil { shellPGID = core.ShellPID } + var isCommandActive atomic.Bool isExecuting := func() bool { + if isCommandActive.Load() { + return true + } pgrp, err := unix.IoctlGetInt(int(ptmx.Fd()), unix.TIOCGPGRP) if err != nil { return false @@ -304,7 +308,18 @@ func runWrapper() { for scanner.Scan() { query := scanner.Text() + if query == "IRIS_CMD_START" { + isCommandActive.Store(true) + bufferMu.Lock() + naiveBuffer = "" + cursorOffset = 0 + bufferMu.Unlock() + writeStdout([]byte(overlay.ClearAndDisable())) + continue + } + if query == "IRIS_CMD_STOP" { + isCommandActive.Store(false) // hook: after user executes a command, print the update notice exactly once per session if !updatePrinted { select { @@ -319,44 +334,34 @@ func runWrapper() { continue } - if overlay.UserNavigated { + isCommandActive.Store(false) + + if overlay.GetUserNavigated() { continue } if query == "" { bufferMu.Lock() + wasEmpty := naiveBuffer == "" naiveBuffer = "" cursorOffset = 0 bufferMu.Unlock() - writeStdout([]byte(overlay.ClearAndDisable())) + if !wasEmpty { + writeStdout([]byte(overlay.ClearAndDisable())) + } continue } - // sync local buffer with actual command line bufferMu.Lock() + if naiveBuffer == query { + bufferMu.Unlock() + continue + } naiveBuffer = query cursorOffset = 0 bufferMu.Unlock() - activeModeMu.RLock() - currentMode := activeMode - activeModeMu.RUnlock() - - results := MergeResults(query, currentMode) - if len(results) == 0 { - writeStdout([]byte(overlay.ClearAndDisable())) - continue - } - 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()) - writeStdout([]byte(rBuf.String())) + renderOverlay() } if err := scanner.Err(); err != nil { logger.Errorf("IPC scanner error: %v", err) @@ -397,7 +402,7 @@ func runWrapper() { var b strings.Builder if !navCopy { - if bufCopy == "" && !overlay.Visible { + if bufCopy == "" && !overlay.IsVisible() { writeStdout([]byte(overlay.ClearAndDisable())) return } @@ -411,26 +416,22 @@ func runWrapper() { return } - if overlay.Visible { + if overlay.IsVisible() { b.WriteString(overlay.Clear()) } - overlay.TypedQuery = bufCopy - overlay.UpdateItems(results) + overlay.SetQueryAndItems(bufCopy, results) } else { - if overlay.Visible { + if overlay.IsVisible() { b.WriteString(overlay.Clear()) } } - overlay.UserNavigated = navCopy + overlay.SetUserNavigated(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 - } - logger.Debugf("RenderOverlay nav: %v, cursor: %d, typedQuery: '%s', currentCmd: '%s'", navCopy, overlay.Cursor, overlay.TypedQuery, currentCmd) + currentCmd := overlay.GetCurrentCmd() + logger.Debugf("RenderOverlay nav: %v, typedQuery: '%s', currentCmd: '%s'", navCopy, overlay.GetTypedQuery(), currentCmd) b.WriteString(overlay.Render()) writeStdout([]byte(b.String())) } @@ -515,44 +516,28 @@ func runWrapper() { continue } - if overlay.Visible && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { + if overlay.IsVisible() && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { intercepted = true userNavigated.Store(true) - overlay.UserNavigated = true - // clear ghost text synchronously - if overlay.LastGhostLen > 0 { + if l := overlay.ClearGhostLen(); l > 0 { var gs strings.Builder gs.WriteString("\0337") - gs.WriteString(strings.Repeat(" ", overlay.LastGhostLen+10)) + gs.WriteString(strings.Repeat(" ", l+10)) gs.WriteString("\0338") writeStdout([]byte(gs.String())) - overlay.LastGhostLen = 0 } - oldCursor := overlay.Cursor arrowDir := "down" if inputSlice[i+2] == 'A' { arrowDir = "up" - overlay.Cursor-- - if overlay.Cursor < 0 { - overlay.Cursor = 0 - } - } else { - overlay.Cursor++ - if overlay.Cursor >= len(overlay.Items) { - overlay.Cursor = len(overlay.Items) - 1 - } } - logger.Debugf("Intercepted %s Arrow, cursor moved %d -> %d", arrowDir, oldCursor, overlay.Cursor) - - // boundary hit - ignore redundant write to avoid PTY flooding - if overlay.Cursor == oldCursor { + moved, selected := overlay.MoveCursor(arrowDir) + if !moved { i += 2 continue } - selected := overlay.Items[overlay.Cursor].Cmd bufferMu.Lock() naiveBuffer = selected cursorOffset = 0 @@ -563,7 +548,7 @@ func runWrapper() { i += 2 continue - } else if !overlay.Visible && naiveBuffer == "" && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { // up/down arrow on empty prompt + } else if !overlay.IsVisible() && naiveBuffer == "" && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { // up/down arrow on empty prompt intercepted = true activeModeMu.Lock() activeMode = "history" @@ -582,43 +567,32 @@ func runWrapper() { var historyList []core.Suggestion if inputSlice[i+2] == 'A' { - // 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 for j := 0; j < limit; j++ { historyList = append(historyList, results[j]) } } - overlay.TypedQuery = "" - overlay.UpdateItems(historyList) + selected := overlay.SetHistoryList(historyList, inputSlice[i+2] == 'A') + if selected != "" { + bufferMu.Lock() + naiveBuffer = selected + cursorOffset = 0 + bufferMu.Unlock() - if inputSlice[i+2] == 'A' { - overlay.Cursor = len(historyList) - 1 // up arrow: start at the bottom - } else { - overlay.Cursor = 0 // down arrow: start at the top + userNavigated.Store(true) + writeStdout([]byte(overlay.Render())) + _, _ = ptmx.Write(append([]byte{0x15}, selected...)) } - - selected := overlay.Items[overlay.Cursor].Cmd - bufferMu.Lock() - naiveBuffer = selected - cursorOffset = 0 - bufferMu.Unlock() - - userNavigated.Store(true) - overlay.UserNavigated = true - - writeStdout([]byte(overlay.Render())) - _, _ = ptmx.Write(append([]byte{0x15}, selected...)) } i += 2 continue - } else if overlay.Visible && !disableGhostText.Load() && inputSlice[i+2] == 'C' { // right arrow + } else if overlay.IsVisible() && !disableGhostText.Load() && inputSlice[i+2] == 'C' { // right arrow bufferMu.Lock() - topCmd := overlay.Items[0].Cmd + topCmd := overlay.GetTopCmd() hasMatch := strings.HasPrefix(strings.ToLower(topCmd), strings.ToLower(naiveBuffer)) var ghostText string if hasMatch { @@ -646,7 +620,7 @@ func runWrapper() { 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 == "") + isEmptyQuery := (overlay.IsVisible() && overlay.GetTypedQuery() == "") || (!overlay.IsVisible() && naiveBuffer == "") bufferMu.Unlock() if isEmptyQuery { intercepted = true @@ -654,7 +628,7 @@ func runWrapper() { continue } bufferMu.Lock() - if naiveBuffer != "" || overlay.Visible { + if naiveBuffer != "" || overlay.IsVisible() { cursorOffset++ if cursorOffset > len(naiveBuffer) { cursorOffset = len(naiveBuffer) @@ -666,7 +640,7 @@ func runWrapper() { isLeftRightArrow = true } else if inputSlice[i+2] == 'C' { bufferMu.Lock() - isEmptyQuery := (overlay.Visible && overlay.TypedQuery == "") || (!overlay.Visible && naiveBuffer == "") + isEmptyQuery := (overlay.IsVisible() && overlay.GetTypedQuery() == "") || (!overlay.IsVisible() && naiveBuffer == "") bufferMu.Unlock() if isEmptyQuery { intercepted = true @@ -674,7 +648,7 @@ func runWrapper() { continue } bufferMu.Lock() - if naiveBuffer != "" || overlay.Visible { + if naiveBuffer != "" || overlay.IsVisible() { cursorOffset-- if cursorOffset < 0 { cursorOffset = 0 @@ -724,35 +698,62 @@ func runWrapper() { logger.Debugf("Intercepted Ctrl+R, toggled mode to %q", activeMode) if userNavigated.Load() { bufferMu.Lock() - naiveBuffer = overlay.TypedQuery + naiveBuffer = overlay.GetTypedQuery() cursorOffset = 0 bufferMu.Unlock() - _, _ = ptmx.Write(append([]byte{0x15}, overlay.TypedQuery...)) + _, _ = ptmx.Write(append([]byte{0x15}, overlay.GetTypedQuery()...)) } userNavigated.Store(false) - overlay.UserNavigated = false - overlay.Visible = true + overlay.Show() 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 // it means only tab to select suggestions, and enter to execute // enter is not used to select suggestions - } else if overlay.Visible && (b == 0x0d || b == 0x0a) { + } else if b == 0x0d || b == 0x0a { intercepted = true - logger.Debugf("Intercepted Enter key, navigated=%v", overlay.UserNavigated) - 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) + " " + logger.Debugf("Intercepted Enter key, navigated=%v", overlay.GetUserNavigated()) + if overlay.IsVisible() && overlay.GetUserNavigated() { + selected := overlay.GetCurrentCmd() + if selected != "" { + activeModeMu.RLock() + currentMode := activeMode + activeModeMu.RUnlock() + if currentMode == "spec" { + selected = strings.TrimSpace(selected) + " " + } + _, _ = ptmx.Write(append([]byte{0x15}, selected...)) } - _, _ = ptmx.Write(append([]byte{0x15}, selected...)) } writeStdout([]byte(overlay.ClearAndDisable())) + renderMu.Lock() + if renderTimer != nil { + renderTimer.Stop() + renderTimer = nil + } + renderMu.Unlock() - _, _ = ptmx.Write([]byte{0x0d}) + isCommandActive.Store(true) + _, _ = ptmx.Write([]byte{b}) + bufferMu.Lock() + naiveBuffer = "" + cursorOffset = 0 + bufferMu.Unlock() + disableGhostText.Store(false) + shouldOverlayDraw = false + userNavigated.Store(false) + continue + } else if b == 0x03 || b == 0x15 { // ctrl+c, ctrl+u + intercepted = true + writeStdout([]byte(overlay.ClearAndDisable())) + renderMu.Lock() + if renderTimer != nil { + renderTimer.Stop() + renderTimer = nil + } + renderMu.Unlock() + isCommandActive.Store(false) + _, _ = ptmx.Write([]byte{b}) bufferMu.Lock() naiveBuffer = "" cursorOffset = 0 @@ -763,11 +764,11 @@ func runWrapper() { continue } else if b == 0x09 { // tab: select suggestions intercepted = true - logger.Debugf("Intercepted Tab key, visible=%v, cursor=%d", overlay.Visible, overlay.Cursor) - if !overlay.Visible { + logger.Debugf("Intercepted Tab key, visible=%v", overlay.IsVisible()) + if !overlay.IsVisible() { shouldOverlayDraw = true } else { - selected := overlay.Items[overlay.Cursor].Cmd + selected := overlay.GetCurrentCmd() writeStdout([]byte(overlay.ClearAndDisable())) activeModeMu.RLock() @@ -784,7 +785,7 @@ func runWrapper() { _, _ = ptmx.Write(append([]byte{0x15}, selected...)) - overlay.Cursor = 0 // this prevents when you tab, it switches between suggestions non-stop + overlay.ResetCursor() // this prevents when you tab, it switches between suggestions non-stop shouldOverlayDraw = true // <- rerender after tab to choose, if you set to false, // when you press tab continually, it will print all folder from menu suggestions @@ -802,7 +803,7 @@ func runWrapper() { case 0x01: // ctrl+a: move to beginning of line bufferMu.Lock() cursorOffset = len(naiveBuffer) - if naiveBuffer != "" || overlay.Visible { + if naiveBuffer != "" || overlay.IsVisible() { shouldOverlayDraw = true } bufferMu.Unlock() @@ -810,7 +811,7 @@ func runWrapper() { case 0x05: // ctrl+e: move to end of line bufferMu.Lock() cursorOffset = 0 - if naiveBuffer != "" || overlay.Visible { + if naiveBuffer != "" || overlay.IsVisible() { shouldOverlayDraw = true } bufferMu.Unlock() @@ -839,14 +840,7 @@ func runWrapper() { isEmptyNow := len(naiveBuffer) == 0 bufferMu.Unlock() - if wasEmpty { - if overlay.Visible { - writeStdout([]byte(overlay.ClearAndDisable())) - } - userNavigated.Store(false) - continue - } - if isEmptyNow { + if wasEmpty || isEmptyNow { writeStdout([]byte(overlay.ClearAndDisable())) userNavigated.Store(false) continue @@ -867,14 +861,7 @@ func runWrapper() { isEmptyNow := len(naiveBuffer) == 0 bufferMu.Unlock() - if wasEmpty { - if overlay.Visible { - writeStdout([]byte(overlay.ClearAndDisable())) - } - userNavigated.Store(false) - continue - } - if isEmptyNow { + if wasEmpty || isEmptyNow { writeStdout([]byte(overlay.ClearAndDisable())) userNavigated.Store(false) continue @@ -936,7 +923,7 @@ func runWrapper() { bufferMu.Unlock() shouldOverlayDraw = true userNavigated.Store(false) - overlay.UserNavigated = false + overlay.SetUserNavigated(false) } } } diff --git a/scripts/iris.zsh b/scripts/iris.zsh index 9af50fd..5510452 100644 --- a/scripts/iris.zsh +++ b/scripts/iris.zsh @@ -12,9 +12,14 @@ if [[ -n "$IRIS_FD" ]]; then print -u $IRIS_FD -N -r -- "IRIS_CMD_STOP" 2>/dev/null } + _iris_preexec() { + print -u $IRIS_FD -N -r -- "IRIS_CMD_START" 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 + add-zsh-hook preexec _iris_preexec fi