detached 4c16dd8

feat(overlay): predictive echo in palette, prompt and copy search (#322)

* feat(overlay): let modal input lines own the terminal cursor for predictive echo

Palette, prompt and copy-search inputs no longer draw an inverse caret cell.
Each model reports its caret column, and the topmost modal with a caret places
the real terminal cursor there, so remote predictive echo types ahead in them.

* refactor(overlay): keep pane cursor shape on modal carets and guard backspace at borders

Modal carets reuse the focused pane's cursor style instead of DECSCUSR 0, so
opening the palette never flips the cursor shape. The redundant
hiddenByOverlay cursor state is removed: overlayInputCursor alone decides the
cursor while an overlay is open. ui.InputCaret owns the zero-row guard.
Predictive backspace no longer erases a border left of the input.

* fix(overlay): keep the app-set cursor shape on modal carets when the pane cursor is hidden
commit
4c16dd8299ca2e7c99d7f7eafad1427a88af0ecd
author
bnema · · 2026-10-07 20:41 UTC
committer
GitHub · · 2026-10-07 20:41 UTC
parent
76d7fa2
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.7.0
16 files · +297 -59
M docs/architecture.md +1 -1
@@ -93,7 +93,7 @@ Attention remains a daemon fact. The broker publishes it from fresh observations
9393
9494 ### Terminal focus
9595
96-On remote attachments the client predicts local echo, after mosh. The attached loop mirrors every applied `Output` in a local `vev-vt` screen, guesses printable keys and backspace on the cursor row, and draws the guesses through the foreground's output lease. `Output.Echo` acknowledges input once its echo reached the screen, so the client checks each guess against the mirror and erases a wrong one. A smoothed round trip from those acknowledgements decides when guesses show and when they are underlined. Input the client cannot model makes later guesses tentative until a correct guess confirms them. Overlays, resizes, and a hidden cursor drop every guess. `echo.predict` selects `adaptive`, `always`, or `never`.
96+On remote attachments the client predicts local echo, after mosh. The attached loop mirrors every applied `Output` in a local `vev-vt` screen, guesses printable keys and backspace on the cursor row, and draws the guesses through the foreground's output lease. `Output.Echo` acknowledges input once its echo reached the screen, so the client checks each guess against the mirror and erases a wrong one. A smoothed round trip from those acknowledgements decides when guesses show and when they are underlined. Input the client cannot model makes later guesses tentative until a correct guess confirms them. Client-owned overlays, resizes, and a hidden cursor drop every guess. Daemon modals with an input line (command palette, prompt, copy search) place the real terminal cursor on their caret instead of drawing one, so typing in them is predicted like a shell line. `echo.predict` selects `adaptive`, `always`, or `never`.
9797
9898 The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window.
9999
@@ -93,7 +93,7 @@ Attention remains a daemon fact. The broker publishes it from fresh observations
93 93
94 ### Terminal focus94 ### Terminal focus
95 95
96-On remote attachments the client predicts local echo, after mosh. The attached loop mirrors every applied `Output` in a local `vev-vt` screen, guesses printable keys and backspace on the cursor row, and draws the guesses through the foreground's output lease. `Output.Echo` acknowledges input once its echo reached the screen, so the client checks each guess against the mirror and erases a wrong one. A smoothed round trip from those acknowledgements decides when guesses show and when they are underlined. Input the client cannot model makes later guesses tentative until a correct guess confirms them. Overlays, resizes, and a hidden cursor drop every guess. `echo.predict` selects `adaptive`, `always`, or `never`.96+On remote attachments the client predicts local echo, after mosh. The attached loop mirrors every applied `Output` in a local `vev-vt` screen, guesses printable keys and backspace on the cursor row, and draws the guesses through the foreground's output lease. `Output.Echo` acknowledges input once its echo reached the screen, so the client checks each guess against the mirror and erases a wrong one. A smoothed round trip from those acknowledgements decides when guesses show and when they are underlined. Input the client cannot model makes later guesses tentative until a correct guess confirms them. Client-owned overlays, resizes, and a hidden cursor drop every guess. Daemon modals with an input line (command palette, prompt, copy search) place the real terminal cursor on their caret instead of drawing one, so typing in them is predicted like a shell line. `echo.predict` selects `adaptive`, `always`, or `never`.
97 97
98 The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window.98 The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window.
99 99
M docs/remote-resilience.md +1 -1
@@ -34,7 +34,7 @@ The command palette is rendered by the remote daemon, so opening it needs a work
3434
3535 ## Typing on a slow link
3636
37-On remote sessions vev shows what you type before the server answers, like mosh. When the round trip exceeds about 60 ms, typed characters and backspace appear at once on the cursor line. Above about 160 ms they are underlined until the server confirms them. A wrong guess, such as a password prompt that echoes nothing, is erased when the server answers. Set `echo.predict = always` or `never` to change this; see [configuration](configuration.md).
37+On remote sessions vev shows what you type before the server answers, like mosh. When the round trip exceeds about 60 ms, typed characters and backspace appear at once on the cursor line, including in the command palette, rename prompts, and copy search. Above about 160 ms they are underlined until the server confirms them. A wrong guess, such as a password prompt that echoes nothing, is erased when the server answers. Set `echo.predict = always` or `never` to change this; see [configuration](configuration.md).
3838
3939 ## Connection states
4040
@@ -34,7 +34,7 @@ The command palette is rendered by the remote daemon, so opening it needs a work
34 34
35 ## Typing on a slow link35 ## Typing on a slow link
36 36
37-On remote sessions vev shows what you type before the server answers, like mosh. When the round trip exceeds about 60 ms, typed characters and backspace appear at once on the cursor line. Above about 160 ms they are underlined until the server confirms them. A wrong guess, such as a password prompt that echoes nothing, is erased when the server answers. Set `echo.predict = always` or `never` to change this; see [configuration](configuration.md).37+On remote sessions vev shows what you type before the server answers, like mosh. When the round trip exceeds about 60 ms, typed characters and backspace appear at once on the cursor line, including in the command palette, rename prompts, and copy search. Above about 160 ms they are underlined until the server confirms them. A wrong guess, such as a password prompt that echoes nothing, is erased when the server answers. Set `echo.predict = always` or `never` to change this; see [configuration](configuration.md).
38 38
39 ## Connection states39 ## Connection states
40 40
M internal/usecase/client/predictive_echo.go +6 -0
@@ -558,6 +558,12 @@ func (p *echoPredictor) backspace(seq uint64, now time.Time) {
558558 }
559559 width := len(p.cells)
560560 col := p.cursor.col - 1
561+ if left, known := p.effective(col); known && isBorderRune(left.Rune) {
562+ // The input starts after a border (a modal box or pane divider):
563+ // backspace cannot erase it.
564+ p.becomeTentative()
565+ return
566+ }
561567 for i := col; i < width; i++ {
562568 cell, known := p.effective(i)
563569 if known && !isPlainCell(cell) {
@@ -558,6 +558,12 @@ func (p *echoPredictor) backspace(seq uint64, now time.Time) {
558 }558 }
559 width := len(p.cells)559 width := len(p.cells)
560 col := p.cursor.col - 1560 col := p.cursor.col - 1
561+ if left, known := p.effective(col); known && isBorderRune(left.Rune) {
562+ // The input starts after a border (a modal box or pane divider):
563+ // backspace cannot erase it.
564+ p.becomeTentative()
565+ return
566+ }
561 for i := col; i < width; i++ {567 for i := col; i < width; i++ {
562 cell, known := p.effective(i)568 cell, known := p.effective(i)
563 if known && !isPlainCell(cell) {569 if known && !isPlainCell(cell) {
M internal/usecase/client/predictive_echo_test.go +41 -0
@@ -378,6 +378,47 @@ func TestEchoPredictorSameSizeResizeErasesGuesses(t *testing.T) {
378378 }
379379 }
380380
381+// TestEchoPredictorTypesAheadInModalInput covers daemon modals (command
382+// palette, prompt, copy search): the daemon places the real cursor on their
383+// input line inside a bordered box, and guesses stay inside the box.
384+func TestEchoPredictorTypesAheadInModalInput(t *testing.T) {
385+ t.Parallel()
386+ h := newEchoHarness(t, domain.EchoPredictAlways)
387+ // Row 2: "│> " then the cursor, with the box's right border at column 15.
388+ h.daemon("\x1b[2;1H│> \x1b[2;16H│\x1b[2;4H", 0)
389+ h.warm(10 * time.Millisecond)
390+ if got := h.typed("s"); len(got) != 0 {
391+ t.Fatalf("first guess is tentative, rendered %q", got)
392+ }
393+ h.daemon("\x1b[2;4Hs", h.seq)
394+ got := string(h.typed("p"))
395+ if want := "\x1b[2;5H\x1b[0mp\x1b[0m\x1b[2;6H"; got != want {
396+ t.Fatalf("render = %q, want %q", got, want)
397+ }
398+ if cell := h.p.screen.Cell(15, 1); cell.Rune != '│' {
399+ t.Fatalf("box border moved: %q", cell.Rune)
400+ }
401+}
402+
403+// TestEchoPredictorBackspaceStopsAtBorder holds backspace past an empty
404+// modal query: the input's left border is never predicted away.
405+func TestEchoPredictorBackspaceStopsAtBorder(t *testing.T) {
406+ t.Parallel()
407+ h := newEchoHarness(t, domain.EchoPredictAlways)
408+ h.daemon("\x1b[2;1H│\x1b[2;16H│\x1b[2;2H", 0)
409+ h.warm(10 * time.Millisecond)
410+ h.typed("s")
411+ h.daemon("\x1b[2;2Hs", h.seq)
412+ h.typed("\x7f")
413+ got := string(h.typed("\x7f"))
414+ if strings.Contains(got, "\x1b[2;1H") {
415+ t.Fatalf("backspace predicted over the border: %q", got)
416+ }
417+ if h.p.cursor.active && h.p.cursor.col < 1 {
418+ t.Fatalf("predicted cursor crossed the border: col %d", h.p.cursor.col)
419+ }
420+}
421+
381422 func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {
382423 t.Parallel()
383424 var f apcFilter
@@ -378,6 +378,47 @@ func TestEchoPredictorSameSizeResizeErasesGuesses(t *testing.T) {
378 }378 }
379 }379 }
380 380
381+// TestEchoPredictorTypesAheadInModalInput covers daemon modals (command
382+// palette, prompt, copy search): the daemon places the real cursor on their
383+// input line inside a bordered box, and guesses stay inside the box.
384+func TestEchoPredictorTypesAheadInModalInput(t *testing.T) {
385+ t.Parallel()
386+ h := newEchoHarness(t, domain.EchoPredictAlways)
387+ // Row 2: "│> " then the cursor, with the box's right border at column 15.
388+ h.daemon("\x1b[2;1H│> \x1b[2;16H│\x1b[2;4H", 0)
389+ h.warm(10 * time.Millisecond)
390+ if got := h.typed("s"); len(got) != 0 {
391+ t.Fatalf("first guess is tentative, rendered %q", got)
392+ }
393+ h.daemon("\x1b[2;4Hs", h.seq)
394+ got := string(h.typed("p"))
395+ if want := "\x1b[2;5H\x1b[0mp\x1b[0m\x1b[2;6H"; got != want {
396+ t.Fatalf("render = %q, want %q", got, want)
397+ }
398+ if cell := h.p.screen.Cell(15, 1); cell.Rune != '│' {
399+ t.Fatalf("box border moved: %q", cell.Rune)
400+ }
401+}
402+
403+// TestEchoPredictorBackspaceStopsAtBorder holds backspace past an empty
404+// modal query: the input's left border is never predicted away.
405+func TestEchoPredictorBackspaceStopsAtBorder(t *testing.T) {
406+ t.Parallel()
407+ h := newEchoHarness(t, domain.EchoPredictAlways)
408+ h.daemon("\x1b[2;1H│\x1b[2;16H│\x1b[2;2H", 0)
409+ h.warm(10 * time.Millisecond)
410+ h.typed("s")
411+ h.daemon("\x1b[2;2Hs", h.seq)
412+ h.typed("\x7f")
413+ got := string(h.typed("\x7f"))
414+ if strings.Contains(got, "\x1b[2;1H") {
415+ t.Fatalf("backspace predicted over the border: %q", got)
416+ }
417+ if h.p.cursor.active && h.p.cursor.col < 1 {
418+ t.Fatalf("predicted cursor crossed the border: col %d", h.p.cursor.col)
419+ }
420+}
421+
381 func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {422 func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {
382 t.Parallel()423 t.Parallel()
383 var f apcFilter424 var f apcFilter
A internal/usecase/daemon/overlay_cursor_test.go +121 -0
@@ -0,0 +1,121 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ vt "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/ui"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestOverlayInputCursor(t *testing.T) {
13+ inner := domain.Rect{X: 3, Y: 2, Width: 10, Height: 4}
14+ withCaret := func(col int) capturedModal {
15+ return capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: col, hasCaret: true}
16+ }
17+ notices := capturedModal{active: true, presentation: ui.Presentation{Inner: inner}}
18+ bar := capturedCursorInputs{style: 6, hasStyle: true, visible: true, renderable: true}
19+ for _, tt := range []struct {
20+ name string
21+ overlays capturedOverlayRenderState
22+ pane capturedCursorInputs
23+ want cursorOut
24+ }{
25+ {
26+ name: "palette caret owns the cursor and keeps the pane shape",
27+ overlays: capturedOverlayRenderState{palette: withCaret(4)},
28+ pane: bar,
29+ want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
30+ },
31+ {
32+ name: "hidden pane cursor keeps its app-set shape",
33+ overlays: capturedOverlayRenderState{palette: withCaret(4)},
34+ pane: capturedCursorInputs{style: 6, hasStyle: true},
35+ want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
36+ },
37+ {
38+ name: "unstyled pane cursor falls back to the default shape",
39+ overlays: capturedOverlayRenderState{palette: withCaret(4)},
40+ pane: capturedCursorInputs{visible: true, renderable: true},
41+ want: cursorOut{valid: true, row: 2, col: 7, style: 1, hasStyle: true},
42+ },
43+ {
44+ name: "prompt above palette owns the cursor",
45+ overlays: capturedOverlayRenderState{palette: withCaret(4), prompt: withCaret(6)},
46+ pane: bar,
47+ want: cursorOut{valid: true, row: 2, col: 9, style: 6, hasStyle: true},
48+ },
49+ {
50+ name: "prompt above copy search owns the cursor",
51+ overlays: capturedOverlayRenderState{copySearch: withCaret(1), prompt: withCaret(6)},
52+ pane: bar,
53+ want: cursorOut{valid: true, row: 2, col: 9, style: 6, hasStyle: true},
54+ },
55+ {
56+ name: "palette above notices owns the cursor",
57+ overlays: capturedOverlayRenderState{palette: withCaret(4), noticesOverlay: notices},
58+ pane: bar,
59+ want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
60+ },
61+ {
62+ name: "copy search caret owns the cursor",
63+ overlays: capturedOverlayRenderState{copySearch: withCaret(1)},
64+ pane: bar,
65+ want: cursorOut{valid: true, row: 2, col: 4, style: 6, hasStyle: true},
66+ },
67+ {
68+ name: "notices above copy search hide the cursor",
69+ overlays: capturedOverlayRenderState{copySearch: withCaret(1), noticesOverlay: notices},
70+ pane: bar,
71+ want: cursorOut{hidden: true},
72+ },
73+ {
74+ name: "modal without caret hides the cursor",
75+ overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: 4}},
76+ pane: bar,
77+ want: cursorOut{hidden: true},
78+ },
79+ {
80+ name: "caret outside the frame hides the cursor",
81+ overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: domain.Rect{X: 18, Y: 2, Width: 10, Height: 1}}, hasCaret: true, caretCol: 4}},
82+ pane: bar,
83+ want: cursorOut{hidden: true},
84+ },
85+ {
86+ name: "copy mode without a modal hides the cursor",
87+ overlays: capturedOverlayRenderState{copyActive: true},
88+ pane: bar,
89+ want: cursorOut{hidden: true},
90+ },
91+ } {
92+ t.Run(tt.name, func(t *testing.T) {
93+ require.Equal(t, tt.want, overlayInputCursor(tt.overlays, tt.pane, 20, 10))
94+ })
95+ }
96+}
97+
98+// TestPaletteInputLineOwnsTerminalCursor drives the production render path:
99+// the open palette shows the real terminal cursor after its query, which is
100+// what lets client predictive echo type ahead in it.
101+func TestPaletteInputLineOwnsTerminalCursor(t *testing.T) {
102+ p, release := newBlockingPTY(t)
103+ defer release()
104+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
105+ client := vt.NewScreen(80, 25)
106+ d.paint(sess, ac, true, nil)
107+ mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
108+
109+ d.handleInput(sess, ac, []byte("\x1b "))
110+ mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
111+ require.True(t, client.CursorVisible(), "open palette shows the real cursor")
112+ row, col := client.CursorRow(), client.CursorCol()
113+ require.Equal(t, '>', client.Cell(col-2, row).Rune, "cursor sits after the query prompt")
114+
115+ d.handleInput(sess, ac, []byte("ab"))
116+ mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
117+ require.True(t, client.CursorVisible())
118+ require.Equal(t, row, client.CursorRow())
119+ require.Equal(t, col+2, client.CursorCol(), "cursor follows typed query")
120+ require.Equal(t, 'b', client.Cell(col+1, row).Rune)
121+}
@@ -0,0 +1,121 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ vt "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/ui"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestOverlayInputCursor(t *testing.T) {
13+ inner := domain.Rect{X: 3, Y: 2, Width: 10, Height: 4}
14+ withCaret := func(col int) capturedModal {
15+ return capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: col, hasCaret: true}
16+ }
17+ notices := capturedModal{active: true, presentation: ui.Presentation{Inner: inner}}
18+ bar := capturedCursorInputs{style: 6, hasStyle: true, visible: true, renderable: true}
19+ for _, tt := range []struct {
20+ name string
21+ overlays capturedOverlayRenderState
22+ pane capturedCursorInputs
23+ want cursorOut
24+ }{
25+ {
26+ name: "palette caret owns the cursor and keeps the pane shape",
27+ overlays: capturedOverlayRenderState{palette: withCaret(4)},
28+ pane: bar,
29+ want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
30+ },
31+ {
32+ name: "hidden pane cursor keeps its app-set shape",
33+ overlays: capturedOverlayRenderState{palette: withCaret(4)},
34+ pane: capturedCursorInputs{style: 6, hasStyle: true},
35+ want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
36+ },
37+ {
38+ name: "unstyled pane cursor falls back to the default shape",
39+ overlays: capturedOverlayRenderState{palette: withCaret(4)},
40+ pane: capturedCursorInputs{visible: true, renderable: true},
41+ want: cursorOut{valid: true, row: 2, col: 7, style: 1, hasStyle: true},
42+ },
43+ {
44+ name: "prompt above palette owns the cursor",
45+ overlays: capturedOverlayRenderState{palette: withCaret(4), prompt: withCaret(6)},
46+ pane: bar,
47+ want: cursorOut{valid: true, row: 2, col: 9, style: 6, hasStyle: true},
48+ },
49+ {
50+ name: "prompt above copy search owns the cursor",
51+ overlays: capturedOverlayRenderState{copySearch: withCaret(1), prompt: withCaret(6)},
52+ pane: bar,
53+ want: cursorOut{valid: true, row: 2, col: 9, style: 6, hasStyle: true},
54+ },
55+ {
56+ name: "palette above notices owns the cursor",
57+ overlays: capturedOverlayRenderState{palette: withCaret(4), noticesOverlay: notices},
58+ pane: bar,
59+ want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
60+ },
61+ {
62+ name: "copy search caret owns the cursor",
63+ overlays: capturedOverlayRenderState{copySearch: withCaret(1)},
64+ pane: bar,
65+ want: cursorOut{valid: true, row: 2, col: 4, style: 6, hasStyle: true},
66+ },
67+ {
68+ name: "notices above copy search hide the cursor",
69+ overlays: capturedOverlayRenderState{copySearch: withCaret(1), noticesOverlay: notices},
70+ pane: bar,
71+ want: cursorOut{hidden: true},
72+ },
73+ {
74+ name: "modal without caret hides the cursor",
75+ overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: 4}},
76+ pane: bar,
77+ want: cursorOut{hidden: true},
78+ },
79+ {
80+ name: "caret outside the frame hides the cursor",
81+ overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: domain.Rect{X: 18, Y: 2, Width: 10, Height: 1}}, hasCaret: true, caretCol: 4}},
82+ pane: bar,
83+ want: cursorOut{hidden: true},
84+ },
85+ {
86+ name: "copy mode without a modal hides the cursor",
87+ overlays: capturedOverlayRenderState{copyActive: true},
88+ pane: bar,
89+ want: cursorOut{hidden: true},
90+ },
91+ } {
92+ t.Run(tt.name, func(t *testing.T) {
93+ require.Equal(t, tt.want, overlayInputCursor(tt.overlays, tt.pane, 20, 10))
94+ })
95+ }
96+}
97+
98+// TestPaletteInputLineOwnsTerminalCursor drives the production render path:
99+// the open palette shows the real terminal cursor after its query, which is
100+// what lets client predictive echo type ahead in it.
101+func TestPaletteInputLineOwnsTerminalCursor(t *testing.T) {
102+ p, release := newBlockingPTY(t)
103+ defer release()
104+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
105+ client := vt.NewScreen(80, 25)
106+ d.paint(sess, ac, true, nil)
107+ mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
108+
109+ d.handleInput(sess, ac, []byte("\x1b "))
110+ mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
111+ require.True(t, client.CursorVisible(), "open palette shows the real cursor")
112+ row, col := client.CursorRow(), client.CursorCol()
113+ require.Equal(t, '>', client.Cell(col-2, row).Rune, "cursor sits after the query prompt")
114+
115+ d.handleInput(sess, ac, []byte("ab"))
116+ mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
117+ require.True(t, client.CursorVisible())
118+ require.Equal(t, row, client.CursorRow())
119+ require.Equal(t, col+2, client.CursorCol(), "cursor follows typed query")
120+ require.Equal(t, 'b', client.Cell(col+1, row).Rune)
121+}
M internal/usecase/daemon/render_capture.go +11 -8
@@ -141,6 +141,10 @@ type capturedModal struct {
141141 presentation ui.Presentation
142142 inner renderer.Frame
143143 focused bool
144+ // caretCol is the input caret column on row 0 of inner, valid when
145+ // hasCaret. The topmost modal with a caret owns the terminal cursor.
146+ caretCol int
147+ hasCaret bool
144148 }
145149
146150 func (o capturedOverlayRenderState) active() bool {
@@ -148,9 +152,9 @@ func (o capturedOverlayRenderState) active() bool {
148152 }
149153
150154 type capturedCursorInputs struct {
151- row, col, style int
152- hasStyle, visible, renderable, hiddenByOverlay bool
153- content domain.Rect
155+ row, col, style int
156+ hasStyle, visible, renderable bool
157+ content domain.Rect
154158 }
155159
156160 // writePaneScreenLocked is the production VT write boundary. The first
@@ -407,7 +411,7 @@ func captureLocalRenderState(
407411 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
408412 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
409413 if captured.focused {
410- state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
414+ state.cursor = captureCursorInputsLocked(p, placement.Content)
411415 }
412416 p.mu.Unlock()
413417 translated := captured.rawDamage[:0]
@@ -444,7 +448,7 @@ func captureLocalRenderState(
444448 // A visible floating pane is the terminal input target, so its structural
445449 // border carries the focused semantic role independently of its content.
446450 state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
447- state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
451+ state.cursor = captureCursorInputsLocked(p, geometry.Inner)
448452 p.mu.Unlock()
449453 }
450454 scratch.receipts = state.receipts
@@ -469,10 +473,9 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
469473 return append(dst[:0], src...)
470474 }
471475
472-func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
476+func captureCursorInputsLocked(p *pane, content domain.Rect) capturedCursorInputs {
473477 style, hasStyle := p.screen.CursorStyle()
474- hidden := overlays.copyActive || overlays.copySearchActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive
475- return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content}
478+ return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, content: content}
476479 }
477480
478481 // appendStackPaneIDs returns exactly the panes whose solved placement can own a
@@ -141,6 +141,10 @@ type capturedModal struct {
141 presentation ui.Presentation141 presentation ui.Presentation
142 inner renderer.Frame142 inner renderer.Frame
143 focused bool143 focused bool
144+ // caretCol is the input caret column on row 0 of inner, valid when
145+ // hasCaret. The topmost modal with a caret owns the terminal cursor.
146+ caretCol int
147+ hasCaret bool
144 }148 }
145 149
146 func (o capturedOverlayRenderState) active() bool {150 func (o capturedOverlayRenderState) active() bool {
@@ -148,9 +152,9 @@ func (o capturedOverlayRenderState) active() bool {
148 }152 }
149 153
150 type capturedCursorInputs struct {154 type capturedCursorInputs struct {
151- row, col, style int155+ row, col, style int
152- hasStyle, visible, renderable, hiddenByOverlay bool156+ hasStyle, visible, renderable bool
153- content domain.Rect157+ content domain.Rect
154 }158 }
155 159
156 // writePaneScreenLocked is the production VT write boundary. The first160 // writePaneScreenLocked is the production VT write boundary. The first
@@ -407,7 +411,7 @@ func captureLocalRenderState(
407 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})411 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
408 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus412 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
409 if captured.focused {413 if captured.focused {
410- state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)414+ state.cursor = captureCursorInputsLocked(p, placement.Content)
411 }415 }
412 p.mu.Unlock()416 p.mu.Unlock()
413 translated := captured.rawDamage[:0]417 translated := captured.rawDamage[:0]
@@ -444,7 +448,7 @@ func captureLocalRenderState(
444 // A visible floating pane is the terminal input target, so its structural448 // A visible floating pane is the terminal input target, so its structural
445 // border carries the focused semantic role independently of its content.449 // border carries the focused semantic role independently of its content.
446 state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}450 state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
447- state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)451+ state.cursor = captureCursorInputsLocked(p, geometry.Inner)
448 p.mu.Unlock()452 p.mu.Unlock()
449 }453 }
450 scratch.receipts = state.receipts454 scratch.receipts = state.receipts
@@ -469,10 +473,9 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
469 return append(dst[:0], src...)473 return append(dst[:0], src...)
470 }474 }
471 475
472-func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {476+func captureCursorInputsLocked(p *pane, content domain.Rect) capturedCursorInputs {
473 style, hasStyle := p.screen.CursorStyle()477 style, hasStyle := p.screen.CursorStyle()
474- hidden := overlays.copyActive || overlays.copySearchActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive478+ return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, content: content}
475- return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content}
476 }479 }
477 480
478 // appendStackPaneIDs returns exactly the panes whose solved placement can own a481 // appendStackPaneIDs returns exactly the panes whose solved placement can own a
M internal/usecase/daemon/render_pipeline.go +31 -5
@@ -244,9 +244,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
244244 damage = appendToastDamage(damage, in.toastFootprints)
245245 damage = appendToastDamage(damage, toastFootprints)
246246 }
247- cursorInputs := state.cursor
248- cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
249- cursor := desiredCapturedCursor(cursorInputs, contentY)
247+ cursor := desiredCapturedCursor(state.cursor, contentY)
248+ if overlaysActive {
249+ cursor = overlayInputCursor(state.overlays, state.cursor, width, frameHeight)
250+ }
250251 // A plain copy viewport is painted on a clone or the spare viewport page,
251252 // never into baseFrame. Keep
252253 // that unadorned base reusable between wheel events. Modal/floating paths
@@ -280,7 +281,7 @@ func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title s
280281 }
281282
282283 func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
283- if !c.renderable || c.hiddenByOverlay || !c.visible {
284+ if !c.renderable || !c.visible {
284285 return cursorOut{hidden: true}
285286 }
286287 style := c.style
@@ -290,6 +291,29 @@ func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
290291 return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
291292 }
292293
294+// overlayInputCursor places the terminal cursor on the input caret of the
295+// topmost modal, in the same priority order composeCapturedOverlays paints.
296+// A real cursor (rather than a drawn caret) lets client predictive echo type
297+// ahead on the input line. Any other overlay hides the cursor. The caret keeps
298+// the focused pane's cursor shape, so opening a modal never changes it.
299+func overlayInputCursor(o capturedOverlayRenderState, pane capturedCursorInputs, width, height int) cursorOut {
300+ for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} {
301+ if !modal.active {
302+ continue
303+ }
304+ row, col := modal.presentation.Inner.Y, modal.presentation.Inner.X+modal.caretCol
305+ if !modal.hasCaret || row < 0 || row >= height || col < 0 || col >= width {
306+ break
307+ }
308+ style := 1
309+ if pane.hasStyle {
310+ style = pane.style
311+ }
312+ return cursorOut{valid: true, row: row, col: col, style: style, hasStyle: true}
313+ }
314+ return cursorOut{hidden: true}
315+}
316+
293317 func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {
294318 if state == nil || snap == nil {
295319 return
@@ -313,6 +337,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
313337 presentation := copySearchModal.Resolve(size)
314338 o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
315339 o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
340+ o.copySearch.caretCol, o.copySearch.hasCaret = snap.copySearchModel.Caret(rectSize(presentation.Inner))
316341 }
317342 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
318343 presentation := noticesModal.Resolve(size)
@@ -330,6 +355,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
330355 }
331356 o.paletteGuidance = snap.paletteFeedback
332357 o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Preview: snap.palettePreview, Feedback: snap.paletteFeedback})
358+ o.palette.caretCol, o.palette.hasCaret = snap.paletteModel.Caret(rectSize(presentation.Inner))
333359 }
334360 if snap.promptActive && snap.promptModel != nil {
335361 modal := promptModalFor(snap.promptModel.Title())
@@ -341,8 +367,8 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
341367 selection = styles.SurfaceActive
342368 }
343369 o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: selection})
370+ o.prompt.caretCol, o.prompt.hasCaret = snap.promptModel.Caret(rectSize(presentation.Inner))
344371 }
345- state.cursor.hiddenByOverlay = o.active()
346372 }
347373
348374 func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {
@@ -244,9 +244,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
244 damage = appendToastDamage(damage, in.toastFootprints)244 damage = appendToastDamage(damage, in.toastFootprints)
245 damage = appendToastDamage(damage, toastFootprints)245 damage = appendToastDamage(damage, toastFootprints)
246 }246 }
247- cursorInputs := state.cursor247+ cursor := desiredCapturedCursor(state.cursor, contentY)
248- cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive248+ if overlaysActive {
249- cursor := desiredCapturedCursor(cursorInputs, contentY)249+ cursor = overlayInputCursor(state.overlays, state.cursor, width, frameHeight)
250+ }
250 // A plain copy viewport is painted on a clone or the spare viewport page,251 // A plain copy viewport is painted on a clone or the spare viewport page,
251 // never into baseFrame. Keep252 // never into baseFrame. Keep
252 // that unadorned base reusable between wheel events. Modal/floating paths253 // that unadorned base reusable between wheel events. Modal/floating paths
@@ -280,7 +281,7 @@ func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title s
280 }281 }
281 282
282 func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {283 func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
283- if !c.renderable || c.hiddenByOverlay || !c.visible {284+ if !c.renderable || !c.visible {
284 return cursorOut{hidden: true}285 return cursorOut{hidden: true}
285 }286 }
286 style := c.style287 style := c.style
@@ -290,6 +291,29 @@ func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
290 return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true}291 return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
291 }292 }
292 293
294+// overlayInputCursor places the terminal cursor on the input caret of the
295+// topmost modal, in the same priority order composeCapturedOverlays paints.
296+// A real cursor (rather than a drawn caret) lets client predictive echo type
297+// ahead on the input line. Any other overlay hides the cursor. The caret keeps
298+// the focused pane's cursor shape, so opening a modal never changes it.
299+func overlayInputCursor(o capturedOverlayRenderState, pane capturedCursorInputs, width, height int) cursorOut {
300+ for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} {
301+ if !modal.active {
302+ continue
303+ }
304+ row, col := modal.presentation.Inner.Y, modal.presentation.Inner.X+modal.caretCol
305+ if !modal.hasCaret || row < 0 || row >= height || col < 0 || col >= width {
306+ break
307+ }
308+ style := 1
309+ if pane.hasStyle {
310+ style = pane.style
311+ }
312+ return cursorOut{valid: true, row: row, col: col, style: style, hasStyle: true}
313+ }
314+ return cursorOut{hidden: true}
315+}
316+
293 func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {317 func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {
294 if state == nil || snap == nil {318 if state == nil || snap == nil {
295 return319 return
@@ -313,6 +337,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
313 presentation := copySearchModal.Resolve(size)337 presentation := copySearchModal.Resolve(size)
314 o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}338 o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
315 o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})339 o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
340+ o.copySearch.caretCol, o.copySearch.hasCaret = snap.copySearchModel.Caret(rectSize(presentation.Inner))
316 }341 }
317 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {342 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
318 presentation := noticesModal.Resolve(size)343 presentation := noticesModal.Resolve(size)
@@ -330,6 +355,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
330 }355 }
331 o.paletteGuidance = snap.paletteFeedback356 o.paletteGuidance = snap.paletteFeedback
332 o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Preview: snap.palettePreview, Feedback: snap.paletteFeedback})357 o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Preview: snap.palettePreview, Feedback: snap.paletteFeedback})
358+ o.palette.caretCol, o.palette.hasCaret = snap.paletteModel.Caret(rectSize(presentation.Inner))
333 }359 }
334 if snap.promptActive && snap.promptModel != nil {360 if snap.promptActive && snap.promptModel != nil {
335 modal := promptModalFor(snap.promptModel.Title())361 modal := promptModalFor(snap.promptModel.Title())
@@ -341,8 +367,8 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
341 selection = styles.SurfaceActive367 selection = styles.SurfaceActive
342 }368 }
343 o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: selection})369 o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: selection})
370+ o.prompt.caretCol, o.prompt.hasCaret = snap.promptModel.Caret(rectSize(presentation.Inner))
344 }371 }
345- state.cursor.hiddenByOverlay = o.active()
346 }372 }
347 373
348 func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {374 func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {
M internal/usecase/daemon/render_test.go +0 -11
@@ -1031,17 +1031,6 @@ func TestPTYKittyAnimationFallbackKeepsProtocolErrorsOutOfShell(t *testing.T) {
10311031 require.Equal(t, uint64(1), snapshot.Usage().Placements, "the supported first frame remains as a static fallback")
10321032 }
10331033
1034-func TestCaptureCursorInputsHidesFocusedCursorForNoticesOverlay(t *testing.T) {
1035- _, sess, _, _ := newManualSessionWithPTYs(t, nil)
1036- p := sess.tabs[0].focusedPane()
1037-
1038- p.mu.Lock()
1039- cursor := captureCursorInputsLocked(p, domain.Rect{Width: 80, Height: 23}, capturedOverlayRenderState{noticesOverlayActive: true})
1040- p.mu.Unlock()
1041-
1042- require.True(t, cursor.hiddenByOverlay)
1043-}
1044-
10451034 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
10461035 p, _ := newBlockingPTY(t)
10471036 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -1031,17 +1031,6 @@ func TestPTYKittyAnimationFallbackKeepsProtocolErrorsOutOfShell(t *testing.T) {
1031 require.Equal(t, uint64(1), snapshot.Usage().Placements, "the supported first frame remains as a static fallback")1031 require.Equal(t, uint64(1), snapshot.Usage().Placements, "the supported first frame remains as a static fallback")
1032 }1032 }
1033 1033
1034-func TestCaptureCursorInputsHidesFocusedCursorForNoticesOverlay(t *testing.T) {
1035- _, sess, _, _ := newManualSessionWithPTYs(t, nil)
1036- p := sess.tabs[0].focusedPane()
1037-
1038- p.mu.Lock()
1039- cursor := captureCursorInputsLocked(p, domain.Rect{Width: 80, Height: 23}, capturedOverlayRenderState{noticesOverlayActive: true})
1040- p.mu.Unlock()
1041-
1042- require.True(t, cursor.hiddenByOverlay)
1043-}
1044-
1045 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {1034 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
1046 p, _ := newBlockingPTY(t)1035 p, _ := newBlockingPTY(t)
1047 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1036 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/palette/model.go +11 -2
@@ -127,6 +127,15 @@ func (m *Model) Query() string {
127127 }
128128 return m.input.Value()
129129 }
130+
131+// paletteInputPrefix leads the query line on row 0 of the rendered frame.
132+const paletteInputPrefix = "> "
133+
134+// Caret is the query caret column on row 0 of a Render frame of size inner
135+// (see ui.InputCaret).
136+func (m *Model) Caret(inner domain.Size) (int, bool) {
137+ return ui.InputCaret(inner, paletteInputPrefix, m.Query())
138+}
130139 func (m *Model) Up() {
131140 if m == nil || len(m.matches) == 0 {
132141 return
@@ -371,10 +380,10 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
371380 }
372381 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
373382 if m == nil {
374- ui.DrawInputLine(frame, 0, "> ", "", base, selection)
383+ ui.DrawInputLine(frame, 0, paletteInputPrefix, "", base)
375384 return frame
376385 }
377- ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
386+ ui.DrawInputLine(frame, 0, paletteInputPrefix, m.Query(), base)
378387 start, visible := 1, frame.Height-1
379388 if visible <= 0 || len(m.matches) == 0 {
380389 return frame
@@ -127,6 +127,15 @@ func (m *Model) Query() string {
127 }127 }
128 return m.input.Value()128 return m.input.Value()
129 }129 }
130+
131+// paletteInputPrefix leads the query line on row 0 of the rendered frame.
132+const paletteInputPrefix = "> "
133+
134+// Caret is the query caret column on row 0 of a Render frame of size inner
135+// (see ui.InputCaret).
136+func (m *Model) Caret(inner domain.Size) (int, bool) {
137+ return ui.InputCaret(inner, paletteInputPrefix, m.Query())
138+}
130 func (m *Model) Up() {139 func (m *Model) Up() {
131 if m == nil || len(m.matches) == 0 {140 if m == nil || len(m.matches) == 0 {
132 return141 return
@@ -371,10 +380,10 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
371 }380 }
372 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})381 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
373 if m == nil {382 if m == nil {
374- ui.DrawInputLine(frame, 0, "> ", "", base, selection)383+ ui.DrawInputLine(frame, 0, paletteInputPrefix, "", base)
375 return frame384 return frame
376 }385 }
377- ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)386+ ui.DrawInputLine(frame, 0, paletteInputPrefix, m.Query(), base)
378 start, visible := 1, frame.Height-1387 start, visible := 1, frame.Height-1
379 if visible <= 0 || len(m.matches) == 0 {388 if visible <= 0 || len(m.matches) == 0 {
380 return frame389 return frame
M internal/usecase/palette/model_test.go +4 -1
@@ -314,7 +314,10 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
314314 require.Equal(t, ' ', frame.At(1, 0).Rune)
315315 require.Equal(t, 'c', frame.At(2, 0).Rune)
316316 require.Equal(t, 'y', frame.At(3, 0).Rune)
317- require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query")
317+ require.False(t, frame.At(4, 0).Style.Inverse, "the real terminal cursor is the caret, none is drawn")
318+ col, ok := m.Caret(domain.Size{Cols: 28, Rows: 3})
319+ require.True(t, ok)
320+ require.Equal(t, 4, col, "caret follows the query")
318321
319322 require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1))
320323 require.NotContains(t, frameRow(frame, 1), "Copy")
@@ -314,7 +314,10 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
314 require.Equal(t, ' ', frame.At(1, 0).Rune)314 require.Equal(t, ' ', frame.At(1, 0).Rune)
315 require.Equal(t, 'c', frame.At(2, 0).Rune)315 require.Equal(t, 'c', frame.At(2, 0).Rune)
316 require.Equal(t, 'y', frame.At(3, 0).Rune)316 require.Equal(t, 'y', frame.At(3, 0).Rune)
317- require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query")317+ require.False(t, frame.At(4, 0).Style.Inverse, "the real terminal cursor is the caret, none is drawn")
318+ col, ok := m.Caret(domain.Size{Cols: 28, Rows: 3})
319+ require.True(t, ok)
320+ require.Equal(t, 4, col, "caret follows the query")
318 321
319 require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1))322 require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1))
320 require.NotContains(t, frameRow(frame, 1), "Copy")323 require.NotContains(t, frameRow(frame, 1), "Copy")
M internal/usecase/prompt/model.go +10 -1
@@ -49,6 +49,15 @@ func (m *Model) Value() string {
4949 return m.text.Value()
5050 }
5151
52+// promptInputPrefix leads the value line on row 0 of the rendered frame.
53+const promptInputPrefix = "> "
54+
55+// Caret is the value caret column on row 0 of a Render frame of size inner
56+// (see ui.InputCaret).
57+func (m *Model) Caret(inner domain.Size) (int, bool) {
58+ return ui.InputCaret(inner, promptInputPrefix, m.Value())
59+}
60+
5261 func (m *Model) Title() string {
5362 if m == nil {
5463 return ""
@@ -79,7 +88,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr
7988 return frame
8089 }
8190 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Base})
82- ui.DrawInputLine(frame, 0, "> ", m.Value(), styles.Base, styles.Selection)
91+ ui.DrawInputLine(frame, 0, promptInputPrefix, m.Value(), styles.Base)
8392 if frame.Height > 1 && m != nil && m.errMsg != "" {
8493 ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection)
8594 }
@@ -49,6 +49,15 @@ func (m *Model) Value() string {
49 return m.text.Value()49 return m.text.Value()
50 }50 }
51 51
52+// promptInputPrefix leads the value line on row 0 of the rendered frame.
53+const promptInputPrefix = "> "
54+
55+// Caret is the value caret column on row 0 of a Render frame of size inner
56+// (see ui.InputCaret).
57+func (m *Model) Caret(inner domain.Size) (int, bool) {
58+ return ui.InputCaret(inner, promptInputPrefix, m.Value())
59+}
60+
52 func (m *Model) Title() string {61 func (m *Model) Title() string {
53 if m == nil {62 if m == nil {
54 return ""63 return ""
@@ -79,7 +88,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr
79 return frame88 return frame
80 }89 }
81 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Base})90 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Base})
82- ui.DrawInputLine(frame, 0, "> ", m.Value(), styles.Base, styles.Selection)91+ ui.DrawInputLine(frame, 0, promptInputPrefix, m.Value(), styles.Base)
83 if frame.Height > 1 && m != nil && m.errMsg != "" {92 if frame.Height > 1 && m != nil && m.errMsg != "" {
84 ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection)93 ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection)
85 }94 }
M internal/usecase/prompt/model_test.go +5 -2
@@ -32,7 +32,10 @@ func TestModelRenderDrawsInputAndError(t *testing.T) {
3232 frame := m.Render(domain.Size{Cols: 24, Rows: 2})
3333
3434 require.Equal(t, "> work ", promptRowText(frame.Row(0)))
35- require.True(t, frame.At(6, 0).Style.Inverse, "caret follows prefilled value")
35+ require.False(t, frame.At(6, 0).Style.Inverse, "the real terminal cursor is the caret, none is drawn")
36+ col, ok := m.Caret(domain.Size{Cols: 24, Rows: 2})
37+ require.True(t, ok)
38+ require.Equal(t, 6, col, "caret follows prefilled value")
3639 require.Equal(t, "name already in use ", promptRowText(frame.Row(1)))
3740 require.True(t, frame.At(0, 1).Style.Inverse, "error line is visually distinct")
3841 }
@@ -65,5 +68,5 @@ func TestModelRenderStyledFillsBaseAndSelection(t *testing.T) {
6568 frame := m.RenderStyled(domain.Size{Cols: 12, Rows: 2}, RenderStyles{Base: base, Selection: selection})
6669
6770 require.True(t, frame.At(11, 1).Style.Equal(base), "blank filler keeps base surface")
68- require.True(t, frame.At(3, 0).Style.Equal(selection), "caret keeps selection surface")
71+ require.True(t, frame.At(3, 0).Style.Equal(base), "caret cell keeps base surface under the real cursor")
6972 }
@@ -32,7 +32,10 @@ func TestModelRenderDrawsInputAndError(t *testing.T) {
32 frame := m.Render(domain.Size{Cols: 24, Rows: 2})32 frame := m.Render(domain.Size{Cols: 24, Rows: 2})
33 33
34 require.Equal(t, "> work ", promptRowText(frame.Row(0)))34 require.Equal(t, "> work ", promptRowText(frame.Row(0)))
35- require.True(t, frame.At(6, 0).Style.Inverse, "caret follows prefilled value")35+ require.False(t, frame.At(6, 0).Style.Inverse, "the real terminal cursor is the caret, none is drawn")
36+ col, ok := m.Caret(domain.Size{Cols: 24, Rows: 2})
37+ require.True(t, ok)
38+ require.Equal(t, 6, col, "caret follows prefilled value")
36 require.Equal(t, "name already in use ", promptRowText(frame.Row(1)))39 require.Equal(t, "name already in use ", promptRowText(frame.Row(1)))
37 require.True(t, frame.At(0, 1).Style.Inverse, "error line is visually distinct")40 require.True(t, frame.At(0, 1).Style.Inverse, "error line is visually distinct")
38 }41 }
@@ -65,5 +68,5 @@ func TestModelRenderStyledFillsBaseAndSelection(t *testing.T) {
65 frame := m.RenderStyled(domain.Size{Cols: 12, Rows: 2}, RenderStyles{Base: base, Selection: selection})68 frame := m.RenderStyled(domain.Size{Cols: 12, Rows: 2}, RenderStyles{Base: base, Selection: selection})
66 69
67 require.True(t, frame.At(11, 1).Style.Equal(base), "blank filler keeps base surface")70 require.True(t, frame.At(11, 1).Style.Equal(base), "blank filler keeps base surface")
68- require.True(t, frame.At(3, 0).Style.Equal(selection), "caret keeps selection surface")71+ require.True(t, frame.At(3, 0).Style.Equal(base), "caret cell keeps base surface under the real cursor")
69 }72 }
M internal/usecase/ui/textinput.go +20 -11
@@ -1,6 +1,9 @@
11 package ui
22
3-import renderer "github.com/bnema/vev-vt"
3+import (
4+ renderer "github.com/bnema/vev-vt"
5+ "github.com/bnema/vev/internal/domain"
6+)
47
58 // TextInput stores editable text as runes for terminal overlay input models.
69 type TextInput struct {
@@ -20,18 +23,24 @@ func (t *TextInput) Value() string { return string(t.runes) }
2023
2124 func (t *TextInput) SetValue(value string) { t.runes = []rune(value) }
2225
23-// DrawInputLine draws prefix + value followed by a reverse-video caret cell.
24-func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style, caretStyle ...renderer.Style) {
26+// DrawInputLine draws prefix + value. The caret is not drawn: the daemon
27+// places the real terminal cursor at InputCaret, so client predictive echo
28+// can type ahead on the input line.
29+func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) {
2530 if y < 0 || y >= f.Height {
2631 return
2732 }
28- x := DrawText(f, 0, y, f.Width, prefix+value, style)
29- if x >= 0 && x < f.Width {
30- caret := style
31- caret.Inverse = true
32- if len(caretStyle) > 0 {
33- caret = caretStyle[0]
34- }
35- f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret})
33+ DrawText(f, 0, y, f.Width, prefix+value, style)
34+}
35+
36+// InputCaret is the caret column of an input line drawn by DrawInputLine on
37+// row 0 of a frame of size inner. It reports false when the frame has no row
38+// or prefix + value leaves no room for the caret, so a clipped line never
39+// shows a misplaced cursor.
40+func InputCaret(inner domain.Size, prefix, value string) (int, bool) {
41+ col := 0
42+ for _, r := range prefix + value {
43+ col += renderer.RuneWidth(r)
3644 }
45+ return col, inner.Rows > 0 && col < inner.Cols
3746 }
@@ -1,6 +1,9 @@
1 package ui1 package ui
2 2
3-import renderer "github.com/bnema/vev-vt"3+import (
4+ renderer "github.com/bnema/vev-vt"
5+ "github.com/bnema/vev/internal/domain"
6+)
4 7
5 // TextInput stores editable text as runes for terminal overlay input models.8 // TextInput stores editable text as runes for terminal overlay input models.
6 type TextInput struct {9 type TextInput struct {
@@ -20,18 +23,24 @@ func (t *TextInput) Value() string { return string(t.runes) }
20 23
21 func (t *TextInput) SetValue(value string) { t.runes = []rune(value) }24 func (t *TextInput) SetValue(value string) { t.runes = []rune(value) }
22 25
23-// DrawInputLine draws prefix + value followed by a reverse-video caret cell.26+// DrawInputLine draws prefix + value. The caret is not drawn: the daemon
24-func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style, caretStyle ...renderer.Style) {27+// places the real terminal cursor at InputCaret, so client predictive echo
28+// can type ahead on the input line.
29+func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) {
25 if y < 0 || y >= f.Height {30 if y < 0 || y >= f.Height {
26 return31 return
27 }32 }
28- x := DrawText(f, 0, y, f.Width, prefix+value, style)33+ DrawText(f, 0, y, f.Width, prefix+value, style)
29- if x >= 0 && x < f.Width {34+}
30- caret := style35+
31- caret.Inverse = true36+// InputCaret is the caret column of an input line drawn by DrawInputLine on
32- if len(caretStyle) > 0 {37+// row 0 of a frame of size inner. It reports false when the frame has no row
33- caret = caretStyle[0]38+// or prefix + value leaves no room for the caret, so a clipped line never
34- }39+// shows a misplaced cursor.
35- f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret})40+func InputCaret(inner domain.Size, prefix, value string) (int, bool) {
41+ col := 0
42+ for _, r := range prefix + value {
43+ col += renderer.RuneWidth(r)
36 }44 }
45+ return col, inner.Rows > 0 && col < inner.Cols
37 }46 }
M internal/usecase/ui/textinput_test.go +24 -14
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/domain"
78 "github.com/stretchr/testify/require"
89 )
910
@@ -22,7 +23,7 @@ func TestTextInputEditsValue(t *testing.T) {
2223 require.Equal(t, "ship", input.Value())
2324 }
2425
25-func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) {
26+func TestDrawInputLineDrawsPrefixAndValueWithoutCaret(t *testing.T) {
2627 frame := renderer.NewFrame(6, 1)
2728 style := renderer.DefaultStyle()
2829
@@ -33,21 +34,30 @@ func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) {
3334 require.Equal(t, 'a', frame.At(2, 0).Rune)
3435 require.Equal(t, 'b', frame.At(3, 0).Rune)
3536 require.Equal(t, 'c', frame.At(4, 0).Rune)
36- require.Equal(t, ' ', frame.At(5, 0).Rune)
37- require.True(t, frame.At(5, 0).Style.Inverse)
37+ require.False(t, frame.At(5, 0).Style.Inverse, "the real terminal cursor is the caret")
3838 }
3939
40-func TestDrawInputLineClipsCaret(t *testing.T) {
41- frame := renderer.NewFrame(4, 1)
42- style := renderer.DefaultStyle()
43-
44- DrawInputLine(frame, 0, "> ", "abcdef", style)
45-
46- require.Equal(t, '>', frame.At(0, 0).Rune)
47- require.Equal(t, ' ', frame.At(1, 0).Rune)
48- require.Equal(t, 'a', frame.At(2, 0).Rune)
49- require.Equal(t, 'b', frame.At(3, 0).Rune)
50- require.False(t, frame.At(3, 0).Style.Inverse, "caret beyond clip should not overwrite final value cell")
40+func TestInputCaret(t *testing.T) {
41+ for _, tt := range []struct {
42+ name string
43+ width, rows int
44+ prefix, value string
45+ wantCol int
46+ wantOK bool
47+ }{
48+ {name: "after value", width: 6, rows: 1, prefix: "> ", value: "abc", wantCol: 5, wantOK: true},
49+ {name: "empty value", width: 6, rows: 1, prefix: "/", wantCol: 1, wantOK: true},
50+ {name: "wide runes count two columns", width: 8, rows: 1, prefix: "> ", value: "日本", wantCol: 6, wantOK: true},
51+ {name: "value fills the line", width: 5, rows: 1, prefix: "> ", value: "abc", wantCol: 5, wantOK: false},
52+ {name: "value clipped", width: 4, rows: 1, prefix: "> ", value: "abcdef", wantCol: 8, wantOK: false},
53+ {name: "no row", width: 6, rows: 0, prefix: "> ", value: "a", wantCol: 3, wantOK: false},
54+ } {
55+ t.Run(tt.name, func(t *testing.T) {
56+ col, ok := InputCaret(domain.Size{Cols: tt.width, Rows: tt.rows}, tt.prefix, tt.value)
57+ require.Equal(t, tt.wantCol, col)
58+ require.Equal(t, tt.wantOK, ok)
59+ })
60+ }
5161 }
5262
5363 func TestDrawInputLineIgnoresInvalidRow(t *testing.T) {
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 renderer "github.com/bnema/vev-vt"6 renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/domain"
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
8 )9 )
9 10
@@ -22,7 +23,7 @@ func TestTextInputEditsValue(t *testing.T) {
22 require.Equal(t, "ship", input.Value())23 require.Equal(t, "ship", input.Value())
23 }24 }
24 25
25-func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) {26+func TestDrawInputLineDrawsPrefixAndValueWithoutCaret(t *testing.T) {
26 frame := renderer.NewFrame(6, 1)27 frame := renderer.NewFrame(6, 1)
27 style := renderer.DefaultStyle()28 style := renderer.DefaultStyle()
28 29
@@ -33,21 +34,30 @@ func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) {
33 require.Equal(t, 'a', frame.At(2, 0).Rune)34 require.Equal(t, 'a', frame.At(2, 0).Rune)
34 require.Equal(t, 'b', frame.At(3, 0).Rune)35 require.Equal(t, 'b', frame.At(3, 0).Rune)
35 require.Equal(t, 'c', frame.At(4, 0).Rune)36 require.Equal(t, 'c', frame.At(4, 0).Rune)
36- require.Equal(t, ' ', frame.At(5, 0).Rune)37+ require.False(t, frame.At(5, 0).Style.Inverse, "the real terminal cursor is the caret")
37- require.True(t, frame.At(5, 0).Style.Inverse)
38 }38 }
39 39
40-func TestDrawInputLineClipsCaret(t *testing.T) {40+func TestInputCaret(t *testing.T) {
41- frame := renderer.NewFrame(4, 1)41+ for _, tt := range []struct {
42- style := renderer.DefaultStyle()42+ name string
43-43+ width, rows int
44- DrawInputLine(frame, 0, "> ", "abcdef", style)44+ prefix, value string
45-45+ wantCol int
46- require.Equal(t, '>', frame.At(0, 0).Rune)46+ wantOK bool
47- require.Equal(t, ' ', frame.At(1, 0).Rune)47+ }{
48- require.Equal(t, 'a', frame.At(2, 0).Rune)48+ {name: "after value", width: 6, rows: 1, prefix: "> ", value: "abc", wantCol: 5, wantOK: true},
49- require.Equal(t, 'b', frame.At(3, 0).Rune)49+ {name: "empty value", width: 6, rows: 1, prefix: "/", wantCol: 1, wantOK: true},
50- require.False(t, frame.At(3, 0).Style.Inverse, "caret beyond clip should not overwrite final value cell")50+ {name: "wide runes count two columns", width: 8, rows: 1, prefix: "> ", value: "日本", wantCol: 6, wantOK: true},
51+ {name: "value fills the line", width: 5, rows: 1, prefix: "> ", value: "abc", wantCol: 5, wantOK: false},
52+ {name: "value clipped", width: 4, rows: 1, prefix: "> ", value: "abcdef", wantCol: 8, wantOK: false},
53+ {name: "no row", width: 6, rows: 0, prefix: "> ", value: "a", wantCol: 3, wantOK: false},
54+ } {
55+ t.Run(tt.name, func(t *testing.T) {
56+ col, ok := InputCaret(domain.Size{Cols: tt.width, Rows: tt.rows}, tt.prefix, tt.value)
57+ require.Equal(t, tt.wantCol, col)
58+ require.Equal(t, tt.wantOK, ok)
59+ })
60+ }
51 }61 }
52 62
53 func TestDrawInputLineIgnoresInvalidRow(t *testing.T) {63 func TestDrawInputLineIgnoresInvalidRow(t *testing.T) {
M internal/usecase/visualsearch/model.go +10 -1
@@ -70,6 +70,15 @@ func (m *Model) Query() string {
7070 return m.input.Value()
7171 }
7272
73+// searchInputPrefix leads the query line on row 0 of the rendered frame.
74+const searchInputPrefix = "/"
75+
76+// Caret is the query caret column on row 0 of a Render frame of size inner
77+// (see ui.InputCaret).
78+func (m *Model) Caret(inner domain.Size) (int, bool) {
79+ return ui.InputCaret(inner, searchInputPrefix, m.Query())
80+}
81+
7382 func (m *Model) Up() {
7483 if m != nil && m.selected > 0 {
7584 m.selected--
@@ -180,7 +189,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr
180189 }
181190 base, selection := styles.Base, styles.Selection
182191 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
183- ui.DrawInputLine(frame, 0, "/", m.Query(), base, selection)
192+ ui.DrawInputLine(frame, 0, searchInputPrefix, m.Query(), base)
184193 visible := frame.Height - 1
185194 if visible <= 0 || len(m.matches) == 0 {
186195 return frame
@@ -70,6 +70,15 @@ func (m *Model) Query() string {
70 return m.input.Value()70 return m.input.Value()
71 }71 }
72 72
73+// searchInputPrefix leads the query line on row 0 of the rendered frame.
74+const searchInputPrefix = "/"
75+
76+// Caret is the query caret column on row 0 of a Render frame of size inner
77+// (see ui.InputCaret).
78+func (m *Model) Caret(inner domain.Size) (int, bool) {
79+ return ui.InputCaret(inner, searchInputPrefix, m.Query())
80+}
81+
73 func (m *Model) Up() {82 func (m *Model) Up() {
74 if m != nil && m.selected > 0 {83 if m != nil && m.selected > 0 {
75 m.selected--84 m.selected--
@@ -180,7 +189,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr
180 }189 }
181 base, selection := styles.Base, styles.Selection190 base, selection := styles.Base, styles.Selection
182 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})191 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
183- ui.DrawInputLine(frame, 0, "/", m.Query(), base, selection)192+ ui.DrawInputLine(frame, 0, searchInputPrefix, m.Query(), base)
184 visible := frame.Height - 1193 visible := frame.Height - 1
185 if visible <= 0 || len(m.matches) == 0 {194 if visible <= 0 || len(m.matches) == 0 {
186 return frame195 return frame
M internal/usecase/visualsearch/model_test.go +1 -1
@@ -234,7 +234,7 @@ func TestVisualSearchRenderStyledUsesSemanticRoles(t *testing.T) {
234234 why string
235235 }{
236236 {name: "blank filler", x: 19, y: 3, want: base, why: "blank filler keeps base surface"},
237- {name: "input caret", x: 2, y: 0, want: selection, why: "input caret keeps selection surface"},
237+ {name: "input caret", x: 2, y: 0, want: base, why: "caret cell keeps base surface under the real cursor"},
238238 } {
239239 t.Run(tt.name, func(t *testing.T) {
240240 m := New(testSnapshot("alpha"))
@@ -234,7 +234,7 @@ func TestVisualSearchRenderStyledUsesSemanticRoles(t *testing.T) {
234 why string234 why string
235 }{235 }{
236 {name: "blank filler", x: 19, y: 3, want: base, why: "blank filler keeps base surface"},236 {name: "blank filler", x: 19, y: 3, want: base, why: "blank filler keeps base surface"},
237- {name: "input caret", x: 2, y: 0, want: selection, why: "input caret keeps selection surface"},237+ {name: "input caret", x: 2, y: 0, want: base, why: "caret cell keeps base surface under the real cursor"},
238 } {238 } {
239 t.Run(tt.name, func(t *testing.T) {239 t.Run(tt.name, func(t *testing.T) {
240 m := New(testSnapshot("alpha"))240 m := New(testSnapshot("alpha"))

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close