detached 9f742a8

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.
commit
9f742a8370edd705b868c2c2a08f1c4da4a1dfd3
author
brice · · 2026-10-07 20:18 UTC
parent
76d7fa2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +229 -34
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_test.go +22 -0
@@ -378,6 +378,28 @@ 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+
381403 func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {
382404 t.Parallel()
383405 var f apcFilter
@@ -378,6 +378,28 @@ 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+
381 func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {403 func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {
382 t.Parallel()404 t.Parallel()
383 var f apcFilter405 var f apcFilter
A internal/usecase/daemon/overlay_cursor_test.go +87 -0
@@ -0,0 +1,87 @@
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+ for _, tt := range []struct {
18+ name string
19+ overlays capturedOverlayRenderState
20+ want cursorOut
21+ }{
22+ {
23+ name: "palette caret owns the cursor",
24+ overlays: capturedOverlayRenderState{palette: withCaret(4)},
25+ want: cursorOut{valid: true, row: 2, col: 7, hasStyle: true},
26+ },
27+ {
28+ name: "prompt above palette owns the cursor",
29+ overlays: capturedOverlayRenderState{palette: withCaret(4), prompt: withCaret(6)},
30+ want: cursorOut{valid: true, row: 2, col: 9, hasStyle: true},
31+ },
32+ {
33+ name: "copy search caret owns the cursor",
34+ overlays: capturedOverlayRenderState{copySearch: withCaret(1)},
35+ want: cursorOut{valid: true, row: 2, col: 4, hasStyle: true},
36+ },
37+ {
38+ name: "notices above copy search hide the cursor",
39+ overlays: capturedOverlayRenderState{copySearch: withCaret(1), noticesOverlay: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}}},
40+ want: cursorOut{hidden: true},
41+ },
42+ {
43+ name: "full input line hides the cursor",
44+ overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: 10}},
45+ want: cursorOut{hidden: true},
46+ },
47+ {
48+ name: "caret outside the frame hides the cursor",
49+ overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: domain.Rect{X: 18, Y: 2, Width: 10, Height: 1}}, hasCaret: true, caretCol: 4}},
50+ want: cursorOut{hidden: true},
51+ },
52+ {
53+ name: "copy mode without a modal hides the cursor",
54+ overlays: capturedOverlayRenderState{copyActive: true},
55+ want: cursorOut{hidden: true},
56+ },
57+ } {
58+ t.Run(tt.name, func(t *testing.T) {
59+ require.Equal(t, tt.want, overlayInputCursor(tt.overlays, 20, 10))
60+ })
61+ }
62+}
63+
64+// TestPaletteInputLineOwnsTerminalCursor drives the production render path:
65+// the open palette shows the real terminal cursor after its query, which is
66+// what lets client predictive echo type ahead in it.
67+func TestPaletteInputLineOwnsTerminalCursor(t *testing.T) {
68+ p, release := newBlockingPTY(t)
69+ defer release()
70+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
71+ client := vt.NewScreen(80, 25)
72+ d.paint(sess, ac, true, nil)
73+ mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
74+
75+ d.handleInput(sess, ac, []byte("\x1b "))
76+ mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
77+ require.True(t, client.CursorVisible(), "open palette shows the real cursor")
78+ row, col := client.CursorRow(), client.CursorCol()
79+ require.Equal(t, '>', client.Cell(col-2, row).Rune, "cursor sits after the query prompt")
80+
81+ d.handleInput(sess, ac, []byte("ab"))
82+ mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
83+ require.True(t, client.CursorVisible())
84+ require.Equal(t, row, client.CursorRow())
85+ require.Equal(t, col+2, client.CursorCol(), "cursor follows typed query")
86+ require.Equal(t, 'b', client.Cell(col+1, row).Rune)
87+}
@@ -0,0 +1,87 @@
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+ for _, tt := range []struct {
18+ name string
19+ overlays capturedOverlayRenderState
20+ want cursorOut
21+ }{
22+ {
23+ name: "palette caret owns the cursor",
24+ overlays: capturedOverlayRenderState{palette: withCaret(4)},
25+ want: cursorOut{valid: true, row: 2, col: 7, hasStyle: true},
26+ },
27+ {
28+ name: "prompt above palette owns the cursor",
29+ overlays: capturedOverlayRenderState{palette: withCaret(4), prompt: withCaret(6)},
30+ want: cursorOut{valid: true, row: 2, col: 9, hasStyle: true},
31+ },
32+ {
33+ name: "copy search caret owns the cursor",
34+ overlays: capturedOverlayRenderState{copySearch: withCaret(1)},
35+ want: cursorOut{valid: true, row: 2, col: 4, hasStyle: true},
36+ },
37+ {
38+ name: "notices above copy search hide the cursor",
39+ overlays: capturedOverlayRenderState{copySearch: withCaret(1), noticesOverlay: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}}},
40+ want: cursorOut{hidden: true},
41+ },
42+ {
43+ name: "full input line hides the cursor",
44+ overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: 10}},
45+ want: cursorOut{hidden: true},
46+ },
47+ {
48+ name: "caret outside the frame hides the cursor",
49+ overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: domain.Rect{X: 18, Y: 2, Width: 10, Height: 1}}, hasCaret: true, caretCol: 4}},
50+ want: cursorOut{hidden: true},
51+ },
52+ {
53+ name: "copy mode without a modal hides the cursor",
54+ overlays: capturedOverlayRenderState{copyActive: true},
55+ want: cursorOut{hidden: true},
56+ },
57+ } {
58+ t.Run(tt.name, func(t *testing.T) {
59+ require.Equal(t, tt.want, overlayInputCursor(tt.overlays, 20, 10))
60+ })
61+ }
62+}
63+
64+// TestPaletteInputLineOwnsTerminalCursor drives the production render path:
65+// the open palette shows the real terminal cursor after its query, which is
66+// what lets client predictive echo type ahead in it.
67+func TestPaletteInputLineOwnsTerminalCursor(t *testing.T) {
68+ p, release := newBlockingPTY(t)
69+ defer release()
70+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
71+ client := vt.NewScreen(80, 25)
72+ d.paint(sess, ac, true, nil)
73+ mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
74+
75+ d.handleInput(sess, ac, []byte("\x1b "))
76+ mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
77+ require.True(t, client.CursorVisible(), "open palette shows the real cursor")
78+ row, col := client.CursorRow(), client.CursorCol()
79+ require.Equal(t, '>', client.Cell(col-2, row).Rune, "cursor sits after the query prompt")
80+
81+ d.handleInput(sess, ac, []byte("ab"))
82+ mustApplyOutput(t, client, awaitFrame(t, sends, "Output"))
83+ require.True(t, client.CursorVisible())
84+ require.Equal(t, row, client.CursorRow())
85+ require.Equal(t, col+2, client.CursorCol(), "cursor follows typed query")
86+ require.Equal(t, 'b', client.Cell(col+1, row).Rune)
87+}
M internal/usecase/daemon/render_capture.go +4 -0
@@ -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 {
@@ -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 {
M internal/usecase/daemon/render_pipeline.go +27 -0
@@ -247,6 +247,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
247247 cursorInputs := state.cursor
248248 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
249249 cursor := desiredCapturedCursor(cursorInputs, contentY)
250+ if overlaysActive {
251+ cursor = overlayInputCursor(state.overlays, width, frameHeight)
252+ }
250253 // A plain copy viewport is painted on a clone or the spare viewport page,
251254 // never into baseFrame. Keep
252255 // that unadorned base reusable between wheel events. Modal/floating paths
@@ -290,6 +293,27 @@ func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
290293 return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
291294 }
292295
296+// overlayInputCursor places the terminal cursor on the input caret of the
297+// topmost modal, in the same priority order composeCapturedOverlays paints.
298+// A real cursor (rather than a drawn caret) lets client predictive echo type
299+// ahead on the input line. Any other overlay hides the cursor.
300+func overlayInputCursor(o capturedOverlayRenderState, width, height int) cursorOut {
301+ for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} {
302+ if !modal.active {
303+ continue
304+ }
305+ inner := modal.presentation.Inner
306+ row, col := inner.Y, inner.X+modal.caretCol
307+ if !modal.hasCaret || modal.caretCol >= inner.Width || inner.Height <= 0 ||
308+ row < 0 || row >= height || col < 0 || col >= width {
309+ break
310+ }
311+ // Style 0 restores the user's terminal default cursor shape.
312+ return cursorOut{valid: true, row: row, col: col, style: 0, 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,6 +367,7 @@ 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 }
345372 state.cursor.hiddenByOverlay = o.active()
346373 }
@@ -247,6 +247,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
247 cursorInputs := state.cursor247 cursorInputs := state.cursor
248 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive248 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
249 cursor := desiredCapturedCursor(cursorInputs, contentY)249 cursor := desiredCapturedCursor(cursorInputs, contentY)
250+ if overlaysActive {
251+ cursor = overlayInputCursor(state.overlays, width, frameHeight)
252+ }
250 // A plain copy viewport is painted on a clone or the spare viewport page,253 // A plain copy viewport is painted on a clone or the spare viewport page,
251 // never into baseFrame. Keep254 // never into baseFrame. Keep
252 // that unadorned base reusable between wheel events. Modal/floating paths255 // that unadorned base reusable between wheel events. Modal/floating paths
@@ -290,6 +293,27 @@ 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}293 return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
291 }294 }
292 295
296+// overlayInputCursor places the terminal cursor on the input caret of the
297+// topmost modal, in the same priority order composeCapturedOverlays paints.
298+// A real cursor (rather than a drawn caret) lets client predictive echo type
299+// ahead on the input line. Any other overlay hides the cursor.
300+func overlayInputCursor(o capturedOverlayRenderState, width, height int) cursorOut {
301+ for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} {
302+ if !modal.active {
303+ continue
304+ }
305+ inner := modal.presentation.Inner
306+ row, col := inner.Y, inner.X+modal.caretCol
307+ if !modal.hasCaret || modal.caretCol >= inner.Width || inner.Height <= 0 ||
308+ row < 0 || row >= height || col < 0 || col >= width {
309+ break
310+ }
311+ // Style 0 restores the user's terminal default cursor shape.
312+ return cursorOut{valid: true, row: row, col: col, style: 0, 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,6 +367,7 @@ 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()372 state.cursor.hiddenByOverlay = o.active()
346 }373 }
M internal/usecase/palette/model.go +14 -2
@@ -127,6 +127,18 @@ 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 frame inner.Cols wide. It
135+// reports false when the query fills the line.
136+func (m *Model) Caret(inner domain.Size) (int, bool) {
137+ if inner.Rows <= 0 {
138+ return 0, false
139+ }
140+ return ui.InputCaret(inner.Cols, paletteInputPrefix, m.Query())
141+}
130142 func (m *Model) Up() {
131143 if m == nil || len(m.matches) == 0 {
132144 return
@@ -371,10 +383,10 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
371383 }
372384 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
373385 if m == nil {
374- ui.DrawInputLine(frame, 0, "> ", "", base, selection)
386+ ui.DrawInputLine(frame, 0, paletteInputPrefix, "", base)
375387 return frame
376388 }
377- ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
389+ ui.DrawInputLine(frame, 0, paletteInputPrefix, m.Query(), base)
378390 start, visible := 1, frame.Height-1
379391 if visible <= 0 || len(m.matches) == 0 {
380392 return frame
@@ -127,6 +127,18 @@ 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 frame inner.Cols wide. It
135+// reports false when the query fills the line.
136+func (m *Model) Caret(inner domain.Size) (int, bool) {
137+ if inner.Rows <= 0 {
138+ return 0, false
139+ }
140+ return ui.InputCaret(inner.Cols, paletteInputPrefix, m.Query())
141+}
130 func (m *Model) Up() {142 func (m *Model) Up() {
131 if m == nil || len(m.matches) == 0 {143 if m == nil || len(m.matches) == 0 {
132 return144 return
@@ -371,10 +383,10 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
371 }383 }
372 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})384 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
373 if m == nil {385 if m == nil {
374- ui.DrawInputLine(frame, 0, "> ", "", base, selection)386+ ui.DrawInputLine(frame, 0, paletteInputPrefix, "", base)
375 return frame387 return frame
376 }388 }
377- ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)389+ ui.DrawInputLine(frame, 0, paletteInputPrefix, m.Query(), base)
378 start, visible := 1, frame.Height-1390 start, visible := 1, frame.Height-1
379 if visible <= 0 || len(m.matches) == 0 {391 if visible <= 0 || len(m.matches) == 0 {
380 return frame392 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 +13 -1
@@ -49,6 +49,18 @@ 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 frame inner.Cols wide. It
56+// reports false when the value fills the line.
57+func (m *Model) Caret(inner domain.Size) (int, bool) {
58+ if inner.Rows <= 0 {
59+ return 0, false
60+ }
61+ return ui.InputCaret(inner.Cols, promptInputPrefix, m.Value())
62+}
63+
5264 func (m *Model) Title() string {
5365 if m == nil {
5466 return ""
@@ -79,7 +91,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr
7991 return frame
8092 }
8193 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)
94+ ui.DrawInputLine(frame, 0, promptInputPrefix, m.Value(), styles.Base)
8395 if frame.Height > 1 && m != nil && m.errMsg != "" {
8496 ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection)
8597 }
@@ -49,6 +49,18 @@ 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 frame inner.Cols wide. It
56+// reports false when the value fills the line.
57+func (m *Model) Caret(inner domain.Size) (int, bool) {
58+ if inner.Rows <= 0 {
59+ return 0, false
60+ }
61+ return ui.InputCaret(inner.Cols, promptInputPrefix, m.Value())
62+}
63+
52 func (m *Model) Title() string {64 func (m *Model) Title() string {
53 if m == nil {65 if m == nil {
54 return ""66 return ""
@@ -79,7 +91,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr
79 return frame91 return frame
80 }92 }
81 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Base})93 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)94+ ui.DrawInputLine(frame, 0, promptInputPrefix, m.Value(), styles.Base)
83 if frame.Height > 1 && m != nil && m.errMsg != "" {95 if frame.Height > 1 && m != nil && m.errMsg != "" {
84 ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection)96 ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection)
85 }97 }
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 +15 -10
@@ -20,18 +20,23 @@ func (t *TextInput) Value() string { return string(t.runes) }
2020
2121 func (t *TextInput) SetValue(value string) { t.runes = []rune(value) }
2222
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) {
23+// DrawInputLine draws prefix + value. The caret is not drawn: the daemon
24+// places the real terminal cursor at InputCaret, so client predictive echo
25+// can type ahead on the input line.
26+func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) {
2527 if y < 0 || y >= f.Height {
2628 return
2729 }
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})
30+ DrawText(f, 0, y, f.Width, prefix+value, style)
31+}
32+
33+// InputCaret is the caret column of an input line drawn by DrawInputLine in a
34+// frame width columns wide. It reports false when prefix + value does not
35+// leave room for the caret, so a clipped line never shows a misplaced cursor.
36+func InputCaret(width int, prefix, value string) (int, bool) {
37+ col := 0
38+ for _, r := range prefix + value {
39+ col += renderer.RuneWidth(r)
3640 }
41+ return col, col < width
3742 }
@@ -20,18 +20,23 @@ func (t *TextInput) Value() string { return string(t.runes) }
20 20
21 func (t *TextInput) SetValue(value string) { t.runes = []rune(value) }21 func (t *TextInput) SetValue(value string) { t.runes = []rune(value) }
22 22
23-// DrawInputLine draws prefix + value followed by a reverse-video caret cell.23+// 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) {24+// places the real terminal cursor at InputCaret, so client predictive echo
25+// can type ahead on the input line.
26+func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) {
25 if y < 0 || y >= f.Height {27 if y < 0 || y >= f.Height {
26 return28 return
27 }29 }
28- x := DrawText(f, 0, y, f.Width, prefix+value, style)30+ DrawText(f, 0, y, f.Width, prefix+value, style)
29- if x >= 0 && x < f.Width {31+}
30- caret := style32+
31- caret.Inverse = true33+// InputCaret is the caret column of an input line drawn by DrawInputLine in a
32- if len(caretStyle) > 0 {34+// frame width columns wide. It reports false when prefix + value does not
33- caret = caretStyle[0]35+// leave room for the caret, so a clipped line never shows a misplaced cursor.
34- }36+func InputCaret(width int, prefix, value string) (int, bool) {
35- f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret})37+ col := 0
38+ for _, r := range prefix + value {
39+ col += renderer.RuneWidth(r)
36 }40 }
41+ return col, col < width
37 }42 }
M internal/usecase/ui/textinput_test.go +22 -14
@@ -22,7 +22,7 @@ func TestTextInputEditsValue(t *testing.T) {
2222 require.Equal(t, "ship", input.Value())
2323 }
2424
25-func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) {
25+func TestDrawInputLineDrawsPrefixAndValueWithoutCaret(t *testing.T) {
2626 frame := renderer.NewFrame(6, 1)
2727 style := renderer.DefaultStyle()
2828
@@ -33,21 +33,29 @@ func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) {
3333 require.Equal(t, 'a', frame.At(2, 0).Rune)
3434 require.Equal(t, 'b', frame.At(3, 0).Rune)
3535 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)
36+ require.False(t, frame.At(5, 0).Style.Inverse, "the real terminal cursor is the caret")
3837 }
3938
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")
39+func TestInputCaret(t *testing.T) {
40+ for _, tt := range []struct {
41+ name string
42+ width int
43+ prefix, value string
44+ wantCol int
45+ wantOK bool
46+ }{
47+ {name: "after value", width: 6, prefix: "> ", value: "abc", wantCol: 5, wantOK: true},
48+ {name: "empty value", width: 6, prefix: "/", wantCol: 1, wantOK: true},
49+ {name: "wide runes count two columns", width: 8, prefix: "> ", value: "日本", wantCol: 6, wantOK: true},
50+ {name: "value fills the line", width: 5, prefix: "> ", value: "abc", wantCol: 5, wantOK: false},
51+ {name: "value clipped", width: 4, prefix: "> ", value: "abcdef", wantCol: 8, wantOK: false},
52+ } {
53+ t.Run(tt.name, func(t *testing.T) {
54+ col, ok := InputCaret(tt.width, tt.prefix, tt.value)
55+ require.Equal(t, tt.wantCol, col)
56+ require.Equal(t, tt.wantOK, ok)
57+ })
58+ }
5159 }
5260
5361 func TestDrawInputLineIgnoresInvalidRow(t *testing.T) {
@@ -22,7 +22,7 @@ func TestTextInputEditsValue(t *testing.T) {
22 require.Equal(t, "ship", input.Value())22 require.Equal(t, "ship", input.Value())
23 }23 }
24 24
25-func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) {25+func TestDrawInputLineDrawsPrefixAndValueWithoutCaret(t *testing.T) {
26 frame := renderer.NewFrame(6, 1)26 frame := renderer.NewFrame(6, 1)
27 style := renderer.DefaultStyle()27 style := renderer.DefaultStyle()
28 28
@@ -33,21 +33,29 @@ func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) {
33 require.Equal(t, 'a', frame.At(2, 0).Rune)33 require.Equal(t, 'a', frame.At(2, 0).Rune)
34 require.Equal(t, 'b', frame.At(3, 0).Rune)34 require.Equal(t, 'b', frame.At(3, 0).Rune)
35 require.Equal(t, 'c', frame.At(4, 0).Rune)35 require.Equal(t, 'c', frame.At(4, 0).Rune)
36- require.Equal(t, ' ', frame.At(5, 0).Rune)36+ 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 }37 }
39 38
40-func TestDrawInputLineClipsCaret(t *testing.T) {39+func TestInputCaret(t *testing.T) {
41- frame := renderer.NewFrame(4, 1)40+ for _, tt := range []struct {
42- style := renderer.DefaultStyle()41+ name string
43-42+ width int
44- DrawInputLine(frame, 0, "> ", "abcdef", style)43+ prefix, value string
45-44+ wantCol int
46- require.Equal(t, '>', frame.At(0, 0).Rune)45+ wantOK bool
47- require.Equal(t, ' ', frame.At(1, 0).Rune)46+ }{
48- require.Equal(t, 'a', frame.At(2, 0).Rune)47+ {name: "after value", width: 6, prefix: "> ", value: "abc", wantCol: 5, wantOK: true},
49- require.Equal(t, 'b', frame.At(3, 0).Rune)48+ {name: "empty value", width: 6, prefix: "/", wantCol: 1, wantOK: true},
50- require.False(t, frame.At(3, 0).Style.Inverse, "caret beyond clip should not overwrite final value cell")49+ {name: "wide runes count two columns", width: 8, prefix: "> ", value: "日本", wantCol: 6, wantOK: true},
50+ {name: "value fills the line", width: 5, prefix: "> ", value: "abc", wantCol: 5, wantOK: false},
51+ {name: "value clipped", width: 4, prefix: "> ", value: "abcdef", wantCol: 8, wantOK: false},
52+ } {
53+ t.Run(tt.name, func(t *testing.T) {
54+ col, ok := InputCaret(tt.width, tt.prefix, tt.value)
55+ require.Equal(t, tt.wantCol, col)
56+ require.Equal(t, tt.wantOK, ok)
57+ })
58+ }
51 }59 }
52 60
53 func TestDrawInputLineIgnoresInvalidRow(t *testing.T) {61 func TestDrawInputLineIgnoresInvalidRow(t *testing.T) {
M internal/usecase/visualsearch/model.go +13 -1
@@ -70,6 +70,18 @@ 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 frame inner.Cols wide. It
77+// reports false when the query fills the line.
78+func (m *Model) Caret(inner domain.Size) (int, bool) {
79+ if inner.Rows <= 0 {
80+ return 0, false
81+ }
82+ return ui.InputCaret(inner.Cols, searchInputPrefix, m.Query())
83+}
84+
7385 func (m *Model) Up() {
7486 if m != nil && m.selected > 0 {
7587 m.selected--
@@ -180,7 +192,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr
180192 }
181193 base, selection := styles.Base, styles.Selection
182194 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)
195+ ui.DrawInputLine(frame, 0, searchInputPrefix, m.Query(), base)
184196 visible := frame.Height - 1
185197 if visible <= 0 || len(m.matches) == 0 {
186198 return frame
@@ -70,6 +70,18 @@ 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 frame inner.Cols wide. It
77+// reports false when the query fills the line.
78+func (m *Model) Caret(inner domain.Size) (int, bool) {
79+ if inner.Rows <= 0 {
80+ return 0, false
81+ }
82+ return ui.InputCaret(inner.Cols, searchInputPrefix, m.Query())
83+}
84+
73 func (m *Model) Up() {85 func (m *Model) Up() {
74 if m != nil && m.selected > 0 {86 if m != nil && m.selected > 0 {
75 m.selected--87 m.selected--
@@ -180,7 +192,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr
180 }192 }
181 base, selection := styles.Base, styles.Selection193 base, selection := styles.Base, styles.Selection
182 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})194 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)195+ ui.DrawInputLine(frame, 0, searchInputPrefix, m.Query(), base)
184 visible := frame.Height - 1196 visible := frame.Height - 1
185 if visible <= 0 || len(m.matches) == 0 {197 if visible <= 0 || len(m.matches) == 0 {
186 return frame198 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