fix: data race and overlay bugs (#20)

- redundant ghost text
- prompt deletion bug on overlay
- overlay bug on the non-exists commands

<img width="1152" height="221" alt="image"
src="https://github.com/user-attachments/assets/29ef0c1f-4cd8-4e36-960f-352010fc3bd1"
/>
<img width="1275" height="470" alt="image"
src="https://github.com/user-attachments/assets/aa765110-f9fa-49c8-94f2-767a827f5e5f"
/>
<img width="2517" height="152" alt="image"
src="https://github.com/user-attachments/assets/47a6668f-fb01-46be-9ad2-2c50c283f175"
/>
This commit is contained in:
VERSE
2026-07-03 12:29:42 +07:00
committed by GitHub
parent ea82216dc9
commit ca9d30af9c
5 changed files with 285 additions and 147 deletions
+1
View File
@@ -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 {
+160 -20
View File
@@ -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 "…"
}
return string(runes[:width-1]) + "…"
}
if len(runes) < width {
return s + strings.Repeat(" ", width-len(runes))
}
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
}
sb.WriteRune(r)
currentWidth += rw
}
sb.WriteString("…")
rem := width - lipgloss.Width(sb.String())
if rem > 0 {
sb.WriteString(strings.Repeat(" ", rem))
}
return sb.String()
}
func (o *Overlay) RenderGhostText(buffer string, userNavigated bool) string {
@@ -229,13 +360,19 @@ 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 o.LastGhostLen > 0 {
padLen += 4
}
if ghostText != "" || padLen > 0 {
s.WriteString("\0337")
if ghostText != "" {
s.WriteString("\033[90m")
@@ -246,8 +383,7 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool) string {
s.WriteString(strings.Repeat(" ", padLen))
}
s.WriteString("\0338")
o.LastGhostLen = len(ghostText)
}
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")
+5
View File
@@ -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":
+93 -106
View File
@@ -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()
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)
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
}
selected := overlay.Items[overlay.Cursor].Cmd
selected := overlay.SetHistoryList(historyList, inputSlice[i+2] == 'A')
if selected != "" {
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,24 +698,24 @@ 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
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()
@@ -750,9 +724,36 @@ func runWrapper() {
}
_, _ = 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)
}
}
}
+5
View File
@@ -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