detached 08ceb7f

fix(client): keep typed guesses on Enter until the next daemon frame

Hiding every guess as soon as Enter was pressed made the typed command vanish until its echo on slow links. Guesses now hide only once a daemon frame arrives while Enter is unanswered, which is when the row may have moved. Undrawing before a frame also restores the cursor when only the predicted cursor moved.
commit
08ceb7fccfff2cd5368f6acddfd24da0581d88c2
author
brice · · 2026-10-08 06:22 UTC
committer
brice · · 2026-10-08 06:24 UTC
parent
d2f974d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +38 -16
M internal/usecase/client/predictive_echo.go +25 -16
@@ -122,17 +122,22 @@ type echoPredictor struct {
122122 mode domain.EchoPredictMode
123123 screen *vt.Screen
124124
125- sent []echoSent
126- echo uint64
127- srtt time.Duration
128- srttKnown bool
129- srttOn bool
130- flagging bool
131- glitch int
132- lastQuick time.Time
133- predEpoch uint64
134- confEpoch uint64
135- holdUntil uint64
125+ sent []echoSent
126+ echo uint64
127+ srtt time.Duration
128+ srttKnown bool
129+ srttOn bool
130+ flagging bool
131+ glitch int
132+ lastQuick time.Time
133+ predEpoch uint64
134+ confEpoch uint64
135+ holdUntil uint64
136+ // held is set by the first daemon frame after an unknown key (Enter)
137+ // that is not its acknowledgement: that frame may move the guessed row,
138+ // so guesses stay hidden until the key is acknowledged. Before such a
139+ // frame, drawn guesses stay put.
140+ held bool
136141 row int
137142 cells []echoCell
138143 cursor echoCursor
@@ -161,13 +166,15 @@ func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) {
161166 // frame would land on unrelated content.
162167 p.forget()
163168 }
164- if data := p.apc.strip(output.Data); len(data) > 0 {
169+ data := p.apc.strip(output.Data)
170+ if len(data) > 0 {
165171 p.screen.Write(data)
166172 }
167173 if output.Echo > p.echo {
168174 p.sample(output.Echo, now)
169175 p.echo = output.Echo
170176 }
177+ p.held = p.echo < p.holdUntil && (p.held || len(data) > 0)
171178 p.cull(now)
172179 }
173180
@@ -624,9 +631,11 @@ func (p *echoPredictor) undraw() []byte {
624631 }
625632 }
626633 p.drawn = p.drawn[:0]
627- if out.Len() > 0 {
634+ if out.Len() > 0 || p.cursorMove {
635+ // The daemon frame is relative to its own cursor, not the predicted one.
628636 out.WriteString("\x1b[0m")
629637 writeEchoCUP(&out, p.screen.CursorRow(), p.screen.CursorCol())
638+ p.cursorMove = false
630639 }
631640 return out.Bytes()
632641 }
@@ -640,9 +649,9 @@ func (p *echoPredictor) render() []byte {
640649 var out bytes.Buffer
641650 show := p.displaying() && p.enabled()
642651 var want []int
643- // Until the daemon answers an unknown key (Enter), the guesses' row may
644- // have moved, so none is drawn.
645- if show && p.cells != nil && p.row < p.screen.Rows() && p.echo >= p.holdUntil {
652+ // Once a frame arrived while an unknown key (Enter) is unanswered, the
653+ // guesses' row may have moved, so none is drawn until the answer.
654+ if show && p.cells != nil && p.row < p.screen.Rows() && !(p.held && p.echo < p.holdUntil) {
646655 for col := range p.cells {
647656 cell := &p.cells[col]
648657 if !cell.active || cell.unknown || cell.tentative(p.confEpoch) {
@@ -122,17 +122,22 @@ type echoPredictor struct {
122 mode domain.EchoPredictMode122 mode domain.EchoPredictMode
123 screen *vt.Screen123 screen *vt.Screen
124 124
125- sent []echoSent125+ sent []echoSent
126- echo uint64126+ echo uint64
127- srtt time.Duration127+ srtt time.Duration
128- srttKnown bool128+ srttKnown bool
129- srttOn bool129+ srttOn bool
130- flagging bool130+ flagging bool
131- glitch int131+ glitch int
132- lastQuick time.Time132+ lastQuick time.Time
133- predEpoch uint64133+ predEpoch uint64
134- confEpoch uint64134+ confEpoch uint64
135- holdUntil uint64135+ holdUntil uint64
136+ // held is set by the first daemon frame after an unknown key (Enter)
137+ // that is not its acknowledgement: that frame may move the guessed row,
138+ // so guesses stay hidden until the key is acknowledged. Before such a
139+ // frame, drawn guesses stay put.
140+ held bool
136 row int141 row int
137 cells []echoCell142 cells []echoCell
138 cursor echoCursor143 cursor echoCursor
@@ -161,13 +166,15 @@ func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) {
161 // frame would land on unrelated content.166 // frame would land on unrelated content.
162 p.forget()167 p.forget()
163 }168 }
164- if data := p.apc.strip(output.Data); len(data) > 0 {169+ data := p.apc.strip(output.Data)
170+ if len(data) > 0 {
165 p.screen.Write(data)171 p.screen.Write(data)
166 }172 }
167 if output.Echo > p.echo {173 if output.Echo > p.echo {
168 p.sample(output.Echo, now)174 p.sample(output.Echo, now)
169 p.echo = output.Echo175 p.echo = output.Echo
170 }176 }
177+ p.held = p.echo < p.holdUntil && (p.held || len(data) > 0)
171 p.cull(now)178 p.cull(now)
172 }179 }
173 180
@@ -624,9 +631,11 @@ func (p *echoPredictor) undraw() []byte {
624 }631 }
625 }632 }
626 p.drawn = p.drawn[:0]633 p.drawn = p.drawn[:0]
627- if out.Len() > 0 {634+ if out.Len() > 0 || p.cursorMove {
635+ // The daemon frame is relative to its own cursor, not the predicted one.
628 out.WriteString("\x1b[0m")636 out.WriteString("\x1b[0m")
629 writeEchoCUP(&out, p.screen.CursorRow(), p.screen.CursorCol())637 writeEchoCUP(&out, p.screen.CursorRow(), p.screen.CursorCol())
638+ p.cursorMove = false
630 }639 }
631 return out.Bytes()640 return out.Bytes()
632 }641 }
@@ -640,9 +649,9 @@ func (p *echoPredictor) render() []byte {
640 var out bytes.Buffer649 var out bytes.Buffer
641 show := p.displaying() && p.enabled()650 show := p.displaying() && p.enabled()
642 var want []int651 var want []int
643- // Until the daemon answers an unknown key (Enter), the guesses' row may652+ // Once a frame arrived while an unknown key (Enter) is unanswered, the
644- // have moved, so none is drawn.653+ // guesses' row may have moved, so none is drawn until the answer.
645- if show && p.cells != nil && p.row < p.screen.Rows() && p.echo >= p.holdUntil {654+ if show && p.cells != nil && p.row < p.screen.Rows() && !(p.held && p.echo < p.holdUntil) {
646 for col := range p.cells {655 for col := range p.cells {
647 cell := &p.cells[col]656 cell := &p.cells[col]
648 if !cell.active || cell.unknown || cell.tentative(p.confEpoch) {657 if !cell.active || cell.unknown || cell.tentative(p.confEpoch) {
M internal/usecase/client/predictive_echo_test.go +13 -0
@@ -126,6 +126,19 @@ func TestEchoPredictorNeverDrawsStaleGuesses(t *testing.T) {
126126 }
127127 }
128128
129+func TestEchoPredictorEnterKeepsDrawnGuessesUntilAFrame(t *testing.T) {
130+ t.Parallel()
131+ h := newEchoHarness(t, domain.EchoPredictAlways)
132+ h.warm(100 * time.Millisecond)
133+ h.confirm()
134+ if got := h.typed("b"); !bytes.Contains(got, []byte("b")) {
135+ t.Fatalf("render = %q, want the guess drawn", got)
136+ }
137+ if got := h.typed("\r"); bytes.Contains(got, []byte(" ")) {
138+ t.Fatalf("render after Enter = %q erases the typed guess before any frame", got)
139+ }
140+}
141+
129142 func TestEchoPredictorUndrawRestoresMirror(t *testing.T) {
130143 t.Parallel()
131144 h := newEchoHarness(t, domain.EchoPredictAlways)
@@ -126,6 +126,19 @@ func TestEchoPredictorNeverDrawsStaleGuesses(t *testing.T) {
126 }126 }
127 }127 }
128 128
129+func TestEchoPredictorEnterKeepsDrawnGuessesUntilAFrame(t *testing.T) {
130+ t.Parallel()
131+ h := newEchoHarness(t, domain.EchoPredictAlways)
132+ h.warm(100 * time.Millisecond)
133+ h.confirm()
134+ if got := h.typed("b"); !bytes.Contains(got, []byte("b")) {
135+ t.Fatalf("render = %q, want the guess drawn", got)
136+ }
137+ if got := h.typed("\r"); bytes.Contains(got, []byte(" ")) {
138+ t.Fatalf("render after Enter = %q erases the typed guess before any frame", got)
139+ }
140+}
141+
129 func TestEchoPredictorUndrawRestoresMirror(t *testing.T) {142 func TestEchoPredictorUndrawRestoresMirror(t *testing.T) {
130 t.Parallel()143 t.Parallel()
131 h := newEchoHarness(t, domain.EchoPredictAlways)144 h := newEchoHarness(t, domain.EchoPredictAlways)

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