detached 29fb826

Merge pull request 'feat(client): predictive local echo on remote attachments' (#7) from feat/predictive-echo into main

commit
29fb8265d386854542fb968f2f98d3bb8b7c3caf
author
bnema · · 2026-10-07 14:34 UTC
merge of
c297fbe + a5a6cfe · diff against the first parent
signature
unsigned
25 files · +2092 -31
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 AGENTS.md +1 -1
@@ -43,7 +43,7 @@ Layer map:
4343 - `internal/ports`: typed application connections and infrastructure-facing use-case seams.
4444 - `internal/adapters/sessionwire`: typed message ↔ raw frame adaptation.
4545 - `internal/usecase/daemon`: sessions, tabs, panes, VT screens, renderer shadows, and daemon features.
46-- `internal/usecase/client`: raw-mode thin client; writes output bytes verbatim and interprets nothing.
46+- `internal/usecase/client`: raw-mode thin client; writes output bytes verbatim. On remote attachments it also mirrors them in a local VT screen for predictive echo.
4747 - `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, clock, and observability implementations.
4848
4949 Before touching daemon teardown paths, read the lock-ordering notes at the top of `internal/usecase/daemon/client.go`.
@@ -43,7 +43,7 @@ Layer map:
43 - `internal/ports`: typed application connections and infrastructure-facing use-case seams.43 - `internal/ports`: typed application connections and infrastructure-facing use-case seams.
44 - `internal/adapters/sessionwire`: typed message ↔ raw frame adaptation.44 - `internal/adapters/sessionwire`: typed message ↔ raw frame adaptation.
45 - `internal/usecase/daemon`: sessions, tabs, panes, VT screens, renderer shadows, and daemon features.45 - `internal/usecase/daemon`: sessions, tabs, panes, VT screens, renderer shadows, and daemon features.
46-- `internal/usecase/client`: raw-mode thin client; writes output bytes verbatim and interprets nothing.46+- `internal/usecase/client`: raw-mode thin client; writes output bytes verbatim. On remote attachments it also mirrors them in a local VT screen for predictive echo.
47 - `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, clock, and observability implementations.47 - `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, clock, and observability implementations.
48 48
49 Before touching daemon teardown paths, read the lock-ordering notes at the top of `internal/usecase/daemon/client.go`.49 Before touching daemon teardown paths, read the lock-ordering notes at the top of `internal/usecase/daemon/client.go`.
M docs/architecture.md +2 -0
@@ -93,6 +93,8 @@ 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`.
97+
9698 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.
9799
98100 ## Broker-owned hosts and daemon composition
@@ -93,6 +93,8 @@ 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`.
97+
96 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.
97 99
98 ## Broker-owned hosts and daemon composition100 ## Broker-owned hosts and daemon composition
M docs/configuration.md +4 -0
@@ -46,6 +46,10 @@ keyboard.kitty-protocol = on
4646 # Outer terminal colors: auto (detect), truecolor, 256, 16, or mono.
4747 terminal.colors = auto
4848
49+# Draw typed characters on remote sessions before the server echoes them:
50+# adaptive (only on a slow link), always, or never.
51+echo.predict = adaptive
52+
4953 # Rebindable actions. Leave a line out to keep its built-in binding.
5054 open-palette = alt+space
5155 toggle-floating-pane = alt+f
@@ -46,6 +46,10 @@ keyboard.kitty-protocol = on
46 # Outer terminal colors: auto (detect), truecolor, 256, 16, or mono.46 # Outer terminal colors: auto (detect), truecolor, 256, 16, or mono.
47 terminal.colors = auto47 terminal.colors = auto
48 48
49+# Draw typed characters on remote sessions before the server echoes them:
50+# adaptive (only on a slow link), always, or never.
51+echo.predict = adaptive
52+
49 # Rebindable actions. Leave a line out to keep its built-in binding.53 # Rebindable actions. Leave a line out to keep its built-in binding.
50 open-palette = alt+space54 open-palette = alt+space
51 toggle-floating-pane = alt+f55 toggle-floating-pane = alt+f
M docs/protocol.md +5 -2
@@ -162,8 +162,11 @@ base. `ValidateOutput` enforces the epoch/base/new chain
162162 - The client coalesces ACKs (`client.go:cumulativeAckQueue`) and keeps
163163 applying + acknowledging daemon frames even while a picker lease owns
164164 the terminal (see below) — admission without physical write.
165-- `Echo` carries the client's last input acknowledgement inside output;
166- `OutputResetRequest` restarts the chain; `UIFence{ActionID}` orders UI
165+- `Echo` is the newest `InputSeq` the daemon applied at least 50 ms ago
166+ (mosh's echo timeout), so the screen it ships with already holds that
167+ input's echo. When no frame is due, the daemon sends it in an empty
168+ side-effect `Output` (`New == 0`). Clients use it to confirm predicted
169+ echo. `OutputResetRequest` restarts the chain; `UIFence{ActionID}` orders UI
167170 operations and `UIReceipt` reports the processed boundary
168171 (`Processed` requires epoch/state/publication; `Unavailable` carries
169172 none).
@@ -162,8 +162,11 @@ base. `ValidateOutput` enforces the epoch/base/new chain
162 - The client coalesces ACKs (`client.go:cumulativeAckQueue`) and keeps162 - The client coalesces ACKs (`client.go:cumulativeAckQueue`) and keeps
163 applying + acknowledging daemon frames even while a picker lease owns163 applying + acknowledging daemon frames even while a picker lease owns
164 the terminal (see below) — admission without physical write.164 the terminal (see below) — admission without physical write.
165-- `Echo` carries the client's last input acknowledgement inside output;165+- `Echo` is the newest `InputSeq` the daemon applied at least 50 ms ago
166- `OutputResetRequest` restarts the chain; `UIFence{ActionID}` orders UI166+ (mosh's echo timeout), so the screen it ships with already holds that
167+ input's echo. When no frame is due, the daemon sends it in an empty
168+ side-effect `Output` (`New == 0`). Clients use it to confirm predicted
169+ echo. `OutputResetRequest` restarts the chain; `UIFence{ActionID}` orders UI
167 operations and `UIReceipt` reports the processed boundary170 operations and `UIReceipt` reports the processed boundary
168 (`Processed` requires epoch/state/publication; `Unavailable` carries171 (`Processed` requires epoch/state/publication; `Unavailable` carries
169 none).172 none).
M docs/remote-resilience.md +4 -0
@@ -32,6 +32,10 @@ The transport is fixed when the host is added (`quic` by default). To switch, ru
3232
3333 The command palette is rendered by the remote daemon, so opening it needs a working link. A client-owned session picker that is already open remains locally interactive during an outage.
3434
35+## Typing on a slow link
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).
38+
3539 ## Connection states
3640
3741 | What you see | Meaning |
@@ -32,6 +32,10 @@ The transport is fixed when the host is added (`quic` by default). To switch, ru
32 32
33 The command palette is rendered by the remote daemon, so opening it needs a working link. A client-owned session picker that is already open remains locally interactive during an outage.33 The command palette is rendered by the remote daemon, so opening it needs a working link. A client-owned session picker that is already open remains locally interactive during an outage.
34 34
35+## Typing on a slow link
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).
38+
35 ## Connection states39 ## Connection states
36 40
37 | What you see | Meaning |41 | What you see | Meaning |
M internal/adapters/config/config.go +21 -0
@@ -38,6 +38,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3838 seenTabsKeys := make(map[string]bool)
3939 seenEphemeralKeys := make(map[string]bool)
4040 seenKeyboardKeys := make(map[string]bool)
41+ seenEchoKeys := make(map[string]bool)
4142 seenTerminalKeys := make(map[string]bool)
4243 seenScrollbackKeys := make(map[string]bool)
4344 seenWebKeys := make(map[string]bool)
@@ -173,6 +174,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
173174 continue
174175 }
175176 cfg.Keyboard.KittyProtocol = on
177+ case key == "echo.predict":
178+ warnings = warnDuplicateKey(warnings, seenEchoKeys, key, lineNo)
179+ mode, ok := parseEchoPredict(value)
180+ if !ok {
181+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid echo.predict %q (want adaptive, always, or never)", value)})
182+ continue
183+ }
184+ cfg.Echo.Predict = mode
176185 case key == "terminal.colors":
177186 warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo)
178187 mode, auto, err := terminalcap.ParseColorMode(value)
@@ -340,6 +349,18 @@ func fileStamp(path string) (stamp, error) {
340349 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
341350 }
342351
352+func parseEchoPredict(value string) (domain.EchoPredictMode, bool) {
353+ switch strings.ToLower(value) {
354+ case "adaptive":
355+ return domain.EchoPredictAdaptive, true
356+ case "always":
357+ return domain.EchoPredictAlways, true
358+ case "never":
359+ return domain.EchoPredictNever, true
360+ }
361+ return 0, false
362+}
363+
343364 func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
344365 if seen[key] {
345366 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
@@ -38,6 +38,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
38 seenTabsKeys := make(map[string]bool)38 seenTabsKeys := make(map[string]bool)
39 seenEphemeralKeys := make(map[string]bool)39 seenEphemeralKeys := make(map[string]bool)
40 seenKeyboardKeys := make(map[string]bool)40 seenKeyboardKeys := make(map[string]bool)
41+ seenEchoKeys := make(map[string]bool)
41 seenTerminalKeys := make(map[string]bool)42 seenTerminalKeys := make(map[string]bool)
42 seenScrollbackKeys := make(map[string]bool)43 seenScrollbackKeys := make(map[string]bool)
43 seenWebKeys := make(map[string]bool)44 seenWebKeys := make(map[string]bool)
@@ -173,6 +174,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
173 continue174 continue
174 }175 }
175 cfg.Keyboard.KittyProtocol = on176 cfg.Keyboard.KittyProtocol = on
177+ case key == "echo.predict":
178+ warnings = warnDuplicateKey(warnings, seenEchoKeys, key, lineNo)
179+ mode, ok := parseEchoPredict(value)
180+ if !ok {
181+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid echo.predict %q (want adaptive, always, or never)", value)})
182+ continue
183+ }
184+ cfg.Echo.Predict = mode
176 case key == "terminal.colors":185 case key == "terminal.colors":
177 warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo)186 warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo)
178 mode, auto, err := terminalcap.ParseColorMode(value)187 mode, auto, err := terminalcap.ParseColorMode(value)
@@ -340,6 +349,18 @@ func fileStamp(path string) (stamp, error) {
340 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil349 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
341 }350 }
342 351
352+func parseEchoPredict(value string) (domain.EchoPredictMode, bool) {
353+ switch strings.ToLower(value) {
354+ case "adaptive":
355+ return domain.EchoPredictAdaptive, true
356+ case "always":
357+ return domain.EchoPredictAlways, true
358+ case "never":
359+ return domain.EchoPredictNever, true
360+ }
361+ return 0, false
362+}
363+
343 func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {364 func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
344 if seen[key] {365 if seen[key] {
345 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})366 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
M internal/adapters/config/config_test.go +52 -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 }
@@ -563,6 +566,55 @@ func TestParseKeyboardKittyProtocol(t *testing.T) {
563566 }
564567 }
565568
569+func TestParseEchoPredict(t *testing.T) {
570+ t.Parallel()
571+
572+ tests := []struct {
573+ name string
574+ input string
575+ want domain.EchoPredictMode
576+ wantWarnings []domain.Warning
577+ }{
578+ {name: "absent key defaults adaptive", input: "theme = dark\n", want: domain.EchoPredictAdaptive},
579+ {name: "adaptive", input: "echo.predict = adaptive\n", want: domain.EchoPredictAdaptive},
580+ {name: "always", input: "echo.predict = always\n", want: domain.EchoPredictAlways},
581+ {name: "never", input: "echo.predict = Never\n", want: domain.EchoPredictNever},
582+ {
583+ name: "invalid value warns and keeps default",
584+ input: "echo.predict = sometimes\n",
585+ want: domain.EchoPredictAdaptive,
586+ wantWarnings: []domain.Warning{
587+ {Line: 1, Msg: "invalid echo.predict \"sometimes\" (want adaptive, always, or never)"},
588+ },
589+ },
590+ {
591+ name: "duplicate key warns and keeps last",
592+ input: "echo.predict = always\necho.predict = never\n",
593+ want: domain.EchoPredictNever,
594+ wantWarnings: []domain.Warning{
595+ {Line: 2, Msg: "duplicate key \"echo.predict\""},
596+ },
597+ },
598+ }
599+
600+ for _, tt := range tests {
601+ t.Run(tt.name, func(t *testing.T) {
602+ t.Parallel()
603+
604+ got, warnings, err := Parse(strings.NewReader(tt.input))
605+ if err != nil {
606+ t.Fatalf("Parse() error = %v", err)
607+ }
608+ if got.Echo.Predict != tt.want {
609+ t.Fatalf("Parse() Echo.Predict = %v, want %v", got.Echo.Predict, tt.want)
610+ }
611+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
612+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
613+ }
614+ })
615+ }
616+}
617+
566618 func TestParseNavigationOverflow(t *testing.T) {
567619 t.Parallel()
568620
@@ -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 }
@@ -563,6 +566,55 @@ func TestParseKeyboardKittyProtocol(t *testing.T) {
563 }566 }
564 }567 }
565 568
569+func TestParseEchoPredict(t *testing.T) {
570+ t.Parallel()
571+
572+ tests := []struct {
573+ name string
574+ input string
575+ want domain.EchoPredictMode
576+ wantWarnings []domain.Warning
577+ }{
578+ {name: "absent key defaults adaptive", input: "theme = dark\n", want: domain.EchoPredictAdaptive},
579+ {name: "adaptive", input: "echo.predict = adaptive\n", want: domain.EchoPredictAdaptive},
580+ {name: "always", input: "echo.predict = always\n", want: domain.EchoPredictAlways},
581+ {name: "never", input: "echo.predict = Never\n", want: domain.EchoPredictNever},
582+ {
583+ name: "invalid value warns and keeps default",
584+ input: "echo.predict = sometimes\n",
585+ want: domain.EchoPredictAdaptive,
586+ wantWarnings: []domain.Warning{
587+ {Line: 1, Msg: "invalid echo.predict \"sometimes\" (want adaptive, always, or never)"},
588+ },
589+ },
590+ {
591+ name: "duplicate key warns and keeps last",
592+ input: "echo.predict = always\necho.predict = never\n",
593+ want: domain.EchoPredictNever,
594+ wantWarnings: []domain.Warning{
595+ {Line: 2, Msg: "duplicate key \"echo.predict\""},
596+ },
597+ },
598+ }
599+
600+ for _, tt := range tests {
601+ t.Run(tt.name, func(t *testing.T) {
602+ t.Parallel()
603+
604+ got, warnings, err := Parse(strings.NewReader(tt.input))
605+ if err != nil {
606+ t.Fatalf("Parse() error = %v", err)
607+ }
608+ if got.Echo.Predict != tt.want {
609+ t.Fatalf("Parse() Echo.Predict = %v, want %v", got.Echo.Predict, tt.want)
610+ }
611+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
612+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
613+ }
614+ })
615+ }
616+}
617+
566 func TestParseNavigationOverflow(t *testing.T) {618 func TestParseNavigationOverflow(t *testing.T) {
567 t.Parallel()619 t.Parallel()
568 620
M internal/app/broker_client.go +6 -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
@@ -698,6 +701,7 @@ func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvir
698701 env := userTerminalAttachmentEnvironment(log, cfg.Terminal)
699702 env.ProbeTerminal = true
700703 env.KittyKeyboard = cfg.Keyboard.KittyProtocol
704+ env.EchoPredict = cfg.Echo.Predict
701705 return env
702706 }
703707
@@ -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
@@ -698,6 +701,7 @@ func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvir
698 env := userTerminalAttachmentEnvironment(log, cfg.Terminal)701 env := userTerminalAttachmentEnvironment(log, cfg.Terminal)
699 env.ProbeTerminal = true702 env.ProbeTerminal = true
700 env.KittyKeyboard = cfg.Keyboard.KittyProtocol703 env.KittyKeyboard = cfg.Keyboard.KittyProtocol
704+ env.EchoPredict = cfg.Echo.Predict
701 return env705 return env
702 }706 }
703 707
M internal/domain/config.go +25 -0
@@ -102,6 +102,27 @@ type KeyboardConfig struct {
102102 KittyProtocol bool
103103 }
104104
105+// EchoPredictMode selects when a remote attachment shows predicted local echo.
106+type EchoPredictMode uint8
107+
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
113+ // EchoPredictAdaptive shows predictions only on a slow or glitchy link.
114+ EchoPredictAdaptive
115+ // EchoPredictAlways shows predictions whatever the link latency.
116+ EchoPredictAlways
117+)
118+
119+// EchoConfig contains client-side predictive echo settings.
120+type EchoConfig struct {
121+ // Predict selects when remote attachments draw typed characters before
122+ // the daemon echoes them.
123+ Predict EchoPredictMode
124+}
125+
105126 // TerminalConfig contains client-side outer-terminal settings. It is read by
106127 // each client at startup and never applied by the daemon, so a change takes
107128 // effect on the next client process.
@@ -153,6 +174,7 @@ type Config struct {
153174 Tabs TabsConfig
154175 Ephemeral EphemeralConfig
155176 Keyboard KeyboardConfig
177+ Echo EchoConfig
156178 Terminal TerminalConfig
157179 Scrollback ScrollbackConfig
158180 }
@@ -198,6 +220,9 @@ func Defaults() Config {
198220 Keyboard: KeyboardConfig{
199221 KittyProtocol: true,
200222 },
223+ Echo: EchoConfig{
224+ Predict: EchoPredictAdaptive,
225+ },
201226 Scrollback: DefaultScrollbackConfig(),
202227 }
203228 }
@@ -102,6 +102,27 @@ type KeyboardConfig struct {
102 KittyProtocol bool102 KittyProtocol bool
103 }103 }
104 104
105+// EchoPredictMode selects when a remote attachment shows predicted local echo.
106+type EchoPredictMode uint8
107+
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
113+ // EchoPredictAdaptive shows predictions only on a slow or glitchy link.
114+ EchoPredictAdaptive
115+ // EchoPredictAlways shows predictions whatever the link latency.
116+ EchoPredictAlways
117+)
118+
119+// EchoConfig contains client-side predictive echo settings.
120+type EchoConfig struct {
121+ // Predict selects when remote attachments draw typed characters before
122+ // the daemon echoes them.
123+ Predict EchoPredictMode
124+}
125+
105 // TerminalConfig contains client-side outer-terminal settings. It is read by126 // TerminalConfig contains client-side outer-terminal settings. It is read by
106 // each client at startup and never applied by the daemon, so a change takes127 // each client at startup and never applied by the daemon, so a change takes
107 // effect on the next client process.128 // effect on the next client process.
@@ -153,6 +174,7 @@ type Config struct {
153 Tabs TabsConfig174 Tabs TabsConfig
154 Ephemeral EphemeralConfig175 Ephemeral EphemeralConfig
155 Keyboard KeyboardConfig176 Keyboard KeyboardConfig
177+ Echo EchoConfig
156 Terminal TerminalConfig178 Terminal TerminalConfig
157 Scrollback ScrollbackConfig179 Scrollback ScrollbackConfig
158 }180 }
@@ -198,6 +220,9 @@ func Defaults() Config {
198 Keyboard: KeyboardConfig{220 Keyboard: KeyboardConfig{
199 KittyProtocol: true,221 KittyProtocol: true,
200 },222 },
223+ Echo: EchoConfig{
224+ Predict: EchoPredictAdaptive,
225+ },
201 Scrollback: DefaultScrollbackConfig(),226 Scrollback: DefaultScrollbackConfig(),
202 }227 }
203 }228 }
A internal/usecase/client/attachment_echo.go +128 -0
@@ -0,0 +1,128 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+// echoTickInterval re-culls pending guesses while a timing trigger may still
13+// fire without output, as mosh's PredictionEngine::wait_time.
14+const echoTickInterval = 50 * time.Millisecond
15+
16+// attachmentEcho connects the prediction engine to one attached loop: it feeds
17+// sent input and applied output to the engine and writes the guesses through
18+// the foreground. It is nil when the attachment does not predict (local
19+// attachments, scripted foregrounds, echo.predict = never), and every method
20+// is a no-op on nil.
21+type attachmentEcho struct {
22+ predictor *echoPredictor
23+ out attachmentPredictionForeground
24+ clock ports.Clock
25+ tick ports.Timer
26+}
27+
28+func newAttachmentEcho(w *sessionAttachmentWorker, fg AttachmentForeground) *attachmentEcho {
29+ if w.cfg.Request.Local || w.cfg.EchoPredict == domain.EchoPredictNever {
30+ return nil
31+ }
32+ out, ok := fg.(attachmentPredictionForeground)
33+ if !ok {
34+ return nil
35+ }
36+ return &attachmentEcho{predictor: newEchoPredictor(w.cfg.EchoPredict), out: out, clock: w.cfg.Clock}
37+}
38+
39+// seed mirrors output written before the loop started (the initial frame).
40+func (e *attachmentEcho) seed(output protocol.Output) {
41+ if e == nil {
42+ return
43+ }
44+ e.predictor.applyOutput(output, e.clock.Now())
45+}
46+
47+// input records one session Input sent with seq.
48+func (e *attachmentEcho) input(seq uint64, data []byte) error {
49+ if e == nil {
50+ return nil
51+ }
52+ e.predictor.input(seq, data, e.clock.Now())
53+ return e.draw()
54+}
55+
56+// output mirrors one Output after the foreground wrote it.
57+func (e *attachmentEcho) output(output protocol.Output) error {
58+ if e == nil {
59+ return nil
60+ }
61+ e.predictor.applyOutput(output, e.clock.Now())
62+ return e.draw()
63+}
64+
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 {
69+ if e == nil {
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
76+ }
77+ e.predictor.reset()
78+ return e.draw()
79+}
80+
81+// tickC fires while a pending guess waits on a timing trigger.
82+func (e *attachmentEcho) tickC() <-chan time.Time {
83+ if e == nil || e.tick == nil {
84+ return nil
85+ }
86+ return e.tick.C()
87+}
88+
89+func (e *attachmentEcho) ticked() error {
90+ if e == nil {
91+ return nil
92+ }
93+ e.tick = nil
94+ e.predictor.cull(e.clock.Now())
95+ return e.draw()
96+}
97+
98+func (e *attachmentEcho) close() {
99+ if e != nil && e.tick != nil {
100+ e.tick.Stop()
101+ e.tick = nil
102+ }
103+}
104+
105+func (e *attachmentEcho) draw() error {
106+ defer e.armTick()
107+ data := e.predictor.render()
108+ if len(data) == 0 {
109+ return nil
110+ }
111+ written, err := e.out.writePrediction(data)
112+ if err != nil {
113+ return fmt.Errorf("writing predicted echo: %w", err)
114+ }
115+ if !written {
116+ // An overlay owns the terminal: its box covers the guesses and the
117+ // daemon repaints everything once it closes.
118+ e.predictor.forget()
119+ }
120+ return nil
121+}
122+
123+func (e *attachmentEcho) armTick() {
124+ if e.tick != nil || !e.predictor.needsTick() {
125+ return
126+ }
127+ e.tick = e.clock.NewTimer(echoTickInterval)
128+}
@@ -0,0 +1,128 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+// echoTickInterval re-culls pending guesses while a timing trigger may still
13+// fire without output, as mosh's PredictionEngine::wait_time.
14+const echoTickInterval = 50 * time.Millisecond
15+
16+// attachmentEcho connects the prediction engine to one attached loop: it feeds
17+// sent input and applied output to the engine and writes the guesses through
18+// the foreground. It is nil when the attachment does not predict (local
19+// attachments, scripted foregrounds, echo.predict = never), and every method
20+// is a no-op on nil.
21+type attachmentEcho struct {
22+ predictor *echoPredictor
23+ out attachmentPredictionForeground
24+ clock ports.Clock
25+ tick ports.Timer
26+}
27+
28+func newAttachmentEcho(w *sessionAttachmentWorker, fg AttachmentForeground) *attachmentEcho {
29+ if w.cfg.Request.Local || w.cfg.EchoPredict == domain.EchoPredictNever {
30+ return nil
31+ }
32+ out, ok := fg.(attachmentPredictionForeground)
33+ if !ok {
34+ return nil
35+ }
36+ return &attachmentEcho{predictor: newEchoPredictor(w.cfg.EchoPredict), out: out, clock: w.cfg.Clock}
37+}
38+
39+// seed mirrors output written before the loop started (the initial frame).
40+func (e *attachmentEcho) seed(output protocol.Output) {
41+ if e == nil {
42+ return
43+ }
44+ e.predictor.applyOutput(output, e.clock.Now())
45+}
46+
47+// input records one session Input sent with seq.
48+func (e *attachmentEcho) input(seq uint64, data []byte) error {
49+ if e == nil {
50+ return nil
51+ }
52+ e.predictor.input(seq, data, e.clock.Now())
53+ return e.draw()
54+}
55+
56+// output mirrors one Output after the foreground wrote it.
57+func (e *attachmentEcho) output(output protocol.Output) error {
58+ if e == nil {
59+ return nil
60+ }
61+ e.predictor.applyOutput(output, e.clock.Now())
62+ return e.draw()
63+}
64+
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 {
69+ if e == nil {
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
76+ }
77+ e.predictor.reset()
78+ return e.draw()
79+}
80+
81+// tickC fires while a pending guess waits on a timing trigger.
82+func (e *attachmentEcho) tickC() <-chan time.Time {
83+ if e == nil || e.tick == nil {
84+ return nil
85+ }
86+ return e.tick.C()
87+}
88+
89+func (e *attachmentEcho) ticked() error {
90+ if e == nil {
91+ return nil
92+ }
93+ e.tick = nil
94+ e.predictor.cull(e.clock.Now())
95+ return e.draw()
96+}
97+
98+func (e *attachmentEcho) close() {
99+ if e != nil && e.tick != nil {
100+ e.tick.Stop()
101+ e.tick = nil
102+ }
103+}
104+
105+func (e *attachmentEcho) draw() error {
106+ defer e.armTick()
107+ data := e.predictor.render()
108+ if len(data) == 0 {
109+ return nil
110+ }
111+ written, err := e.out.writePrediction(data)
112+ if err != nil {
113+ return fmt.Errorf("writing predicted echo: %w", err)
114+ }
115+ if !written {
116+ // An overlay owns the terminal: its box covers the guesses and the
117+ // daemon repaints everything once it closes.
118+ e.predictor.forget()
119+ }
120+ return nil
121+}
122+
123+func (e *attachmentEcho) armTick() {
124+ if e.tick != nil || !e.predictor.needsTick() {
125+ return
126+ }
127+ e.tick = e.clock.NewTimer(echoTickInterval)
128+}
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_input.go +8 -1
@@ -33,6 +33,7 @@ type attachmentInput struct {
3333 fg AttachmentForeground
3434 stream ports.BrokerLogicalConnection
3535 picker *attachmentMovePicker
36+ echo *attachmentEcho
3637 clock ports.Clock
3738 seq uint64
3839
@@ -99,12 +100,13 @@ func (d *paletteDeadline) fired(kind paletteGenerationEventKind) paletteGenerati
99100 return event
100101 }
101102
102-func newAttachmentInput(ctx context.Context, w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker) *attachmentInput {
103+func newAttachmentInput(ctx context.Context, w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker, echo *attachmentEcho) *attachmentInput {
103104 in := &attachmentInput{
104105 worker: w,
105106 fg: fg,
106107 stream: stream,
107108 picker: picker,
109+ echo: echo,
108110 clock: w.cfg.Clock,
109111 wake: make(chan struct{}, 1),
110112 themes: w.cfg.Theme,
@@ -311,6 +313,11 @@ func (in *attachmentInput) flush(ctx context.Context) error {
311313 if err := in.send(ctx, message); err != nil {
312314 return err
313315 }
316+ if m, ok := message.(protocol.Input); ok {
317+ if err := in.echo.input(m.InputSeq, m.Data); err != nil {
318+ return err
319+ }
320+ }
314321 }
315322 return nil
316323 }
@@ -33,6 +33,7 @@ type attachmentInput struct {
33 fg AttachmentForeground33 fg AttachmentForeground
34 stream ports.BrokerLogicalConnection34 stream ports.BrokerLogicalConnection
35 picker *attachmentMovePicker35 picker *attachmentMovePicker
36+ echo *attachmentEcho
36 clock ports.Clock37 clock ports.Clock
37 seq uint6438 seq uint64
38 39
@@ -99,12 +100,13 @@ func (d *paletteDeadline) fired(kind paletteGenerationEventKind) paletteGenerati
99 return event100 return event
100 }101 }
101 102
102-func newAttachmentInput(ctx context.Context, w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker) *attachmentInput {103+func newAttachmentInput(ctx context.Context, w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker, echo *attachmentEcho) *attachmentInput {
103 in := &attachmentInput{104 in := &attachmentInput{
104 worker: w,105 worker: w,
105 fg: fg,106 fg: fg,
106 stream: stream,107 stream: stream,
107 picker: picker,108 picker: picker,
109+ echo: echo,
108 clock: w.cfg.Clock,110 clock: w.cfg.Clock,
109 wake: make(chan struct{}, 1),111 wake: make(chan struct{}, 1),
110 themes: w.cfg.Theme,112 themes: w.cfg.Theme,
@@ -311,6 +313,11 @@ func (in *attachmentInput) flush(ctx context.Context) error {
311 if err := in.send(ctx, message); err != nil {313 if err := in.send(ctx, message); err != nil {
312 return err314 return err
313 }315 }
316+ if m, ok := message.(protocol.Input); ok {
317+ if err := in.echo.input(m.InputSeq, m.Data); err != nil {
318+ return err
319+ }
320+ }
314 }321 }
315 return nil322 return nil
316 }323 }
M internal/usecase/client/attachment_session.go +39 -18
@@ -99,6 +99,8 @@ type sessionAttachmentConfig struct {
9999 Tab attachmentTab
100100 // Tabs retains this client's committed tab per authority and session.
101101 Tabs *sessionTabMemory
102+ // EchoPredict selects predictive local echo on remote attachments.
103+ EchoPredict domain.EchoPredictMode
102104 }
103105
104106 // sessionAttachmentWorker implements AttachmentWorker for one broker logical
@@ -191,35 +193,35 @@ func (w *sessionAttachmentWorker) Run(ctx context.Context, fg AttachmentForegrou
191193 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("unexpected %T before welcome", welcome)}
192194 }
193195
194- state, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming)
196+ state, initial, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming)
195197 if event != nil {
196198 return *event
197199 }
198200
199- return w.pumpAttached(ctx, fg, stream, token, state, incoming)
201+ return w.pumpAttached(ctx, fg, stream, token, state, initial, incoming)
200202 }
201203
202204 // awaitInitialPublication consumes server messages until the first valid full
203-// output frame is committed. It returns the output state to continue with, or
204-// a typed terminal event.
205-func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, incoming <-chan serverReceive) (outputApplyState, *AttachmentEvent) {
205+// output frame is committed. It returns the output state to continue with and
206+// the committed frame, or a typed terminal event.
207+func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, incoming <-chan serverReceive) (outputApplyState, protocol.Output, *AttachmentEvent) {
206208 state := outputApplyState{}
207209 for {
208210 message, err := w.awaitServer(ctx, fg, incoming)
209211 if err != nil {
210212 event := w.settle(ctx, fg, stream, token, err)
211- return state, &event
213+ return state, protocol.Output{}, &event
212214 }
213215 switch typed := message.(type) {
214216 case protocol.Output:
215217 next, ok := state.next(typed)
216218 if !ok {
217219 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentPublication}
218- return state, &event
220+ return state, protocol.Output{}, &event
219221 }
220222 if err := w.validatePublication(typed); err != nil {
221223 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}
222- return state, &event
224+ return state, protocol.Output{}, &event
223225 }
224226 state = next
225227 w.noteCommitted(fg, state.context)
@@ -229,11 +231,11 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
229231 // that write, flush, and commit succeeded.
230232 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusConnecting), typed.Data); err != nil {
231233 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing initial output: %w", err)}
232- return state, &event
234+ return state, protocol.Output{}, &event
233235 }
234236 if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil {
235237 event := w.settle(ctx, fg, stream, token, err)
236- return state, &event
238+ return state, protocol.Output{}, &event
237239 }
238240 // Attachment is the committed publication. Complete the deadline
239241 // boundary before presenting attached, so attached always implies the
@@ -241,14 +243,14 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
241243 if w.cfg.BeforeAttached != nil {
242244 if err := w.cfg.BeforeAttached(); err != nil {
243245 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}
244- return state, &event
246+ return state, protocol.Output{}, &event
245247 }
246248 }
247249 // A refused marker means the grant was revoked underneath us, so the
248250 // run ends as a failure.
249251 if !fg.MarkAttached() {
250252 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentForegroundRevoked}
251- return state, &event
253+ return state, protocol.Output{}, &event
252254 }
253255 // The attached presentation is published last, once the committed frame and
254256 // the attached marker are in place. It carries the real action generation
@@ -257,17 +259,17 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
257259 // drained.
258260 if err := fg.PublishAttached(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached)); err != nil {
259261 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing attached presentation: %w", err)}
260- return state, &event
262+ return state, protocol.Output{}, &event
261263 }
262- return state, nil
264+ return state, typed, nil
263265 case protocol.ErrorMsg:
264266 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}
265- return state, &event
267+ return state, protocol.Output{}, &event
266268 case protocol.Detached:
267269 // The daemon ended the attempt before publishing anything: it
268270 // never attached.
269271 event := AttachmentEvent{Token: token, Kind: AttachmentEventEnded}
270- return state, &event
272+ return state, protocol.Output{}, &event
271273 default:
272274 // Everything else the daemon may publish before the initial frame
273275 // stays outside this slice's scope and is ignored.
@@ -301,7 +303,7 @@ var (
301303
302304 // pumpAttached publishes output and concurrently forwards the foreground's
303305 // authorized input, latest geometry and explicit lifecycle decision.
304-func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, raw ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent {
306+func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, raw ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, initial protocol.Output, incoming <-chan serverReceive) AttachmentEvent {
305307 pumpCtx, cancel := context.WithCancel(ctx)
306308 defer cancel()
307309 // Every send while attached goes through the outbox so a stalled link
@@ -329,7 +331,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
329331 samePeerUI := attachmentSamePeerUI(fg)
330332 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.Color)}
331333 defer picker.stopEscape()
332- input := newAttachmentInput(ctx, w, fg, stream, picker)
334+ echo := newAttachmentEcho(w, fg)
335+ defer echo.close()
336+ echo.seed(initial)
337+ input := newAttachmentInput(ctx, w, fg, stream, picker, echo)
333338 defer input.close()
334339 if err := input.start(ctx); err != nil {
335340 return w.settle(ctx, fg, stream, token, err)
@@ -437,6 +442,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
437442 if err := input.completionFired(ctx); err != nil {
438443 return w.settle(ctx, fg, stream, token, err)
439444 }
445+ case <-echo.tickC():
446+ if err := echo.ticked(); err != nil {
447+ return w.settle(ctx, fg, stream, token, err)
448+ }
440449 case <-repaint:
441450 // An overlay released the terminal: the picker box is still on screen
442451 // and suppressed frames were never written, so ask the daemon for an
@@ -491,6 +500,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
491500 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {
492501 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing output: %w", err)}
493502 }
503+ // The guesses go on top of the frame just written.
504+ if err := echo.output(typed); err != nil {
505+ return w.settle(ctx, fg, stream, token, err)
506+ }
494507 if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil {
495508 return w.settle(ctx, fg, stream, token, err)
496509 }
@@ -626,6 +639,9 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
626639 event.message = protocol.Input{InputSeq: input.nextSeq(), ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)}
627640 }
628641 if resize, ok := event.message.(protocol.Resize); ok {
642+ if err := echo.resized(resize.Size); err != nil {
643+ return w.settle(ctx, fg, stream, token, err)
644+ }
629645 if err := picker.resize(ctx, state, resize.Size); err != nil {
630646 return w.settle(ctx, fg, stream, token, err)
631647 }
@@ -645,6 +661,11 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
645661 if err := await(from, event.input.actionID); err != nil {
646662 return w.settle(ctx, fg, stream, token, err)
647663 }
664+ if message, ok := event.message.(protocol.Input); ok {
665+ if err := echo.input(message.InputSeq, message.Data); err != nil {
666+ return w.settle(ctx, fg, stream, token, err)
667+ }
668+ }
648669 }
649670 if event.action != 0 {
650671 // The lifecycle message must reach the daemon before the
@@ -99,6 +99,8 @@ type sessionAttachmentConfig struct {
99 Tab attachmentTab99 Tab attachmentTab
100 // Tabs retains this client's committed tab per authority and session.100 // Tabs retains this client's committed tab per authority and session.
101 Tabs *sessionTabMemory101 Tabs *sessionTabMemory
102+ // EchoPredict selects predictive local echo on remote attachments.
103+ EchoPredict domain.EchoPredictMode
102 }104 }
103 105
104 // sessionAttachmentWorker implements AttachmentWorker for one broker logical106 // sessionAttachmentWorker implements AttachmentWorker for one broker logical
@@ -191,35 +193,35 @@ func (w *sessionAttachmentWorker) Run(ctx context.Context, fg AttachmentForegrou
191 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("unexpected %T before welcome", welcome)}193 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("unexpected %T before welcome", welcome)}
192 }194 }
193 195
194- state, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming)196+ state, initial, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming)
195 if event != nil {197 if event != nil {
196 return *event198 return *event
197 }199 }
198 200
199- return w.pumpAttached(ctx, fg, stream, token, state, incoming)201+ return w.pumpAttached(ctx, fg, stream, token, state, initial, incoming)
200 }202 }
201 203
202 // awaitInitialPublication consumes server messages until the first valid full204 // awaitInitialPublication consumes server messages until the first valid full
203-// output frame is committed. It returns the output state to continue with, or205+// output frame is committed. It returns the output state to continue with and
204-// a typed terminal event.206+// the committed frame, or a typed terminal event.
205-func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, incoming <-chan serverReceive) (outputApplyState, *AttachmentEvent) {207+func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, incoming <-chan serverReceive) (outputApplyState, protocol.Output, *AttachmentEvent) {
206 state := outputApplyState{}208 state := outputApplyState{}
207 for {209 for {
208 message, err := w.awaitServer(ctx, fg, incoming)210 message, err := w.awaitServer(ctx, fg, incoming)
209 if err != nil {211 if err != nil {
210 event := w.settle(ctx, fg, stream, token, err)212 event := w.settle(ctx, fg, stream, token, err)
211- return state, &event213+ return state, protocol.Output{}, &event
212 }214 }
213 switch typed := message.(type) {215 switch typed := message.(type) {
214 case protocol.Output:216 case protocol.Output:
215 next, ok := state.next(typed)217 next, ok := state.next(typed)
216 if !ok {218 if !ok {
217 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentPublication}219 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentPublication}
218- return state, &event220+ return state, protocol.Output{}, &event
219 }221 }
220 if err := w.validatePublication(typed); err != nil {222 if err := w.validatePublication(typed); err != nil {
221 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}223 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}
222- return state, &event224+ return state, protocol.Output{}, &event
223 }225 }
224 state = next226 state = next
225 w.noteCommitted(fg, state.context)227 w.noteCommitted(fg, state.context)
@@ -229,11 +231,11 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
229 // that write, flush, and commit succeeded.231 // that write, flush, and commit succeeded.
230 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusConnecting), typed.Data); err != nil {232 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusConnecting), typed.Data); err != nil {
231 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing initial output: %w", err)}233 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing initial output: %w", err)}
232- return state, &event234+ return state, protocol.Output{}, &event
233 }235 }
234 if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil {236 if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil {
235 event := w.settle(ctx, fg, stream, token, err)237 event := w.settle(ctx, fg, stream, token, err)
236- return state, &event238+ return state, protocol.Output{}, &event
237 }239 }
238 // Attachment is the committed publication. Complete the deadline240 // Attachment is the committed publication. Complete the deadline
239 // boundary before presenting attached, so attached always implies the241 // boundary before presenting attached, so attached always implies the
@@ -241,14 +243,14 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
241 if w.cfg.BeforeAttached != nil {243 if w.cfg.BeforeAttached != nil {
242 if err := w.cfg.BeforeAttached(); err != nil {244 if err := w.cfg.BeforeAttached(); err != nil {
243 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}245 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}
244- return state, &event246+ return state, protocol.Output{}, &event
245 }247 }
246 }248 }
247 // A refused marker means the grant was revoked underneath us, so the249 // A refused marker means the grant was revoked underneath us, so the
248 // run ends as a failure.250 // run ends as a failure.
249 if !fg.MarkAttached() {251 if !fg.MarkAttached() {
250 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentForegroundRevoked}252 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentForegroundRevoked}
251- return state, &event253+ return state, protocol.Output{}, &event
252 }254 }
253 // The attached presentation is published last, once the committed frame and255 // The attached presentation is published last, once the committed frame and
254 // the attached marker are in place. It carries the real action generation256 // the attached marker are in place. It carries the real action generation
@@ -257,17 +259,17 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
257 // drained.259 // drained.
258 if err := fg.PublishAttached(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached)); err != nil {260 if err := fg.PublishAttached(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached)); err != nil {
259 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing attached presentation: %w", err)}261 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing attached presentation: %w", err)}
260- return state, &event262+ return state, protocol.Output{}, &event
261 }263 }
262- return state, nil264+ return state, typed, nil
263 case protocol.ErrorMsg:265 case protocol.ErrorMsg:
264 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}266 event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}
265- return state, &event267+ return state, protocol.Output{}, &event
266 case protocol.Detached:268 case protocol.Detached:
267 // The daemon ended the attempt before publishing anything: it269 // The daemon ended the attempt before publishing anything: it
268 // never attached.270 // never attached.
269 event := AttachmentEvent{Token: token, Kind: AttachmentEventEnded}271 event := AttachmentEvent{Token: token, Kind: AttachmentEventEnded}
270- return state, &event272+ return state, protocol.Output{}, &event
271 default:273 default:
272 // Everything else the daemon may publish before the initial frame274 // Everything else the daemon may publish before the initial frame
273 // stays outside this slice's scope and is ignored.275 // stays outside this slice's scope and is ignored.
@@ -301,7 +303,7 @@ var (
301 303
302 // pumpAttached publishes output and concurrently forwards the foreground's304 // pumpAttached publishes output and concurrently forwards the foreground's
303 // authorized input, latest geometry and explicit lifecycle decision.305 // authorized input, latest geometry and explicit lifecycle decision.
304-func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, raw ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent {306+func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, raw ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, initial protocol.Output, incoming <-chan serverReceive) AttachmentEvent {
305 pumpCtx, cancel := context.WithCancel(ctx)307 pumpCtx, cancel := context.WithCancel(ctx)
306 defer cancel()308 defer cancel()
307 // Every send while attached goes through the outbox so a stalled link309 // Every send while attached goes through the outbox so a stalled link
@@ -329,7 +331,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
329 samePeerUI := attachmentSamePeerUI(fg)331 samePeerUI := attachmentSamePeerUI(fg)
330 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.Color)}332 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.Color)}
331 defer picker.stopEscape()333 defer picker.stopEscape()
332- input := newAttachmentInput(ctx, w, fg, stream, picker)334+ echo := newAttachmentEcho(w, fg)
335+ defer echo.close()
336+ echo.seed(initial)
337+ input := newAttachmentInput(ctx, w, fg, stream, picker, echo)
333 defer input.close()338 defer input.close()
334 if err := input.start(ctx); err != nil {339 if err := input.start(ctx); err != nil {
335 return w.settle(ctx, fg, stream, token, err)340 return w.settle(ctx, fg, stream, token, err)
@@ -437,6 +442,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
437 if err := input.completionFired(ctx); err != nil {442 if err := input.completionFired(ctx); err != nil {
438 return w.settle(ctx, fg, stream, token, err)443 return w.settle(ctx, fg, stream, token, err)
439 }444 }
445+ case <-echo.tickC():
446+ if err := echo.ticked(); err != nil {
447+ return w.settle(ctx, fg, stream, token, err)
448+ }
440 case <-repaint:449 case <-repaint:
441 // An overlay released the terminal: the picker box is still on screen450 // An overlay released the terminal: the picker box is still on screen
442 // and suppressed frames were never written, so ask the daemon for an451 // and suppressed frames were never written, so ask the daemon for an
@@ -491,6 +500,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
491 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {500 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {
492 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing output: %w", err)}501 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing output: %w", err)}
493 }502 }
503+ // The guesses go on top of the frame just written.
504+ if err := echo.output(typed); err != nil {
505+ return w.settle(ctx, fg, stream, token, err)
506+ }
494 if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil {507 if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil {
495 return w.settle(ctx, fg, stream, token, err)508 return w.settle(ctx, fg, stream, token, err)
496 }509 }
@@ -626,6 +639,9 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
626 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...)}
627 }640 }
628 if resize, ok := event.message.(protocol.Resize); ok {641 if resize, ok := event.message.(protocol.Resize); ok {
642+ if err := echo.resized(resize.Size); err != nil {
643+ return w.settle(ctx, fg, stream, token, err)
644+ }
629 if err := picker.resize(ctx, state, resize.Size); err != nil {645 if err := picker.resize(ctx, state, resize.Size); err != nil {
630 return w.settle(ctx, fg, stream, token, err)646 return w.settle(ctx, fg, stream, token, err)
631 }647 }
@@ -645,6 +661,11 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
645 if err := await(from, event.input.actionID); err != nil {661 if err := await(from, event.input.actionID); err != nil {
646 return w.settle(ctx, fg, stream, token, err)662 return w.settle(ctx, fg, stream, token, err)
647 }663 }
664+ if message, ok := event.message.(protocol.Input); ok {
665+ if err := echo.input(message.InputSeq, message.Data); err != nil {
666+ return w.settle(ctx, fg, stream, token, err)
667+ }
668+ }
648 }669 }
649 if event.action != 0 {670 if event.action != 0 {
650 // The lifecycle message must reach the daemon before the671 // The lifecycle message must reach the daemon before the
M internal/usecase/client/attachment_worker.go +47 -0
@@ -997,6 +997,53 @@ func (f *attachmentForeground) writeTerminalQuery(data []byte) error {
997997 return err
998998 }
999999
1000+// attachmentPredictionForeground is the optional predictive-echo seam of the
1001+// real foreground. Scripted foregrounds may omit it; the worker then never
1002+// predicts.
1003+type attachmentPredictionForeground interface {
1004+ writePrediction(data []byte) (bool, error)
1005+}
1006+
1007+var _ attachmentPredictionForeground = (*attachmentForeground)(nil)
1008+
1009+// writePrediction writes and flushes predicted echo under the output lease.
1010+// It reports false without writing while an overlay owns the terminal. The
1011+// guesses are client-local drawing, so the UI observation channel is not
1012+// involved.
1013+func (f *attachmentForeground) writePrediction(data []byte) (bool, error) {
1014+ if f == nil {
1015+ return false, errAttachmentForegroundRevoked
1016+ }
1017+ var err error
1018+ written := false
1019+ ok := f.lease.send(func() bool {
1020+ if !f.authority.actionAuthorized(f) {
1021+ return false
1022+ }
1023+ if f.overlayKind() != attachmentOverlayNone {
1024+ return true
1025+ }
1026+ if supervisorNil(f.term) || supervisorNil(f.term.Out()) {
1027+ err = ports.ErrUIUnavailable
1028+ return true
1029+ }
1030+ var n int
1031+ n, err = f.term.Out().Write(data)
1032+ if err == nil && n != len(data) {
1033+ err = io.ErrShortWrite
1034+ }
1035+ if err == nil {
1036+ err = f.term.Flush()
1037+ }
1038+ written = err == nil
1039+ return true
1040+ })
1041+ if !ok {
1042+ return false, errAttachmentForegroundRevoked
1043+ }
1044+ return written, err
1045+}
1046+
10001047 // attachmentRun is one in-flight worker generation owned by the supervisor.
10011048 type attachmentRun struct {
10021049 host *attachmentHost
@@ -997,6 +997,53 @@ func (f *attachmentForeground) writeTerminalQuery(data []byte) error {
997 return err997 return err
998 }998 }
999 999
1000+// attachmentPredictionForeground is the optional predictive-echo seam of the
1001+// real foreground. Scripted foregrounds may omit it; the worker then never
1002+// predicts.
1003+type attachmentPredictionForeground interface {
1004+ writePrediction(data []byte) (bool, error)
1005+}
1006+
1007+var _ attachmentPredictionForeground = (*attachmentForeground)(nil)
1008+
1009+// writePrediction writes and flushes predicted echo under the output lease.
1010+// It reports false without writing while an overlay owns the terminal. The
1011+// guesses are client-local drawing, so the UI observation channel is not
1012+// involved.
1013+func (f *attachmentForeground) writePrediction(data []byte) (bool, error) {
1014+ if f == nil {
1015+ return false, errAttachmentForegroundRevoked
1016+ }
1017+ var err error
1018+ written := false
1019+ ok := f.lease.send(func() bool {
1020+ if !f.authority.actionAuthorized(f) {
1021+ return false
1022+ }
1023+ if f.overlayKind() != attachmentOverlayNone {
1024+ return true
1025+ }
1026+ if supervisorNil(f.term) || supervisorNil(f.term.Out()) {
1027+ err = ports.ErrUIUnavailable
1028+ return true
1029+ }
1030+ var n int
1031+ n, err = f.term.Out().Write(data)
1032+ if err == nil && n != len(data) {
1033+ err = io.ErrShortWrite
1034+ }
1035+ if err == nil {
1036+ err = f.term.Flush()
1037+ }
1038+ written = err == nil
1039+ return true
1040+ })
1041+ if !ok {
1042+ return false, errAttachmentForegroundRevoked
1043+ }
1044+ return written, err
1045+}
1046+
1000 // attachmentRun is one in-flight worker generation owned by the supervisor.1047 // attachmentRun is one in-flight worker generation owned by the supervisor.
1001 type attachmentRun struct {1048 type attachmentRun struct {
1002 host *attachmentHost1049 host *attachmentHost
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+}
A internal/usecase/client/predictive_echo.go +807 -0
@@ -0,0 +1,807 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "slices"
6+ "strconv"
7+ "strings"
8+ "time"
9+ "unicode/utf8"
10+
11+ vt "github.com/bnema/vev-vt"
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/protocol"
14+)
15+
16+// Predictive local echo, after mosh's PredictionEngine
17+// (src/frontend/terminaloverlay.cc).
18+//
19+// On a slow link the client guesses the echo of printable keys and backspace
20+// on the cursor row and draws it before the daemon confirms it. The guess is
21+// checked against a local mirror of the daemon's composed output once the
22+// daemon acknowledges the input in Output.Echo. Anything the engine cannot
23+// model (control bytes, escape sequences, wide runes, the last column) makes
24+// later guesses tentative: they stay hidden until a correct guess of the same
25+// epoch confirms it. A wrong tentative guess kills its epoch; a wrong
26+// confirmed guess resets the engine.
27+//
28+// The attached loop is the only caller, so the engine holds no lock.
29+
30+// Adaptive display thresholds from mosh. They apply to the send interval,
31+// half the smoothed round trip clamped to [20ms, 250ms].
32+const (
33+ echoSRTTTriggerLow = 20 * time.Millisecond
34+ echoSRTTTriggerHigh = 30 * time.Millisecond
35+ echoFlagTriggerLow = 50 * time.Millisecond
36+ echoFlagTriggerHigh = 80 * time.Millisecond
37+
38+ echoSendIntervalMin = 20 * time.Millisecond
39+ echoSendIntervalMax = 250 * time.Millisecond
40+
41+ // echoGlitchThreshold shows predictions when one is pending this long.
42+ echoGlitchThreshold = 250 * time.Millisecond
43+ // echoGlitchFlagThreshold also underlines them.
44+ echoGlitchFlagThreshold = 5000 * time.Millisecond
45+ // echoGlitchRepairCount quick confirmations, each at least
46+ // echoGlitchRepairMinInterval apart, cure a glitch.
47+ echoGlitchRepairCount = 10
48+ echoGlitchRepairMinInterval = 150 * time.Millisecond
49+
50+ // echoServerDelay is the daemon's echo acknowledgement delay
51+ // (usecase/daemon echoAckDelay). It is removed from each round-trip
52+ // sample so the thresholds compare network time, as mosh's SRTT does.
53+ echoServerDelay = 50 * time.Millisecond
54+
55+ // maxEchoSent bounds the send-time history used for round-trip samples.
56+ maxEchoSent = 256
57+ // maxEchoOriginals bounds the overwritten contents kept per cell.
58+ maxEchoOriginals = 8
59+)
60+
61+type echoValidity uint8
62+
63+const (
64+ echoInactive echoValidity = iota
65+ echoPending
66+ echoCorrect
67+ echoCorrectNoCredit
68+ echoIncorrect
69+)
70+
71+// echoCell is one predicted cell of the prediction row.
72+type echoCell struct {
73+ active bool
74+ unknown bool
75+ expires uint64
76+ epoch uint64
77+ predictedAt time.Time
78+ replacement vt.Cell
79+ originals []rune
80+}
81+
82+func (c *echoCell) tentative(confirmed uint64) bool { return c.epoch > confirmed }
83+
84+func (c *echoCell) reset() { *c = echoCell{originals: c.originals[:0]} }
85+
86+// resetWithOriginal keeps a known replacement as overwritten content, so a
87+// later guess that merely restores it earns no credit.
88+func (c *echoCell) resetWithOriginal() {
89+ if !c.active || c.unknown {
90+ c.reset()
91+ return
92+ }
93+ originals := append(c.originals, c.replacement.Rune)
94+ if len(originals) > maxEchoOriginals {
95+ originals = originals[len(originals)-maxEchoOriginals:]
96+ }
97+ *c = echoCell{originals: originals}
98+}
99+
100+func (c *echoCell) arm(seq, epoch uint64, now time.Time) {
101+ c.active = true
102+ c.epoch = epoch
103+ c.expires = seq
104+ c.predictedAt = now
105+}
106+
107+// echoCursor is the predicted cursor position.
108+type echoCursor struct {
109+ active bool
110+ row int
111+ col int
112+ expires uint64
113+ epoch uint64
114+}
115+
116+type echoSent struct {
117+ seq uint64
118+ at time.Time
119+}
120+
121+// echoPredictor is the prediction engine of one attachment run.
122+type echoPredictor struct {
123+ mode domain.EchoPredictMode
124+ screen *vt.Screen
125+
126+ sent []echoSent
127+ echo uint64
128+ srtt time.Duration
129+ srttKnown bool
130+ srttOn bool
131+ flagging bool
132+ glitch int
133+ lastQuick time.Time
134+ predEpoch uint64
135+ confEpoch uint64
136+ holdUntil uint64
137+ row int
138+ cells []echoCell
139+ cursor echoCursor
140+ drawnRow int
141+ drawn []int
142+ cursorMove bool
143+ apc apcFilter
144+}
145+
146+func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor {
147+ return &echoPredictor{mode: mode, predEpoch: 1, screen: vt.NewScreen(1, 1)}
148+}
149+
150+// applyOutput feeds one applied Output to the mirror, samples the round trip
151+// when Echo advances, and validates pending guesses.
152+func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) {
153+ // Side effects (New == 0) may carry a placeholder size; only state
154+ // frames describe the grid.
155+ if output.New != 0 && output.Size.Valid() && (output.Size.Cols != p.screen.Columns() || output.Size.Rows != p.screen.Rows()) {
156+ p.screen.Resize(output.Size.Cols, output.Size.Rows)
157+ // The resized frame repaints the whole terminal.
158+ p.forget()
159+ }
160+ if output.Full {
161+ // A full frame repainted every drawn guess.
162+ p.drawn = p.drawn[:0]
163+ p.cursorMove = false
164+ }
165+ if data := p.apc.strip(output.Data); len(data) > 0 {
166+ p.screen.Write(data)
167+ }
168+ if output.Echo > p.echo {
169+ p.sample(output.Echo, now)
170+ p.echo = output.Echo
171+ }
172+ p.cull(now)
173+}
174+
175+// sample updates the smoothed round trip from the send time of seq.
176+func (p *echoPredictor) sample(seq uint64, now time.Time) {
177+ keep := p.sent[:0]
178+ for _, sent := range p.sent {
179+ if sent.seq == seq {
180+ rtt := max(now.Sub(sent.at)-echoServerDelay, 0)
181+ if p.srttKnown {
182+ p.srtt = (7*p.srtt + rtt) / 8
183+ } else {
184+ p.srtt, p.srttKnown = rtt, true
185+ }
186+ }
187+ if sent.seq > seq {
188+ keep = append(keep, sent)
189+ }
190+ }
191+ p.sent = keep
192+}
193+
194+func (p *echoPredictor) sendInterval() time.Duration {
195+ interval := (p.srtt + 1) / 2
196+ return min(max(interval, echoSendIntervalMin), echoSendIntervalMax)
197+}
198+
199+// enabled reports whether the mirror describes a screen the engine can
200+// predict on: a visible cursor on a usable grid. The mirror holds the
201+// daemon's composed frame, which never enters the alternate screen, so
202+// full-screen pane programs rely on epoch validation, as in mosh.
203+func (p *echoPredictor) enabled() bool {
204+ return p.mode != domain.EchoPredictNever && p.screen.CursorVisible() &&
205+ p.screen.Columns() > 1 && p.screen.Rows() > 0
206+}
207+
208+func (p *echoPredictor) active() bool {
209+ if p.cursor.active {
210+ return true
211+ }
212+ for i := range p.cells {
213+ if p.cells[i].active {
214+ return true
215+ }
216+ }
217+ return false
218+}
219+
220+func (p *echoPredictor) displaying() bool {
221+ switch p.mode {
222+ case domain.EchoPredictNever:
223+ return false
224+ case domain.EchoPredictAlways:
225+ return true
226+ }
227+ return p.srttOn || p.glitch > 0
228+}
229+
230+func (p *echoPredictor) becomeTentative() { p.predEpoch++ }
231+
232+// reset drops every guess. Drawn cells stay recorded so render repaints them.
233+func (p *echoPredictor) reset() {
234+ for i := range p.cells {
235+ p.cells[i].reset()
236+ }
237+ p.cursor = echoCursor{}
238+ p.becomeTentative()
239+}
240+
241+// forget drops every guess and the drawn record, for when something else
242+// repaints or owns the terminal (a full frame, an overlay).
243+func (p *echoPredictor) forget() {
244+ p.reset()
245+ p.drawn = p.drawn[:0]
246+ p.cursorMove = false
247+}
248+
249+func (p *echoPredictor) killEpoch(epoch uint64) {
250+ for i := range p.cells {
251+ if p.cells[i].active && p.cells[i].epoch >= epoch {
252+ p.cells[i].reset()
253+ }
254+ }
255+ if p.cursor.active && p.cursor.epoch >= epoch {
256+ p.cursor = echoCursor{}
257+ }
258+ p.becomeTentative()
259+}
260+
261+// cull validates guesses against the mirror and updates the display
262+// triggers, as mosh's PredictionEngine::cull.
263+func (p *echoPredictor) cull(now time.Time) {
264+ if p.mode == domain.EchoPredictNever {
265+ return
266+ }
267+ if !p.enabled() {
268+ p.reset()
269+ return
270+ }
271+ if p.srttKnown {
272+ interval := p.sendInterval()
273+ if interval > echoSRTTTriggerHigh {
274+ p.srttOn = true
275+ } else if p.srttOn && interval <= echoSRTTTriggerLow && !p.active() {
276+ p.srttOn = false
277+ }
278+ if interval > echoFlagTriggerHigh {
279+ p.flagging = true
280+ } else if interval <= echoFlagTriggerLow {
281+ p.flagging = false
282+ }
283+ }
284+ if p.glitch > echoGlitchRepairCount {
285+ p.flagging = true
286+ }
287+ if p.row >= p.screen.Rows() || len(p.cells) != p.screen.Columns() {
288+ p.cells = nil
289+ }
290+ for col := range p.cells {
291+ cell := &p.cells[col]
292+ switch p.cellValidity(col) {
293+ case echoIncorrect:
294+ if cell.tentative(p.confEpoch) {
295+ p.killEpoch(cell.epoch)
296+ continue
297+ }
298+ p.reset()
299+ return
300+ case echoCorrect:
301+ p.confEpoch = max(p.confEpoch, cell.epoch)
302+ if now.Sub(cell.predictedAt) < echoGlitchThreshold && p.glitch > 0 && now.Sub(p.lastQuick) >= echoGlitchRepairMinInterval {
303+ p.glitch--
304+ p.lastQuick = now
305+ }
306+ // Later guesses take the confirmed style.
307+ style := p.screen.Cell(col, p.row).Style
308+ for k := col; k < len(p.cells); k++ {
309+ p.cells[k].replacement.Style = style
310+ }
311+ cell.reset()
312+ case echoCorrectNoCredit:
313+ cell.reset()
314+ case echoPending:
315+ pending := now.Sub(cell.predictedAt)
316+ if pending >= echoGlitchFlagThreshold {
317+ p.glitch = 2 * echoGlitchRepairCount
318+ } else if pending >= echoGlitchThreshold && p.glitch < echoGlitchRepairCount {
319+ p.glitch = echoGlitchRepairCount
320+ }
321+ }
322+ }
323+ if p.cursor.active && p.echo >= p.cursor.expires {
324+ if p.screen.CursorRow() != p.cursor.row || p.screen.CursorCol() != p.cursor.col {
325+ p.reset()
326+ return
327+ }
328+ p.cursor = echoCursor{}
329+ }
330+}
331+
332+func (p *echoPredictor) cellValidity(col int) echoValidity {
333+ cell := &p.cells[col]
334+ if !cell.active {
335+ return echoInactive
336+ }
337+ if p.echo < cell.expires {
338+ return echoPending
339+ }
340+ if cell.unknown || isBlankRune(cell.replacement.Rune) {
341+ return echoCorrectNoCredit
342+ }
343+ current := p.screen.Cell(col, p.row)
344+ if current.Rune != cell.replacement.Rune {
345+ return echoIncorrect
346+ }
347+ for _, original := range cell.originals {
348+ if original == cell.replacement.Rune {
349+ return echoCorrectNoCredit
350+ }
351+ }
352+ return echoCorrect
353+}
354+
355+func isBlankRune(r rune) bool { return r == 0 || r == ' ' }
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+
374+// input records the Input numbered seq and predicts its bytes.
375+func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) {
376+ if seq == 0 || p.mode == domain.EchoPredictNever {
377+ return
378+ }
379+ if len(p.sent) == maxEchoSent {
380+ p.sent = append(p.sent[:0], p.sent[1:]...)
381+ }
382+ p.sent = append(p.sent, echoSent{seq: seq, at: now})
383+ p.cull(now)
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 {
388+ return
389+ }
390+ // A bracketed paste arrives as one Input: it is not typing.
391+ if bytes.Contains(data, []byte("\x1b[200~")) {
392+ p.becomeTentative()
393+ return
394+ }
395+ for len(data) > 0 {
396+ if data[0] == 0x1b {
397+ data = data[escapeLength(data):]
398+ p.becomeTentative()
399+ continue
400+ }
401+ r, size := utf8.DecodeRune(data)
402+ data = data[size:]
403+ switch {
404+ case r == utf8.RuneError && size == 1:
405+ p.becomeTentative()
406+ case r == 0x7f:
407+ p.backspace(seq, now)
408+ case r == '\r':
409+ // The next row's state is only known once the daemon answers.
410+ p.unknownKey(seq)
411+ case vt.RuneWidth(r) != 1:
412+ p.becomeTentative()
413+ default:
414+ p.print(r, seq, now)
415+ }
416+ }
417+}
418+
419+// escapeLength is the length of the escape sequence at the start of data, or
420+// all of data when it is incomplete.
421+func escapeLength(data []byte) int {
422+ if len(data) < 2 {
423+ return len(data)
424+ }
425+ switch data[1] {
426+ case '[':
427+ for i := 2; i < len(data); i++ {
428+ if data[i] >= 0x40 && data[i] <= 0x7e {
429+ return i + 1
430+ }
431+ }
432+ return len(data)
433+ case 'O':
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)
446+ }
447+ return 2
448+}
449+
450+// initCursor starts the cursor prediction from the mirror, and the cell row
451+// with it. It reports false while the cursor row is not known yet.
452+func (p *echoPredictor) initCursor() bool {
453+ if p.echo < p.holdUntil {
454+ return false
455+ }
456+ if !p.cursor.active {
457+ row, col := p.screen.CursorRow(), p.screen.CursorCol()
458+ if row < 0 || row >= p.screen.Rows() || col < 0 || col >= p.screen.Columns() {
459+ return false
460+ }
461+ p.cursor = echoCursor{active: true, row: row, col: col, epoch: p.predEpoch}
462+ }
463+ if p.cells == nil || p.row != p.cursor.row || len(p.cells) != p.screen.Columns() {
464+ p.row = p.cursor.row
465+ p.cells = make([]echoCell, p.screen.Columns())
466+ }
467+ return true
468+}
469+
470+// effective is the cell the user will see at col: a known guess, else the
471+// mirror.
472+func (p *echoPredictor) effective(col int) (vt.Cell, bool) {
473+ cell := &p.cells[col]
474+ if cell.active {
475+ if cell.unknown {
476+ return vt.Cell{}, false
477+ }
478+ return cell.replacement, true
479+ }
480+ return p.screen.Cell(col, p.row), true
481+}
482+
483+func (p *echoPredictor) moveCursor(col int, seq uint64) {
484+ p.cursor.col = col
485+ p.cursor.expires = seq
486+ p.cursor.epoch = p.predEpoch
487+}
488+
489+// print predicts one width-1 rune in insert mode. The shift stops at the
490+// first blank, so a split layout's borders to the right never move.
491+func (p *echoPredictor) print(r rune, seq uint64, now time.Time) {
492+ if !p.initCursor() {
493+ p.becomeTentative()
494+ return
495+ }
496+ width := len(p.cells)
497+ col := p.cursor.col
498+ if col+1 >= width {
499+ // mosh: the last column is tricky (wrap markers, shells differ).
500+ p.becomeTentative()
501+ }
502+ end := col
503+ for end < width-1 {
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+ }
511+ if known && isBlankRune(cell.Rune) {
512+ break
513+ }
514+ end++
515+ }
516+ for i := end; i > col; i-- {
517+ cell := &p.cells[i]
518+ prev, known := p.effective(i - 1)
519+ original := p.screen.Cell(i, p.row).Rune
520+ cell.resetWithOriginal()
521+ cell.arm(seq, p.predEpoch, now)
522+ cell.originals = append(cell.originals, original)
523+ if i == width-1 || !known {
524+ cell.unknown = true
525+ } else {
526+ cell.replacement = prev
527+ }
528+ }
529+ style := vt.DefaultStyle()
530+ if col > 0 {
531+ if left, known := p.effective(col - 1); known {
532+ style = left.Style
533+ }
534+ }
535+ cell := &p.cells[col]
536+ original := p.screen.Cell(col, p.row).Rune
537+ cell.resetWithOriginal()
538+ cell.arm(seq, p.predEpoch, now)
539+ cell.replacement = vt.Cell{Rune: r, Style: style}
540+ cell.originals = append(cell.originals, original)
541+ if col < width-1 {
542+ p.moveCursor(col+1, seq)
543+ } else {
544+ p.becomeTentative()
545+ p.moveCursor(col, seq)
546+ }
547+}
548+
549+// backspace predicts DEL: the cursor moves left and the text after it moves
550+// left up to the first blank.
551+func (p *echoPredictor) backspace(seq uint64, now time.Time) {
552+ if !p.initCursor() {
553+ p.becomeTentative()
554+ return
555+ }
556+ if p.cursor.col == 0 {
557+ return
558+ }
559+ width := len(p.cells)
560+ 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+ }
571+ p.moveCursor(col, seq)
572+ for i := col; i < width; i++ {
573+ cell := &p.cells[i]
574+ original := p.screen.Cell(i, p.row).Rune
575+ var next vt.Cell
576+ known := false
577+ if i+2 < width {
578+ next, known = p.effective(i + 1)
579+ }
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+ }
585+ cell.resetWithOriginal()
586+ cell.arm(seq, p.predEpoch, now)
587+ cell.originals = append(cell.originals, original)
588+ if !known {
589+ cell.unknown = true
590+ } else {
591+ cell.replacement = next
592+ }
593+ if blank {
594+ break
595+ }
596+ }
597+}
598+
599+// needsTick reports whether a pending guess still waits on a timing trigger
600+// (mosh's timing_tests_necessary), so the caller re-culls without output.
601+func (p *echoPredictor) needsTick() bool {
602+ return p.mode == domain.EchoPredictAdaptive && p.active() && !(p.glitch > 0 && p.flagging)
603+}
604+
605+// render returns the terminal bytes that bring the screen from the last
606+// render to the current guesses: drawn cells that no longer show a guess are
607+// repainted from the mirror, shown guesses are drawn again (a daemon frame
608+// may have overwritten them), and the cursor goes to the predicted position
609+// or back to the real one.
610+func (p *echoPredictor) render() []byte {
611+ var out bytes.Buffer
612+ show := p.displaying() && p.enabled()
613+ var want []int
614+ if show && p.cells != nil && p.row < p.screen.Rows() {
615+ for col := range p.cells {
616+ cell := &p.cells[col]
617+ if !cell.active || cell.unknown || cell.tentative(p.confEpoch) {
618+ continue
619+ }
620+ if p.screen.Cell(col, p.row).Equal(cell.replacement) && !p.flagging {
621+ continue
622+ }
623+ want = append(want, col)
624+ }
625+ }
626+ for _, col := range p.drawn {
627+ if p.drawnRow == p.row && slices.Contains(want, col) {
628+ continue
629+ }
630+ if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() {
631+ p.restoreCell(&out, p.drawnRow, col)
632+ }
633+ }
634+ for _, col := range want {
635+ cell := p.cells[col].replacement
636+ flag := p.flagging && !(isBlankRune(cell.Rune) && isBlankRune(p.screen.Cell(col, p.row).Rune))
637+ writeEchoCell(&out, p.row, col, cell, flag)
638+ }
639+ p.drawn = append(p.drawn[:0], want...)
640+ p.drawnRow = p.row
641+
642+ row, col := p.screen.CursorRow(), p.screen.CursorCol()
643+ moved := false
644+ if show && p.cursor.active && p.cursor.epoch <= p.confEpoch {
645+ moved = p.cursor.row != row || p.cursor.col != col
646+ row, col = p.cursor.row, p.cursor.col
647+ }
648+ if out.Len() > 0 || moved || p.cursorMove {
649+ out.WriteString("\x1b[0m")
650+ writeEchoCUP(&out, row, col)
651+ }
652+ p.cursorMove = moved
653+ return out.Bytes()
654+}
655+
656+func writeEchoCUP(out *bytes.Buffer, row, col int) {
657+ out.WriteString("\x1b[")
658+ out.WriteString(strconv.Itoa(row + 1))
659+ out.WriteByte(';')
660+ out.WriteString(strconv.Itoa(col + 1))
661+ out.WriteByte('H')
662+}
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+
675+// writeEchoCell draws one cell. Mirror styles come from daemon output already
676+// projected to this terminal's color profile, so they are written as is.
677+func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) {
678+ writeEchoCUP(out, row, col)
679+ out.WriteString(echoSGR(cell.Style, underline))
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)
687+ }
688+}
689+
690+func echoSGR(style vt.Style, underline bool) string {
691+ var b strings.Builder
692+ b.WriteString("\x1b[0")
693+ if style.Bold {
694+ b.WriteString(";1")
695+ }
696+ if style.Attrs&vt.AttrDim != 0 {
697+ b.WriteString(";2")
698+ }
699+ if style.Italic {
700+ b.WriteString(";3")
701+ }
702+ switch {
703+ case underline:
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+ }
718+ }
719+ if style.Attrs&vt.AttrBlink != 0 {
720+ b.WriteString(";5")
721+ }
722+ if style.Inverse {
723+ b.WriteString(";7")
724+ }
725+ if style.Attrs&vt.AttrStrikethrough != 0 {
726+ b.WriteString(";9")
727+ }
728+ writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90)
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+ }
736+ b.WriteByte('m')
737+ return b.String()
738+}
739+
740+func writeEchoColor(b *strings.Builder, hasRGB bool, rgb vt.RGB, index, extended, normal, bright int) {
741+ switch {
742+ case hasRGB:
743+ b.WriteString(";" + strconv.Itoa(extended) + ";2;" + strconv.Itoa(int(rgb.R)) + ";" + strconv.Itoa(int(rgb.G)) + ";" + strconv.Itoa(int(rgb.B)))
744+ case index < 0:
745+ case index < 8:
746+ b.WriteString(";" + strconv.Itoa(normal+index))
747+ case index < 16:
748+ b.WriteString(";" + strconv.Itoa(bright+index-8))
749+ case index < 256:
750+ b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index))
751+ }
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+}
@@ -0,0 +1,807 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "slices"
6+ "strconv"
7+ "strings"
8+ "time"
9+ "unicode/utf8"
10+
11+ vt "github.com/bnema/vev-vt"
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/protocol"
14+)
15+
16+// Predictive local echo, after mosh's PredictionEngine
17+// (src/frontend/terminaloverlay.cc).
18+//
19+// On a slow link the client guesses the echo of printable keys and backspace
20+// on the cursor row and draws it before the daemon confirms it. The guess is
21+// checked against a local mirror of the daemon's composed output once the
22+// daemon acknowledges the input in Output.Echo. Anything the engine cannot
23+// model (control bytes, escape sequences, wide runes, the last column) makes
24+// later guesses tentative: they stay hidden until a correct guess of the same
25+// epoch confirms it. A wrong tentative guess kills its epoch; a wrong
26+// confirmed guess resets the engine.
27+//
28+// The attached loop is the only caller, so the engine holds no lock.
29+
30+// Adaptive display thresholds from mosh. They apply to the send interval,
31+// half the smoothed round trip clamped to [20ms, 250ms].
32+const (
33+ echoSRTTTriggerLow = 20 * time.Millisecond
34+ echoSRTTTriggerHigh = 30 * time.Millisecond
35+ echoFlagTriggerLow = 50 * time.Millisecond
36+ echoFlagTriggerHigh = 80 * time.Millisecond
37+
38+ echoSendIntervalMin = 20 * time.Millisecond
39+ echoSendIntervalMax = 250 * time.Millisecond
40+
41+ // echoGlitchThreshold shows predictions when one is pending this long.
42+ echoGlitchThreshold = 250 * time.Millisecond
43+ // echoGlitchFlagThreshold also underlines them.
44+ echoGlitchFlagThreshold = 5000 * time.Millisecond
45+ // echoGlitchRepairCount quick confirmations, each at least
46+ // echoGlitchRepairMinInterval apart, cure a glitch.
47+ echoGlitchRepairCount = 10
48+ echoGlitchRepairMinInterval = 150 * time.Millisecond
49+
50+ // echoServerDelay is the daemon's echo acknowledgement delay
51+ // (usecase/daemon echoAckDelay). It is removed from each round-trip
52+ // sample so the thresholds compare network time, as mosh's SRTT does.
53+ echoServerDelay = 50 * time.Millisecond
54+
55+ // maxEchoSent bounds the send-time history used for round-trip samples.
56+ maxEchoSent = 256
57+ // maxEchoOriginals bounds the overwritten contents kept per cell.
58+ maxEchoOriginals = 8
59+)
60+
61+type echoValidity uint8
62+
63+const (
64+ echoInactive echoValidity = iota
65+ echoPending
66+ echoCorrect
67+ echoCorrectNoCredit
68+ echoIncorrect
69+)
70+
71+// echoCell is one predicted cell of the prediction row.
72+type echoCell struct {
73+ active bool
74+ unknown bool
75+ expires uint64
76+ epoch uint64
77+ predictedAt time.Time
78+ replacement vt.Cell
79+ originals []rune
80+}
81+
82+func (c *echoCell) tentative(confirmed uint64) bool { return c.epoch > confirmed }
83+
84+func (c *echoCell) reset() { *c = echoCell{originals: c.originals[:0]} }
85+
86+// resetWithOriginal keeps a known replacement as overwritten content, so a
87+// later guess that merely restores it earns no credit.
88+func (c *echoCell) resetWithOriginal() {
89+ if !c.active || c.unknown {
90+ c.reset()
91+ return
92+ }
93+ originals := append(c.originals, c.replacement.Rune)
94+ if len(originals) > maxEchoOriginals {
95+ originals = originals[len(originals)-maxEchoOriginals:]
96+ }
97+ *c = echoCell{originals: originals}
98+}
99+
100+func (c *echoCell) arm(seq, epoch uint64, now time.Time) {
101+ c.active = true
102+ c.epoch = epoch
103+ c.expires = seq
104+ c.predictedAt = now
105+}
106+
107+// echoCursor is the predicted cursor position.
108+type echoCursor struct {
109+ active bool
110+ row int
111+ col int
112+ expires uint64
113+ epoch uint64
114+}
115+
116+type echoSent struct {
117+ seq uint64
118+ at time.Time
119+}
120+
121+// echoPredictor is the prediction engine of one attachment run.
122+type echoPredictor struct {
123+ mode domain.EchoPredictMode
124+ screen *vt.Screen
125+
126+ sent []echoSent
127+ echo uint64
128+ srtt time.Duration
129+ srttKnown bool
130+ srttOn bool
131+ flagging bool
132+ glitch int
133+ lastQuick time.Time
134+ predEpoch uint64
135+ confEpoch uint64
136+ holdUntil uint64
137+ row int
138+ cells []echoCell
139+ cursor echoCursor
140+ drawnRow int
141+ drawn []int
142+ cursorMove bool
143+ apc apcFilter
144+}
145+
146+func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor {
147+ return &echoPredictor{mode: mode, predEpoch: 1, screen: vt.NewScreen(1, 1)}
148+}
149+
150+// applyOutput feeds one applied Output to the mirror, samples the round trip
151+// when Echo advances, and validates pending guesses.
152+func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) {
153+ // Side effects (New == 0) may carry a placeholder size; only state
154+ // frames describe the grid.
155+ if output.New != 0 && output.Size.Valid() && (output.Size.Cols != p.screen.Columns() || output.Size.Rows != p.screen.Rows()) {
156+ p.screen.Resize(output.Size.Cols, output.Size.Rows)
157+ // The resized frame repaints the whole terminal.
158+ p.forget()
159+ }
160+ if output.Full {
161+ // A full frame repainted every drawn guess.
162+ p.drawn = p.drawn[:0]
163+ p.cursorMove = false
164+ }
165+ if data := p.apc.strip(output.Data); len(data) > 0 {
166+ p.screen.Write(data)
167+ }
168+ if output.Echo > p.echo {
169+ p.sample(output.Echo, now)
170+ p.echo = output.Echo
171+ }
172+ p.cull(now)
173+}
174+
175+// sample updates the smoothed round trip from the send time of seq.
176+func (p *echoPredictor) sample(seq uint64, now time.Time) {
177+ keep := p.sent[:0]
178+ for _, sent := range p.sent {
179+ if sent.seq == seq {
180+ rtt := max(now.Sub(sent.at)-echoServerDelay, 0)
181+ if p.srttKnown {
182+ p.srtt = (7*p.srtt + rtt) / 8
183+ } else {
184+ p.srtt, p.srttKnown = rtt, true
185+ }
186+ }
187+ if sent.seq > seq {
188+ keep = append(keep, sent)
189+ }
190+ }
191+ p.sent = keep
192+}
193+
194+func (p *echoPredictor) sendInterval() time.Duration {
195+ interval := (p.srtt + 1) / 2
196+ return min(max(interval, echoSendIntervalMin), echoSendIntervalMax)
197+}
198+
199+// enabled reports whether the mirror describes a screen the engine can
200+// predict on: a visible cursor on a usable grid. The mirror holds the
201+// daemon's composed frame, which never enters the alternate screen, so
202+// full-screen pane programs rely on epoch validation, as in mosh.
203+func (p *echoPredictor) enabled() bool {
204+ return p.mode != domain.EchoPredictNever && p.screen.CursorVisible() &&
205+ p.screen.Columns() > 1 && p.screen.Rows() > 0
206+}
207+
208+func (p *echoPredictor) active() bool {
209+ if p.cursor.active {
210+ return true
211+ }
212+ for i := range p.cells {
213+ if p.cells[i].active {
214+ return true
215+ }
216+ }
217+ return false
218+}
219+
220+func (p *echoPredictor) displaying() bool {
221+ switch p.mode {
222+ case domain.EchoPredictNever:
223+ return false
224+ case domain.EchoPredictAlways:
225+ return true
226+ }
227+ return p.srttOn || p.glitch > 0
228+}
229+
230+func (p *echoPredictor) becomeTentative() { p.predEpoch++ }
231+
232+// reset drops every guess. Drawn cells stay recorded so render repaints them.
233+func (p *echoPredictor) reset() {
234+ for i := range p.cells {
235+ p.cells[i].reset()
236+ }
237+ p.cursor = echoCursor{}
238+ p.becomeTentative()
239+}
240+
241+// forget drops every guess and the drawn record, for when something else
242+// repaints or owns the terminal (a full frame, an overlay).
243+func (p *echoPredictor) forget() {
244+ p.reset()
245+ p.drawn = p.drawn[:0]
246+ p.cursorMove = false
247+}
248+
249+func (p *echoPredictor) killEpoch(epoch uint64) {
250+ for i := range p.cells {
251+ if p.cells[i].active && p.cells[i].epoch >= epoch {
252+ p.cells[i].reset()
253+ }
254+ }
255+ if p.cursor.active && p.cursor.epoch >= epoch {
256+ p.cursor = echoCursor{}
257+ }
258+ p.becomeTentative()
259+}
260+
261+// cull validates guesses against the mirror and updates the display
262+// triggers, as mosh's PredictionEngine::cull.
263+func (p *echoPredictor) cull(now time.Time) {
264+ if p.mode == domain.EchoPredictNever {
265+ return
266+ }
267+ if !p.enabled() {
268+ p.reset()
269+ return
270+ }
271+ if p.srttKnown {
272+ interval := p.sendInterval()
273+ if interval > echoSRTTTriggerHigh {
274+ p.srttOn = true
275+ } else if p.srttOn && interval <= echoSRTTTriggerLow && !p.active() {
276+ p.srttOn = false
277+ }
278+ if interval > echoFlagTriggerHigh {
279+ p.flagging = true
280+ } else if interval <= echoFlagTriggerLow {
281+ p.flagging = false
282+ }
283+ }
284+ if p.glitch > echoGlitchRepairCount {
285+ p.flagging = true
286+ }
287+ if p.row >= p.screen.Rows() || len(p.cells) != p.screen.Columns() {
288+ p.cells = nil
289+ }
290+ for col := range p.cells {
291+ cell := &p.cells[col]
292+ switch p.cellValidity(col) {
293+ case echoIncorrect:
294+ if cell.tentative(p.confEpoch) {
295+ p.killEpoch(cell.epoch)
296+ continue
297+ }
298+ p.reset()
299+ return
300+ case echoCorrect:
301+ p.confEpoch = max(p.confEpoch, cell.epoch)
302+ if now.Sub(cell.predictedAt) < echoGlitchThreshold && p.glitch > 0 && now.Sub(p.lastQuick) >= echoGlitchRepairMinInterval {
303+ p.glitch--
304+ p.lastQuick = now
305+ }
306+ // Later guesses take the confirmed style.
307+ style := p.screen.Cell(col, p.row).Style
308+ for k := col; k < len(p.cells); k++ {
309+ p.cells[k].replacement.Style = style
310+ }
311+ cell.reset()
312+ case echoCorrectNoCredit:
313+ cell.reset()
314+ case echoPending:
315+ pending := now.Sub(cell.predictedAt)
316+ if pending >= echoGlitchFlagThreshold {
317+ p.glitch = 2 * echoGlitchRepairCount
318+ } else if pending >= echoGlitchThreshold && p.glitch < echoGlitchRepairCount {
319+ p.glitch = echoGlitchRepairCount
320+ }
321+ }
322+ }
323+ if p.cursor.active && p.echo >= p.cursor.expires {
324+ if p.screen.CursorRow() != p.cursor.row || p.screen.CursorCol() != p.cursor.col {
325+ p.reset()
326+ return
327+ }
328+ p.cursor = echoCursor{}
329+ }
330+}
331+
332+func (p *echoPredictor) cellValidity(col int) echoValidity {
333+ cell := &p.cells[col]
334+ if !cell.active {
335+ return echoInactive
336+ }
337+ if p.echo < cell.expires {
338+ return echoPending
339+ }
340+ if cell.unknown || isBlankRune(cell.replacement.Rune) {
341+ return echoCorrectNoCredit
342+ }
343+ current := p.screen.Cell(col, p.row)
344+ if current.Rune != cell.replacement.Rune {
345+ return echoIncorrect
346+ }
347+ for _, original := range cell.originals {
348+ if original == cell.replacement.Rune {
349+ return echoCorrectNoCredit
350+ }
351+ }
352+ return echoCorrect
353+}
354+
355+func isBlankRune(r rune) bool { return r == 0 || r == ' ' }
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+
374+// input records the Input numbered seq and predicts its bytes.
375+func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) {
376+ if seq == 0 || p.mode == domain.EchoPredictNever {
377+ return
378+ }
379+ if len(p.sent) == maxEchoSent {
380+ p.sent = append(p.sent[:0], p.sent[1:]...)
381+ }
382+ p.sent = append(p.sent, echoSent{seq: seq, at: now})
383+ p.cull(now)
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 {
388+ return
389+ }
390+ // A bracketed paste arrives as one Input: it is not typing.
391+ if bytes.Contains(data, []byte("\x1b[200~")) {
392+ p.becomeTentative()
393+ return
394+ }
395+ for len(data) > 0 {
396+ if data[0] == 0x1b {
397+ data = data[escapeLength(data):]
398+ p.becomeTentative()
399+ continue
400+ }
401+ r, size := utf8.DecodeRune(data)
402+ data = data[size:]
403+ switch {
404+ case r == utf8.RuneError && size == 1:
405+ p.becomeTentative()
406+ case r == 0x7f:
407+ p.backspace(seq, now)
408+ case r == '\r':
409+ // The next row's state is only known once the daemon answers.
410+ p.unknownKey(seq)
411+ case vt.RuneWidth(r) != 1:
412+ p.becomeTentative()
413+ default:
414+ p.print(r, seq, now)
415+ }
416+ }
417+}
418+
419+// escapeLength is the length of the escape sequence at the start of data, or
420+// all of data when it is incomplete.
421+func escapeLength(data []byte) int {
422+ if len(data) < 2 {
423+ return len(data)
424+ }
425+ switch data[1] {
426+ case '[':
427+ for i := 2; i < len(data); i++ {
428+ if data[i] >= 0x40 && data[i] <= 0x7e {
429+ return i + 1
430+ }
431+ }
432+ return len(data)
433+ case 'O':
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)
446+ }
447+ return 2
448+}
449+
450+// initCursor starts the cursor prediction from the mirror, and the cell row
451+// with it. It reports false while the cursor row is not known yet.
452+func (p *echoPredictor) initCursor() bool {
453+ if p.echo < p.holdUntil {
454+ return false
455+ }
456+ if !p.cursor.active {
457+ row, col := p.screen.CursorRow(), p.screen.CursorCol()
458+ if row < 0 || row >= p.screen.Rows() || col < 0 || col >= p.screen.Columns() {
459+ return false
460+ }
461+ p.cursor = echoCursor{active: true, row: row, col: col, epoch: p.predEpoch}
462+ }
463+ if p.cells == nil || p.row != p.cursor.row || len(p.cells) != p.screen.Columns() {
464+ p.row = p.cursor.row
465+ p.cells = make([]echoCell, p.screen.Columns())
466+ }
467+ return true
468+}
469+
470+// effective is the cell the user will see at col: a known guess, else the
471+// mirror.
472+func (p *echoPredictor) effective(col int) (vt.Cell, bool) {
473+ cell := &p.cells[col]
474+ if cell.active {
475+ if cell.unknown {
476+ return vt.Cell{}, false
477+ }
478+ return cell.replacement, true
479+ }
480+ return p.screen.Cell(col, p.row), true
481+}
482+
483+func (p *echoPredictor) moveCursor(col int, seq uint64) {
484+ p.cursor.col = col
485+ p.cursor.expires = seq
486+ p.cursor.epoch = p.predEpoch
487+}
488+
489+// print predicts one width-1 rune in insert mode. The shift stops at the
490+// first blank, so a split layout's borders to the right never move.
491+func (p *echoPredictor) print(r rune, seq uint64, now time.Time) {
492+ if !p.initCursor() {
493+ p.becomeTentative()
494+ return
495+ }
496+ width := len(p.cells)
497+ col := p.cursor.col
498+ if col+1 >= width {
499+ // mosh: the last column is tricky (wrap markers, shells differ).
500+ p.becomeTentative()
501+ }
502+ end := col
503+ for end < width-1 {
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+ }
511+ if known && isBlankRune(cell.Rune) {
512+ break
513+ }
514+ end++
515+ }
516+ for i := end; i > col; i-- {
517+ cell := &p.cells[i]
518+ prev, known := p.effective(i - 1)
519+ original := p.screen.Cell(i, p.row).Rune
520+ cell.resetWithOriginal()
521+ cell.arm(seq, p.predEpoch, now)
522+ cell.originals = append(cell.originals, original)
523+ if i == width-1 || !known {
524+ cell.unknown = true
525+ } else {
526+ cell.replacement = prev
527+ }
528+ }
529+ style := vt.DefaultStyle()
530+ if col > 0 {
531+ if left, known := p.effective(col - 1); known {
532+ style = left.Style
533+ }
534+ }
535+ cell := &p.cells[col]
536+ original := p.screen.Cell(col, p.row).Rune
537+ cell.resetWithOriginal()
538+ cell.arm(seq, p.predEpoch, now)
539+ cell.replacement = vt.Cell{Rune: r, Style: style}
540+ cell.originals = append(cell.originals, original)
541+ if col < width-1 {
542+ p.moveCursor(col+1, seq)
543+ } else {
544+ p.becomeTentative()
545+ p.moveCursor(col, seq)
546+ }
547+}
548+
549+// backspace predicts DEL: the cursor moves left and the text after it moves
550+// left up to the first blank.
551+func (p *echoPredictor) backspace(seq uint64, now time.Time) {
552+ if !p.initCursor() {
553+ p.becomeTentative()
554+ return
555+ }
556+ if p.cursor.col == 0 {
557+ return
558+ }
559+ width := len(p.cells)
560+ 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+ }
571+ p.moveCursor(col, seq)
572+ for i := col; i < width; i++ {
573+ cell := &p.cells[i]
574+ original := p.screen.Cell(i, p.row).Rune
575+ var next vt.Cell
576+ known := false
577+ if i+2 < width {
578+ next, known = p.effective(i + 1)
579+ }
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+ }
585+ cell.resetWithOriginal()
586+ cell.arm(seq, p.predEpoch, now)
587+ cell.originals = append(cell.originals, original)
588+ if !known {
589+ cell.unknown = true
590+ } else {
591+ cell.replacement = next
592+ }
593+ if blank {
594+ break
595+ }
596+ }
597+}
598+
599+// needsTick reports whether a pending guess still waits on a timing trigger
600+// (mosh's timing_tests_necessary), so the caller re-culls without output.
601+func (p *echoPredictor) needsTick() bool {
602+ return p.mode == domain.EchoPredictAdaptive && p.active() && !(p.glitch > 0 && p.flagging)
603+}
604+
605+// render returns the terminal bytes that bring the screen from the last
606+// render to the current guesses: drawn cells that no longer show a guess are
607+// repainted from the mirror, shown guesses are drawn again (a daemon frame
608+// may have overwritten them), and the cursor goes to the predicted position
609+// or back to the real one.
610+func (p *echoPredictor) render() []byte {
611+ var out bytes.Buffer
612+ show := p.displaying() && p.enabled()
613+ var want []int
614+ if show && p.cells != nil && p.row < p.screen.Rows() {
615+ for col := range p.cells {
616+ cell := &p.cells[col]
617+ if !cell.active || cell.unknown || cell.tentative(p.confEpoch) {
618+ continue
619+ }
620+ if p.screen.Cell(col, p.row).Equal(cell.replacement) && !p.flagging {
621+ continue
622+ }
623+ want = append(want, col)
624+ }
625+ }
626+ for _, col := range p.drawn {
627+ if p.drawnRow == p.row && slices.Contains(want, col) {
628+ continue
629+ }
630+ if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() {
631+ p.restoreCell(&out, p.drawnRow, col)
632+ }
633+ }
634+ for _, col := range want {
635+ cell := p.cells[col].replacement
636+ flag := p.flagging && !(isBlankRune(cell.Rune) && isBlankRune(p.screen.Cell(col, p.row).Rune))
637+ writeEchoCell(&out, p.row, col, cell, flag)
638+ }
639+ p.drawn = append(p.drawn[:0], want...)
640+ p.drawnRow = p.row
641+
642+ row, col := p.screen.CursorRow(), p.screen.CursorCol()
643+ moved := false
644+ if show && p.cursor.active && p.cursor.epoch <= p.confEpoch {
645+ moved = p.cursor.row != row || p.cursor.col != col
646+ row, col = p.cursor.row, p.cursor.col
647+ }
648+ if out.Len() > 0 || moved || p.cursorMove {
649+ out.WriteString("\x1b[0m")
650+ writeEchoCUP(&out, row, col)
651+ }
652+ p.cursorMove = moved
653+ return out.Bytes()
654+}
655+
656+func writeEchoCUP(out *bytes.Buffer, row, col int) {
657+ out.WriteString("\x1b[")
658+ out.WriteString(strconv.Itoa(row + 1))
659+ out.WriteByte(';')
660+ out.WriteString(strconv.Itoa(col + 1))
661+ out.WriteByte('H')
662+}
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+
675+// writeEchoCell draws one cell. Mirror styles come from daemon output already
676+// projected to this terminal's color profile, so they are written as is.
677+func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) {
678+ writeEchoCUP(out, row, col)
679+ out.WriteString(echoSGR(cell.Style, underline))
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)
687+ }
688+}
689+
690+func echoSGR(style vt.Style, underline bool) string {
691+ var b strings.Builder
692+ b.WriteString("\x1b[0")
693+ if style.Bold {
694+ b.WriteString(";1")
695+ }
696+ if style.Attrs&vt.AttrDim != 0 {
697+ b.WriteString(";2")
698+ }
699+ if style.Italic {
700+ b.WriteString(";3")
701+ }
702+ switch {
703+ case underline:
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+ }
718+ }
719+ if style.Attrs&vt.AttrBlink != 0 {
720+ b.WriteString(";5")
721+ }
722+ if style.Inverse {
723+ b.WriteString(";7")
724+ }
725+ if style.Attrs&vt.AttrStrikethrough != 0 {
726+ b.WriteString(";9")
727+ }
728+ writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90)
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+ }
736+ b.WriteByte('m')
737+ return b.String()
738+}
739+
740+func writeEchoColor(b *strings.Builder, hasRGB bool, rgb vt.RGB, index, extended, normal, bright int) {
741+ switch {
742+ case hasRGB:
743+ b.WriteString(";" + strconv.Itoa(extended) + ";2;" + strconv.Itoa(int(rgb.R)) + ";" + strconv.Itoa(int(rgb.G)) + ";" + strconv.Itoa(int(rgb.B)))
744+ case index < 0:
745+ case index < 8:
746+ b.WriteString(";" + strconv.Itoa(normal+index))
747+ case index < 16:
748+ b.WriteString(";" + strconv.Itoa(bright+index-8))
749+ case index < 256:
750+ b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index))
751+ }
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+}
A internal/usecase/client/predictive_echo_test.go +413 -0
@@ -0,0 +1,413 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol"
11+)
12+
13+var echoTestStart = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
14+
15+// echoHarness drives one predictor against a 20x3 mirror whose prompt "$ "
16+// leaves the cursor at row 0, column 2.
17+type echoHarness struct {
18+ t *testing.T
19+ p *echoPredictor
20+ now time.Time
21+ seq uint64
22+}
23+
24+func newEchoHarness(t *testing.T, mode domain.EchoPredictMode) *echoHarness {
25+ t.Helper()
26+ h := &echoHarness{t: t, p: newEchoPredictor(mode), now: echoTestStart}
27+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}, h.now)
28+ return h
29+}
30+
31+// warm sends one empty input and acknowledges it after rtt of network time.
32+func (h *echoHarness) warm(rtt time.Duration) {
33+ h.seq++
34+ h.p.input(h.seq, nil, h.now)
35+ h.now = h.now.Add(rtt + echoServerDelay)
36+ h.p.applyOutput(protocol.Output{Epoch: 1, Echo: h.seq}, h.now)
37+}
38+
39+func (h *echoHarness) typed(data string) []byte {
40+ h.seq++
41+ h.p.input(h.seq, []byte(data), h.now)
42+ return h.p.render()
43+}
44+
45+// daemon applies a state frame that writes data and acknowledges echo.
46+func (h *echoHarness) daemon(data string, echo uint64) []byte {
47+ h.now = h.now.Add(time.Millisecond)
48+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Echo: echo, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(data)}, h.now)
49+ return h.p.render()
50+}
51+
52+// confirm types a and lets the daemon echo it, which confirms the first epoch.
53+func (h *echoHarness) confirm() {
54+ h.t.Helper()
55+ if got := h.typed("a"); len(got) != 0 {
56+ h.t.Fatalf("first guess is tentative, rendered %q", got)
57+ }
58+ h.daemon("\x1b[1;3Ha", h.seq)
59+ if h.p.confEpoch == 0 {
60+ h.t.Fatal("echoed guess did not confirm its epoch")
61+ }
62+}
63+
64+func TestEchoPredictorDisplay(t *testing.T) {
65+ t.Parallel()
66+ tests := []struct {
67+ name string
68+ mode domain.EchoPredictMode
69+ rtt time.Duration
70+ want string
71+ }{
72+ {name: "fast link hides guesses", mode: domain.EchoPredictAdaptive, rtt: 10 * time.Millisecond, want: ""},
73+ {name: "slow link shows guesses", mode: domain.EchoPredictAdaptive, rtt: 100 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"},
74+ {name: "very slow link underlines guesses", mode: domain.EchoPredictAdaptive, rtt: 200 * time.Millisecond, want: "\x1b[1;4H\x1b[0;4mb\x1b[0m\x1b[1;5H"},
75+ {name: "always shows on a fast link", mode: domain.EchoPredictAlways, rtt: 10 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"},
76+ {name: "never predicts", mode: domain.EchoPredictNever, rtt: 200 * time.Millisecond, want: ""},
77+ }
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ t.Parallel()
81+ h := newEchoHarness(t, tt.mode)
82+ h.warm(tt.rtt)
83+ if tt.mode != domain.EchoPredictNever {
84+ h.confirm()
85+ }
86+ if got := string(h.typed("b")); got != tt.want {
87+ t.Fatalf("render = %q, want %q", got, tt.want)
88+ }
89+ })
90+ }
91+}
92+
93+func TestEchoPredictorConfirmationRepaintsFromMirror(t *testing.T) {
94+ t.Parallel()
95+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
96+ h.warm(200 * time.Millisecond)
97+ h.confirm()
98+ if got := string(h.typed("b")); !strings.Contains(got, "\x1b[0;4mb") {
99+ t.Fatalf("pending guess is not underlined: %q", got)
100+ }
101+ got := string(h.daemon("\x1b[1;4Hb", h.seq))
102+ if want := "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"; got != want {
103+ t.Fatalf("confirmed render = %q, want %q", got, want)
104+ }
105+ if h.p.active() {
106+ t.Fatal("confirmed guess is still active")
107+ }
108+}
109+
110+func TestEchoPredictorWrongEchoErasesGuess(t *testing.T) {
111+ t.Parallel()
112+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
113+ h.warm(100 * time.Millisecond)
114+ h.confirm()
115+ if got := string(h.typed("p")); !strings.Contains(got, "p") {
116+ t.Fatalf("guess not drawn: %q", got)
117+ }
118+ // A password prompt echoes nothing.
119+ got := string(h.daemon("", h.seq))
120+ if want := "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
121+ t.Fatalf("render after wrong echo = %q, want %q", got, want)
122+ }
123+ if h.p.active() {
124+ t.Fatal("wrong guess survived")
125+ }
126+ // The reset makes the next guess tentative again.
127+ if got := h.typed("q"); len(got) != 0 {
128+ t.Fatalf("guess after reset rendered %q", got)
129+ }
130+}
131+
132+func TestEchoPredictorBackspace(t *testing.T) {
133+ t.Parallel()
134+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
135+ h.warm(100 * time.Millisecond)
136+ h.confirm()
137+ got := string(h.typed("\x7f"))
138+ if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
139+ t.Fatalf("backspace render = %q, want %q", got, want)
140+ }
141+ got = string(h.daemon("\x1b[1;3H \x1b[1;3H", h.seq))
142+ if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
143+ t.Fatalf("confirmed backspace render = %q, want %q", got, want)
144+ }
145+ if h.p.active() {
146+ t.Fatal("confirmed backspace is still active")
147+ }
148+}
149+
150+func TestEchoPredictorInsertShiftsText(t *testing.T) {
151+ t.Parallel()
152+ h := newEchoHarness(t, domain.EchoPredictAlways)
153+ h.warm(10 * time.Millisecond)
154+ // "$ ac" with the cursor on c.
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)
156+ h.p.confEpoch = h.p.predEpoch
157+ got := string(h.typed("b"))
158+ if want := "\x1b[1;4H\x1b[0mb\x1b[1;5H\x1b[0mc\x1b[0m\x1b[1;5H"; got != want {
159+ t.Fatalf("insert render = %q, want %q", got, want)
160+ }
161+}
162+
163+func TestEchoPredictorUnknownInputIsTentative(t *testing.T) {
164+ t.Parallel()
165+ tests := []struct {
166+ name string
167+ input string
168+ }{
169+ {name: "control byte", input: "\x03"},
170+ {name: "escape sequence", input: "\x1b[A"},
171+ {name: "kitty CSI-u key", input: "\x1b[97;5u"},
172+ {name: "carriage return", input: "\r"},
173+ {name: "wide rune", input: "界"},
174+ {name: "bracketed paste", input: "\x1b[200~text\x1b[201~"},
175+ }
176+ for _, tt := range tests {
177+ t.Run(tt.name, func(t *testing.T) {
178+ t.Parallel()
179+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
180+ h.warm(100 * time.Millisecond)
181+ h.confirm()
182+ h.typed(tt.input)
183+ if got := h.typed("z"); len(got) != 0 {
184+ t.Fatalf("guess after %s rendered %q", tt.name, got)
185+ }
186+ })
187+ }
188+}
189+
190+func TestEchoPredictorWrongTentativeGuessKillsItsEpoch(t *testing.T) {
191+ t.Parallel()
192+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
193+ h.warm(100 * time.Millisecond)
194+ h.confirm()
195+ confirmed := h.p.confEpoch
196+ h.typed("\x03z")
197+ // The daemon cleared the line instead of echoing z.
198+ h.daemon("\x1b[1;1H\x1b[2K$ ", h.seq)
199+ if h.p.active() {
200+ t.Fatal("killed epoch left guesses")
201+ }
202+ if h.p.confEpoch != confirmed {
203+ t.Fatalf("confirmed epoch = %d, want %d", h.p.confEpoch, confirmed)
204+ }
205+}
206+
207+func TestEchoPredictorHiddenCursorDisables(t *testing.T) {
208+ t.Parallel()
209+ h := newEchoHarness(t, domain.EchoPredictAlways)
210+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[?25l")}, h.now)
211+ h.p.confEpoch = h.p.predEpoch
212+ if got := h.typed("b"); len(got) != 0 {
213+ t.Fatalf("hidden cursor rendered %q", got)
214+ }
215+ if h.p.active() {
216+ t.Fatal("hidden cursor kept guesses")
217+ }
218+}
219+
220+func TestEchoPredictorSRTTHysteresis(t *testing.T) {
221+ t.Parallel()
222+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
223+ h.warm(200 * time.Millisecond)
224+ if !h.p.srttOn || !h.p.flagging {
225+ t.Fatalf("slow link: srttOn=%v flagging=%v, want both on", h.p.srttOn, h.p.flagging)
226+ }
227+ sawMiddle := false
228+ for range 64 {
229+ h.warm(0)
230+ interval := h.p.sendInterval()
231+ if interval > echoSRTTTriggerLow && interval <= echoSRTTTriggerHigh {
232+ sawMiddle = true
233+ if !h.p.srttOn {
234+ t.Fatalf("display turned off at send interval %v", interval)
235+ }
236+ }
237+ if interval <= echoFlagTriggerLow && h.p.flagging {
238+ t.Fatalf("still flagging at send interval %v", interval)
239+ }
240+ if interval <= echoSRTTTriggerLow {
241+ break
242+ }
243+ }
244+ if !sawMiddle {
245+ t.Fatal("never sampled the hysteresis band")
246+ }
247+ if h.p.srttOn {
248+ t.Fatal("display still on after the link recovered")
249+ }
250+}
251+
252+func TestEchoPredictorSRTTStaysOnWhileGuessesShow(t *testing.T) {
253+ t.Parallel()
254+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
255+ h.warm(100 * time.Millisecond)
256+ h.confirm()
257+ h.typed("b")
258+ // Fast acknowledgements of other input keep the b guess pending.
259+ h.p.srtt = 0
260+ h.p.cull(h.now)
261+ if !h.p.srttOn {
262+ t.Fatal("display turned off while a guess is shown")
263+ }
264+}
265+
266+func TestEchoPredictorGlitchShowsAndFlags(t *testing.T) {
267+ t.Parallel()
268+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
269+ h.warm(0)
270+ h.confirm()
271+ if got := h.typed("b"); len(got) != 0 {
272+ t.Fatalf("fast link rendered %q", got)
273+ }
274+ if !h.p.needsTick() {
275+ t.Fatal("pending guess needs no timing tick")
276+ }
277+ h.now = h.now.Add(echoGlitchThreshold)
278+ h.p.cull(h.now)
279+ if got := string(h.p.render()); got != "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H" {
280+ t.Fatalf("glitch render = %q", got)
281+ }
282+ h.now = h.now.Add(echoGlitchFlagThreshold)
283+ h.p.cull(h.now)
284+ h.p.cull(h.now)
285+ if !h.p.flagging {
286+ t.Fatal("long glitch does not underline")
287+ }
288+ if h.p.needsTick() {
289+ t.Fatal("timing tick still armed once every trigger fired")
290+ }
291+}
292+
293+func TestEchoPredictorResizeForgetsGuesses(t *testing.T) {
294+ t.Parallel()
295+ h := newEchoHarness(t, domain.EchoPredictAlways)
296+ h.warm(100 * time.Millisecond)
297+ h.confirm()
298+ h.typed("b")
299+ h.p.applyOutput(protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 30, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}, h.now)
300+ if h.p.active() || len(h.p.drawn) != 0 {
301+ t.Fatal("resize kept guesses")
302+ }
303+ if got := h.p.render(); len(got) != 0 {
304+ t.Fatalf("render after resize = %q", got)
305+ }
306+}
307+
308+func TestEchoSGR(t *testing.T) {
309+ t.Parallel()
310+ style := newEchoHarness(t, domain.EchoPredictAlways).p.screen.Cell(0, 0).Style
311+ style.Bold = true
312+ style.Foreground = 9
313+ style.Background = 200
314+ if got, want := echoSGR(style, true), "\x1b[0;1;4;91;48;5;200m"; got != want {
315+ t.Fatalf("echoSGR = %q, want %q", got, want)
316+ }
317+ style.HasForegroundRGB = true
318+ style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B = 1, 2, 3
319+ if got, want := echoSGR(style, false), "\x1b[0;1;38;2;1;2;3;48;5;200m"; got != want {
320+ t.Fatalf("echoSGR rgb = %q, want %q", got, want)
321+ }
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+}
@@ -0,0 +1,413 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol"
11+)
12+
13+var echoTestStart = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
14+
15+// echoHarness drives one predictor against a 20x3 mirror whose prompt "$ "
16+// leaves the cursor at row 0, column 2.
17+type echoHarness struct {
18+ t *testing.T
19+ p *echoPredictor
20+ now time.Time
21+ seq uint64
22+}
23+
24+func newEchoHarness(t *testing.T, mode domain.EchoPredictMode) *echoHarness {
25+ t.Helper()
26+ h := &echoHarness{t: t, p: newEchoPredictor(mode), now: echoTestStart}
27+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}, h.now)
28+ return h
29+}
30+
31+// warm sends one empty input and acknowledges it after rtt of network time.
32+func (h *echoHarness) warm(rtt time.Duration) {
33+ h.seq++
34+ h.p.input(h.seq, nil, h.now)
35+ h.now = h.now.Add(rtt + echoServerDelay)
36+ h.p.applyOutput(protocol.Output{Epoch: 1, Echo: h.seq}, h.now)
37+}
38+
39+func (h *echoHarness) typed(data string) []byte {
40+ h.seq++
41+ h.p.input(h.seq, []byte(data), h.now)
42+ return h.p.render()
43+}
44+
45+// daemon applies a state frame that writes data and acknowledges echo.
46+func (h *echoHarness) daemon(data string, echo uint64) []byte {
47+ h.now = h.now.Add(time.Millisecond)
48+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Echo: echo, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(data)}, h.now)
49+ return h.p.render()
50+}
51+
52+// confirm types a and lets the daemon echo it, which confirms the first epoch.
53+func (h *echoHarness) confirm() {
54+ h.t.Helper()
55+ if got := h.typed("a"); len(got) != 0 {
56+ h.t.Fatalf("first guess is tentative, rendered %q", got)
57+ }
58+ h.daemon("\x1b[1;3Ha", h.seq)
59+ if h.p.confEpoch == 0 {
60+ h.t.Fatal("echoed guess did not confirm its epoch")
61+ }
62+}
63+
64+func TestEchoPredictorDisplay(t *testing.T) {
65+ t.Parallel()
66+ tests := []struct {
67+ name string
68+ mode domain.EchoPredictMode
69+ rtt time.Duration
70+ want string
71+ }{
72+ {name: "fast link hides guesses", mode: domain.EchoPredictAdaptive, rtt: 10 * time.Millisecond, want: ""},
73+ {name: "slow link shows guesses", mode: domain.EchoPredictAdaptive, rtt: 100 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"},
74+ {name: "very slow link underlines guesses", mode: domain.EchoPredictAdaptive, rtt: 200 * time.Millisecond, want: "\x1b[1;4H\x1b[0;4mb\x1b[0m\x1b[1;5H"},
75+ {name: "always shows on a fast link", mode: domain.EchoPredictAlways, rtt: 10 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"},
76+ {name: "never predicts", mode: domain.EchoPredictNever, rtt: 200 * time.Millisecond, want: ""},
77+ }
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ t.Parallel()
81+ h := newEchoHarness(t, tt.mode)
82+ h.warm(tt.rtt)
83+ if tt.mode != domain.EchoPredictNever {
84+ h.confirm()
85+ }
86+ if got := string(h.typed("b")); got != tt.want {
87+ t.Fatalf("render = %q, want %q", got, tt.want)
88+ }
89+ })
90+ }
91+}
92+
93+func TestEchoPredictorConfirmationRepaintsFromMirror(t *testing.T) {
94+ t.Parallel()
95+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
96+ h.warm(200 * time.Millisecond)
97+ h.confirm()
98+ if got := string(h.typed("b")); !strings.Contains(got, "\x1b[0;4mb") {
99+ t.Fatalf("pending guess is not underlined: %q", got)
100+ }
101+ got := string(h.daemon("\x1b[1;4Hb", h.seq))
102+ if want := "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"; got != want {
103+ t.Fatalf("confirmed render = %q, want %q", got, want)
104+ }
105+ if h.p.active() {
106+ t.Fatal("confirmed guess is still active")
107+ }
108+}
109+
110+func TestEchoPredictorWrongEchoErasesGuess(t *testing.T) {
111+ t.Parallel()
112+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
113+ h.warm(100 * time.Millisecond)
114+ h.confirm()
115+ if got := string(h.typed("p")); !strings.Contains(got, "p") {
116+ t.Fatalf("guess not drawn: %q", got)
117+ }
118+ // A password prompt echoes nothing.
119+ got := string(h.daemon("", h.seq))
120+ if want := "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
121+ t.Fatalf("render after wrong echo = %q, want %q", got, want)
122+ }
123+ if h.p.active() {
124+ t.Fatal("wrong guess survived")
125+ }
126+ // The reset makes the next guess tentative again.
127+ if got := h.typed("q"); len(got) != 0 {
128+ t.Fatalf("guess after reset rendered %q", got)
129+ }
130+}
131+
132+func TestEchoPredictorBackspace(t *testing.T) {
133+ t.Parallel()
134+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
135+ h.warm(100 * time.Millisecond)
136+ h.confirm()
137+ got := string(h.typed("\x7f"))
138+ if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
139+ t.Fatalf("backspace render = %q, want %q", got, want)
140+ }
141+ got = string(h.daemon("\x1b[1;3H \x1b[1;3H", h.seq))
142+ if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
143+ t.Fatalf("confirmed backspace render = %q, want %q", got, want)
144+ }
145+ if h.p.active() {
146+ t.Fatal("confirmed backspace is still active")
147+ }
148+}
149+
150+func TestEchoPredictorInsertShiftsText(t *testing.T) {
151+ t.Parallel()
152+ h := newEchoHarness(t, domain.EchoPredictAlways)
153+ h.warm(10 * time.Millisecond)
154+ // "$ ac" with the cursor on c.
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)
156+ h.p.confEpoch = h.p.predEpoch
157+ got := string(h.typed("b"))
158+ if want := "\x1b[1;4H\x1b[0mb\x1b[1;5H\x1b[0mc\x1b[0m\x1b[1;5H"; got != want {
159+ t.Fatalf("insert render = %q, want %q", got, want)
160+ }
161+}
162+
163+func TestEchoPredictorUnknownInputIsTentative(t *testing.T) {
164+ t.Parallel()
165+ tests := []struct {
166+ name string
167+ input string
168+ }{
169+ {name: "control byte", input: "\x03"},
170+ {name: "escape sequence", input: "\x1b[A"},
171+ {name: "kitty CSI-u key", input: "\x1b[97;5u"},
172+ {name: "carriage return", input: "\r"},
173+ {name: "wide rune", input: "界"},
174+ {name: "bracketed paste", input: "\x1b[200~text\x1b[201~"},
175+ }
176+ for _, tt := range tests {
177+ t.Run(tt.name, func(t *testing.T) {
178+ t.Parallel()
179+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
180+ h.warm(100 * time.Millisecond)
181+ h.confirm()
182+ h.typed(tt.input)
183+ if got := h.typed("z"); len(got) != 0 {
184+ t.Fatalf("guess after %s rendered %q", tt.name, got)
185+ }
186+ })
187+ }
188+}
189+
190+func TestEchoPredictorWrongTentativeGuessKillsItsEpoch(t *testing.T) {
191+ t.Parallel()
192+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
193+ h.warm(100 * time.Millisecond)
194+ h.confirm()
195+ confirmed := h.p.confEpoch
196+ h.typed("\x03z")
197+ // The daemon cleared the line instead of echoing z.
198+ h.daemon("\x1b[1;1H\x1b[2K$ ", h.seq)
199+ if h.p.active() {
200+ t.Fatal("killed epoch left guesses")
201+ }
202+ if h.p.confEpoch != confirmed {
203+ t.Fatalf("confirmed epoch = %d, want %d", h.p.confEpoch, confirmed)
204+ }
205+}
206+
207+func TestEchoPredictorHiddenCursorDisables(t *testing.T) {
208+ t.Parallel()
209+ h := newEchoHarness(t, domain.EchoPredictAlways)
210+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[?25l")}, h.now)
211+ h.p.confEpoch = h.p.predEpoch
212+ if got := h.typed("b"); len(got) != 0 {
213+ t.Fatalf("hidden cursor rendered %q", got)
214+ }
215+ if h.p.active() {
216+ t.Fatal("hidden cursor kept guesses")
217+ }
218+}
219+
220+func TestEchoPredictorSRTTHysteresis(t *testing.T) {
221+ t.Parallel()
222+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
223+ h.warm(200 * time.Millisecond)
224+ if !h.p.srttOn || !h.p.flagging {
225+ t.Fatalf("slow link: srttOn=%v flagging=%v, want both on", h.p.srttOn, h.p.flagging)
226+ }
227+ sawMiddle := false
228+ for range 64 {
229+ h.warm(0)
230+ interval := h.p.sendInterval()
231+ if interval > echoSRTTTriggerLow && interval <= echoSRTTTriggerHigh {
232+ sawMiddle = true
233+ if !h.p.srttOn {
234+ t.Fatalf("display turned off at send interval %v", interval)
235+ }
236+ }
237+ if interval <= echoFlagTriggerLow && h.p.flagging {
238+ t.Fatalf("still flagging at send interval %v", interval)
239+ }
240+ if interval <= echoSRTTTriggerLow {
241+ break
242+ }
243+ }
244+ if !sawMiddle {
245+ t.Fatal("never sampled the hysteresis band")
246+ }
247+ if h.p.srttOn {
248+ t.Fatal("display still on after the link recovered")
249+ }
250+}
251+
252+func TestEchoPredictorSRTTStaysOnWhileGuessesShow(t *testing.T) {
253+ t.Parallel()
254+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
255+ h.warm(100 * time.Millisecond)
256+ h.confirm()
257+ h.typed("b")
258+ // Fast acknowledgements of other input keep the b guess pending.
259+ h.p.srtt = 0
260+ h.p.cull(h.now)
261+ if !h.p.srttOn {
262+ t.Fatal("display turned off while a guess is shown")
263+ }
264+}
265+
266+func TestEchoPredictorGlitchShowsAndFlags(t *testing.T) {
267+ t.Parallel()
268+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
269+ h.warm(0)
270+ h.confirm()
271+ if got := h.typed("b"); len(got) != 0 {
272+ t.Fatalf("fast link rendered %q", got)
273+ }
274+ if !h.p.needsTick() {
275+ t.Fatal("pending guess needs no timing tick")
276+ }
277+ h.now = h.now.Add(echoGlitchThreshold)
278+ h.p.cull(h.now)
279+ if got := string(h.p.render()); got != "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H" {
280+ t.Fatalf("glitch render = %q", got)
281+ }
282+ h.now = h.now.Add(echoGlitchFlagThreshold)
283+ h.p.cull(h.now)
284+ h.p.cull(h.now)
285+ if !h.p.flagging {
286+ t.Fatal("long glitch does not underline")
287+ }
288+ if h.p.needsTick() {
289+ t.Fatal("timing tick still armed once every trigger fired")
290+ }
291+}
292+
293+func TestEchoPredictorResizeForgetsGuesses(t *testing.T) {
294+ t.Parallel()
295+ h := newEchoHarness(t, domain.EchoPredictAlways)
296+ h.warm(100 * time.Millisecond)
297+ h.confirm()
298+ h.typed("b")
299+ h.p.applyOutput(protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 30, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}, h.now)
300+ if h.p.active() || len(h.p.drawn) != 0 {
301+ t.Fatal("resize kept guesses")
302+ }
303+ if got := h.p.render(); len(got) != 0 {
304+ t.Fatalf("render after resize = %q", got)
305+ }
306+}
307+
308+func TestEchoSGR(t *testing.T) {
309+ t.Parallel()
310+ style := newEchoHarness(t, domain.EchoPredictAlways).p.screen.Cell(0, 0).Style
311+ style.Bold = true
312+ style.Foreground = 9
313+ style.Background = 200
314+ if got, want := echoSGR(style, true), "\x1b[0;1;4;91;48;5;200m"; got != want {
315+ t.Fatalf("echoSGR = %q, want %q", got, want)
316+ }
317+ style.HasForegroundRGB = true
318+ style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B = 1, 2, 3
319+ if got, want := echoSGR(style, false), "\x1b[0;1;38;2;1;2;3;48;5;200m"; got != want {
320+ t.Fatalf("echoSGR rgb = %q, want %q", got, want)
321+ }
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+}
M internal/usecase/client/supervisor.go +2 -0
@@ -396,6 +396,8 @@ type AttachmentEnvironment struct {
396396 // KittyKeyboard enables the kitty keyboard protocol when the probe finds
397397 // it, which makes Ctrl+1..9 recent-session switching available.
398398 KittyKeyboard bool
399+ // EchoPredict selects predictive local echo on remote attachments.
400+ EchoPredict domain.EchoPredictMode
399401 }
400402
401403 // AttachmentLifecycleAction is an explicit process/attachment decision.
@@ -396,6 +396,8 @@ type AttachmentEnvironment struct {
396 // KittyKeyboard enables the kitty keyboard protocol when the probe finds396 // KittyKeyboard enables the kitty keyboard protocol when the probe finds
397 // it, which makes Ctrl+1..9 recent-session switching available.397 // it, which makes Ctrl+1..9 recent-session switching available.
398 KittyKeyboard bool398 KittyKeyboard bool
399+ // EchoPredict selects predictive local echo on remote attachments.
400+ EchoPredict domain.EchoPredictMode
399 }401 }
400 402
401 // AttachmentLifecycleAction is an explicit process/attachment decision.403 // AttachmentLifecycleAction is an explicit process/attachment decision.
M internal/usecase/client/supervisor_attach.go +1 -0
@@ -357,6 +357,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
357357 Clipboard: s.cfg.Clipboard,
358358 Tab: tab,
359359 Tabs: &s.tabs,
360+ EchoPredict: environment.EchoPredict,
360361 })
361362 if err != nil {
362363 return nil, err
@@ -357,6 +357,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
357 Clipboard: s.cfg.Clipboard,357 Clipboard: s.cfg.Clipboard,
358 Tab: tab,358 Tab: tab,
359 Tabs: &s.tabs,359 Tabs: &s.tabs,
360+ EchoPredict: environment.EchoPredict,
360 })361 })
361 if err != nil {362 if err != nil {
362 return nil, err363 return nil, err
M internal/usecase/daemon/client.go +4 -1
@@ -68,7 +68,10 @@ type attachedClient struct {
6868 // credential instead of consuming it.
6969 resumeClaimToken uint64
7070 parked bool
71- echoAck atomic.Uint64
71+ // echoAck is the newest client InputSeq applied at least echoAckDelay ago;
72+ // echo owns its updates.
73+ echoAck atomic.Uint64
74+ echo echoAckTracker
7275 // focus holds the domain.TerminalFocus the client last reported. It is an
7376 // atomic so any daemon path reads it without taking a lock; see
7477 // terminalFocus.
@@ -68,7 +68,10 @@ type attachedClient struct {
68 // credential instead of consuming it.68 // credential instead of consuming it.
69 resumeClaimToken uint6469 resumeClaimToken uint64
70 parked bool70 parked bool
71- echoAck atomic.Uint6471+ // echoAck is the newest client InputSeq applied at least echoAckDelay ago;
72+ // echo owns its updates.
73+ echoAck atomic.Uint64
74+ echo echoAckTracker
72 // focus holds the domain.TerminalFocus the client last reported. It is an75 // focus holds the domain.TerminalFocus the client last reported. It is an
73 // atomic so any daemon path reads it without taking a lock; see76 // atomic so any daemon path reads it without taking a lock; see
74 // terminalFocus.77 // terminalFocus.
A internal/usecase/daemon/echo_ack.go +155 -0
@@ -0,0 +1,155 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+// echoAckDelay is how long sequenced input must have been applied before the
11+// daemon acknowledges it in Output.Echo. Like mosh's ECHO_TIMEOUT, it gives the
12+// PTY time to echo the input, so the screen published with the acknowledgement
13+// already contains whatever echo the input produced.
14+const echoAckDelay = 50 * time.Millisecond
15+
16+// maxPendingEchoes bounds the per-attachment input history. Acknowledgements
17+// are cumulative, so dropping the oldest entry only delays its confirmation to
18+// a newer one.
19+const maxPendingEchoes = 256
20+
21+type pendingEcho struct {
22+ seq uint64
23+ at time.Time
24+}
25+
26+// echoAckTracker turns applied input sequence numbers into the cumulative
27+// Output.Echo acknowledgement. echoAck is written only under mu.
28+type echoAckTracker struct {
29+ mu sync.Mutex
30+ pending []pendingEcho
31+ timer pendingByteTimer
32+}
33+
34+// noteInputApplied records that the input numbered seq reached the session and
35+// schedules its acknowledgement. Sequence zero is unsequenced input.
36+func (d *Daemon) noteInputApplied(ac *attachedClient, seq uint64) {
37+ if ac == nil || seq == 0 {
38+ return
39+ }
40+ t := &ac.echo
41+ t.mu.Lock()
42+ defer t.mu.Unlock()
43+ if seq <= ac.echoAck.Load() || len(t.pending) > 0 && seq <= t.pending[len(t.pending)-1].seq {
44+ return
45+ }
46+ if len(t.pending) == maxPendingEchoes {
47+ t.pending = append(t.pending[:0], t.pending[1:]...)
48+ }
49+ t.pending = append(t.pending, pendingEcho{seq: seq, at: d.clock.Now()})
50+ if t.timer.timer == nil {
51+ d.armEchoAckLocked(ac, echoAckDelay)
52+ }
53+}
54+
55+// armEchoAckLocked starts the single echo timer. The caller holds echo.mu.
56+func (d *Daemon) armEchoAckLocked(ac *attachedClient, delay time.Duration) {
57+ t := &ac.echo
58+ t.timer.retain(d.clock, delay, func(fired ports.Timer) {
59+ if d.advanceEchoAck(ac, fired) {
60+ d.sendEchoAck(ac)
61+ }
62+ })
63+}
64+
65+// armEchoAckRetryLocked re-sends the current acknowledgement after one more
66+// echo delay. The caller holds echo.mu.
67+func (d *Daemon) armEchoAckRetryLocked(ac *attachedClient) {
68+ t := &ac.echo
69+ t.timer.retain(d.clock, echoAckDelay, func(fired ports.Timer) {
70+ t.mu.Lock()
71+ current := t.timer.timer == fired
72+ if current {
73+ t.timer.timer, t.timer.done = nil, nil
74+ if len(t.pending) > 0 {
75+ // New input owns the next timer and its ack covers this one.
76+ d.armEchoAckLocked(ac, max(echoAckDelay-d.clock.Now().Sub(t.pending[0].at), 0))
77+ current = false
78+ }
79+ }
80+ t.mu.Unlock()
81+ if current {
82+ d.sendEchoAck(ac)
83+ }
84+ })
85+}
86+
87+// advanceEchoAck moves echoAck to the newest input applied at least
88+// echoAckDelay ago and re-arms for the rest. It reports whether echoAck moved.
89+func (d *Daemon) advanceEchoAck(ac *attachedClient, fired ports.Timer) bool {
90+ t := &ac.echo
91+ t.mu.Lock()
92+ defer t.mu.Unlock()
93+ if t.timer.timer != fired {
94+ return false // stopped by a reset or superseded
95+ }
96+ t.timer.timer, t.timer.done = nil, nil
97+ now := d.clock.Now()
98+ acked := uint64(0)
99+ n := 0
100+ for n < len(t.pending) && now.Sub(t.pending[n].at) >= echoAckDelay {
101+ acked = t.pending[n].seq
102+ n++
103+ }
104+ t.pending = append(t.pending[:0], t.pending[n:]...)
105+ if len(t.pending) > 0 {
106+ d.armEchoAckLocked(ac, max(echoAckDelay-now.Sub(t.pending[0].at), 0))
107+ }
108+ if acked <= ac.echoAck.Load() {
109+ return false
110+ }
111+ ac.echoAck.Store(acked)
112+ return true
113+}
114+
115+// sendEchoAck publishes a new acknowledgement in an empty side-effect Output,
116+// so the client learns about it even when the screen does not change.
117+//
118+// Side effects bypass the output window. While the window is full, the frame
119+// holding the acknowledged echo may still be withheld, and an early ack would
120+// make the client judge its guesses against an older screen. The ack then
121+// waits: the next frame carries it, and a retry covers a screen that never
122+// changes.
123+func (d *Daemon) sendEchoAck(ac *attachedClient) {
124+ if ac.attachmentActivity() != attachmentActive {
125+ return
126+ }
127+ sess := ac.currentAttachmentSession()
128+ if sess == nil {
129+ return
130+ }
131+ if ac.output != nil && ac.output.atCapacity() {
132+ t := &ac.echo
133+ t.mu.Lock()
134+ if t.timer.timer == nil {
135+ d.armEchoAckRetryLocked(ac)
136+ }
137+ t.mu.Unlock()
138+ return
139+ }
140+ failed, err := d.boundedSendOutputErrTransport(ac, nil)
141+ if err != nil && failed != nil {
142+ d.detachOnSendError(sess, ac, failed)
143+ }
144+}
145+
146+// resetEchoAck forgets every acknowledgement. A resumed client restarts its
147+// input numbering, so the replacement link must start from zero too.
148+func (ac *attachedClient) resetEchoAck() {
149+ t := &ac.echo
150+ t.mu.Lock()
151+ defer t.mu.Unlock()
152+ t.timer.stop()
153+ t.pending = nil
154+ ac.echoAck.Store(0)
155+}
@@ -0,0 +1,155 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+// echoAckDelay is how long sequenced input must have been applied before the
11+// daemon acknowledges it in Output.Echo. Like mosh's ECHO_TIMEOUT, it gives the
12+// PTY time to echo the input, so the screen published with the acknowledgement
13+// already contains whatever echo the input produced.
14+const echoAckDelay = 50 * time.Millisecond
15+
16+// maxPendingEchoes bounds the per-attachment input history. Acknowledgements
17+// are cumulative, so dropping the oldest entry only delays its confirmation to
18+// a newer one.
19+const maxPendingEchoes = 256
20+
21+type pendingEcho struct {
22+ seq uint64
23+ at time.Time
24+}
25+
26+// echoAckTracker turns applied input sequence numbers into the cumulative
27+// Output.Echo acknowledgement. echoAck is written only under mu.
28+type echoAckTracker struct {
29+ mu sync.Mutex
30+ pending []pendingEcho
31+ timer pendingByteTimer
32+}
33+
34+// noteInputApplied records that the input numbered seq reached the session and
35+// schedules its acknowledgement. Sequence zero is unsequenced input.
36+func (d *Daemon) noteInputApplied(ac *attachedClient, seq uint64) {
37+ if ac == nil || seq == 0 {
38+ return
39+ }
40+ t := &ac.echo
41+ t.mu.Lock()
42+ defer t.mu.Unlock()
43+ if seq <= ac.echoAck.Load() || len(t.pending) > 0 && seq <= t.pending[len(t.pending)-1].seq {
44+ return
45+ }
46+ if len(t.pending) == maxPendingEchoes {
47+ t.pending = append(t.pending[:0], t.pending[1:]...)
48+ }
49+ t.pending = append(t.pending, pendingEcho{seq: seq, at: d.clock.Now()})
50+ if t.timer.timer == nil {
51+ d.armEchoAckLocked(ac, echoAckDelay)
52+ }
53+}
54+
55+// armEchoAckLocked starts the single echo timer. The caller holds echo.mu.
56+func (d *Daemon) armEchoAckLocked(ac *attachedClient, delay time.Duration) {
57+ t := &ac.echo
58+ t.timer.retain(d.clock, delay, func(fired ports.Timer) {
59+ if d.advanceEchoAck(ac, fired) {
60+ d.sendEchoAck(ac)
61+ }
62+ })
63+}
64+
65+// armEchoAckRetryLocked re-sends the current acknowledgement after one more
66+// echo delay. The caller holds echo.mu.
67+func (d *Daemon) armEchoAckRetryLocked(ac *attachedClient) {
68+ t := &ac.echo
69+ t.timer.retain(d.clock, echoAckDelay, func(fired ports.Timer) {
70+ t.mu.Lock()
71+ current := t.timer.timer == fired
72+ if current {
73+ t.timer.timer, t.timer.done = nil, nil
74+ if len(t.pending) > 0 {
75+ // New input owns the next timer and its ack covers this one.
76+ d.armEchoAckLocked(ac, max(echoAckDelay-d.clock.Now().Sub(t.pending[0].at), 0))
77+ current = false
78+ }
79+ }
80+ t.mu.Unlock()
81+ if current {
82+ d.sendEchoAck(ac)
83+ }
84+ })
85+}
86+
87+// advanceEchoAck moves echoAck to the newest input applied at least
88+// echoAckDelay ago and re-arms for the rest. It reports whether echoAck moved.
89+func (d *Daemon) advanceEchoAck(ac *attachedClient, fired ports.Timer) bool {
90+ t := &ac.echo
91+ t.mu.Lock()
92+ defer t.mu.Unlock()
93+ if t.timer.timer != fired {
94+ return false // stopped by a reset or superseded
95+ }
96+ t.timer.timer, t.timer.done = nil, nil
97+ now := d.clock.Now()
98+ acked := uint64(0)
99+ n := 0
100+ for n < len(t.pending) && now.Sub(t.pending[n].at) >= echoAckDelay {
101+ acked = t.pending[n].seq
102+ n++
103+ }
104+ t.pending = append(t.pending[:0], t.pending[n:]...)
105+ if len(t.pending) > 0 {
106+ d.armEchoAckLocked(ac, max(echoAckDelay-now.Sub(t.pending[0].at), 0))
107+ }
108+ if acked <= ac.echoAck.Load() {
109+ return false
110+ }
111+ ac.echoAck.Store(acked)
112+ return true
113+}
114+
115+// sendEchoAck publishes a new acknowledgement in an empty side-effect Output,
116+// so the client learns about it even when the screen does not change.
117+//
118+// Side effects bypass the output window. While the window is full, the frame
119+// holding the acknowledged echo may still be withheld, and an early ack would
120+// make the client judge its guesses against an older screen. The ack then
121+// waits: the next frame carries it, and a retry covers a screen that never
122+// changes.
123+func (d *Daemon) sendEchoAck(ac *attachedClient) {
124+ if ac.attachmentActivity() != attachmentActive {
125+ return
126+ }
127+ sess := ac.currentAttachmentSession()
128+ if sess == nil {
129+ return
130+ }
131+ if ac.output != nil && ac.output.atCapacity() {
132+ t := &ac.echo
133+ t.mu.Lock()
134+ if t.timer.timer == nil {
135+ d.armEchoAckRetryLocked(ac)
136+ }
137+ t.mu.Unlock()
138+ return
139+ }
140+ failed, err := d.boundedSendOutputErrTransport(ac, nil)
141+ if err != nil && failed != nil {
142+ d.detachOnSendError(sess, ac, failed)
143+ }
144+}
145+
146+// resetEchoAck forgets every acknowledgement. A resumed client restarts its
147+// input numbering, so the replacement link must start from zero too.
148+func (ac *attachedClient) resetEchoAck() {
149+ t := &ac.echo
150+ t.mu.Lock()
151+ defer t.mu.Unlock()
152+ t.timer.stop()
153+ t.pending = nil
154+ ac.echoAck.Store(0)
155+}
A internal/usecase/daemon/echo_ack_test.go +142 -0
@@ -0,0 +1,142 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+)
13+
14+// echoTestClock is a settable clock whose timers fire only when the test says.
15+type echoTestClock struct {
16+ mu sync.Mutex
17+ now time.Time
18+ timers chan echoTestTimer
19+}
20+
21+type echoTestTimer struct {
22+ timer *manualAttentionTimer
23+ delay time.Duration
24+}
25+
26+func newEchoTestClock() *echoTestClock {
27+ return &echoTestClock{now: time.Unix(100, 0), timers: make(chan echoTestTimer, 16)}
28+}
29+
30+func (c *echoTestClock) Now() time.Time {
31+ c.mu.Lock()
32+ defer c.mu.Unlock()
33+ return c.now
34+}
35+
36+func (c *echoTestClock) advance(d time.Duration) {
37+ c.mu.Lock()
38+ c.now = c.now.Add(d)
39+ c.mu.Unlock()
40+}
41+
42+func (c *echoTestClock) NewTimer(d time.Duration) ports.Timer {
43+ t := &manualAttentionTimer{ch: make(chan time.Time, 1)}
44+ c.timers <- echoTestTimer{timer: t, delay: d}
45+ return t
46+}
47+
48+// nextTimer returns the next echo timer, skipping the send-bound timers the
49+// ack-only output path creates.
50+func (c *echoTestClock) nextTimer(t *testing.T) (*manualAttentionTimer, time.Duration) {
51+ t.Helper()
52+ deadline := time.After(time.Second)
53+ for {
54+ select {
55+ case next := <-c.timers:
56+ if next.delay <= echoAckDelay {
57+ return next.timer, next.delay
58+ }
59+ case <-deadline:
60+ t.Fatal("timed out waiting for echo timer")
61+ return nil, 0
62+ }
63+ }
64+}
65+
66+func TestEchoAckWaitsForEchoTimeoutAndSendsAckOnlyOutput(t *testing.T) {
67+ clock := newEchoTestClock()
68+ d, _, ac, sends := newManualSessionWithPTYsClock(t, clock, newQuietPTY())
69+
70+ d.noteInputApplied(ac, 1)
71+ clock.advance(20 * time.Millisecond)
72+ d.noteInputApplied(ac, 2)
73+ timer, delay := clock.nextTimer(t)
74+ require.Equal(t, echoAckDelay, delay)
75+ require.Zero(t, ac.echoAck.Load(), "input is not echoed when applied")
76+
77+ clock.advance(30 * time.Millisecond)
78+ timer.fire()
79+ output := decodeServerMessage(t, awaitFrame(t, sends, "Output")).(protocol.Output)
80+ require.Equal(t, uint64(1), output.Echo)
81+ require.Zero(t, output.New, "an ack-only output is a side effect")
82+ require.Empty(t, output.Data)
83+
84+ // Input 2 is still younger than the timeout: the tracker re-arms for its
85+ // remaining age.
86+ timer, delay = clock.nextTimer(t)
87+ require.Equal(t, 20*time.Millisecond, delay)
88+ clock.advance(20 * time.Millisecond)
89+ timer.fire()
90+ output = decodeServerMessage(t, awaitFrame(t, sends, "Output")).(protocol.Output)
91+ require.Equal(t, uint64(2), output.Echo)
92+ require.Equal(t, uint64(2), ac.echoAck.Load())
93+}
94+
95+func TestEchoAckIgnoresUnsequencedAndStaleInput(t *testing.T) {
96+ clock := newEchoTestClock()
97+ d, _, ac, _ := newManualSessionWithPTYsClock(t, clock, newQuietPTY())
98+ ac.echoAck.Store(5)
99+
100+ d.noteInputApplied(ac, 0)
101+ d.noteInputApplied(ac, 5)
102+ require.Empty(t, ac.echo.pending)
103+ require.Empty(t, clock.timers)
104+}
105+
106+func TestResetEchoAckDropsPendingInput(t *testing.T) {
107+ clock := newEchoTestClock()
108+ d, _, ac, sends := newManualSessionWithPTYsClock(t, clock, newQuietPTY())
109+
110+ d.noteInputApplied(ac, 7)
111+ timer, _ := clock.nextTimer(t)
112+ ac.resetEchoAck()
113+ clock.advance(echoAckDelay)
114+ timer.fire()
115+
116+ require.Zero(t, ac.echoAck.Load())
117+ require.Empty(t, drainAllFrames(sends), "a reset tracker must not publish the old link's input")
118+}
119+
120+func TestEchoAckWaitsWhileOutputWindowIsFull(t *testing.T) {
121+ clock := newEchoTestClock()
122+ d, _, ac, sends := newManualSessionWithPTYsClock(t, clock, newQuietPTY())
123+ ac.output.maxOutstandingAtomic.Store(1)
124+ ac.output.outstandingAtomic.Store(1)
125+
126+ d.noteInputApplied(ac, 1)
127+ timer, _ := clock.nextTimer(t)
128+ clock.advance(echoAckDelay)
129+ timer.fire()
130+
131+ retry, delay := clock.nextTimer(t)
132+ require.Equal(t, echoAckDelay, delay, "a full window defers the ack")
133+ require.Equal(t, uint64(1), ac.echoAck.Load(), "the next frame may still carry it")
134+ require.Empty(t, drainAllFrames(sends), "no ack-only output may overtake withheld frames")
135+
136+ ac.output.outstandingAtomic.Store(0)
137+ clock.advance(echoAckDelay)
138+ retry.fire()
139+ output := decodeServerMessage(t, awaitFrame(t, sends, "Output")).(protocol.Output)
140+ require.Equal(t, uint64(1), output.Echo)
141+ require.Zero(t, output.New)
142+}
@@ -0,0 +1,142 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+)
13+
14+// echoTestClock is a settable clock whose timers fire only when the test says.
15+type echoTestClock struct {
16+ mu sync.Mutex
17+ now time.Time
18+ timers chan echoTestTimer
19+}
20+
21+type echoTestTimer struct {
22+ timer *manualAttentionTimer
23+ delay time.Duration
24+}
25+
26+func newEchoTestClock() *echoTestClock {
27+ return &echoTestClock{now: time.Unix(100, 0), timers: make(chan echoTestTimer, 16)}
28+}
29+
30+func (c *echoTestClock) Now() time.Time {
31+ c.mu.Lock()
32+ defer c.mu.Unlock()
33+ return c.now
34+}
35+
36+func (c *echoTestClock) advance(d time.Duration) {
37+ c.mu.Lock()
38+ c.now = c.now.Add(d)
39+ c.mu.Unlock()
40+}
41+
42+func (c *echoTestClock) NewTimer(d time.Duration) ports.Timer {
43+ t := &manualAttentionTimer{ch: make(chan time.Time, 1)}
44+ c.timers <- echoTestTimer{timer: t, delay: d}
45+ return t
46+}
47+
48+// nextTimer returns the next echo timer, skipping the send-bound timers the
49+// ack-only output path creates.
50+func (c *echoTestClock) nextTimer(t *testing.T) (*manualAttentionTimer, time.Duration) {
51+ t.Helper()
52+ deadline := time.After(time.Second)
53+ for {
54+ select {
55+ case next := <-c.timers:
56+ if next.delay <= echoAckDelay {
57+ return next.timer, next.delay
58+ }
59+ case <-deadline:
60+ t.Fatal("timed out waiting for echo timer")
61+ return nil, 0
62+ }
63+ }
64+}
65+
66+func TestEchoAckWaitsForEchoTimeoutAndSendsAckOnlyOutput(t *testing.T) {
67+ clock := newEchoTestClock()
68+ d, _, ac, sends := newManualSessionWithPTYsClock(t, clock, newQuietPTY())
69+
70+ d.noteInputApplied(ac, 1)
71+ clock.advance(20 * time.Millisecond)
72+ d.noteInputApplied(ac, 2)
73+ timer, delay := clock.nextTimer(t)
74+ require.Equal(t, echoAckDelay, delay)
75+ require.Zero(t, ac.echoAck.Load(), "input is not echoed when applied")
76+
77+ clock.advance(30 * time.Millisecond)
78+ timer.fire()
79+ output := decodeServerMessage(t, awaitFrame(t, sends, "Output")).(protocol.Output)
80+ require.Equal(t, uint64(1), output.Echo)
81+ require.Zero(t, output.New, "an ack-only output is a side effect")
82+ require.Empty(t, output.Data)
83+
84+ // Input 2 is still younger than the timeout: the tracker re-arms for its
85+ // remaining age.
86+ timer, delay = clock.nextTimer(t)
87+ require.Equal(t, 20*time.Millisecond, delay)
88+ clock.advance(20 * time.Millisecond)
89+ timer.fire()
90+ output = decodeServerMessage(t, awaitFrame(t, sends, "Output")).(protocol.Output)
91+ require.Equal(t, uint64(2), output.Echo)
92+ require.Equal(t, uint64(2), ac.echoAck.Load())
93+}
94+
95+func TestEchoAckIgnoresUnsequencedAndStaleInput(t *testing.T) {
96+ clock := newEchoTestClock()
97+ d, _, ac, _ := newManualSessionWithPTYsClock(t, clock, newQuietPTY())
98+ ac.echoAck.Store(5)
99+
100+ d.noteInputApplied(ac, 0)
101+ d.noteInputApplied(ac, 5)
102+ require.Empty(t, ac.echo.pending)
103+ require.Empty(t, clock.timers)
104+}
105+
106+func TestResetEchoAckDropsPendingInput(t *testing.T) {
107+ clock := newEchoTestClock()
108+ d, _, ac, sends := newManualSessionWithPTYsClock(t, clock, newQuietPTY())
109+
110+ d.noteInputApplied(ac, 7)
111+ timer, _ := clock.nextTimer(t)
112+ ac.resetEchoAck()
113+ clock.advance(echoAckDelay)
114+ timer.fire()
115+
116+ require.Zero(t, ac.echoAck.Load())
117+ require.Empty(t, drainAllFrames(sends), "a reset tracker must not publish the old link's input")
118+}
119+
120+func TestEchoAckWaitsWhileOutputWindowIsFull(t *testing.T) {
121+ clock := newEchoTestClock()
122+ d, _, ac, sends := newManualSessionWithPTYsClock(t, clock, newQuietPTY())
123+ ac.output.maxOutstandingAtomic.Store(1)
124+ ac.output.outstandingAtomic.Store(1)
125+
126+ d.noteInputApplied(ac, 1)
127+ timer, _ := clock.nextTimer(t)
128+ clock.advance(echoAckDelay)
129+ timer.fire()
130+
131+ retry, delay := clock.nextTimer(t)
132+ require.Equal(t, echoAckDelay, delay, "a full window defers the ack")
133+ require.Equal(t, uint64(1), ac.echoAck.Load(), "the next frame may still carry it")
134+ require.Empty(t, drainAllFrames(sends), "no ack-only output may overtake withheld frames")
135+
136+ ac.output.outstandingAtomic.Store(0)
137+ clock.advance(echoAckDelay)
138+ retry.fire()
139+ output := decodeServerMessage(t, awaitFrame(t, sends, "Output")).(protocol.Output)
140+ require.Equal(t, uint64(1), output.Echo)
141+ require.Zero(t, output.New)
142+}
M internal/usecase/daemon/input.go +7 -6
@@ -14,19 +14,20 @@ import (
1414 "github.com/bnema/vev/internal/usecase/mouse"
1515 )
1616
17-func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint64, data []byte) {
18- // Do not acknowledge client-side echo prediction here: input has only been
19- // accepted/routed, not necessarily echoed by the PTY and incorporated into a
20- // rendered screen state. Until prediction is implemented against rendered
21- // output state, Echo must remain conservative.
17+// handleSequencedInput applies input and schedules its echo acknowledgement.
18+// Echo is not acknowledged immediately: the PTY needs time to echo the input
19+// into a rendered screen state (see echoAckDelay).
20+func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, seq uint64, data []byte) {
2221 d.handleInput(sess, ac, data)
22+ d.noteInputApplied(ac, seq)
2323 }
2424
25-func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, _ uint64, data []byte) {
25+func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, seq uint64, data []byte) {
2626 if !effect.current() {
2727 return
2828 }
2929 d.handleInputForAttachment(effect, data)
30+ d.noteInputApplied(effect.ac, seq)
3031 }
3132
3233 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
@@ -14,19 +14,20 @@ import (
14 "github.com/bnema/vev/internal/usecase/mouse"14 "github.com/bnema/vev/internal/usecase/mouse"
15 )15 )
16 16
17-func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint64, data []byte) {17+// handleSequencedInput applies input and schedules its echo acknowledgement.
18- // Do not acknowledge client-side echo prediction here: input has only been18+// Echo is not acknowledged immediately: the PTY needs time to echo the input
19- // accepted/routed, not necessarily echoed by the PTY and incorporated into a19+// into a rendered screen state (see echoAckDelay).
20- // rendered screen state. Until prediction is implemented against rendered20+func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, seq uint64, data []byte) {
21- // output state, Echo must remain conservative.
22 d.handleInput(sess, ac, data)21 d.handleInput(sess, ac, data)
22+ d.noteInputApplied(ac, seq)
23 }23 }
24 24
25-func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, _ uint64, data []byte) {25+func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, seq uint64, data []byte) {
26 if !effect.current() {26 if !effect.current() {
27 return27 return
28 }28 }
29 d.handleInputForAttachment(effect, data)29 d.handleInputForAttachment(effect, data)
30+ d.noteInputApplied(effect.ac, seq)
30 }31 }
31 32
32 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {33 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
M internal/usecase/daemon/resume.go +1 -0
@@ -627,6 +627,7 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection,
627627 // abandoned transport's output chain before binding the replacement so the
628628 // mandatory first paint cannot be blocked by ACKs that died with the link.
629629 ac.rebaseOutput()
630+ ac.resetEchoAck()
630631 ac.output.setWindow(h.MaxOutputInFlight)
631632 ac.replaceTransport(tr)
632633 // A parked attachment can resume through any route. Reapply the declared
@@ -627,6 +627,7 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection,
627 // abandoned transport's output chain before binding the replacement so the627 // abandoned transport's output chain before binding the replacement so the
628 // mandatory first paint cannot be blocked by ACKs that died with the link.628 // mandatory first paint cannot be blocked by ACKs that died with the link.
629 ac.rebaseOutput()629 ac.rebaseOutput()
630+ ac.resetEchoAck()
630 ac.output.setWindow(h.MaxOutputInFlight)631 ac.output.setWindow(h.MaxOutputInFlight)
631 ac.replaceTransport(tr)632 ac.replaceTransport(tr)
632 // A parked attachment can resume through any route. Reapply the declared633 // A parked attachment can resume through any route. Reapply the declared

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