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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 122 | 122 | mode domain.EchoPredictMode | |
| 123 | 123 | screen *vt.Screen | |
| 124 | 124 | ||
| 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 | |
| 136 | 141 | row int | |
| 137 | 142 | cells []echoCell | |
| 138 | 143 | cursor echoCursor | |
| @@ -161,13 +166,15 @@ func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) { | |||
| 161 | 166 | // frame would land on unrelated content. | |
| 162 | 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 | 171 | p.screen.Write(data) | |
| 166 | 172 | } | |
| 167 | 173 | if output.Echo > p.echo { | |
| 168 | 174 | p.sample(output.Echo, now) | |
| 169 | 175 | p.echo = output.Echo | |
| 170 | 176 | } | |
| 177 | + | p.held = p.echo < p.holdUntil && (p.held || len(data) > 0) | |
| 171 | 178 | p.cull(now) | |
| 172 | 179 | } | |
| 173 | 180 | ||
| @@ -624,9 +631,11 @@ func (p *echoPredictor) undraw() []byte { | |||
| 624 | 631 | } | |
| 625 | 632 | } | |
| 626 | 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 | 636 | out.WriteString("\x1b[0m") | |
| 629 | 637 | writeEchoCUP(&out, p.screen.CursorRow(), p.screen.CursorCol()) | |
| 638 | + | p.cursorMove = false | |
| 630 | 639 | } | |
| 631 | 640 | return out.Bytes() | |
| 632 | 641 | } | |
| @@ -640,9 +649,9 @@ func (p *echoPredictor) render() []byte { | |||
| 640 | 649 | var out bytes.Buffer | |
| 641 | 650 | show := p.displaying() && p.enabled() | |
| 642 | 651 | 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) { | |
| 646 | 655 | for col := range p.cells { | |
| 647 | 656 | cell := &p.cells[col] | |
| 648 | 657 | if !cell.active || cell.unknown || cell.tentative(p.confEpoch) { | |
| @@ -122,17 +122,22 @@ type echoPredictor struct { | |||||
| 122 | mode domain.EchoPredictMode | 122 | mode domain.EchoPredictMode | ||
| 123 | screen *vt.Screen | 123 | screen *vt.Screen | ||
| 124 | 124 | ||||
| 125 | - | sent []echoSent | 125 | + | sent []echoSent |
| 126 | - | echo uint64 | 126 | + | echo uint64 |
| 127 | - | srtt time.Duration | 127 | + | srtt time.Duration |
| 128 | - | srttKnown bool | 128 | + | srttKnown bool |
| 129 | - | srttOn bool | 129 | + | srttOn bool |
| 130 | - | flagging bool | 130 | + | flagging bool |
| 131 | - | glitch int | 131 | + | glitch int |
| 132 | - | lastQuick time.Time | 132 | + | lastQuick time.Time |
| 133 | - | predEpoch uint64 | 133 | + | predEpoch uint64 |
| 134 | - | confEpoch uint64 | 134 | + | confEpoch uint64 |
| 135 | - | holdUntil 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 | |||
| 136 | row int | 141 | row int | ||
| 137 | cells []echoCell | 142 | cells []echoCell | ||
| 138 | cursor echoCursor | 143 | 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.Echo | 175 | 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.Buffer | 649 | var out bytes.Buffer | ||
| 641 | show := p.displaying() && p.enabled() | 650 | show := p.displaying() && p.enabled() | ||
| 642 | var want []int | 651 | var want []int | ||
| 643 | - | // Until the daemon answers an unknown key (Enter), the guesses' row may | 652 | + | // 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) { | |||
| 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 | 142 | func TestEchoPredictorUndrawRestoresMirror(t *testing.T) { | |
| 130 | 143 | t.Parallel() | |
| 131 | 144 | 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) | ||