diff --git a/integration/overlay.go b/integration/overlay.go index 6b81225..8cc55db 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -210,6 +210,30 @@ 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 + // 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) { @@ -268,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 { @@ -305,11 +330,13 @@ 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 o.Cursor = 0 o.StartIdx = 0 + o.hasAnchor = false } func (o *Overlay) InjectAISuggestion(sugg spec.Suggestion) bool { @@ -398,7 +425,9 @@ 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 o.Visible = len(o.Items) > 0 if startAtBottom && len(o.Items) > 0 { @@ -630,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 @@ -664,6 +693,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 @@ -934,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 "" } @@ -957,6 +996,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() } @@ -972,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 @@ -989,6 +1030,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() } diff --git a/root/wrapper.go b/root/wrapper.go index 7670da5..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" @@ -161,6 +162,32 @@ 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. 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 + +// 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 @@ -371,6 +398,8 @@ func runWrapper() { var deferredDrawMu sync.Mutex 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". @@ -378,6 +407,8 @@ func runWrapper() { deferredDrawMu.Lock() defer deferredDrawMu.Unlock() deferredDraw = draw + deferredEchoSeen = false + deferredDrawDeadline = time.Now().Add(maxRepaintWait) if deferredDrawTimer != nil { deferredDrawTimer.Stop() } @@ -393,12 +424,70 @@ 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() { 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 deferredEchoSeen { + return + } + if deferredDrawTimer != nil && time.Now().Add(repaintSettleDelay).Before(deferredDrawDeadline) { deferredDrawTimer.Reset(repaintSettleDelay) } } @@ -458,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() { @@ -476,7 +563,13 @@ func runWrapper() { writeStdout([]byte(b.String())) } 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() } @@ -566,8 +659,14 @@ 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) + noteEcho(chunk) bufferMu.Lock() nbEmpty := naiveBuffer == ""