From 8837a7fc8ee90fd29befdf3dbe2d2b3764f51950 Mon Sep 17 00:00:00 2001 From: verse91 Date: Mon, 24 Aug 2026 11:45:40 +0700 Subject: [PATCH 1/4] fix(overlay): keep the menu in one column while navigating the list --- integration/overlay.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/integration/overlay.go b/integration/overlay.go index 6b81225..8384e75 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -210,6 +210,11 @@ type Overlay struct { // row: it walks to the end-of-text column, which is only where the cursor // really is when nothing has moved it left. CursorAtEnd bool + // anchorCol pins the box's column for as long as the user is walking the + // list. Selecting an entry rewrites the line, so following the cursor makes + // the box jump left and right under the entry being read. + anchorCol int + hasAnchor bool } func (o *Overlay) SetCursorAtEnd(v bool) { @@ -310,6 +315,7 @@ func (o *Overlay) SetQueryAndItems(query string, items []spec.Suggestion) { o.Visible = len(o.Items) > 0 o.Cursor = 0 o.StartIdx = 0 + o.hasAnchor = false } func (o *Overlay) InjectAISuggestion(sugg spec.Suggestion) bool { @@ -399,6 +405,7 @@ func (o *Overlay) SetHistoryList(items []spec.Suggestion, startAtBottom bool) st defer o.mu.Unlock() o.TypedQuery = "" o.UserNavigated = true + o.hasAnchor = false o.Items = items o.Visible = len(o.Items) > 0 if startAtBottom && len(o.Items) > 0 { @@ -664,6 +671,15 @@ func (o *Overlay) draw() string { if targetCol < 0 { targetCol = 0 } + // Hold the column still while the user walks the list. Each step rewrites + // the shell's line to the selected entry, so the cursor -- and with it the + // box -- would otherwise jump to a new column on every keypress. + if o.UserNavigated && o.hasAnchor { + targetCol = o.anchorCol + } else { + o.anchorCol = targetCol + o.hasAnchor = true + } logger.Debugf("Overlay draw: pLen=%d, typedLen=%d, totalCol=%d, cursorCol=%d, targetCol=%d, width=%d", o.PromptLen, typedLen, totalCol, cursorCol, targetCol, width) // The box is placed relative to the cursor, so a change in how many rows the @@ -957,6 +973,7 @@ func (o *Overlay) HideMenu(query string) string { clearLinesBelow(&s, clearRows()) // the box is gone, so nothing is hanging below a wrapped input any more lastDrawnInputRows.Store(0) + o.hasAnchor = false s.WriteString(ansi.SetModeAutoWrap) return s.String() } @@ -989,6 +1006,7 @@ func (o *Overlay) ClearAndDisable() string { clearLinesBelow(&s, clearRows()) // the box is gone, so nothing is hanging below a wrapped input any more lastDrawnInputRows.Store(0) + o.hasAnchor = false s.WriteString(ansi.SetModeAutoWrap) return s.String() } From 22f7b7b8fb18ae92a1b07c01a7d6f05b27c71997 Mon Sep 17 00:00:00 2001 From: verse91 Date: Mon, 24 Aug 2026 11:49:23 +0700 Subject: [PATCH 2/4] perf(overlay): only wait for the shell repaint when the menu will move --- integration/overlay.go | 8 ++++++++ root/wrapper.go | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/integration/overlay.go b/integration/overlay.go index 8384e75..3de1fe6 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -217,6 +217,14 @@ type Overlay struct { hasAnchor bool } +// InputRowsFor reports how many rows past the first the prompt plus text +// occupy, so callers can tell whether replacing the line will move the box. +func (o *Overlay) InputRowsFor(text string) int { + o.mu.Lock() + defer o.mu.Unlock() + return inputRows(o.PromptLen + lipgloss.Width(text)) +} + func (o *Overlay) SetCursorAtEnd(v bool) { o.mu.Lock() defer o.mu.Unlock() diff --git a/root/wrapper.go b/root/wrapper.go index 7670da5..723d62c 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -161,6 +161,9 @@ func menuOnlyHidden(mode config.GhostTextMode, menuEnabled bool) bool { // chunks, short enough that navigation still feels immediate. const repaintSettleDelay = 12 * time.Millisecond +// maxRepaintWait caps how long a deferred draw can be pushed back in total. +const maxRepaintWait = 40 * time.Millisecond + // runWrapper sets up the pty environment, launches the shell, // and manages the main input loop to provide real-time suggestions // it handles raw terminal mode to intercept keystrokes and @@ -371,6 +374,7 @@ func runWrapper() { var deferredDrawMu sync.Mutex var deferredDrawTimer *time.Timer var deferredDraw func() + var deferredDrawDeadline time.Time // drawAfterRepaint runs draw once the pty has been quiet for a moment, // which is as close as iris gets to "the shell has finished repainting". @@ -378,6 +382,7 @@ func runWrapper() { deferredDrawMu.Lock() defer deferredDrawMu.Unlock() deferredDraw = draw + deferredDrawDeadline = time.Now().Add(maxRepaintWait) if deferredDrawTimer != nil { deferredDrawTimer.Stop() } @@ -398,7 +403,9 @@ func runWrapper() { postponeDeferredDraw := func() { deferredDrawMu.Lock() defer deferredDrawMu.Unlock() - if deferredDrawTimer != nil { + // never past the deadline: a command that keeps writing must not hold + // the menu back indefinitely + if deferredDrawTimer != nil && time.Now().Add(repaintSettleDelay).Before(deferredDrawDeadline) { deferredDrawTimer.Reset(repaintSettleDelay) } } @@ -449,6 +456,7 @@ func runWrapper() { isHistMode := activeMode == "history" activeModeMu.RUnlock() var toWrite []byte + prevBuf := naiveBuffer if isHistMode && selectedCmd != "" { naiveBuffer = selectedCmd toWrite = shell.ReplaceLine([]byte(selectedCmd), cursorOffset) @@ -475,7 +483,11 @@ func runWrapper() { b.WriteString(overlay.Render()) writeStdout([]byte(b.String())) } - if len(toWrite) > 0 { + // Only wait for the repaint when it will actually move the box. + // The replacement lands on the same rows unless it wraps onto a + // different number of them, and deferring every step makes walking + // the list feel like it is catching up with the keyboard. + if len(toWrite) > 0 && overlay.InputRowsFor(prevBuf) != overlay.InputRowsFor(bufCopy) { drawAfterRepaint(draw) } else { draw() From 8d90c5e0e0ea8fb909cfca39596c48503df769d1 Mon Sep 17 00:00:00 2001 From: verse91 Date: Mon, 24 Aug 2026 17:48:43 +0700 Subject: [PATCH 3/4] fix(overlay): wait for the shell repaint before drawing over a rewritten line --- integration/overlay.go | 8 -------- root/wrapper.go | 28 +++++++++++++++++++--------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/integration/overlay.go b/integration/overlay.go index 3de1fe6..8384e75 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -217,14 +217,6 @@ type Overlay struct { hasAnchor bool } -// InputRowsFor reports how many rows past the first the prompt plus text -// occupy, so callers can tell whether replacing the line will move the box. -func (o *Overlay) InputRowsFor(text string) int { - o.mu.Lock() - defer o.mu.Unlock() - return inputRows(o.PromptLen + lipgloss.Width(text)) -} - func (o *Overlay) SetCursorAtEnd(v bool) { o.mu.Lock() defer o.mu.Unlock() diff --git a/root/wrapper.go b/root/wrapper.go index 723d62c..2062a5a 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -161,8 +161,12 @@ func menuOnlyHidden(mode config.GhostTextMode, menuEnabled bool) bool { // chunks, short enough that navigation still feels immediate. const repaintSettleDelay = 12 * time.Millisecond -// maxRepaintWait caps how long a deferred draw can be pushed back in total. -const maxRepaintWait = 40 * time.Millisecond +// maxRepaintWait caps how long a deferred draw can be pushed back in total. It +// is generous because it only ever applies when the replacement moves the box: +// a menu that appears late is a blink, but one drawn into a line the shell is +// still painting corrupts it for good. The shell repaints incrementally and +// will not repair cells it does not know were overwritten. +const maxRepaintWait = 150 * time.Millisecond // runWrapper sets up the pty environment, launches the shell, // and manages the main input loop to provide real-time suggestions @@ -456,7 +460,6 @@ func runWrapper() { isHistMode := activeMode == "history" activeModeMu.RUnlock() var toWrite []byte - prevBuf := naiveBuffer if isHistMode && selectedCmd != "" { naiveBuffer = selectedCmd toWrite = shell.ReplaceLine([]byte(selectedCmd), cursorOffset) @@ -483,11 +486,13 @@ func runWrapper() { b.WriteString(overlay.Render()) writeStdout([]byte(b.String())) } - // Only wait for the repaint when it will actually move the box. - // The replacement lands on the same rows unless it wraps onto a - // different number of them, and deferring every step makes walking - // the list feel like it is catching up with the keyboard. - if len(toWrite) > 0 && overlay.InputRowsFor(prevBuf) != overlay.InputRowsFor(bufCopy) { + // Any rewrite has to wait for the shell. Comparing how the old and + // new lines wrap is not enough: while keys are still arriving the + // cursor is wherever an earlier, longer line left it, so "these two + // wrap the same" says nothing about where the box would land. + // Waiting also coalesces a held key into one draw instead of one + // per keypress. + if len(toWrite) > 0 { drawAfterRepaint(draw) } else { draw() @@ -578,8 +583,13 @@ func runWrapper() { } altScreenCarry = keepAltScreenCarry(chunk) - writeStdout(chunk) + // Push the pending draw back before writing, not after: stdout is + // the terminal (tmux, and whatever renders it), so this write can + // block for as long as that side is slow to consume. Postponing + // afterwards lets the settle timer expire mid-repaint, and the box + // then lands in the middle of a line the shell is still painting. postponeDeferredDraw() + writeStdout(chunk) bufferMu.Lock() nbEmpty := naiveBuffer == "" From cce9b89d34e2d8caaf6f582d580338b3e397676f Mon Sep 17 00:00:00 2001 From: verse91 Date: Mon, 24 Aug 2026 18:00:19 +0700 Subject: [PATCH 4/4] perf(overlay): draw the menu once the shell echoes the replaced line --- integration/overlay.go | 26 ++++++++++- root/wrapper.go | 101 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 114 insertions(+), 13 deletions(-) diff --git a/integration/overlay.go b/integration/overlay.go index 8384e75..8cc55db 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -215,6 +215,25 @@ type Overlay struct { // the box jump left and right under the entry being read. anchorCol int hasAnchor bool + // ScreenLine is what iris believes the shell is currently displaying. It + // trails TypedQuery while a rewrite is held back during navigation, and the + // box is placed against this, not against the entry being highlighted. + ScreenLine string +} + +// SetSelection updates the highlighted entry without claiming the shell has +// redrawn its line yet. +func (o *Overlay) SetSelection(q string) { + o.mu.Lock() + defer o.mu.Unlock() + o.TypedQuery = q +} + +// SetScreenLine records that the shell's line now holds q. +func (o *Overlay) SetScreenLine(q string) { + o.mu.Lock() + defer o.mu.Unlock() + o.ScreenLine = q } func (o *Overlay) SetCursorAtEnd(v bool) { @@ -273,6 +292,7 @@ func (o *Overlay) SetTypedQuery(q string) { o.mu.Lock() defer o.mu.Unlock() o.TypedQuery = q + o.ScreenLine = q } func (o *Overlay) GetCurrentCmd() string { @@ -310,6 +330,7 @@ func (o *Overlay) SetQueryAndItems(query string, items []spec.Suggestion) { o.mu.Lock() defer o.mu.Unlock() o.TypedQuery = query + o.ScreenLine = query o.UserNavigated = false o.Items = items o.Visible = len(o.Items) > 0 @@ -404,6 +425,7 @@ func (o *Overlay) SetHistoryList(items []spec.Suggestion, startAtBottom bool) st o.mu.Lock() defer o.mu.Unlock() o.TypedQuery = "" + o.ScreenLine = "" o.UserNavigated = true o.hasAnchor = false o.Items = items @@ -637,7 +659,7 @@ func (o *Overlay) draw() string { var s strings.Builder s.WriteString(ansi.ResetModeAutoWrap) - typedLen := lipgloss.Width(o.TypedQuery) + typedLen := lipgloss.Width(o.ScreenLine) width := termWidth() // ComputeCursorCol returns the total visual width, not the column on the @@ -950,6 +972,7 @@ func (o *Overlay) HideMenu(query string) string { defer o.mu.Unlock() o.TypedQuery = query + o.ScreenLine = query if !o.Visible && len(o.Items) == 0 && o.LastGhostLen == 0 { return "" } @@ -989,6 +1012,7 @@ func (o *Overlay) ClearAndDisable() string { o.Visible = false o.Items = nil o.TypedQuery = "" + o.ScreenLine = "" o.UserNavigated = false o.Cursor = 0 o.StartIdx = 0 diff --git a/root/wrapper.go b/root/wrapper.go index 2062a5a..7ba439c 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -18,6 +18,7 @@ import ( "syscall" "time" + "github.com/charmbracelet/x/ansi" "github.com/creack/pty" "github.com/versenilvis/iris/integration" "github.com/versenilvis/iris/integration/shell" @@ -168,6 +169,25 @@ const repaintSettleDelay = 12 * time.Millisecond // will not repair cells it does not know were overwritten. const maxRepaintWait = 150 * time.Millisecond +// echoMarkerLen is how much of the tail of a replaced line iris looks for in +// the shell's output to know the repaint reached the end. +const echoMarkerLen = 12 + +// echoSettleDelay is the short window kept after the echo is seen, for the tail +// of the repaint. +const echoSettleDelay = 5 * time.Millisecond + +// echoMarker is the tail of text that its echo must contain. Escape sequences +// are stripped from the shell's output before matching, so highlighting that +// splits the line into coloured runs does not hide it. +func echoMarker(text string) []byte { + stripped := ansi.Strip(text) + if r := []rune(stripped); len(r) > echoMarkerLen { + stripped = string(r[len(r)-echoMarkerLen:]) + } + return []byte(stripped) +} + // runWrapper sets up the pty environment, launches the shell, // and manages the main input loop to provide real-time suggestions // it handles raw terminal mode to intercept keystrokes and @@ -379,6 +399,7 @@ func runWrapper() { var deferredDrawTimer *time.Timer var deferredDraw func() var deferredDrawDeadline time.Time + var deferredEchoSeen bool // drawAfterRepaint runs draw once the pty has been quiet for a moment, // which is as close as iris gets to "the shell has finished repainting". @@ -386,6 +407,7 @@ func runWrapper() { deferredDrawMu.Lock() defer deferredDrawMu.Unlock() deferredDraw = draw + deferredEchoSeen = false deferredDrawDeadline = time.Now().Add(maxRepaintWait) if deferredDrawTimer != nil { deferredDrawTimer.Stop() @@ -402,6 +424,59 @@ func runWrapper() { }) } + // hastenDeferredDraw brings the pending draw forward once the shell has + // echoed the line back. A short window still follows, for the tail of the + // repaint, but nothing may extend it again -- that extending is what turned + // the wait into the frame rate. + hastenDeferredDraw := func() { + deferredDrawMu.Lock() + defer deferredDrawMu.Unlock() + deferredEchoSeen = true + if deferredDrawTimer != nil { + deferredDrawTimer.Reset(echoSettleDelay) + } + } + + // drawAfterEcho holds a draw until the shell has echoed back the tail of + // the line iris just told it to display. That is a fact about this specific + // repaint, so navigation stays at full rate however slowly the terminal + // drains -- unlike waiting for the pty to fall quiet, which never happens + // while a key is held. + var echoMu sync.Mutex + var echoWant []byte + var echoSeen []byte + + drawAfterEcho := func(want []byte, draw func()) { + echoMu.Lock() + echoWant = want + echoSeen = echoSeen[:0] + echoMu.Unlock() + // the timer stays armed as a backstop, in case the echo never arrives + // in a form iris recognises + drawAfterRepaint(draw) + } + + // noteEcho feeds shell output to the pending draw's marker. + noteEcho := func(chunk []byte) { + echoMu.Lock() + if echoWant == nil { + echoMu.Unlock() + return + } + echoSeen = append(echoSeen, []byte(ansi.Strip(string(chunk)))...) + if keep := len(echoWant) * 8; len(echoSeen) > keep { + echoSeen = append(echoSeen[:0], echoSeen[len(echoSeen)-keep:]...) + } + if !bytes.Contains(echoSeen, echoWant) { + echoMu.Unlock() + return + } + echoWant = nil + echoSeen = echoSeen[:0] + echoMu.Unlock() + hastenDeferredDraw() + } + // postponeDeferredDraw pushes the pending draw back while the shell is // still writing, so the box lands after the last of the repaint. postponeDeferredDraw := func() { @@ -409,6 +484,9 @@ func runWrapper() { defer deferredDrawMu.Unlock() // never past the deadline: a command that keeps writing must not hold // the menu back indefinitely + if deferredEchoSeen { + return + } if deferredDrawTimer != nil && time.Now().Add(repaintSettleDelay).Before(deferredDrawDeadline) { deferredDrawTimer.Reset(repaintSettleDelay) } @@ -469,13 +547,11 @@ func runWrapper() { offsetCopy := cursorOffset bufferMu.Unlock() - if len(toWrite) > 0 { - _, _ = ptmx.Write(toWrite) - } - // ghost text is derived from TypedQuery, so history navigation that // rewrites the buffer must move it too or the hint lags a selection - overlay.SetTypedQuery(bufCopy) + // the highlight moves now, but the shell's line only catches up + // when the held-back rewrite lands + overlay.SetSelection(bufCopy) overlay.SetCursorAtEnd(offsetCopy == 0) draw := func() { @@ -486,14 +562,14 @@ func runWrapper() { b.WriteString(overlay.Render()) writeStdout([]byte(b.String())) } - // Any rewrite has to wait for the shell. Comparing how the old and - // new lines wrap is not enough: while keys are still arriving the - // cursor is wherever an earlier, longer line left it, so "these two - // wrap the same" says nothing about where the box would land. - // Waiting also coalesces a held key into one draw instead of one - // per keypress. if len(toWrite) > 0 { - drawAfterRepaint(draw) + _, _ = ptmx.Write(toWrite) + overlay.SetScreenLine(bufCopy) + // Wait for the shell to echo this line back rather than for the + // pty to fall quiet. Under continuous navigation it never falls + // quiet, so the quiet-based wait always ran out its cap and + // that cap became the frame rate. + drawAfterEcho(echoMarker(bufCopy), draw) } else { draw() } @@ -590,6 +666,7 @@ func runWrapper() { // then lands in the middle of a line the shell is still painting. postponeDeferredDraw() writeStdout(chunk) + noteEcho(chunk) bufferMu.Lock() nbEmpty := naiveBuffer == ""