diff --git a/README.md b/README.md index d37591e..cb10a96 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,7 @@ style = "modern" # "modern" or "classic" ghost-text = 1 # 0 = off, 1 = menu + ghost text, 2 = ghost text only hidden-files = false # include dotfiles in suggestions max-suggestions = 100 # max suggestions ranked before display -max-height = 15 # max visible rows in the menu +max-height = 6 # max visible rows in the menu max-width = 0 # max menu width, 0 = auto nerd-fonts = true # use nerd-font icons diff --git a/integration/overlay.go b/integration/overlay.go index 7ba1ec9..6b81225 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -23,6 +23,9 @@ const ( defaultMaxItems = 6 // borderLines is the top and bottom border the box draws around its items. borderLines = 2 + // maxMenuItems is the largest ui.max-height accepted, mirroring the bound + // config validation enforces. + maxMenuItems = 50 ) // lastDrawnLines records the total height of the most recently drawn box, so a @@ -31,26 +34,42 @@ const ( // leave the surplus rows on screen. var lastDrawnLines atomic.Int32 +// lastDrawnInputRows records how many rows the prompt plus the typed text +// wrapped onto when the box was last drawn. The box hangs off the cursor, so +// when that count changes the whole box moves with it and the terminal keeps +// whatever the new one no longer covers. +var lastDrawnInputRows atomic.Int32 + +// inputRows is how many rows past the first the prompt and typed text occupy. +func inputRows(totalCol int) int { + w := termWidth() + if w <= 0 { + return 0 + } + return totalCol / w +} + // menuItemRows is how many suggestion rows the overlay may show. // -// ui.max-height is the height of the whole box, matching its name and the -// comment `iris config init` writes, so the borders come out of that budget. -// It is also clamped to the terminal: a box taller than the window can't be -// scrolled back into view and would push the prompt off screen. +// ui.max-height counts suggestions, not the lines the box occupies: it is the +// number people compare against what they can see, and counting the border +// into it meant max-height = 6 drew 4 rows. +// It is still clamped to the terminal, since a box taller than the window +// can't be scrolled back into view and would push the prompt off screen. func menuItemRows() int { rows := config.Get().UI.MaxHeight - if rows < 3 || rows > 50 { - rows = defaultMaxItems + borderLines + if rows < 1 || rows > maxMenuItems { + rows = defaultMaxItems } if h := termHeight(); h > 0 { - // Leave a row for the prompt itself plus one of breathing room. - if avail := h - 2; rows > avail { + // Leave room for the border, the prompt itself, and one row of + // breathing room. + if avail := h - borderLines - 2; rows > avail { rows = avail } } - rows -= borderLines if rows < 1 { rows = 1 } @@ -58,13 +77,15 @@ func menuItemRows() int { } // clearRows is the number of lines a clear must erase: whatever is on screen -// now, which may be taller than what the next draw will produce. +// now, which may be taller than what the next draw will produce, plus the rows +// a box drawn against a wrapped input reached further down than the cursor +// sits today. func clearRows() int { n := int(lastDrawnLines.Load()) if want := menuItemRows() + borderLines; want > n { n = want } - return n + return n + int(lastDrawnInputRows.Load()) } func ComputeCursorCol(data []byte) int { @@ -185,6 +206,16 @@ type Overlay struct { TypedQuery string UserNavigated bool PromptLen int + // CursorAtEnd gates the erase that follows the input onto a new wrapped + // 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 +} + +func (o *Overlay) SetCursorAtEnd(v bool) { + o.mu.Lock() + defer o.mu.Unlock() + o.CursorAtEnd = v } func (o *Overlay) SetPromptLen(l int) { @@ -197,7 +228,7 @@ func (o *Overlay) SetPromptLen(l int) { } func NewOverlay() *Overlay { - return &Overlay{Visible: false, Cursor: 0, StartIdx: 0} + return &Overlay{Visible: false, Cursor: 0, StartIdx: 0, CursorAtEnd: true} } func (o *Overlay) UpdateItems(items []spec.Suggestion) { @@ -635,6 +666,26 @@ func (o *Overlay) draw() string { } 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 + // input wraps onto moves it. Whatever the previous box covered and this one + // will not has to be erased by hand; the shell only repaints the input. + rowsNow := inputRows(totalCol) + wrapShift := int(lastDrawnInputRows.Load()) - rowsNow + lastDrawnInputRows.Store(int32(rowsNow)) + + if wrapShift < 0 && o.CursorAtEnd { + // the input grew onto the row the old top border was on, and the shell + // redraw stops where the text stops. Start past the ghost text so the + // hint written just before this survives. + s.WriteString(ansi.SaveCursor) + s.WriteString("\r") + if from := cursorCol + o.LastGhostLen; from > 0 { + s.WriteString(ansi.CursorForward(from)) + } + s.WriteString(ansi.EraseLineRight) + s.WriteString(ansi.RestoreCursor) + } + s.WriteString(ansi.SaveCursor) windowSize := min(len(o.Items), menuItemRows()) @@ -843,6 +894,13 @@ func (o *Overlay) draw() string { s.WriteString(titledEdge("╰", "╯", inner, footerInfo, border, inner-lipgloss.Width(footerInfo)-2)) + // the previous box sat lower down; erase the rows this one leaves behind + for i := range wrapShift { + s.WriteString(ansi.RestoreCursor) + s.WriteString(ansi.CursorDown(totalLines + i + 1)) + s.WriteString("\r" + ansi.EraseEntireLine) + } + s.WriteString(ansi.RestoreCursor) s.WriteString(ansi.SetModeAutoWrap) return s.String() @@ -897,6 +955,8 @@ 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) s.WriteString(ansi.SetModeAutoWrap) return s.String() } @@ -927,6 +987,8 @@ 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) s.WriteString(ansi.SetModeAutoWrap) return s.String() } diff --git a/integration/overlay_test.go b/integration/overlay_test.go index 9ee2f1e..391363b 100644 --- a/integration/overlay_test.go +++ b/integration/overlay_test.go @@ -161,15 +161,16 @@ func TestRenderMatchedTitle_CaseInsensitive(t *testing.T) { } func TestMenuItemRowsHonorsMaxHeight(t *testing.T) { - // ui.max-height was parsed and validated but never read: the box was fixed - // at 6 rows however it was configured, unlike ui.max-width. + // ui.max-height counts suggestion rows. It was read as the height of the + // whole box for a while, so a configured 6 drew 4 rows. tests := []struct { name string maxHeight int want int }{ - {"configured", 15, 15 - borderLines}, - {"minimum", 3, 1}, + {"configured", 15, 15}, + {"borders do not eat rows", 6, 6}, + {"minimum", 1, 1}, {"zero falls back", 0, defaultMaxItems}, {"out of range falls back", 999, defaultMaxItems}, } @@ -209,7 +210,7 @@ func TestScrolloffIsSymmetric(t *testing.T) { // The window kept a row of context above the highlight but none below, so // paging down pinned it to the last visible row while paging up did not. cfg := config.DefaultConfig() - cfg.UI.MaxHeight = 8 // 6 item rows + cfg.UI.MaxHeight = 6 config.Init(cfg) items := make([]spec.Suggestion, 20) @@ -239,3 +240,98 @@ func TestScrolloffIsSymmetric(t *testing.T) { } } } + +func TestInputRowsCountsWrappedRows(t *testing.T) { + // termWidth() falls back to 120 when stdout is not a TTY + tests := []struct { + totalCol int + want int + }{ + {0, 0}, + {119, 0}, + {120, 1}, + {300, 2}, + } + + for _, tt := range tests { + if got := inputRows(tt.totalCol); got != tt.want { + t.Errorf("inputRows(%d) = %d; want %d", tt.totalCol, got, tt.want) + } + } +} + +func wrappedOverlay(t *testing.T, promptLen int) (*Overlay, string) { + t.Helper() + cfg := config.DefaultConfig() + config.Init(cfg) + + items := make([]spec.Suggestion, 10) + for i := range items { + items[i] = spec.Suggestion{Cmd: string(rune('a' + i))} + } + + o := NewOverlay() + o.SetQueryAndItems("q", items) + o.SetPromptLen(promptLen) + return o, o.Render() +} + +func TestDrawErasesRowsLeftBehindWhenInputStopsWrapping(t *testing.T) { + // The box hangs off the cursor. A command long enough to wrap pushes the + // cursor down, so the box sits lower; when the line shrinks back the cursor + // moves up and the rows the old box occupied were left on screen. + o, _ := wrappedOverlay(t, 250) // two wrapped rows + + o.SetPromptLen(10) // back to a single row + out := o.Render() + + totalLines := min(len(o.Items), menuItemRows()) + borderLines + for _, extra := range []int{1, 2} { + want := ansi.CursorDown(totalLines + extra) + if !strings.Contains(out, want) { + t.Errorf("redraw does not erase row %d below the box (missing %q)", extra, want) + } + } +} + +func TestDrawErasesTheStaleTopBorderWhenInputGrows(t *testing.T) { + // The other direction: the input wraps onto the row the previous top border + // was on. The shell repaints only as far as the text reaches, so the tail of + // the old border survives to its right. + o, _ := wrappedOverlay(t, 10) + + o.SetPromptLen(250) + out := o.Render() + + if !strings.Contains(out, ansi.EraseLineRight) { + t.Errorf("redraw does not erase the stale border to the right of the wrapped input: %q", out) + } +} + +func TestClearCoversABoxDrawnUnderWrappedInput(t *testing.T) { + o, _ := wrappedOverlay(t, 250) + + rows := clearRows() + if want := min(len(o.Items), menuItemRows()) + borderLines + 2; rows != want { + t.Errorf("clearRows() = %d; want %d (the box plus the two rows it hangs below the cursor)", rows, want) + } + + o.ClearAndDisable() + if got := lastDrawnInputRows.Load(); got != 0 { + t.Errorf("lastDrawnInputRows after teardown = %d; want 0", got) + } +} + +func TestDrawLeavesTheLineAloneWhenTheCursorIsNotAtTheEnd(t *testing.T) { + // The erase walks to the end-of-text column, so with the cursor moved back + // into the middle of the command it would land mid-text and wipe it. + o, _ := wrappedOverlay(t, 10) + + o.SetCursorAtEnd(false) + o.SetPromptLen(250) + out := o.Render() + + if strings.Contains(out, ansi.EraseLineRight) { + t.Error("redraw erased to end of line while the cursor was mid-command") + } +} diff --git a/integration/shell/adapter.go b/integration/shell/adapter.go index e09f651..6821eb2 100644 --- a/integration/shell/adapter.go +++ b/integration/shell/adapter.go @@ -14,21 +14,25 @@ import ( ) // ReplaceLine builds the byte sequence that clears the shell's current input -// line and types text in its place. +// line and types text in its place. cursorFromEnd is how many characters the +// cursor sits to the left of the end of the line. // -// The clear is ctrl+e then ctrl+u, not ctrl+u alone. ctrl+u is only -// kill-whole-line under zsh's stock emacs keymap; `bindkey '^U' -// backward-kill-line` is a common preference, and it is also what bash does by -// default (unix-line-discard). Those kill from the cursor backwards, so with -// the cursor mid-line everything to its right survived and collided with the -// text typed next -- selecting a suggestion left the tail of the old buffer -// interleaved through the new one. +// ctrl+u only clears the whole line from the end: it is kill-whole-line in +// zsh's stock emacs keymap, but kills backwards under bash (unix-line-discard), +// fish, zsh's vi keymap, and `bindkey '^U' backward-kill-line`. Left mid-line, +// everything to the right of the cursor survived and collided with the text +// typed next. // -// Moving to end-of-line first makes all three bindings equivalent: there is -// nothing to the right left to preserve. -func ReplaceLine(text []byte) []byte { - out := make([]byte, 0, len(text)+2) - out = append(out, 0x05, 0x15) // ctrl+e (end-of-line), ctrl+u (kill) +// The cursor therefore has to reach the end first, and it walks there with the +// right arrow rather than ctrl+e. ctrl+e is a key people rebind -- binding it +// to atuin-search is common, and iris sending it opened the atuin overlay every +// time it rewrote the line. +func ReplaceLine(text []byte, cursorFromEnd int) []byte { + out := make([]byte, 0, len(text)+3*max(cursorFromEnd, 0)+1) + for range cursorFromEnd { + out = append(out, 0x1b, '[', 'C') // right arrow (forward-char) + } + out = append(out, 0x15) // ctrl+u (kill) return append(out, text...) } @@ -37,7 +41,7 @@ type Adapter interface { GetName() string GetShellPath() string GetEnv(fd int, pid int) []string - PrepareSelectSequence(selected string) []byte + PrepareSelectSequence(selected string, cursorFromEnd int) []byte // ScanAliases returns a map of alias name to target command ScanAliases() map[string]string } @@ -64,8 +68,8 @@ func (b *BashAdapter) GetShellPath() string { return "bash" } func (b *BashAdapter) GetEnv(fd int, pid int) []string { return append(os.Environ(), "IRIS_FD="+fmt.Sprint(fd), "IRIS_PID="+fmt.Sprint(pid)) } -func (b *BashAdapter) PrepareSelectSequence(selected string) []byte { - return ReplaceLine([]byte(selected)) +func (b *BashAdapter) PrepareSelectSequence(selected string, cursorFromEnd int) []byte { + return ReplaceLine([]byte(selected), cursorFromEnd) } func (b *BashAdapter) ScanAliases() map[string]string { return ScanPosixAliases([]string{".bashrc", ".bash_profile", ".bash_aliases"}) @@ -79,8 +83,8 @@ func (z *ZshAdapter) GetShellPath() string { return "zsh" } func (z *ZshAdapter) GetEnv(fd int, pid int) []string { return append(os.Environ(), "IRIS_FD="+fmt.Sprint(fd), "IRIS_PID="+fmt.Sprint(pid)) } -func (z *ZshAdapter) PrepareSelectSequence(selected string) []byte { - return ReplaceLine([]byte(selected)) +func (z *ZshAdapter) PrepareSelectSequence(selected string, cursorFromEnd int) []byte { + return ReplaceLine([]byte(selected), cursorFromEnd) } func (z *ZshAdapter) ScanAliases() map[string]string { envSet := os.Getenv("ZDOTDIR") != "" @@ -139,8 +143,8 @@ func (f *FishAdapter) GetShellPath() string { return "fish" } func (f *FishAdapter) GetEnv(fd int, pid int) []string { return append(os.Environ(), "IRIS_FD="+fmt.Sprint(fd), "IRIS_PID="+fmt.Sprint(pid)) } -func (f *FishAdapter) PrepareSelectSequence(selected string) []byte { - return ReplaceLine([]byte(selected)) +func (f *FishAdapter) PrepareSelectSequence(selected string, cursorFromEnd int) []byte { + return ReplaceLine([]byte(selected), cursorFromEnd) } func (f *FishAdapter) ScanAliases() map[string]string { // fish uses 'alias' command in config.fish or separate function files diff --git a/integration/shell/adapter_test.go b/integration/shell/adapter_test.go index dbf4b09..d6e5578 100644 --- a/integration/shell/adapter_test.go +++ b/integration/shell/adapter_test.go @@ -1,6 +1,7 @@ package shell import ( + "bytes" "os" "path/filepath" "reflect" @@ -202,25 +203,41 @@ alias spaced='git status -sb' } } -func TestReplaceLineMovesToEndBeforeKilling(t *testing.T) { - // ctrl+u alone is only kill-whole-line under zsh's stock emacs keymap. - // Under `bindkey '^U' backward-kill-line` (and bash's default - // unix-line-discard) it kills backwards from the cursor, so anything to the - // right of the cursor survived and collided with the text typed next. - // Prefixing ctrl+e makes the three equivalent. - got := ReplaceLine([]byte("cd config/")) +func TestReplaceLineNeverSendsCtrlE(t *testing.T) { + // iris used to move to end-of-line with ctrl+e. Binding ctrl+e to + // atuin-search is common, so every line rewrite -- history navigation, + // selecting a suggestion -- popped the atuin overlay. + for _, cursorFromEnd := range []int{0, 1, 4} { + got := ReplaceLine([]byte("cd config/"), cursorFromEnd) + if bytes.IndexByte(got, 0x05) >= 0 { + t.Errorf("ReplaceLine(_, %d) = %q; must not contain ctrl+e", cursorFromEnd, got) + } + } +} - if len(got) < 2 || got[0] != 0x05 || got[1] != 0x15 { - t.Fatalf("ReplaceLine() = %q; want it to start with ctrl+e, ctrl+u", got) +func TestReplaceLineWalksToTheEndBeforeKilling(t *testing.T) { + // ctrl+u kills backwards under bash, fish and zsh's vi keymap, so the + // cursor has to be at the end or the tail of the old line survives. + tests := []struct { + cursorFromEnd int + want string + }{ + {0, "\x15cd config/"}, + {1, "\x1b[C\x15cd config/"}, + {3, "\x1b[C\x1b[C\x1b[C\x15cd config/"}, } - if string(got[2:]) != "cd config/" { - t.Errorf("ReplaceLine() payload = %q; want %q", got[2:], "cd config/") + + for _, tt := range tests { + if got := ReplaceLine([]byte("cd config/"), tt.cursorFromEnd); string(got) != tt.want { + t.Errorf("ReplaceLine(_, %d) = %q; want %q", tt.cursorFromEnd, got, tt.want) + } } } + func TestPrepareSelectSequenceUsesReplaceLine(t *testing.T) { for _, a := range []Adapter{&ZshAdapter{}, &BashAdapter{}, &FishAdapter{}} { - got := a.PrepareSelectSequence("git status") - want := ReplaceLine([]byte("git status")) + got := a.PrepareSelectSequence("git status", 2) + want := ReplaceLine([]byte("git status"), 2) if string(got) != string(want) { t.Errorf("%s PrepareSelectSequence() = %q; want %q", a.GetName(), got, want) } diff --git a/internal/config/config.go b/internal/config/config.go index 83a1f7b..3f27dac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -352,8 +352,8 @@ func validate(cfg *Config) error { return fmt.Errorf("ui.max-suggestions: must be between 1 and 500") } - if cfg.UI.MaxHeight < 3 || cfg.UI.MaxHeight > 50 { - return fmt.Errorf("ui.max-height: must be between 3 and 50") + if cfg.UI.MaxHeight < 1 || cfg.UI.MaxHeight > 50 { + return fmt.Errorf("ui.max-height: must be between 1 and 50") } return nil diff --git a/internal/config/defaults.go b/internal/config/defaults.go index c85af52..b84902f 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -22,7 +22,7 @@ func DefaultConfig() *Config { GhostText: GhostTextOn, ShowHiddenFiles: false, MaxSuggestions: 100, - MaxHeight: 15, + MaxHeight: 6, MaxWidth: 0, // 0 means no limit, fallback to terminal width NerdFonts: true, }, diff --git a/root/altscreen.go b/root/altscreen.go new file mode 100644 index 0000000..4b4499e --- /dev/null +++ b/root/altscreen.go @@ -0,0 +1,64 @@ +package root + +import "bytes" + +var altScreenTransitions = []struct { + seq []byte + enter bool +}{ + {[]byte("\x1b[?1049h"), true}, + {[]byte("\x1b[?1049l"), false}, + {[]byte("\x1b[?1047h"), true}, + {[]byte("\x1b[?1047l"), false}, + {[]byte("\x1b[?47h"), true}, + {[]byte("\x1b[?47l"), false}, +} + +// altScreenCarryLen is one byte short of the longest sequence above, so a +// switch split across two PTY reads is still seen whole. +const altScreenCarryLen = 7 + +// lastAltScreenTransition reports the final alternate screen switch in chunk. +// +// The last one wins rather than the first: fish probes the terminal at startup +// by entering and leaving the alternate screen inside a single write, and +// treating that as "a full screen app took over" suppressed the overlay for the +// rest of the session. +func lastAltScreenTransition(chunk []byte) (enter bool, found bool) { + last := -1 + for _, t := range altScreenTransitions { + if i := bytes.LastIndex(chunk, t.seq); i > last { + last, enter, found = i, t.enter, true + } + } + return enter, found +} + +// scanAltScreen reports the final alternate screen switch in chunk, also +// looking at the boundary with the previous read so a sequence split across two +// reads is not missed. +func scanAltScreen(carry, chunk []byte) (enter bool, found bool) { + if len(carry) > 0 { + head := chunk + if len(head) > altScreenCarryLen { + head = head[:altScreenCarryLen] + } + joined := make([]byte, 0, len(carry)+len(head)) + joined = append(append(joined, carry...), head...) + if e, ok := lastAltScreenTransition(joined); ok { + enter, found = e, true + } + } + // anything wholly inside chunk comes after the boundary, so it wins + if e, ok := lastAltScreenTransition(chunk); ok { + enter, found = e, true + } + return enter, found +} + +func keepAltScreenCarry(chunk []byte) []byte { + if len(chunk) > altScreenCarryLen { + chunk = chunk[len(chunk)-altScreenCarryLen:] + } + return append([]byte(nil), chunk...) +} diff --git a/root/altscreen_test.go b/root/altscreen_test.go new file mode 100644 index 0000000..1168e17 --- /dev/null +++ b/root/altscreen_test.go @@ -0,0 +1,72 @@ +package root + +import "testing" + +func TestLastAltScreenTransition(t *testing.T) { + // the exact startup probe fish 4.8 writes, captured from a bare PTY: it + // enters and leaves the alternate screen inside one write + fishProbe := []byte("\x1b[?u\x1b[>0q\x1b]11;?\x1b\\\x1b[?1049h\x1bP+q696e646e\x1b\\\x1bP+q71756572792d6f732d6e616d65\x1b\\\x1b[?1049l\x1b[0c") + + tests := []struct { + name string + chunk []byte + wantEnter bool + wantFound bool + }{ + {"fish startup probe leaves the main screen", fishProbe, false, true}, + {"tui takes over", []byte("\x1b[?1049h\x1b[2J"), true, true}, + {"tui gives the screen back", []byte("\x1b[?1049l\x1b[K"), false, true}, + {"legacy 47h", []byte("\x1b[?47h"), true, true}, + {"legacy 1047l", []byte("\x1b[?1047l"), false, true}, + {"plain output", []byte("hello\x1b[0m"), false, false}, + {"enter after a probe stays active", append(append([]byte{}, fishProbe...), []byte("\x1b[?1049h")...), true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + enter, found := lastAltScreenTransition(tt.chunk) + if found != tt.wantFound || enter != tt.wantEnter { + t.Fatalf("got (enter=%v, found=%v), want (enter=%v, found=%v)", enter, found, tt.wantEnter, tt.wantFound) + } + }) + } +} + +func TestLastAltScreenTransitionAcrossChunks(t *testing.T) { + first := []byte("some output\x1b[?10") + second := []byte("49h\x1b[2J") + + if _, found := lastAltScreenTransition(first); found { + t.Fatal("half a sequence should not count as a transition") + } + + carry := keepAltScreenCarry(first) + enter, found := scanAltScreen(carry, second) + if !found || !enter { + t.Fatalf("split sequence not detected: enter=%v found=%v", enter, found) + } +} + +func TestScanAltScreenPrefersTheLaterSwitch(t *testing.T) { + // a TUI restoring the main screen right where the previous read ended, then + // a second one taking over inside the same read + carry := keepAltScreenCarry([]byte("frame\x1b[?104")) + enter, found := scanAltScreen(carry, []byte("9l\x1b[K\x1b[?1049h")) + if !found || !enter { + t.Fatalf("later switch ignored: enter=%v found=%v", enter, found) + } + + enter, found = scanAltScreen(carry, []byte("9l\x1b[K")) + if !found || enter { + t.Fatalf("boundary exit not seen: enter=%v found=%v", enter, found) + } +} + +func TestKeepAltScreenCarryDoesNotAliasTheReadBuffer(t *testing.T) { + buf := []byte("abcdefghij") + carry := keepAltScreenCarry(buf) + copy(buf, "0000000000") + if string(carry) != "defghij" { + t.Fatalf("carry changed with the read buffer: %q", carry) + } +} diff --git a/root/config_cmd.go b/root/config_cmd.go index ef653dd..898da9c 100644 --- a/root/config_cmd.go +++ b/root/config_cmd.go @@ -89,8 +89,8 @@ ghost-text = 1 # maximum suggestions to display max-suggestions = 100 -# maximum height of the overlay -max-height = 15 +# maximum suggestion rows shown in the menu +max-height = 6 # maximum width of the overlay (0 = responsive to terminal) max-width = 0 diff --git a/root/init.go b/root/init.go index 04305ea..2e19e33 100644 --- a/root/init.go +++ b/root/init.go @@ -274,8 +274,8 @@ ghost-text = 1 # maximum suggestions to display max-suggestions = 100 -# maximum height of the overlay -max-height = 15 +# maximum suggestion rows shown in the menu +max-height = 6 # maximum width of the overlay (0 = responsive to terminal) max-width = 0 diff --git a/root/wrapper.go b/root/wrapper.go index ed550d0..7670da5 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -156,6 +156,11 @@ func menuOnlyHidden(mode config.GhostTextMode, menuEnabled bool) bool { return !menuEnabled && mode == config.GhostTextIndividual } +// repaintSettleDelay is how long the pty has to stay quiet before a deferred +// overlay draw runs. Long enough to cover a shell repaint arriving in several +// chunks, short enough that navigation still feels immediate. +const repaintSettleDelay = 12 * 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 @@ -358,14 +363,49 @@ func runWrapper() { renderer() } }) + // A line rewrite reaches the terminal through the shell: iris writes the + // replacement to the pty, the shell repaints, and only then does the cursor + // sit on the row the box has to hang off. Drawing straight away anchors the + // box at the old row, and a line that grew onto more wrapped rows repaints + // over the top of it. + var deferredDrawMu sync.Mutex + var deferredDrawTimer *time.Timer + var deferredDraw func() + + // 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". + drawAfterRepaint := func(draw func()) { + deferredDrawMu.Lock() + defer deferredDrawMu.Unlock() + deferredDraw = draw + if deferredDrawTimer != nil { + deferredDrawTimer.Stop() + } + deferredDrawTimer = time.AfterFunc(repaintSettleDelay, func() { + deferredDrawMu.Lock() + run := deferredDraw + deferredDraw = nil + deferredDrawTimer = nil + deferredDrawMu.Unlock() + if run != nil { + run() + } + }) + } + + // 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 { + deferredDrawTimer.Reset(repaintSettleDelay) + } + } + isExecuting := func() bool { if isAltScreenActive.Load() { - pgrp, pgrpErr := unix.IoctlGetInt(int(ptmx.Fd()), unix.TIOCGPGRP) - if pgrpErr == nil && pgrp == shellPGID { - isAltScreenActive.Store(false) - } else { - return true - } + return true } if isCommandActive.Load() { // for bash: no preexec/precmd hooks, so fall back to TIOCGPGRP to detect when shell returns @@ -411,8 +451,8 @@ func runWrapper() { var toWrite []byte if isHistMode && selectedCmd != "" { naiveBuffer = selectedCmd + toWrite = shell.ReplaceLine([]byte(selectedCmd), cursorOffset) cursorOffset = 0 - toWrite = shell.ReplaceLine([]byte(selectedCmd)) } bufCopy := naiveBuffer offsetCopy := cursorOffset @@ -425,13 +465,21 @@ func runWrapper() { // 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) + overlay.SetCursorAtEnd(offsetCopy == 0) - var b strings.Builder - if !disableGhostText.Load() { - b.WriteString(overlay.RenderGhostText(bufCopy, true, offsetCopy == 0)) + draw := func() { + var b strings.Builder + if !disableGhostText.Load() { + b.WriteString(overlay.RenderGhostText(bufCopy, true, offsetCopy == 0)) + } + b.WriteString(overlay.Render()) + writeStdout([]byte(b.String())) + } + if len(toWrite) > 0 { + drawAfterRepaint(draw) + } else { + draw() } - b.WriteString(overlay.Render()) - writeStdout([]byte(b.String())) } else if suggestionsEnabled { // hidden-overlay history navigation only when suggestions are enabled; // otherwise let the navigation keys pass through to the shell @@ -471,12 +519,13 @@ func runWrapper() { if selected != "" { bufferMu.Lock() naiveBuffer = selected + replace := shell.ReplaceLine([]byte(selected), cursorOffset) cursorOffset = 0 bufferMu.Unlock() userNavigated.Store(true) writeStdout([]byte(overlay.Render())) - _, _ = ptmx.Write(shell.ReplaceLine([]byte(selected))) + _, _ = ptmx.Write(replace) } } } @@ -494,6 +543,7 @@ func runWrapper() { } }() var lastPromptBuf []byte + var altScreenCarry []byte buf := make([]byte, 4096) for { n, err := ptmx.Read(buf) @@ -508,14 +558,16 @@ func runWrapper() { // detect alternate screen buffer (smcup/rmcup) used by TUI apps (nvim, atuin, fzf) chunk := buf[:n] - if bytes.Contains(chunk, []byte("\x1b[?1049h")) || bytes.Contains(chunk, []byte("\x1b[?1047h")) || bytes.Contains(chunk, []byte("\x1b[?47h")) { - isAltScreenActive.Store(true) - writeStdout([]byte(overlay.ClearAndDisable())) - } else if bytes.Contains(chunk, []byte("\x1b[?1049l")) || bytes.Contains(chunk, []byte("\x1b[?1047l")) || bytes.Contains(chunk, []byte("\x1b[?47l")) { - isAltScreenActive.Store(false) + if enter, ok := scanAltScreen(altScreenCarry, chunk); ok { + isAltScreenActive.Store(enter) + if enter { + writeStdout([]byte(overlay.ClearAndDisable())) + } } + altScreenCarry = keepAltScreenCarry(chunk) writeStdout(chunk) + postponeDeferredDraw() bufferMu.Lock() nbEmpty := naiveBuffer == "" @@ -582,6 +634,9 @@ func runWrapper() { } } isCommandActive.Store(false) + // the shell reached a new prompt, so nothing owns the alternate + // screen any more even if a killed TUI never restored it + isAltScreenActive.Store(false) SetCurrentAISuggestion(nil) bufferMu.Lock() cmdToRecord := lastSubmittedCommand @@ -633,6 +688,9 @@ func runWrapper() { } isCommandActive.Store(false) + // a query means the shell's line editor is live, so any full screen + // app launched from a widget (atuin, fzf) has handed the screen back + isAltScreenActive.Store(false) if overlay.GetUserNavigated() { continue @@ -793,6 +851,7 @@ func runWrapper() { } overlay.SetUserNavigated(navCopy) + overlay.SetCursorAtEnd(offsetCopy == 0) if !disableGhostText.Load() { b.WriteString(overlay.RenderGhostText(bufCopy, navCopy, offsetCopy == 0)) } @@ -902,9 +961,10 @@ func runWrapper() { if userNavigated.Load() { bufferMu.Lock() naiveBuffer = overlay.GetTypedQuery() + replace := shell.ReplaceLine([]byte(overlay.GetTypedQuery()), cursorOffset) cursorOffset = 0 bufferMu.Unlock() - _, _ = ptmx.Write(shell.ReplaceLine([]byte(overlay.GetTypedQuery()))) + _, _ = ptmx.Write(replace) } userNavigated.Store(false) overlay.Show() @@ -950,9 +1010,10 @@ func runWrapper() { } bufferMu.Lock() naiveBuffer = selected + replace := shell.ReplaceLine([]byte(selected), cursorOffset) cursorOffset = 0 bufferMu.Unlock() - _, _ = ptmx.Write(shell.ReplaceLine([]byte(selected))) + _, _ = ptmx.Write(replace) overlay.ClearGhostTextState() userNavigated.Store(false) @@ -1001,8 +1062,11 @@ func runWrapper() { selectedCmd = s + " " } } + bufferMu.Lock() + offset := cursorOffset + bufferMu.Unlock() // update the line first - _, _ = ptmx.Write(shell.ReplaceLine([]byte(selectedCmd))) + _, _ = ptmx.Write(shell.ReplaceLine([]byte(selectedCmd), offset)) cmdToSubmit = selectedCmd } else { bufferMu.Lock() @@ -1016,11 +1080,12 @@ func runWrapper() { disableGhostText.Store(newCfg.UI.GhostText == config.GhostTextOff) } msg := "echo -e '\\033[32m✓ Iris configuration reloaded successfully.\\033[0m'\r" - _, _ = ptmx.Write(shell.ReplaceLine([]byte(msg))) bufferMu.Lock() + replace := shell.ReplaceLine([]byte(msg), cursorOffset) naiveBuffer = "" cursorOffset = 0 bufferMu.Unlock() + _, _ = ptmx.Write(replace) activeModeMu.Lock() activeMode = loadMode() activeModeMu.Unlock() @@ -1308,12 +1373,13 @@ func runWrapper() { bufferMu.Unlock() if isSpaceAlias && ok { - // clear the current alias and replace it with the full command - _, _ = ptmx.Write(shell.ReplaceLine([]byte(target + " "))) bufferMu.Lock() + replace := shell.ReplaceLine([]byte(target+" "), cursorOffset) naiveBuffer = target + " " cursorOffset = 0 bufferMu.Unlock() + // clear the current alias and replace it with the full command + _, _ = ptmx.Write(replace) shouldOverlayDraw = true continue }