detached a5a6cfe

fix(client): keep predicted echo exact on wide text, borders and resizes

- Refuse to shift wide, payload or border cells and repaint wide glyphs whole.
- Emit underline styles and colors when restoring cells.
- Strip APC graphics from the mirror and skip OSC/DCS input.
- Erase drawn guesses on a same-size resize.
- Predict nothing before the first echo acknowledgement.
- Default echo.predict to never outside the user's config and apply it on
  every terminal attach path.
- Test the attachment glue with a generated prediction foreground mock.
commit
a5a6cfeadb0c83ac3f7de6725868d4fa5b24e0b3
author
brice · · 2026-10-07 13:38 UTC
parent
a686aba
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
feat/predictive-echo
10 files · +494 -23
M .mockery.yml +7 -0
@@ -47,6 +47,13 @@ packages:
4747 dir: internal/usecase/daemon
4848 filename: action_runner_mock_test.go
4949 pkgname: daemon
50+ github.com/bnema/vev/internal/usecase/client:
51+ interfaces:
52+ attachmentPredictionForeground:
53+ config:
54+ dir: internal/usecase/client
55+ filename: prediction_foreground_mock_test.go
56+ pkgname: client
5057 github.com/bnema/vev/internal/usecase/command:
5158 interfaces:
5259 Context:
@@ -47,6 +47,13 @@ packages:
47 dir: internal/usecase/daemon47 dir: internal/usecase/daemon
48 filename: action_runner_mock_test.go48 filename: action_runner_mock_test.go
49 pkgname: daemon49 pkgname: daemon
50+ github.com/bnema/vev/internal/usecase/client:
51+ interfaces:
52+ attachmentPredictionForeground:
53+ config:
54+ dir: internal/usecase/client
55+ filename: prediction_foreground_mock_test.go
56+ pkgname: client
50 github.com/bnema/vev/internal/usecase/command:57 github.com/bnema/vev/internal/usecase/command:
51 interfaces:58 interfaces:
52 Context:59 Context:
M internal/adapters/config/config_test.go +3 -0
@@ -355,6 +355,9 @@ func TestParse(t *testing.T) {
355355 if !tt.want.Keyboard.KittyProtocol {
356356 tt.want.Keyboard = domain.Defaults().Keyboard
357357 }
358+ if tt.want.Echo.Predict == domain.EchoPredictNever {
359+ tt.want.Echo = domain.Defaults().Echo
360+ }
358361 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
359362 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
360363 }
@@ -355,6 +355,9 @@ func TestParse(t *testing.T) {
355 if !tt.want.Keyboard.KittyProtocol {355 if !tt.want.Keyboard.KittyProtocol {
356 tt.want.Keyboard = domain.Defaults().Keyboard356 tt.want.Keyboard = domain.Defaults().Keyboard
357 }357 }
358+ if tt.want.Echo.Predict == domain.EchoPredictNever {
359+ tt.want.Echo = domain.Defaults().Echo
360+ }
358 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {361 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
359 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)362 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
360 }363 }
M internal/app/broker_client.go +5 -2
@@ -685,9 +685,12 @@ func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConf
685685 }
686686
687687 // configuredTerminalAttachmentEnvironment loads the user's config and returns
688-// userTerminalAttachmentEnvironment for it.
688+// userTerminalAttachmentEnvironment for it, with the user's echo setting.
689689 func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
690- return userTerminalAttachmentEnvironment(log, loadConfigOrDefaults(log, platform.ConfigPath()).Terminal)
690+ cfg := loadConfigOrDefaults(log, platform.ConfigPath())
691+ env := userTerminalAttachmentEnvironment(log, cfg.Terminal)
692+ env.EchoPredict = cfg.Echo.Predict
693+ return env
691694 }
692695
693696 // outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment
@@ -685,9 +685,12 @@ func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConf
685 }685 }
686 686
687 // configuredTerminalAttachmentEnvironment loads the user's config and returns687 // configuredTerminalAttachmentEnvironment loads the user's config and returns
688-// userTerminalAttachmentEnvironment for it.688+// userTerminalAttachmentEnvironment for it, with the user's echo setting.
689 func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {689 func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
690- return userTerminalAttachmentEnvironment(log, loadConfigOrDefaults(log, platform.ConfigPath()).Terminal)690+ cfg := loadConfigOrDefaults(log, platform.ConfigPath())
691+ env := userTerminalAttachmentEnvironment(log, cfg.Terminal)
692+ env.EchoPredict = cfg.Echo.Predict
693+ return env
691 }694 }
692 695
693 // outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment696 // outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment
M internal/domain/config.go +8 -3
@@ -106,12 +106,14 @@ type KeyboardConfig struct {
106106 type EchoPredictMode uint8
107107
108108 const (
109+ // EchoPredictNever disables prediction. It is the zero value, so a
110+ // client composed without the user's config (virtual terminals, UI
111+ // drivers) never predicts.
112+ EchoPredictNever EchoPredictMode = iota
109113 // EchoPredictAdaptive shows predictions only on a slow or glitchy link.
110- EchoPredictAdaptive EchoPredictMode = iota
114+ EchoPredictAdaptive
111115 // EchoPredictAlways shows predictions whatever the link latency.
112116 EchoPredictAlways
113- // EchoPredictNever disables prediction.
114- EchoPredictNever
115117 )
116118
117119 // EchoConfig contains client-side predictive echo settings.
@@ -218,6 +220,9 @@ func Defaults() Config {
218220 Keyboard: KeyboardConfig{
219221 KittyProtocol: true,
220222 },
223+ Echo: EchoConfig{
224+ Predict: EchoPredictAdaptive,
225+ },
221226 Scrollback: DefaultScrollbackConfig(),
222227 }
223228 }
@@ -106,12 +106,14 @@ type KeyboardConfig struct {
106 type EchoPredictMode uint8106 type EchoPredictMode uint8
107 107
108 const (108 const (
109+ // EchoPredictNever disables prediction. It is the zero value, so a
110+ // client composed without the user's config (virtual terminals, UI
111+ // drivers) never predicts.
112+ EchoPredictNever EchoPredictMode = iota
109 // EchoPredictAdaptive shows predictions only on a slow or glitchy link.113 // EchoPredictAdaptive shows predictions only on a slow or glitchy link.
110- EchoPredictAdaptive EchoPredictMode = iota114+ EchoPredictAdaptive
111 // EchoPredictAlways shows predictions whatever the link latency.115 // EchoPredictAlways shows predictions whatever the link latency.
112 EchoPredictAlways116 EchoPredictAlways
113- // EchoPredictNever disables prediction.
114- EchoPredictNever
115 )117 )
116 118
117 // EchoConfig contains client-side predictive echo settings.119 // EchoConfig contains client-side predictive echo settings.
@@ -218,6 +220,9 @@ func Defaults() Config {
218 Keyboard: KeyboardConfig{220 Keyboard: KeyboardConfig{
219 KittyProtocol: true,221 KittyProtocol: true,
220 },222 },
223+ Echo: EchoConfig{
224+ Predict: EchoPredictAdaptive,
225+ },
221 Scrollback: DefaultScrollbackConfig(),226 Scrollback: DefaultScrollbackConfig(),
222 }227 }
223 }228 }
M internal/usecase/client/attachment_echo.go +12 -4
@@ -62,12 +62,20 @@ func (e *attachmentEcho) output(output protocol.Output) error {
6262 return e.draw()
6363 }
6464
65-// resized drops every guess: the next frame repaints the new geometry.
66-func (e *attachmentEcho) resized() {
65+// resized handles a terminal resize. A new size makes the daemon repaint
66+// everything, so the guesses are simply forgotten. The same size brings no
67+// repaint, so drawn guesses are erased from the mirror instead.
68+func (e *attachmentEcho) resized(size domain.Size) error {
6769 if e == nil {
68- return
70+ return nil
71+ }
72+ screen := e.predictor.screen
73+ if size.Cols != screen.Columns() || size.Rows != screen.Rows() {
74+ e.predictor.forget()
75+ return nil
6976 }
70- e.predictor.forget()
77+ e.predictor.reset()
78+ return e.draw()
7179 }
7280
7381 // tickC fires while a pending guess waits on a timing trigger.
@@ -62,12 +62,20 @@ func (e *attachmentEcho) output(output protocol.Output) error {
62 return e.draw()62 return e.draw()
63 }63 }
64 64
65-// resized drops every guess: the next frame repaints the new geometry.65+// resized handles a terminal resize. A new size makes the daemon repaint
66-func (e *attachmentEcho) resized() {66+// everything, so the guesses are simply forgotten. The same size brings no
67+// repaint, so drawn guesses are erased from the mirror instead.
68+func (e *attachmentEcho) resized(size domain.Size) error {
67 if e == nil {69 if e == nil {
68- return70+ return nil
71+ }
72+ screen := e.predictor.screen
73+ if size.Cols != screen.Columns() || size.Rows != screen.Rows() {
74+ e.predictor.forget()
75+ return nil
69 }76 }
70- e.predictor.forget()77+ e.predictor.reset()
78+ return e.draw()
71 }79 }
72 80
73 // tickC fires while a pending guess waits on a timing trigger.81 // tickC fires while a pending guess waits on a timing trigger.
A internal/usecase/client/attachment_echo_test.go +106 -0
@@ -0,0 +1,106 @@
1+package client
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/mock"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
12+ "github.com/bnema/vev/internal/protocol"
13+)
14+
15+func newTestAttachmentEcho(t *testing.T, mode domain.EchoPredictMode) (*attachmentEcho, *mockattachmentPredictionForeground, *portsmocks.MockClock) {
16+ t.Helper()
17+ out := newMockattachmentPredictionForeground(t)
18+ clock := portsmocks.NewMockClock(t)
19+ clock.EXPECT().Now().Return(echoTestStart).Maybe()
20+ echo := &attachmentEcho{predictor: newEchoPredictor(mode), out: out, clock: clock}
21+ echo.seed(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")})
22+ // One acknowledged input gives the engine its first round trip.
23+ echo.predictor.input(1, nil, echoTestStart)
24+ echo.predictor.applyOutput(protocol.Output{Epoch: 1, Echo: 1}, echoTestStart.Add(echoServerDelay))
25+ echo.predictor.confEpoch = echo.predictor.predEpoch
26+ return echo, out, clock
27+}
28+
29+func TestNewAttachmentEchoSkipsNonPredictingAttachments(t *testing.T) {
30+ t.Parallel()
31+ fg := newMockattachmentPredictionForeground(t)
32+ tests := []struct {
33+ name string
34+ local bool
35+ mode domain.EchoPredictMode
36+ fg AttachmentForeground
37+ }{
38+ {name: "local attachment", local: true, mode: domain.EchoPredictAdaptive},
39+ {name: "prediction off", mode: domain.EchoPredictNever},
40+ {name: "foreground without prediction seam", mode: domain.EchoPredictAdaptive},
41+ }
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ t.Parallel()
45+ w := &sessionAttachmentWorker{cfg: sessionAttachmentConfig{EchoPredict: tt.mode}}
46+ w.cfg.Request.Local = tt.local
47+ require.Nil(t, newAttachmentEcho(w, tt.fg))
48+ })
49+ }
50+ require.NotNil(t, newAttachmentEcho(&sessionAttachmentWorker{cfg: sessionAttachmentConfig{EchoPredict: domain.EchoPredictAdaptive, Clock: systemClock{}}}, struct {
51+ AttachmentForeground
52+ attachmentPredictionForeground
53+ }{attachmentPredictionForeground: fg}), "a remote attachment with the seam predicts")
54+}
55+
56+func TestAttachmentEchoDrawsGuessAndArmsTick(t *testing.T) {
57+ t.Parallel()
58+ echo, out, clock := newTestAttachmentEcho(t, domain.EchoPredictAdaptive)
59+ // A slow link: guesses show, and the tick waits on the glitch trigger.
60+ echo.predictor.srtt = 100 * time.Millisecond
61+ tick := portsmocks.NewMockTimer(t)
62+ clock.EXPECT().NewTimer(echoTickInterval).Return(tick).Once()
63+ var drawn []byte
64+ out.EXPECT().writePrediction(mock.Anything).RunAndReturn(func(data []byte) (bool, error) {
65+ drawn = append([]byte(nil), data...)
66+ return true, nil
67+ }).Once()
68+
69+ require.NoError(t, echo.input(2, []byte("b")))
70+ require.Equal(t, "\x1b[1;3H\x1b[0mb\x1b[0m\x1b[1;4H", string(drawn))
71+
72+ fired := make(chan time.Time)
73+ tick.EXPECT().C().Return(fired).Once()
74+ require.Equal(t, (<-chan time.Time)(fired), echo.tickC())
75+ tick.EXPECT().Stop().Return(true).Once()
76+ echo.close()
77+ require.Nil(t, echo.tickC())
78+}
79+
80+func TestAttachmentEchoForgetsGuessesWhileOverlayOwnsTerminal(t *testing.T) {
81+ t.Parallel()
82+ echo, out, _ := newTestAttachmentEcho(t, domain.EchoPredictAlways)
83+ out.EXPECT().writePrediction(mock.Anything).Return(false, nil).Once()
84+
85+ require.NoError(t, echo.input(2, []byte("b")))
86+ require.False(t, echo.predictor.active(), "guesses survived an overlay")
87+ require.Empty(t, echo.predictor.drawn, "an overlay-covered guess must not be repainted later")
88+}
89+
90+func TestAttachmentEchoSameSizeResizeRepaintsDrawnGuesses(t *testing.T) {
91+ t.Parallel()
92+ echo, out, _ := newTestAttachmentEcho(t, domain.EchoPredictAlways)
93+ out.EXPECT().writePrediction(mock.Anything).Return(true, nil).Once()
94+ require.NoError(t, echo.input(2, []byte("b")))
95+
96+ var repaint []byte
97+ out.EXPECT().writePrediction(mock.Anything).RunAndReturn(func(data []byte) (bool, error) {
98+ repaint = append([]byte(nil), data...)
99+ return true, nil
100+ }).Once()
101+ require.NoError(t, echo.resized(domain.Size{Cols: 20, Rows: 3}))
102+ require.Equal(t, "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H", string(repaint))
103+
104+ // A new size brings a full repaint from the daemon: nothing is written.
105+ require.NoError(t, echo.resized(domain.Size{Cols: 30, Rows: 3}))
106+}
@@ -0,0 +1,106 @@
1+package client
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/mock"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
12+ "github.com/bnema/vev/internal/protocol"
13+)
14+
15+func newTestAttachmentEcho(t *testing.T, mode domain.EchoPredictMode) (*attachmentEcho, *mockattachmentPredictionForeground, *portsmocks.MockClock) {
16+ t.Helper()
17+ out := newMockattachmentPredictionForeground(t)
18+ clock := portsmocks.NewMockClock(t)
19+ clock.EXPECT().Now().Return(echoTestStart).Maybe()
20+ echo := &attachmentEcho{predictor: newEchoPredictor(mode), out: out, clock: clock}
21+ echo.seed(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")})
22+ // One acknowledged input gives the engine its first round trip.
23+ echo.predictor.input(1, nil, echoTestStart)
24+ echo.predictor.applyOutput(protocol.Output{Epoch: 1, Echo: 1}, echoTestStart.Add(echoServerDelay))
25+ echo.predictor.confEpoch = echo.predictor.predEpoch
26+ return echo, out, clock
27+}
28+
29+func TestNewAttachmentEchoSkipsNonPredictingAttachments(t *testing.T) {
30+ t.Parallel()
31+ fg := newMockattachmentPredictionForeground(t)
32+ tests := []struct {
33+ name string
34+ local bool
35+ mode domain.EchoPredictMode
36+ fg AttachmentForeground
37+ }{
38+ {name: "local attachment", local: true, mode: domain.EchoPredictAdaptive},
39+ {name: "prediction off", mode: domain.EchoPredictNever},
40+ {name: "foreground without prediction seam", mode: domain.EchoPredictAdaptive},
41+ }
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ t.Parallel()
45+ w := &sessionAttachmentWorker{cfg: sessionAttachmentConfig{EchoPredict: tt.mode}}
46+ w.cfg.Request.Local = tt.local
47+ require.Nil(t, newAttachmentEcho(w, tt.fg))
48+ })
49+ }
50+ require.NotNil(t, newAttachmentEcho(&sessionAttachmentWorker{cfg: sessionAttachmentConfig{EchoPredict: domain.EchoPredictAdaptive, Clock: systemClock{}}}, struct {
51+ AttachmentForeground
52+ attachmentPredictionForeground
53+ }{attachmentPredictionForeground: fg}), "a remote attachment with the seam predicts")
54+}
55+
56+func TestAttachmentEchoDrawsGuessAndArmsTick(t *testing.T) {
57+ t.Parallel()
58+ echo, out, clock := newTestAttachmentEcho(t, domain.EchoPredictAdaptive)
59+ // A slow link: guesses show, and the tick waits on the glitch trigger.
60+ echo.predictor.srtt = 100 * time.Millisecond
61+ tick := portsmocks.NewMockTimer(t)
62+ clock.EXPECT().NewTimer(echoTickInterval).Return(tick).Once()
63+ var drawn []byte
64+ out.EXPECT().writePrediction(mock.Anything).RunAndReturn(func(data []byte) (bool, error) {
65+ drawn = append([]byte(nil), data...)
66+ return true, nil
67+ }).Once()
68+
69+ require.NoError(t, echo.input(2, []byte("b")))
70+ require.Equal(t, "\x1b[1;3H\x1b[0mb\x1b[0m\x1b[1;4H", string(drawn))
71+
72+ fired := make(chan time.Time)
73+ tick.EXPECT().C().Return(fired).Once()
74+ require.Equal(t, (<-chan time.Time)(fired), echo.tickC())
75+ tick.EXPECT().Stop().Return(true).Once()
76+ echo.close()
77+ require.Nil(t, echo.tickC())
78+}
79+
80+func TestAttachmentEchoForgetsGuessesWhileOverlayOwnsTerminal(t *testing.T) {
81+ t.Parallel()
82+ echo, out, _ := newTestAttachmentEcho(t, domain.EchoPredictAlways)
83+ out.EXPECT().writePrediction(mock.Anything).Return(false, nil).Once()
84+
85+ require.NoError(t, echo.input(2, []byte("b")))
86+ require.False(t, echo.predictor.active(), "guesses survived an overlay")
87+ require.Empty(t, echo.predictor.drawn, "an overlay-covered guess must not be repainted later")
88+}
89+
90+func TestAttachmentEchoSameSizeResizeRepaintsDrawnGuesses(t *testing.T) {
91+ t.Parallel()
92+ echo, out, _ := newTestAttachmentEcho(t, domain.EchoPredictAlways)
93+ out.EXPECT().writePrediction(mock.Anything).Return(true, nil).Once()
94+ require.NoError(t, echo.input(2, []byte("b")))
95+
96+ var repaint []byte
97+ out.EXPECT().writePrediction(mock.Anything).RunAndReturn(func(data []byte) (bool, error) {
98+ repaint = append([]byte(nil), data...)
99+ return true, nil
100+ }).Once()
101+ require.NoError(t, echo.resized(domain.Size{Cols: 20, Rows: 3}))
102+ require.Equal(t, "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H", string(repaint))
103+
104+ // A new size brings a full repaint from the daemon: nothing is written.
105+ require.NoError(t, echo.resized(domain.Size{Cols: 30, Rows: 3}))
106+}
M internal/usecase/client/attachment_session.go +3 -1
@@ -639,7 +639,9 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
639639 event.message = protocol.Input{InputSeq: input.nextSeq(), ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)}
640640 }
641641 if resize, ok := event.message.(protocol.Resize); ok {
642- echo.resized()
642+ if err := echo.resized(resize.Size); err != nil {
643+ return w.settle(ctx, fg, stream, token, err)
644+ }
643645 if err := picker.resize(ctx, state, resize.Size); err != nil {
644646 return w.settle(ctx, fg, stream, token, err)
645647 }
@@ -639,7 +639,9 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
639 event.message = protocol.Input{InputSeq: input.nextSeq(), ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)}639 event.message = protocol.Input{InputSeq: input.nextSeq(), ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)}
640 }640 }
641 if resize, ok := event.message.(protocol.Resize); ok {641 if resize, ok := event.message.(protocol.Resize); ok {
642- echo.resized()642+ if err := echo.resized(resize.Size); err != nil {
643+ return w.settle(ctx, fg, stream, token, err)
644+ }
643 if err := picker.resize(ctx, state, resize.Size); err != nil {645 if err := picker.resize(ctx, state, resize.Size); err != nil {
644 return w.settle(ctx, fg, stream, token, err)646 return w.settle(ctx, fg, stream, token, err)
645 }647 }
A internal/usecase/client/prediction_foreground_mock_test.go +105 -0
@@ -0,0 +1,105 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package client
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockattachmentPredictionForeground creates a new instance of mockattachmentPredictionForeground. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockattachmentPredictionForeground(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockattachmentPredictionForeground {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockattachmentPredictionForeground{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockattachmentPredictionForeground is an autogenerated mock type for the attachmentPredictionForeground type
35+type mockattachmentPredictionForeground struct {
36+ mock.Mock
37+}
38+
39+type mockattachmentPredictionForeground_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockattachmentPredictionForeground) EXPECT() *mockattachmentPredictionForeground_Expecter {
44+ return &mockattachmentPredictionForeground_Expecter{mock: &_m.Mock}
45+}
46+
47+// writePrediction provides a mock function for the type mockattachmentPredictionForeground
48+func (_mock *mockattachmentPredictionForeground) writePrediction(data []byte) (bool, error) {
49+ ret := _mock.Called(data)
50+
51+ if len(ret) == 0 {
52+ panic("no return value specified for writePrediction")
53+ }
54+
55+ var r0 bool
56+ var r1 error
57+ if returnFunc, ok := ret.Get(0).(func([]byte) (bool, error)); ok {
58+ return returnFunc(data)
59+ }
60+ if returnFunc, ok := ret.Get(0).(func([]byte) bool); ok {
61+ r0 = returnFunc(data)
62+ } else {
63+ r0 = ret.Get(0).(bool)
64+ }
65+ if returnFunc, ok := ret.Get(1).(func([]byte) error); ok {
66+ r1 = returnFunc(data)
67+ } else {
68+ r1 = ret.Error(1)
69+ }
70+ return r0, r1
71+}
72+
73+// mockattachmentPredictionForeground_writePrediction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'writePrediction'
74+type mockattachmentPredictionForeground_writePrediction_Call struct {
75+ *mock.Call
76+}
77+
78+// writePrediction is a helper method to define mock.On call
79+// - data []byte
80+func (_e *mockattachmentPredictionForeground_Expecter) writePrediction(data any) *mockattachmentPredictionForeground_writePrediction_Call {
81+ return &mockattachmentPredictionForeground_writePrediction_Call{Call: _e.mock.On("writePrediction", data)}
82+}
83+
84+func (_c *mockattachmentPredictionForeground_writePrediction_Call) Run(run func(data []byte)) *mockattachmentPredictionForeground_writePrediction_Call {
85+ _c.Call.Run(func(args mock.Arguments) {
86+ var arg0 []byte
87+ if args[0] != nil {
88+ arg0 = args[0].([]byte)
89+ }
90+ run(
91+ arg0,
92+ )
93+ })
94+ return _c
95+}
96+
97+func (_c *mockattachmentPredictionForeground_writePrediction_Call) Return(b bool, err error) *mockattachmentPredictionForeground_writePrediction_Call {
98+ _c.Call.Return(b, err)
99+ return _c
100+}
101+
102+func (_c *mockattachmentPredictionForeground_writePrediction_Call) RunAndReturn(run func(data []byte) (bool, error)) *mockattachmentPredictionForeground_writePrediction_Call {
103+ _c.Call.Return(run)
104+ return _c
105+}
@@ -0,0 +1,105 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package client
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockattachmentPredictionForeground creates a new instance of mockattachmentPredictionForeground. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockattachmentPredictionForeground(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockattachmentPredictionForeground {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockattachmentPredictionForeground{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockattachmentPredictionForeground is an autogenerated mock type for the attachmentPredictionForeground type
35+type mockattachmentPredictionForeground struct {
36+ mock.Mock
37+}
38+
39+type mockattachmentPredictionForeground_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockattachmentPredictionForeground) EXPECT() *mockattachmentPredictionForeground_Expecter {
44+ return &mockattachmentPredictionForeground_Expecter{mock: &_m.Mock}
45+}
46+
47+// writePrediction provides a mock function for the type mockattachmentPredictionForeground
48+func (_mock *mockattachmentPredictionForeground) writePrediction(data []byte) (bool, error) {
49+ ret := _mock.Called(data)
50+
51+ if len(ret) == 0 {
52+ panic("no return value specified for writePrediction")
53+ }
54+
55+ var r0 bool
56+ var r1 error
57+ if returnFunc, ok := ret.Get(0).(func([]byte) (bool, error)); ok {
58+ return returnFunc(data)
59+ }
60+ if returnFunc, ok := ret.Get(0).(func([]byte) bool); ok {
61+ r0 = returnFunc(data)
62+ } else {
63+ r0 = ret.Get(0).(bool)
64+ }
65+ if returnFunc, ok := ret.Get(1).(func([]byte) error); ok {
66+ r1 = returnFunc(data)
67+ } else {
68+ r1 = ret.Error(1)
69+ }
70+ return r0, r1
71+}
72+
73+// mockattachmentPredictionForeground_writePrediction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'writePrediction'
74+type mockattachmentPredictionForeground_writePrediction_Call struct {
75+ *mock.Call
76+}
77+
78+// writePrediction is a helper method to define mock.On call
79+// - data []byte
80+func (_e *mockattachmentPredictionForeground_Expecter) writePrediction(data any) *mockattachmentPredictionForeground_writePrediction_Call {
81+ return &mockattachmentPredictionForeground_writePrediction_Call{Call: _e.mock.On("writePrediction", data)}
82+}
83+
84+func (_c *mockattachmentPredictionForeground_writePrediction_Call) Run(run func(data []byte)) *mockattachmentPredictionForeground_writePrediction_Call {
85+ _c.Call.Run(func(args mock.Arguments) {
86+ var arg0 []byte
87+ if args[0] != nil {
88+ arg0 = args[0].([]byte)
89+ }
90+ run(
91+ arg0,
92+ )
93+ })
94+ return _c
95+}
96+
97+func (_c *mockattachmentPredictionForeground_writePrediction_Call) Return(b bool, err error) *mockattachmentPredictionForeground_writePrediction_Call {
98+ _c.Call.Return(b, err)
99+ return _c
100+}
101+
102+func (_c *mockattachmentPredictionForeground_writePrediction_Call) RunAndReturn(run func(data []byte) (bool, error)) *mockattachmentPredictionForeground_writePrediction_Call {
103+ _c.Call.Return(run)
104+ return _c
105+}
M internal/usecase/client/predictive_echo.go +152 -13
@@ -140,6 +140,7 @@ type echoPredictor struct {
140140 drawnRow int
141141 drawn []int
142142 cursorMove bool
143+ apc apcFilter
143144 }
144145
145146 func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor {
@@ -161,7 +162,9 @@ func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) {
161162 p.drawn = p.drawn[:0]
162163 p.cursorMove = false
163164 }
164- p.screen.Write(output.Data)
165+ if data := p.apc.strip(output.Data); len(data) > 0 {
166+ p.screen.Write(data)
167+ }
165168 if output.Echo > p.echo {
166169 p.sample(output.Echo, now)
167170 p.echo = output.Echo
@@ -351,6 +354,23 @@ func (p *echoPredictor) cellValidity(col int) echoValidity {
351354
352355 func isBlankRune(r rune) bool { return r == 0 || r == ' ' }
353356
357+// isBorderRune reports a box-drawing rune: a pane border or bar the shell
358+// line never shifts across.
359+func isBorderRune(r rune) bool { return r >= 0x2500 && r <= 0x257f }
360+
361+// isPlainCell reports a cell the engine can move and repaint exactly: one
362+// column, no grapheme or hyperlink payload.
363+func isPlainCell(cell vt.Cell) bool {
364+ return !cell.Continuation && cell.Payload.Empty() && (cell.Rune == 0 || vt.RuneWidth(cell.Rune) == 1)
365+}
366+
367+// unknownKey gives up on the cursor row until the daemon answers this input.
368+func (p *echoPredictor) unknownKey(seq uint64) {
369+ p.becomeTentative()
370+ p.cursor = echoCursor{}
371+ p.holdUntil = seq
372+}
373+
354374 // input records the Input numbered seq and predicts its bytes.
355375 func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) {
356376 if seq == 0 || p.mode == domain.EchoPredictNever {
@@ -361,7 +381,10 @@ func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) {
361381 }
362382 p.sent = append(p.sent, echoSent{seq: seq, at: now})
363383 p.cull(now)
364- if !p.enabled() {
384+ // A daemon that never acknowledges echo (Echo stays 0) could never
385+ // confirm or refute a guess, so nothing is predicted before the first
386+ // acknowledgement.
387+ if !p.enabled() || !p.srttKnown {
365388 return
366389 }
367390 // A bracketed paste arrives as one Input: it is not typing.
@@ -384,9 +407,7 @@ func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) {
384407 p.backspace(seq, now)
385408 case r == '\r':
386409 // The next row's state is only known once the daemon answers.
387- p.becomeTentative()
388- p.cursor = echoCursor{}
389- p.holdUntil = seq
410+ p.unknownKey(seq)
390411 case vt.RuneWidth(r) != 1:
391412 p.becomeTentative()
392413 default:
@@ -411,6 +432,17 @@ func escapeLength(data []byte) int {
411432 return len(data)
412433 case 'O':
413434 return min(3, len(data))
435+ case ']', 'P', '_', '^', 'X':
436+ // OSC, DCS, APC, PM, SOS: up to BEL or ST.
437+ for i := 2; i < len(data); i++ {
438+ if data[i] == 0x07 {
439+ return i + 1
440+ }
441+ if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
442+ return i + 2
443+ }
444+ }
445+ return len(data)
414446 }
415447 return 2
416448 }
@@ -470,6 +502,12 @@ func (p *echoPredictor) print(r rune, seq uint64, now time.Time) {
470502 end := col
471503 for end < width-1 {
472504 cell, known := p.effective(end)
505+ if known && (!isPlainCell(cell) || isBorderRune(cell.Rune)) {
506+ // Wide or payload text cannot be shifted exactly, and a border
507+ // would move with the line.
508+ p.unknownKey(seq)
509+ return
510+ }
473511 if known && isBlankRune(cell.Rune) {
474512 break
475513 }
@@ -520,6 +558,16 @@ func (p *echoPredictor) backspace(seq uint64, now time.Time) {
520558 }
521559 width := len(p.cells)
522560 col := p.cursor.col - 1
561+ for i := col; i < width; i++ {
562+ cell, known := p.effective(i)
563+ if known && !isPlainCell(cell) {
564+ p.unknownKey(seq)
565+ return
566+ }
567+ if known && (isBlankRune(cell.Rune) || isBorderRune(cell.Rune)) {
568+ break
569+ }
570+ }
523571 p.moveCursor(col, seq)
524572 for i := col; i < width; i++ {
525573 cell := &p.cells[i]
@@ -530,6 +578,10 @@ func (p *echoPredictor) backspace(seq uint64, now time.Time) {
530578 next, known = p.effective(i + 1)
531579 }
532580 blank := known && isBlankRune(next.Rune)
581+ if known && isBorderRune(next.Rune) {
582+ // The text ends at a border: what fills this cell is unknown.
583+ known, blank = false, true
584+ }
533585 cell.resetWithOriginal()
534586 cell.arm(seq, p.predEpoch, now)
535587 cell.originals = append(cell.originals, original)
@@ -576,7 +628,7 @@ func (p *echoPredictor) render() []byte {
576628 continue
577629 }
578630 if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() {
579- writeEchoCell(&out, p.drawnRow, col, p.screen.Cell(col, p.drawnRow), false)
631+ p.restoreCell(&out, p.drawnRow, col)
580632 }
581633 }
582634 for _, col := range want {
@@ -609,18 +661,30 @@ func writeEchoCUP(out *bytes.Buffer, row, col int) {
609661 out.WriteByte('H')
610662 }
611663
664+// restoreCell repaints the mirror's cell at (row, col). The right half of a
665+// wide glyph is repainted from its left cell, which covers both columns.
666+func (p *echoPredictor) restoreCell(out *bytes.Buffer, row, col int) {
667+ cell := p.screen.Cell(col, row)
668+ if cell.Continuation && col > 0 {
669+ col--
670+ cell = p.screen.Cell(col, row)
671+ }
672+ writeEchoCell(out, row, col, cell, false)
673+}
674+
612675 // writeEchoCell draws one cell. Mirror styles come from daemon output already
613676 // projected to this terminal's color profile, so they are written as is.
614677 func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) {
615678 writeEchoCUP(out, row, col)
616679 out.WriteString(echoSGR(cell.Style, underline))
617- r := cell.Rune
618- if cell.Continuation || r == 0 || vt.RuneWidth(r) != 1 {
619- // A wide rune's halves cannot be drawn alone; a blank is the safe
620- // stand-in until the daemon's next frame.
621- r = ' '
680+ switch {
681+ case cell.Continuation || cell.Rune == 0:
682+ out.WriteByte(' ')
683+ case cell.Payload.Grapheme() != "":
684+ out.WriteString(cell.Payload.Grapheme())
685+ default:
686+ out.WriteRune(cell.Rune)
622687 }
623- out.WriteRune(r)
624688 }
625689
626690 func echoSGR(style vt.Style, underline bool) string {
@@ -635,8 +699,22 @@ func echoSGR(style vt.Style, underline bool) string {
635699 if style.Italic {
636700 b.WriteString(";3")
637701 }
638- if underline || style.Attrs&vt.AttrUnderline != 0 {
702+ switch {
703+ case underline:
639704 b.WriteString(";4")
705+ case style.Attrs&vt.AttrUnderline != 0:
706+ switch style.UnderlineStyle {
707+ case vt.UnderlineDouble:
708+ b.WriteString(";21")
709+ case vt.UnderlineCurly:
710+ b.WriteString(";4:3")
711+ case vt.UnderlineDotted:
712+ b.WriteString(";4:4")
713+ case vt.UnderlineDashed:
714+ b.WriteString(";4:5")
715+ default:
716+ b.WriteString(";4")
717+ }
640718 }
641719 if style.Attrs&vt.AttrBlink != 0 {
642720 b.WriteString(";5")
@@ -649,6 +727,12 @@ func echoSGR(style vt.Style, underline bool) string {
649727 }
650728 writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90)
651729 writeEchoColor(&b, style.HasBackgroundRGB, style.BackgroundRGB, style.Background, 48, 40, 100)
730+ switch {
731+ case style.HasUnderlineColorRGB:
732+ b.WriteString(";58;2;" + strconv.Itoa(int(style.UnderlineColorRGB.R)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.G)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.B)))
733+ case style.HasUnderlineColor:
734+ b.WriteString(";58;5;" + strconv.Itoa(style.UnderlineColor))
735+ }
652736 b.WriteByte('m')
653737 return b.String()
654738 }
@@ -666,3 +750,58 @@ func writeEchoColor(b *strings.Builder, hasRGB bool, rgb vt.RGB, index, extended
666750 b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index))
667751 }
668752 }
753+
754+// apcFilter drops APC strings (ESC _ ... ST), which carry kitty graphics, from
755+// the mirror's input: the engine needs text only, and decoding images would
756+// cost the client CPU and memory. Its state spans Output boundaries.
757+type apcFilter struct {
758+ state uint8
759+}
760+
761+const (
762+ apcText uint8 = iota
763+ apcEscape
764+ apcBody
765+ apcBodyEscape
766+)
767+
768+func (f *apcFilter) strip(data []byte) []byte {
769+ if f.state == apcText && bytes.IndexByte(data, 0x1b) < 0 {
770+ return data
771+ }
772+ out := make([]byte, 0, len(data))
773+ for _, b := range data {
774+ switch f.state {
775+ case apcText:
776+ if b == 0x1b {
777+ f.state = apcEscape
778+ continue
779+ }
780+ out = append(out, b)
781+ case apcEscape:
782+ if b == '_' {
783+ f.state = apcBody
784+ continue
785+ }
786+ out = append(out, 0x1b, b)
787+ f.state = apcText
788+ if b == 0x1b {
789+ out = out[:len(out)-1]
790+ f.state = apcEscape
791+ }
792+ case apcBody:
793+ if b == 0x1b {
794+ f.state = apcBodyEscape
795+ }
796+ case apcBodyEscape:
797+ switch b {
798+ case '\\':
799+ f.state = apcText
800+ case 0x1b:
801+ default:
802+ f.state = apcBody
803+ }
804+ }
805+ }
806+ return out
807+}
@@ -140,6 +140,7 @@ type echoPredictor struct {
140 drawnRow int140 drawnRow int
141 drawn []int141 drawn []int
142 cursorMove bool142 cursorMove bool
143+ apc apcFilter
143 }144 }
144 145
145 func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor {146 func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor {
@@ -161,7 +162,9 @@ func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) {
161 p.drawn = p.drawn[:0]162 p.drawn = p.drawn[:0]
162 p.cursorMove = false163 p.cursorMove = false
163 }164 }
164- p.screen.Write(output.Data)165+ if data := p.apc.strip(output.Data); len(data) > 0 {
166+ p.screen.Write(data)
167+ }
165 if output.Echo > p.echo {168 if output.Echo > p.echo {
166 p.sample(output.Echo, now)169 p.sample(output.Echo, now)
167 p.echo = output.Echo170 p.echo = output.Echo
@@ -351,6 +354,23 @@ func (p *echoPredictor) cellValidity(col int) echoValidity {
351 354
352 func isBlankRune(r rune) bool { return r == 0 || r == ' ' }355 func isBlankRune(r rune) bool { return r == 0 || r == ' ' }
353 356
357+// isBorderRune reports a box-drawing rune: a pane border or bar the shell
358+// line never shifts across.
359+func isBorderRune(r rune) bool { return r >= 0x2500 && r <= 0x257f }
360+
361+// isPlainCell reports a cell the engine can move and repaint exactly: one
362+// column, no grapheme or hyperlink payload.
363+func isPlainCell(cell vt.Cell) bool {
364+ return !cell.Continuation && cell.Payload.Empty() && (cell.Rune == 0 || vt.RuneWidth(cell.Rune) == 1)
365+}
366+
367+// unknownKey gives up on the cursor row until the daemon answers this input.
368+func (p *echoPredictor) unknownKey(seq uint64) {
369+ p.becomeTentative()
370+ p.cursor = echoCursor{}
371+ p.holdUntil = seq
372+}
373+
354 // input records the Input numbered seq and predicts its bytes.374 // input records the Input numbered seq and predicts its bytes.
355 func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) {375 func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) {
356 if seq == 0 || p.mode == domain.EchoPredictNever {376 if seq == 0 || p.mode == domain.EchoPredictNever {
@@ -361,7 +381,10 @@ func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) {
361 }381 }
362 p.sent = append(p.sent, echoSent{seq: seq, at: now})382 p.sent = append(p.sent, echoSent{seq: seq, at: now})
363 p.cull(now)383 p.cull(now)
364- if !p.enabled() {384+ // A daemon that never acknowledges echo (Echo stays 0) could never
385+ // confirm or refute a guess, so nothing is predicted before the first
386+ // acknowledgement.
387+ if !p.enabled() || !p.srttKnown {
365 return388 return
366 }389 }
367 // A bracketed paste arrives as one Input: it is not typing.390 // A bracketed paste arrives as one Input: it is not typing.
@@ -384,9 +407,7 @@ func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) {
384 p.backspace(seq, now)407 p.backspace(seq, now)
385 case r == '\r':408 case r == '\r':
386 // The next row's state is only known once the daemon answers.409 // The next row's state is only known once the daemon answers.
387- p.becomeTentative()410+ p.unknownKey(seq)
388- p.cursor = echoCursor{}
389- p.holdUntil = seq
390 case vt.RuneWidth(r) != 1:411 case vt.RuneWidth(r) != 1:
391 p.becomeTentative()412 p.becomeTentative()
392 default:413 default:
@@ -411,6 +432,17 @@ func escapeLength(data []byte) int {
411 return len(data)432 return len(data)
412 case 'O':433 case 'O':
413 return min(3, len(data))434 return min(3, len(data))
435+ case ']', 'P', '_', '^', 'X':
436+ // OSC, DCS, APC, PM, SOS: up to BEL or ST.
437+ for i := 2; i < len(data); i++ {
438+ if data[i] == 0x07 {
439+ return i + 1
440+ }
441+ if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
442+ return i + 2
443+ }
444+ }
445+ return len(data)
414 }446 }
415 return 2447 return 2
416 }448 }
@@ -470,6 +502,12 @@ func (p *echoPredictor) print(r rune, seq uint64, now time.Time) {
470 end := col502 end := col
471 for end < width-1 {503 for end < width-1 {
472 cell, known := p.effective(end)504 cell, known := p.effective(end)
505+ if known && (!isPlainCell(cell) || isBorderRune(cell.Rune)) {
506+ // Wide or payload text cannot be shifted exactly, and a border
507+ // would move with the line.
508+ p.unknownKey(seq)
509+ return
510+ }
473 if known && isBlankRune(cell.Rune) {511 if known && isBlankRune(cell.Rune) {
474 break512 break
475 }513 }
@@ -520,6 +558,16 @@ func (p *echoPredictor) backspace(seq uint64, now time.Time) {
520 }558 }
521 width := len(p.cells)559 width := len(p.cells)
522 col := p.cursor.col - 1560 col := p.cursor.col - 1
561+ for i := col; i < width; i++ {
562+ cell, known := p.effective(i)
563+ if known && !isPlainCell(cell) {
564+ p.unknownKey(seq)
565+ return
566+ }
567+ if known && (isBlankRune(cell.Rune) || isBorderRune(cell.Rune)) {
568+ break
569+ }
570+ }
523 p.moveCursor(col, seq)571 p.moveCursor(col, seq)
524 for i := col; i < width; i++ {572 for i := col; i < width; i++ {
525 cell := &p.cells[i]573 cell := &p.cells[i]
@@ -530,6 +578,10 @@ func (p *echoPredictor) backspace(seq uint64, now time.Time) {
530 next, known = p.effective(i + 1)578 next, known = p.effective(i + 1)
531 }579 }
532 blank := known && isBlankRune(next.Rune)580 blank := known && isBlankRune(next.Rune)
581+ if known && isBorderRune(next.Rune) {
582+ // The text ends at a border: what fills this cell is unknown.
583+ known, blank = false, true
584+ }
533 cell.resetWithOriginal()585 cell.resetWithOriginal()
534 cell.arm(seq, p.predEpoch, now)586 cell.arm(seq, p.predEpoch, now)
535 cell.originals = append(cell.originals, original)587 cell.originals = append(cell.originals, original)
@@ -576,7 +628,7 @@ func (p *echoPredictor) render() []byte {
576 continue628 continue
577 }629 }
578 if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() {630 if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() {
579- writeEchoCell(&out, p.drawnRow, col, p.screen.Cell(col, p.drawnRow), false)631+ p.restoreCell(&out, p.drawnRow, col)
580 }632 }
581 }633 }
582 for _, col := range want {634 for _, col := range want {
@@ -609,18 +661,30 @@ func writeEchoCUP(out *bytes.Buffer, row, col int) {
609 out.WriteByte('H')661 out.WriteByte('H')
610 }662 }
611 663
664+// restoreCell repaints the mirror's cell at (row, col). The right half of a
665+// wide glyph is repainted from its left cell, which covers both columns.
666+func (p *echoPredictor) restoreCell(out *bytes.Buffer, row, col int) {
667+ cell := p.screen.Cell(col, row)
668+ if cell.Continuation && col > 0 {
669+ col--
670+ cell = p.screen.Cell(col, row)
671+ }
672+ writeEchoCell(out, row, col, cell, false)
673+}
674+
612 // writeEchoCell draws one cell. Mirror styles come from daemon output already675 // writeEchoCell draws one cell. Mirror styles come from daemon output already
613 // projected to this terminal's color profile, so they are written as is.676 // projected to this terminal's color profile, so they are written as is.
614 func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) {677 func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) {
615 writeEchoCUP(out, row, col)678 writeEchoCUP(out, row, col)
616 out.WriteString(echoSGR(cell.Style, underline))679 out.WriteString(echoSGR(cell.Style, underline))
617- r := cell.Rune680+ switch {
618- if cell.Continuation || r == 0 || vt.RuneWidth(r) != 1 {681+ case cell.Continuation || cell.Rune == 0:
619- // A wide rune's halves cannot be drawn alone; a blank is the safe682+ out.WriteByte(' ')
620- // stand-in until the daemon's next frame.683+ case cell.Payload.Grapheme() != "":
621- r = ' '684+ out.WriteString(cell.Payload.Grapheme())
685+ default:
686+ out.WriteRune(cell.Rune)
622 }687 }
623- out.WriteRune(r)
624 }688 }
625 689
626 func echoSGR(style vt.Style, underline bool) string {690 func echoSGR(style vt.Style, underline bool) string {
@@ -635,8 +699,22 @@ func echoSGR(style vt.Style, underline bool) string {
635 if style.Italic {699 if style.Italic {
636 b.WriteString(";3")700 b.WriteString(";3")
637 }701 }
638- if underline || style.Attrs&vt.AttrUnderline != 0 {702+ switch {
703+ case underline:
639 b.WriteString(";4")704 b.WriteString(";4")
705+ case style.Attrs&vt.AttrUnderline != 0:
706+ switch style.UnderlineStyle {
707+ case vt.UnderlineDouble:
708+ b.WriteString(";21")
709+ case vt.UnderlineCurly:
710+ b.WriteString(";4:3")
711+ case vt.UnderlineDotted:
712+ b.WriteString(";4:4")
713+ case vt.UnderlineDashed:
714+ b.WriteString(";4:5")
715+ default:
716+ b.WriteString(";4")
717+ }
640 }718 }
641 if style.Attrs&vt.AttrBlink != 0 {719 if style.Attrs&vt.AttrBlink != 0 {
642 b.WriteString(";5")720 b.WriteString(";5")
@@ -649,6 +727,12 @@ func echoSGR(style vt.Style, underline bool) string {
649 }727 }
650 writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90)728 writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90)
651 writeEchoColor(&b, style.HasBackgroundRGB, style.BackgroundRGB, style.Background, 48, 40, 100)729 writeEchoColor(&b, style.HasBackgroundRGB, style.BackgroundRGB, style.Background, 48, 40, 100)
730+ switch {
731+ case style.HasUnderlineColorRGB:
732+ b.WriteString(";58;2;" + strconv.Itoa(int(style.UnderlineColorRGB.R)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.G)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.B)))
733+ case style.HasUnderlineColor:
734+ b.WriteString(";58;5;" + strconv.Itoa(style.UnderlineColor))
735+ }
652 b.WriteByte('m')736 b.WriteByte('m')
653 return b.String()737 return b.String()
654 }738 }
@@ -666,3 +750,58 @@ func writeEchoColor(b *strings.Builder, hasRGB bool, rgb vt.RGB, index, extended
666 b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index))750 b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index))
667 }751 }
668 }752 }
753+
754+// apcFilter drops APC strings (ESC _ ... ST), which carry kitty graphics, from
755+// the mirror's input: the engine needs text only, and decoding images would
756+// cost the client CPU and memory. Its state spans Output boundaries.
757+type apcFilter struct {
758+ state uint8
759+}
760+
761+const (
762+ apcText uint8 = iota
763+ apcEscape
764+ apcBody
765+ apcBodyEscape
766+)
767+
768+func (f *apcFilter) strip(data []byte) []byte {
769+ if f.state == apcText && bytes.IndexByte(data, 0x1b) < 0 {
770+ return data
771+ }
772+ out := make([]byte, 0, len(data))
773+ for _, b := range data {
774+ switch f.state {
775+ case apcText:
776+ if b == 0x1b {
777+ f.state = apcEscape
778+ continue
779+ }
780+ out = append(out, b)
781+ case apcEscape:
782+ if b == '_' {
783+ f.state = apcBody
784+ continue
785+ }
786+ out = append(out, 0x1b, b)
787+ f.state = apcText
788+ if b == 0x1b {
789+ out = out[:len(out)-1]
790+ f.state = apcEscape
791+ }
792+ case apcBody:
793+ if b == 0x1b {
794+ f.state = apcBodyEscape
795+ }
796+ case apcBodyEscape:
797+ switch b {
798+ case '\\':
799+ f.state = apcText
800+ case 0x1b:
801+ default:
802+ f.state = apcBody
803+ }
804+ }
805+ }
806+ return out
807+}
M internal/usecase/client/predictive_echo_test.go +93 -0
@@ -1,6 +1,7 @@
11 package client
22
33 import (
4+ "bytes"
45 "strings"
56 "testing"
67 "time"
@@ -149,6 +150,7 @@ func TestEchoPredictorBackspace(t *testing.T) {
149150 func TestEchoPredictorInsertShiftsText(t *testing.T) {
150151 t.Parallel()
151152 h := newEchoHarness(t, domain.EchoPredictAlways)
153+ h.warm(10 * time.Millisecond)
152154 // "$ ac" with the cursor on c.
153155 h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("ac\x1b[1;4H")}, h.now)
154156 h.p.confEpoch = h.p.predEpoch
@@ -318,3 +320,94 @@ func TestEchoSGR(t *testing.T) {
318320 t.Fatalf("echoSGR rgb = %q, want %q", got, want)
319321 }
320322 }
323+
324+func TestEchoPredictorWaitsForFirstAcknowledgement(t *testing.T) {
325+ t.Parallel()
326+ h := newEchoHarness(t, domain.EchoPredictAlways)
327+ h.p.confEpoch = h.p.predEpoch
328+ if got := h.typed("b"); len(got) != 0 || h.p.active() {
329+ t.Fatalf("predicted before any echo acknowledgement: %q", got)
330+ }
331+}
332+
333+func TestEchoPredictorRefusesUnshiftableText(t *testing.T) {
334+ t.Parallel()
335+ tests := []struct {
336+ name string
337+ frame string
338+ }{
339+ {name: "wide rune after the cursor", frame: "a界\x1b[1;3H"},
340+ {name: "pane border after the cursor", frame: "ab│\x1b[1;3H"},
341+ }
342+ for _, tt := range tests {
343+ t.Run(tt.name, func(t *testing.T) {
344+ t.Parallel()
345+ h := newEchoHarness(t, domain.EchoPredictAlways)
346+ h.warm(10 * time.Millisecond)
347+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(tt.frame)}, h.now)
348+ h.p.confEpoch = h.p.predEpoch
349+ if got := h.typed("x"); len(got) != 0 || h.p.active() {
350+ t.Fatalf("shifted unshiftable text: %q", got)
351+ }
352+ })
353+ }
354+}
355+
356+func TestEchoPredictorRestoresWideCellWhole(t *testing.T) {
357+ t.Parallel()
358+ h := newEchoHarness(t, domain.EchoPredictAlways)
359+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[1;1H\x1b[4:3m界\x1b[0m")}, h.now)
360+ var out bytes.Buffer
361+ h.p.restoreCell(&out, 0, 1)
362+ if got, want := out.String(), "\x1b[1;1H\x1b[0;4:3m界"; got != want {
363+ t.Fatalf("restoreCell = %q, want %q", got, want)
364+ }
365+}
366+
367+func TestEchoPredictorSameSizeResizeErasesGuesses(t *testing.T) {
368+ t.Parallel()
369+ h := newEchoHarness(t, domain.EchoPredictAlways)
370+ h.warm(10 * time.Millisecond)
371+ h.confirm()
372+ if got := h.typed("b"); len(got) == 0 {
373+ t.Fatal("guess not drawn")
374+ }
375+ h.p.reset()
376+ if got, want := string(h.p.render()), "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
377+ t.Fatalf("render after reset = %q, want %q", got, want)
378+ }
379+}
380+
381+func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {
382+ t.Parallel()
383+ var f apcFilter
384+ chunks := []string{"a\x1b_Gf=100;AAA", "A\x1b", "\\b\x1b[1mc\x1b"}
385+ var got []byte
386+ for _, chunk := range chunks {
387+ got = append(got, f.strip([]byte(chunk))...)
388+ }
389+ got = append(got, f.strip([]byte("[0m"))...)
390+ if want := "ab\x1b[1mc\x1b[0m"; string(got) != want {
391+ t.Fatalf("strip = %q, want %q", got, want)
392+ }
393+}
394+
395+func TestEscapeLength(t *testing.T) {
396+ t.Parallel()
397+ tests := []struct {
398+ in string
399+ want int
400+ }{
401+ {in: "\x1b[A", want: 3},
402+ {in: "\x1bOA", want: 3},
403+ {in: "\x1b]11;?\x07x", want: 7},
404+ {in: "\x1b]11;?\x1b\\x", want: 8},
405+ {in: "\x1b]11;", want: 5},
406+ {in: "\x1bx", want: 2},
407+ }
408+ for _, tt := range tests {
409+ if got := escapeLength([]byte(tt.in)); got != tt.want {
410+ t.Errorf("escapeLength(%q) = %d, want %d", tt.in, got, tt.want)
411+ }
412+ }
413+}
@@ -1,6 +1,7 @@
1 package client1 package client
2 2
3 import (3 import (
4+ "bytes"
4 "strings"5 "strings"
5 "testing"6 "testing"
6 "time"7 "time"
@@ -149,6 +150,7 @@ func TestEchoPredictorBackspace(t *testing.T) {
149 func TestEchoPredictorInsertShiftsText(t *testing.T) {150 func TestEchoPredictorInsertShiftsText(t *testing.T) {
150 t.Parallel()151 t.Parallel()
151 h := newEchoHarness(t, domain.EchoPredictAlways)152 h := newEchoHarness(t, domain.EchoPredictAlways)
153+ h.warm(10 * time.Millisecond)
152 // "$ ac" with the cursor on c.154 // "$ ac" with the cursor on c.
153 h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("ac\x1b[1;4H")}, h.now)155 h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("ac\x1b[1;4H")}, h.now)
154 h.p.confEpoch = h.p.predEpoch156 h.p.confEpoch = h.p.predEpoch
@@ -318,3 +320,94 @@ func TestEchoSGR(t *testing.T) {
318 t.Fatalf("echoSGR rgb = %q, want %q", got, want)320 t.Fatalf("echoSGR rgb = %q, want %q", got, want)
319 }321 }
320 }322 }
323+
324+func TestEchoPredictorWaitsForFirstAcknowledgement(t *testing.T) {
325+ t.Parallel()
326+ h := newEchoHarness(t, domain.EchoPredictAlways)
327+ h.p.confEpoch = h.p.predEpoch
328+ if got := h.typed("b"); len(got) != 0 || h.p.active() {
329+ t.Fatalf("predicted before any echo acknowledgement: %q", got)
330+ }
331+}
332+
333+func TestEchoPredictorRefusesUnshiftableText(t *testing.T) {
334+ t.Parallel()
335+ tests := []struct {
336+ name string
337+ frame string
338+ }{
339+ {name: "wide rune after the cursor", frame: "a界\x1b[1;3H"},
340+ {name: "pane border after the cursor", frame: "ab│\x1b[1;3H"},
341+ }
342+ for _, tt := range tests {
343+ t.Run(tt.name, func(t *testing.T) {
344+ t.Parallel()
345+ h := newEchoHarness(t, domain.EchoPredictAlways)
346+ h.warm(10 * time.Millisecond)
347+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(tt.frame)}, h.now)
348+ h.p.confEpoch = h.p.predEpoch
349+ if got := h.typed("x"); len(got) != 0 || h.p.active() {
350+ t.Fatalf("shifted unshiftable text: %q", got)
351+ }
352+ })
353+ }
354+}
355+
356+func TestEchoPredictorRestoresWideCellWhole(t *testing.T) {
357+ t.Parallel()
358+ h := newEchoHarness(t, domain.EchoPredictAlways)
359+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[1;1H\x1b[4:3m界\x1b[0m")}, h.now)
360+ var out bytes.Buffer
361+ h.p.restoreCell(&out, 0, 1)
362+ if got, want := out.String(), "\x1b[1;1H\x1b[0;4:3m界"; got != want {
363+ t.Fatalf("restoreCell = %q, want %q", got, want)
364+ }
365+}
366+
367+func TestEchoPredictorSameSizeResizeErasesGuesses(t *testing.T) {
368+ t.Parallel()
369+ h := newEchoHarness(t, domain.EchoPredictAlways)
370+ h.warm(10 * time.Millisecond)
371+ h.confirm()
372+ if got := h.typed("b"); len(got) == 0 {
373+ t.Fatal("guess not drawn")
374+ }
375+ h.p.reset()
376+ if got, want := string(h.p.render()), "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
377+ t.Fatalf("render after reset = %q, want %q", got, want)
378+ }
379+}
380+
381+func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {
382+ t.Parallel()
383+ var f apcFilter
384+ chunks := []string{"a\x1b_Gf=100;AAA", "A\x1b", "\\b\x1b[1mc\x1b"}
385+ var got []byte
386+ for _, chunk := range chunks {
387+ got = append(got, f.strip([]byte(chunk))...)
388+ }
389+ got = append(got, f.strip([]byte("[0m"))...)
390+ if want := "ab\x1b[1mc\x1b[0m"; string(got) != want {
391+ t.Fatalf("strip = %q, want %q", got, want)
392+ }
393+}
394+
395+func TestEscapeLength(t *testing.T) {
396+ t.Parallel()
397+ tests := []struct {
398+ in string
399+ want int
400+ }{
401+ {in: "\x1b[A", want: 3},
402+ {in: "\x1bOA", want: 3},
403+ {in: "\x1b]11;?\x07x", want: 7},
404+ {in: "\x1b]11;?\x1b\\x", want: 8},
405+ {in: "\x1b]11;", want: 5},
406+ {in: "\x1bx", want: 2},
407+ }
408+ for _, tt := range tests {
409+ if got := escapeLength([]byte(tt.in)); got != tt.want {
410+ t.Errorf("escapeLength(%q) = %d, want %d", tt.in, got, tt.want)
411+ }
412+ }
413+}

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