detached 4491ca1

feat(client): predict local echo on remote attachments

Mirror applied output in a local VT screen, guess printable keys and
backspace on the cursor row, and draw the guesses until Output.Echo
confirms or refutes them. Adaptive SRTT and glitch triggers, tentative
epochs and underlining follow mosh. Configured by echo.predict.
commit
4491ca12e953c309c07962f09c0f43c29e8019a6
author
brice · · 2026-10-07 12:09 UTC
committer
brice · · 2026-10-07 12:15 UTC
parent
e73c690
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +1310 -22
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 +49 -0
@@ -563,6 +563,55 @@ func TestParseKeyboardKittyProtocol(t *testing.T) {
563563 }
564564 }
565565
566+func TestParseEchoPredict(t *testing.T) {
567+ t.Parallel()
568+
569+ tests := []struct {
570+ name string
571+ input string
572+ want domain.EchoPredictMode
573+ wantWarnings []domain.Warning
574+ }{
575+ {name: "absent key defaults adaptive", input: "theme = dark\n", want: domain.EchoPredictAdaptive},
576+ {name: "adaptive", input: "echo.predict = adaptive\n", want: domain.EchoPredictAdaptive},
577+ {name: "always", input: "echo.predict = always\n", want: domain.EchoPredictAlways},
578+ {name: "never", input: "echo.predict = Never\n", want: domain.EchoPredictNever},
579+ {
580+ name: "invalid value warns and keeps default",
581+ input: "echo.predict = sometimes\n",
582+ want: domain.EchoPredictAdaptive,
583+ wantWarnings: []domain.Warning{
584+ {Line: 1, Msg: "invalid echo.predict \"sometimes\" (want adaptive, always, or never)"},
585+ },
586+ },
587+ {
588+ name: "duplicate key warns and keeps last",
589+ input: "echo.predict = always\necho.predict = never\n",
590+ want: domain.EchoPredictNever,
591+ wantWarnings: []domain.Warning{
592+ {Line: 2, Msg: "duplicate key \"echo.predict\""},
593+ },
594+ },
595+ }
596+
597+ for _, tt := range tests {
598+ t.Run(tt.name, func(t *testing.T) {
599+ t.Parallel()
600+
601+ got, warnings, err := Parse(strings.NewReader(tt.input))
602+ if err != nil {
603+ t.Fatalf("Parse() error = %v", err)
604+ }
605+ if got.Echo.Predict != tt.want {
606+ t.Fatalf("Parse() Echo.Predict = %v, want %v", got.Echo.Predict, tt.want)
607+ }
608+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
609+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
610+ }
611+ })
612+ }
613+}
614+
566615 func TestParseNavigationOverflow(t *testing.T) {
567616 t.Parallel()
568617
@@ -563,6 +563,55 @@ func TestParseKeyboardKittyProtocol(t *testing.T) {
563 }563 }
564 }564 }
565 565
566+func TestParseEchoPredict(t *testing.T) {
567+ t.Parallel()
568+
569+ tests := []struct {
570+ name string
571+ input string
572+ want domain.EchoPredictMode
573+ wantWarnings []domain.Warning
574+ }{
575+ {name: "absent key defaults adaptive", input: "theme = dark\n", want: domain.EchoPredictAdaptive},
576+ {name: "adaptive", input: "echo.predict = adaptive\n", want: domain.EchoPredictAdaptive},
577+ {name: "always", input: "echo.predict = always\n", want: domain.EchoPredictAlways},
578+ {name: "never", input: "echo.predict = Never\n", want: domain.EchoPredictNever},
579+ {
580+ name: "invalid value warns and keeps default",
581+ input: "echo.predict = sometimes\n",
582+ want: domain.EchoPredictAdaptive,
583+ wantWarnings: []domain.Warning{
584+ {Line: 1, Msg: "invalid echo.predict \"sometimes\" (want adaptive, always, or never)"},
585+ },
586+ },
587+ {
588+ name: "duplicate key warns and keeps last",
589+ input: "echo.predict = always\necho.predict = never\n",
590+ want: domain.EchoPredictNever,
591+ wantWarnings: []domain.Warning{
592+ {Line: 2, Msg: "duplicate key \"echo.predict\""},
593+ },
594+ },
595+ }
596+
597+ for _, tt := range tests {
598+ t.Run(tt.name, func(t *testing.T) {
599+ t.Parallel()
600+
601+ got, warnings, err := Parse(strings.NewReader(tt.input))
602+ if err != nil {
603+ t.Fatalf("Parse() error = %v", err)
604+ }
605+ if got.Echo.Predict != tt.want {
606+ t.Fatalf("Parse() Echo.Predict = %v, want %v", got.Echo.Predict, tt.want)
607+ }
608+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
609+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
610+ }
611+ })
612+ }
613+}
614+
566 func TestParseNavigationOverflow(t *testing.T) {615 func TestParseNavigationOverflow(t *testing.T) {
567 t.Parallel()616 t.Parallel()
568 617
M internal/app/broker_client.go +1 -0
@@ -698,6 +698,7 @@ func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvir
698698 env := userTerminalAttachmentEnvironment(log, cfg.Terminal)
699699 env.ProbeTerminal = true
700700 env.KittyKeyboard = cfg.Keyboard.KittyProtocol
701+ env.EchoPredict = cfg.Echo.Predict
701702 return env
702703 }
703704
@@ -698,6 +698,7 @@ func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvir
698 env := userTerminalAttachmentEnvironment(log, cfg.Terminal)698 env := userTerminalAttachmentEnvironment(log, cfg.Terminal)
699 env.ProbeTerminal = true699 env.ProbeTerminal = true
700 env.KittyKeyboard = cfg.Keyboard.KittyProtocol700 env.KittyKeyboard = cfg.Keyboard.KittyProtocol
701+ env.EchoPredict = cfg.Echo.Predict
701 return env702 return env
702 }703 }
703 704
M internal/domain/config.go +20 -0
@@ -102,6 +102,25 @@ 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+ // EchoPredictAdaptive shows predictions only on a slow or glitchy link.
110+ EchoPredictAdaptive EchoPredictMode = iota
111+ // EchoPredictAlways shows predictions whatever the link latency.
112+ EchoPredictAlways
113+ // EchoPredictNever disables prediction.
114+ EchoPredictNever
115+)
116+
117+// EchoConfig contains client-side predictive echo settings.
118+type EchoConfig struct {
119+ // Predict selects when remote attachments draw typed characters before
120+ // the daemon echoes them.
121+ Predict EchoPredictMode
122+}
123+
105124 // TerminalConfig contains client-side outer-terminal settings. It is read by
106125 // each client at startup and never applied by the daemon, so a change takes
107126 // effect on the next client process.
@@ -153,6 +172,7 @@ type Config struct {
153172 Tabs TabsConfig
154173 Ephemeral EphemeralConfig
155174 Keyboard KeyboardConfig
175+ Echo EchoConfig
156176 Terminal TerminalConfig
157177 Scrollback ScrollbackConfig
158178 }
@@ -102,6 +102,25 @@ 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+ // EchoPredictAdaptive shows predictions only on a slow or glitchy link.
110+ EchoPredictAdaptive EchoPredictMode = iota
111+ // EchoPredictAlways shows predictions whatever the link latency.
112+ EchoPredictAlways
113+ // EchoPredictNever disables prediction.
114+ EchoPredictNever
115+)
116+
117+// EchoConfig contains client-side predictive echo settings.
118+type EchoConfig struct {
119+ // Predict selects when remote attachments draw typed characters before
120+ // the daemon echoes them.
121+ Predict EchoPredictMode
122+}
123+
105 // TerminalConfig contains client-side outer-terminal settings. It is read by124 // 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 takes125 // each client at startup and never applied by the daemon, so a change takes
107 // effect on the next client process.126 // effect on the next client process.
@@ -153,6 +172,7 @@ type Config struct {
153 Tabs TabsConfig172 Tabs TabsConfig
154 Ephemeral EphemeralConfig173 Ephemeral EphemeralConfig
155 Keyboard KeyboardConfig174 Keyboard KeyboardConfig
175+ Echo EchoConfig
156 Terminal TerminalConfig176 Terminal TerminalConfig
157 Scrollback ScrollbackConfig177 Scrollback ScrollbackConfig
158 }178 }
A internal/usecase/client/attachment_echo.go +120 -0
@@ -0,0 +1,120 @@
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 drops every guess: the next frame repaints the new geometry.
66+func (e *attachmentEcho) resized() {
67+ if e == nil {
68+ return
69+ }
70+ e.predictor.forget()
71+}
72+
73+// tickC fires while a pending guess waits on a timing trigger.
74+func (e *attachmentEcho) tickC() <-chan time.Time {
75+ if e == nil || e.tick == nil {
76+ return nil
77+ }
78+ return e.tick.C()
79+}
80+
81+func (e *attachmentEcho) ticked() error {
82+ if e == nil {
83+ return nil
84+ }
85+ e.tick = nil
86+ e.predictor.cull(e.clock.Now())
87+ return e.draw()
88+}
89+
90+func (e *attachmentEcho) close() {
91+ if e != nil && e.tick != nil {
92+ e.tick.Stop()
93+ e.tick = nil
94+ }
95+}
96+
97+func (e *attachmentEcho) draw() error {
98+ defer e.armTick()
99+ data := e.predictor.render()
100+ if len(data) == 0 {
101+ return nil
102+ }
103+ written, err := e.out.writePrediction(data)
104+ if err != nil {
105+ return fmt.Errorf("writing predicted echo: %w", err)
106+ }
107+ if !written {
108+ // An overlay owns the terminal: its box covers the guesses and the
109+ // daemon repaints everything once it closes.
110+ e.predictor.forget()
111+ }
112+ return nil
113+}
114+
115+func (e *attachmentEcho) armTick() {
116+ if e.tick != nil || !e.predictor.needsTick() {
117+ return
118+ }
119+ e.tick = e.clock.NewTimer(echoTickInterval)
120+}
@@ -0,0 +1,120 @@
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 drops every guess: the next frame repaints the new geometry.
66+func (e *attachmentEcho) resized() {
67+ if e == nil {
68+ return
69+ }
70+ e.predictor.forget()
71+}
72+
73+// tickC fires while a pending guess waits on a timing trigger.
74+func (e *attachmentEcho) tickC() <-chan time.Time {
75+ if e == nil || e.tick == nil {
76+ return nil
77+ }
78+ return e.tick.C()
79+}
80+
81+func (e *attachmentEcho) ticked() error {
82+ if e == nil {
83+ return nil
84+ }
85+ e.tick = nil
86+ e.predictor.cull(e.clock.Now())
87+ return e.draw()
88+}
89+
90+func (e *attachmentEcho) close() {
91+ if e != nil && e.tick != nil {
92+ e.tick.Stop()
93+ e.tick = nil
94+ }
95+}
96+
97+func (e *attachmentEcho) draw() error {
98+ defer e.armTick()
99+ data := e.predictor.render()
100+ if len(data) == 0 {
101+ return nil
102+ }
103+ written, err := e.out.writePrediction(data)
104+ if err != nil {
105+ return fmt.Errorf("writing predicted echo: %w", err)
106+ }
107+ if !written {
108+ // An overlay owns the terminal: its box covers the guesses and the
109+ // daemon repaints everything once it closes.
110+ e.predictor.forget()
111+ }
112+ return nil
113+}
114+
115+func (e *attachmentEcho) armTick() {
116+ if e.tick != nil || !e.predictor.needsTick() {
117+ return
118+ }
119+ e.tick = e.clock.NewTimer(echoTickInterval)
120+}
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 +37 -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,7 @@ 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+ echo.resized()
629643 if err := picker.resize(ctx, state, resize.Size); err != nil {
630644 return w.settle(ctx, fg, stream, token, err)
631645 }
@@ -645,6 +659,11 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
645659 if err := await(from, event.input.actionID); err != nil {
646660 return w.settle(ctx, fg, stream, token, err)
647661 }
662+ if message, ok := event.message.(protocol.Input); ok {
663+ if err := echo.input(message.InputSeq, message.Data); err != nil {
664+ return w.settle(ctx, fg, stream, token, err)
665+ }
666+ }
648667 }
649668 if event.action != 0 {
650669 // 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,7 @@ 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+ echo.resized()
629 if err := picker.resize(ctx, state, resize.Size); err != nil {643 if err := picker.resize(ctx, state, resize.Size); err != nil {
630 return w.settle(ctx, fg, stream, token, err)644 return w.settle(ctx, fg, stream, token, err)
631 }645 }
@@ -645,6 +659,11 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
645 if err := await(from, event.input.actionID); err != nil {659 if err := await(from, event.input.actionID); err != nil {
646 return w.settle(ctx, fg, stream, token, err)660 return w.settle(ctx, fg, stream, token, err)
647 }661 }
662+ if message, ok := event.message.(protocol.Input); ok {
663+ if err := echo.input(message.InputSeq, message.Data); err != nil {
664+ return w.settle(ctx, fg, stream, token, err)
665+ }
666+ }
648 }667 }
649 if event.action != 0 {668 if event.action != 0 {
650 // The lifecycle message must reach the daemon before the669 // 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/predictive_echo.go +668 -0
@@ -0,0 +1,668 @@
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+}
144+
145+func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor {
146+ return &echoPredictor{mode: mode, predEpoch: 1, screen: vt.NewScreen(1, 1)}
147+}
148+
149+// applyOutput feeds one applied Output to the mirror, samples the round trip
150+// when Echo advances, and validates pending guesses.
151+func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) {
152+ // Side effects (New == 0) may carry a placeholder size; only state
153+ // frames describe the grid.
154+ if output.New != 0 && output.Size.Valid() && (output.Size.Cols != p.screen.Columns() || output.Size.Rows != p.screen.Rows()) {
155+ p.screen.Resize(output.Size.Cols, output.Size.Rows)
156+ // The resized frame repaints the whole terminal.
157+ p.forget()
158+ }
159+ if output.Full {
160+ // A full frame repainted every drawn guess.
161+ p.drawn = p.drawn[:0]
162+ p.cursorMove = false
163+ }
164+ p.screen.Write(output.Data)
165+ if output.Echo > p.echo {
166+ p.sample(output.Echo, now)
167+ p.echo = output.Echo
168+ }
169+ p.cull(now)
170+}
171+
172+// sample updates the smoothed round trip from the send time of seq.
173+func (p *echoPredictor) sample(seq uint64, now time.Time) {
174+ keep := p.sent[:0]
175+ for _, sent := range p.sent {
176+ if sent.seq == seq {
177+ rtt := max(now.Sub(sent.at)-echoServerDelay, 0)
178+ if p.srttKnown {
179+ p.srtt = (7*p.srtt + rtt) / 8
180+ } else {
181+ p.srtt, p.srttKnown = rtt, true
182+ }
183+ }
184+ if sent.seq > seq {
185+ keep = append(keep, sent)
186+ }
187+ }
188+ p.sent = keep
189+}
190+
191+func (p *echoPredictor) sendInterval() time.Duration {
192+ interval := (p.srtt + 1) / 2
193+ return min(max(interval, echoSendIntervalMin), echoSendIntervalMax)
194+}
195+
196+// enabled reports whether the mirror describes a screen the engine can
197+// predict on: a visible cursor on a usable grid. The mirror holds the
198+// daemon's composed frame, which never enters the alternate screen, so
199+// full-screen pane programs rely on epoch validation, as in mosh.
200+func (p *echoPredictor) enabled() bool {
201+ return p.mode != domain.EchoPredictNever && p.screen.CursorVisible() &&
202+ p.screen.Columns() > 1 && p.screen.Rows() > 0
203+}
204+
205+func (p *echoPredictor) active() bool {
206+ if p.cursor.active {
207+ return true
208+ }
209+ for i := range p.cells {
210+ if p.cells[i].active {
211+ return true
212+ }
213+ }
214+ return false
215+}
216+
217+func (p *echoPredictor) displaying() bool {
218+ switch p.mode {
219+ case domain.EchoPredictNever:
220+ return false
221+ case domain.EchoPredictAlways:
222+ return true
223+ }
224+ return p.srttOn || p.glitch > 0
225+}
226+
227+func (p *echoPredictor) becomeTentative() { p.predEpoch++ }
228+
229+// reset drops every guess. Drawn cells stay recorded so render repaints them.
230+func (p *echoPredictor) reset() {
231+ for i := range p.cells {
232+ p.cells[i].reset()
233+ }
234+ p.cursor = echoCursor{}
235+ p.becomeTentative()
236+}
237+
238+// forget drops every guess and the drawn record, for when something else
239+// repaints or owns the terminal (a full frame, an overlay).
240+func (p *echoPredictor) forget() {
241+ p.reset()
242+ p.drawn = p.drawn[:0]
243+ p.cursorMove = false
244+}
245+
246+func (p *echoPredictor) killEpoch(epoch uint64) {
247+ for i := range p.cells {
248+ if p.cells[i].active && p.cells[i].epoch >= epoch {
249+ p.cells[i].reset()
250+ }
251+ }
252+ if p.cursor.active && p.cursor.epoch >= epoch {
253+ p.cursor = echoCursor{}
254+ }
255+ p.becomeTentative()
256+}
257+
258+// cull validates guesses against the mirror and updates the display
259+// triggers, as mosh's PredictionEngine::cull.
260+func (p *echoPredictor) cull(now time.Time) {
261+ if p.mode == domain.EchoPredictNever {
262+ return
263+ }
264+ if !p.enabled() {
265+ p.reset()
266+ return
267+ }
268+ if p.srttKnown {
269+ interval := p.sendInterval()
270+ if interval > echoSRTTTriggerHigh {
271+ p.srttOn = true
272+ } else if p.srttOn && interval <= echoSRTTTriggerLow && !p.active() {
273+ p.srttOn = false
274+ }
275+ if interval > echoFlagTriggerHigh {
276+ p.flagging = true
277+ } else if interval <= echoFlagTriggerLow {
278+ p.flagging = false
279+ }
280+ }
281+ if p.glitch > echoGlitchRepairCount {
282+ p.flagging = true
283+ }
284+ if p.row >= p.screen.Rows() || len(p.cells) != p.screen.Columns() {
285+ p.cells = nil
286+ }
287+ for col := range p.cells {
288+ cell := &p.cells[col]
289+ switch p.cellValidity(col) {
290+ case echoIncorrect:
291+ if cell.tentative(p.confEpoch) {
292+ p.killEpoch(cell.epoch)
293+ continue
294+ }
295+ p.reset()
296+ return
297+ case echoCorrect:
298+ p.confEpoch = max(p.confEpoch, cell.epoch)
299+ if now.Sub(cell.predictedAt) < echoGlitchThreshold && p.glitch > 0 && now.Sub(p.lastQuick) >= echoGlitchRepairMinInterval {
300+ p.glitch--
301+ p.lastQuick = now
302+ }
303+ // Later guesses take the confirmed style.
304+ style := p.screen.Cell(col, p.row).Style
305+ for k := col; k < len(p.cells); k++ {
306+ p.cells[k].replacement.Style = style
307+ }
308+ cell.reset()
309+ case echoCorrectNoCredit:
310+ cell.reset()
311+ case echoPending:
312+ pending := now.Sub(cell.predictedAt)
313+ if pending >= echoGlitchFlagThreshold {
314+ p.glitch = 2 * echoGlitchRepairCount
315+ } else if pending >= echoGlitchThreshold && p.glitch < echoGlitchRepairCount {
316+ p.glitch = echoGlitchRepairCount
317+ }
318+ }
319+ }
320+ if p.cursor.active && p.echo >= p.cursor.expires {
321+ if p.screen.CursorRow() != p.cursor.row || p.screen.CursorCol() != p.cursor.col {
322+ p.reset()
323+ return
324+ }
325+ p.cursor = echoCursor{}
326+ }
327+}
328+
329+func (p *echoPredictor) cellValidity(col int) echoValidity {
330+ cell := &p.cells[col]
331+ if !cell.active {
332+ return echoInactive
333+ }
334+ if p.echo < cell.expires {
335+ return echoPending
336+ }
337+ if cell.unknown || isBlankRune(cell.replacement.Rune) {
338+ return echoCorrectNoCredit
339+ }
340+ current := p.screen.Cell(col, p.row)
341+ if current.Rune != cell.replacement.Rune {
342+ return echoIncorrect
343+ }
344+ for _, original := range cell.originals {
345+ if original == cell.replacement.Rune {
346+ return echoCorrectNoCredit
347+ }
348+ }
349+ return echoCorrect
350+}
351+
352+func isBlankRune(r rune) bool { return r == 0 || r == ' ' }
353+
354+// input records the Input numbered seq and predicts its bytes.
355+func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) {
356+ if seq == 0 || p.mode == domain.EchoPredictNever {
357+ return
358+ }
359+ if len(p.sent) == maxEchoSent {
360+ p.sent = append(p.sent[:0], p.sent[1:]...)
361+ }
362+ p.sent = append(p.sent, echoSent{seq: seq, at: now})
363+ p.cull(now)
364+ if !p.enabled() {
365+ return
366+ }
367+ // A bracketed paste arrives as one Input: it is not typing.
368+ if bytes.Contains(data, []byte("\x1b[200~")) {
369+ p.becomeTentative()
370+ return
371+ }
372+ for len(data) > 0 {
373+ if data[0] == 0x1b {
374+ data = data[escapeLength(data):]
375+ p.becomeTentative()
376+ continue
377+ }
378+ r, size := utf8.DecodeRune(data)
379+ data = data[size:]
380+ switch {
381+ case r == utf8.RuneError && size == 1:
382+ p.becomeTentative()
383+ case r == 0x7f:
384+ p.backspace(seq, now)
385+ case r == '\r':
386+ // The next row's state is only known once the daemon answers.
387+ p.becomeTentative()
388+ p.cursor = echoCursor{}
389+ p.holdUntil = seq
390+ case vt.RuneWidth(r) != 1:
391+ p.becomeTentative()
392+ default:
393+ p.print(r, seq, now)
394+ }
395+ }
396+}
397+
398+// escapeLength is the length of the escape sequence at the start of data, or
399+// all of data when it is incomplete.
400+func escapeLength(data []byte) int {
401+ if len(data) < 2 {
402+ return len(data)
403+ }
404+ switch data[1] {
405+ case '[':
406+ for i := 2; i < len(data); i++ {
407+ if data[i] >= 0x40 && data[i] <= 0x7e {
408+ return i + 1
409+ }
410+ }
411+ return len(data)
412+ case 'O':
413+ return min(3, len(data))
414+ }
415+ return 2
416+}
417+
418+// initCursor starts the cursor prediction from the mirror, and the cell row
419+// with it. It reports false while the cursor row is not known yet.
420+func (p *echoPredictor) initCursor() bool {
421+ if p.echo < p.holdUntil {
422+ return false
423+ }
424+ if !p.cursor.active {
425+ row, col := p.screen.CursorRow(), p.screen.CursorCol()
426+ if row < 0 || row >= p.screen.Rows() || col < 0 || col >= p.screen.Columns() {
427+ return false
428+ }
429+ p.cursor = echoCursor{active: true, row: row, col: col, epoch: p.predEpoch}
430+ }
431+ if p.cells == nil || p.row != p.cursor.row || len(p.cells) != p.screen.Columns() {
432+ p.row = p.cursor.row
433+ p.cells = make([]echoCell, p.screen.Columns())
434+ }
435+ return true
436+}
437+
438+// effective is the cell the user will see at col: a known guess, else the
439+// mirror.
440+func (p *echoPredictor) effective(col int) (vt.Cell, bool) {
441+ cell := &p.cells[col]
442+ if cell.active {
443+ if cell.unknown {
444+ return vt.Cell{}, false
445+ }
446+ return cell.replacement, true
447+ }
448+ return p.screen.Cell(col, p.row), true
449+}
450+
451+func (p *echoPredictor) moveCursor(col int, seq uint64) {
452+ p.cursor.col = col
453+ p.cursor.expires = seq
454+ p.cursor.epoch = p.predEpoch
455+}
456+
457+// print predicts one width-1 rune in insert mode. The shift stops at the
458+// first blank, so a split layout's borders to the right never move.
459+func (p *echoPredictor) print(r rune, seq uint64, now time.Time) {
460+ if !p.initCursor() {
461+ p.becomeTentative()
462+ return
463+ }
464+ width := len(p.cells)
465+ col := p.cursor.col
466+ if col+1 >= width {
467+ // mosh: the last column is tricky (wrap markers, shells differ).
468+ p.becomeTentative()
469+ }
470+ end := col
471+ for end < width-1 {
472+ cell, known := p.effective(end)
473+ if known && isBlankRune(cell.Rune) {
474+ break
475+ }
476+ end++
477+ }
478+ for i := end; i > col; i-- {
479+ cell := &p.cells[i]
480+ prev, known := p.effective(i - 1)
481+ original := p.screen.Cell(i, p.row).Rune
482+ cell.resetWithOriginal()
483+ cell.arm(seq, p.predEpoch, now)
484+ cell.originals = append(cell.originals, original)
485+ if i == width-1 || !known {
486+ cell.unknown = true
487+ } else {
488+ cell.replacement = prev
489+ }
490+ }
491+ style := vt.DefaultStyle()
492+ if col > 0 {
493+ if left, known := p.effective(col - 1); known {
494+ style = left.Style
495+ }
496+ }
497+ cell := &p.cells[col]
498+ original := p.screen.Cell(col, p.row).Rune
499+ cell.resetWithOriginal()
500+ cell.arm(seq, p.predEpoch, now)
501+ cell.replacement = vt.Cell{Rune: r, Style: style}
502+ cell.originals = append(cell.originals, original)
503+ if col < width-1 {
504+ p.moveCursor(col+1, seq)
505+ } else {
506+ p.becomeTentative()
507+ p.moveCursor(col, seq)
508+ }
509+}
510+
511+// backspace predicts DEL: the cursor moves left and the text after it moves
512+// left up to the first blank.
513+func (p *echoPredictor) backspace(seq uint64, now time.Time) {
514+ if !p.initCursor() {
515+ p.becomeTentative()
516+ return
517+ }
518+ if p.cursor.col == 0 {
519+ return
520+ }
521+ width := len(p.cells)
522+ col := p.cursor.col - 1
523+ p.moveCursor(col, seq)
524+ for i := col; i < width; i++ {
525+ cell := &p.cells[i]
526+ original := p.screen.Cell(i, p.row).Rune
527+ var next vt.Cell
528+ known := false
529+ if i+2 < width {
530+ next, known = p.effective(i + 1)
531+ }
532+ blank := known && isBlankRune(next.Rune)
533+ cell.resetWithOriginal()
534+ cell.arm(seq, p.predEpoch, now)
535+ cell.originals = append(cell.originals, original)
536+ if !known {
537+ cell.unknown = true
538+ } else {
539+ cell.replacement = next
540+ }
541+ if blank {
542+ break
543+ }
544+ }
545+}
546+
547+// needsTick reports whether a pending guess still waits on a timing trigger
548+// (mosh's timing_tests_necessary), so the caller re-culls without output.
549+func (p *echoPredictor) needsTick() bool {
550+ return p.mode == domain.EchoPredictAdaptive && p.active() && !(p.glitch > 0 && p.flagging)
551+}
552+
553+// render returns the terminal bytes that bring the screen from the last
554+// render to the current guesses: drawn cells that no longer show a guess are
555+// repainted from the mirror, shown guesses are drawn again (a daemon frame
556+// may have overwritten them), and the cursor goes to the predicted position
557+// or back to the real one.
558+func (p *echoPredictor) render() []byte {
559+ var out bytes.Buffer
560+ show := p.displaying() && p.enabled()
561+ var want []int
562+ if show && p.cells != nil && p.row < p.screen.Rows() {
563+ for col := range p.cells {
564+ cell := &p.cells[col]
565+ if !cell.active || cell.unknown || cell.tentative(p.confEpoch) {
566+ continue
567+ }
568+ if p.screen.Cell(col, p.row).Equal(cell.replacement) && !p.flagging {
569+ continue
570+ }
571+ want = append(want, col)
572+ }
573+ }
574+ for _, col := range p.drawn {
575+ if p.drawnRow == p.row && slices.Contains(want, col) {
576+ continue
577+ }
578+ if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() {
579+ writeEchoCell(&out, p.drawnRow, col, p.screen.Cell(col, p.drawnRow), false)
580+ }
581+ }
582+ for _, col := range want {
583+ cell := p.cells[col].replacement
584+ flag := p.flagging && !(isBlankRune(cell.Rune) && isBlankRune(p.screen.Cell(col, p.row).Rune))
585+ writeEchoCell(&out, p.row, col, cell, flag)
586+ }
587+ p.drawn = append(p.drawn[:0], want...)
588+ p.drawnRow = p.row
589+
590+ row, col := p.screen.CursorRow(), p.screen.CursorCol()
591+ moved := false
592+ if show && p.cursor.active && p.cursor.epoch <= p.confEpoch {
593+ moved = p.cursor.row != row || p.cursor.col != col
594+ row, col = p.cursor.row, p.cursor.col
595+ }
596+ if out.Len() > 0 || moved || p.cursorMove {
597+ out.WriteString("\x1b[0m")
598+ writeEchoCUP(&out, row, col)
599+ }
600+ p.cursorMove = moved
601+ return out.Bytes()
602+}
603+
604+func writeEchoCUP(out *bytes.Buffer, row, col int) {
605+ out.WriteString("\x1b[")
606+ out.WriteString(strconv.Itoa(row + 1))
607+ out.WriteByte(';')
608+ out.WriteString(strconv.Itoa(col + 1))
609+ out.WriteByte('H')
610+}
611+
612+// writeEchoCell draws one cell. Mirror styles come from daemon output already
613+// projected to this terminal's color profile, so they are written as is.
614+func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) {
615+ writeEchoCUP(out, row, col)
616+ out.WriteString(echoSGR(cell.Style, underline))
617+ r := cell.Rune
618+ if cell.Continuation || r == 0 || vt.RuneWidth(r) != 1 {
619+ // A wide rune's halves cannot be drawn alone; a blank is the safe
620+ // stand-in until the daemon's next frame.
621+ r = ' '
622+ }
623+ out.WriteRune(r)
624+}
625+
626+func echoSGR(style vt.Style, underline bool) string {
627+ var b strings.Builder
628+ b.WriteString("\x1b[0")
629+ if style.Bold {
630+ b.WriteString(";1")
631+ }
632+ if style.Attrs&vt.AttrDim != 0 {
633+ b.WriteString(";2")
634+ }
635+ if style.Italic {
636+ b.WriteString(";3")
637+ }
638+ if underline || style.Attrs&vt.AttrUnderline != 0 {
639+ b.WriteString(";4")
640+ }
641+ if style.Attrs&vt.AttrBlink != 0 {
642+ b.WriteString(";5")
643+ }
644+ if style.Inverse {
645+ b.WriteString(";7")
646+ }
647+ if style.Attrs&vt.AttrStrikethrough != 0 {
648+ b.WriteString(";9")
649+ }
650+ writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90)
651+ writeEchoColor(&b, style.HasBackgroundRGB, style.BackgroundRGB, style.Background, 48, 40, 100)
652+ b.WriteByte('m')
653+ return b.String()
654+}
655+
656+func writeEchoColor(b *strings.Builder, hasRGB bool, rgb vt.RGB, index, extended, normal, bright int) {
657+ switch {
658+ case hasRGB:
659+ b.WriteString(";" + strconv.Itoa(extended) + ";2;" + strconv.Itoa(int(rgb.R)) + ";" + strconv.Itoa(int(rgb.G)) + ";" + strconv.Itoa(int(rgb.B)))
660+ case index < 0:
661+ case index < 8:
662+ b.WriteString(";" + strconv.Itoa(normal+index))
663+ case index < 16:
664+ b.WriteString(";" + strconv.Itoa(bright+index-8))
665+ case index < 256:
666+ b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index))
667+ }
668+}
@@ -0,0 +1,668 @@
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+}
144+
145+func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor {
146+ return &echoPredictor{mode: mode, predEpoch: 1, screen: vt.NewScreen(1, 1)}
147+}
148+
149+// applyOutput feeds one applied Output to the mirror, samples the round trip
150+// when Echo advances, and validates pending guesses.
151+func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) {
152+ // Side effects (New == 0) may carry a placeholder size; only state
153+ // frames describe the grid.
154+ if output.New != 0 && output.Size.Valid() && (output.Size.Cols != p.screen.Columns() || output.Size.Rows != p.screen.Rows()) {
155+ p.screen.Resize(output.Size.Cols, output.Size.Rows)
156+ // The resized frame repaints the whole terminal.
157+ p.forget()
158+ }
159+ if output.Full {
160+ // A full frame repainted every drawn guess.
161+ p.drawn = p.drawn[:0]
162+ p.cursorMove = false
163+ }
164+ p.screen.Write(output.Data)
165+ if output.Echo > p.echo {
166+ p.sample(output.Echo, now)
167+ p.echo = output.Echo
168+ }
169+ p.cull(now)
170+}
171+
172+// sample updates the smoothed round trip from the send time of seq.
173+func (p *echoPredictor) sample(seq uint64, now time.Time) {
174+ keep := p.sent[:0]
175+ for _, sent := range p.sent {
176+ if sent.seq == seq {
177+ rtt := max(now.Sub(sent.at)-echoServerDelay, 0)
178+ if p.srttKnown {
179+ p.srtt = (7*p.srtt + rtt) / 8
180+ } else {
181+ p.srtt, p.srttKnown = rtt, true
182+ }
183+ }
184+ if sent.seq > seq {
185+ keep = append(keep, sent)
186+ }
187+ }
188+ p.sent = keep
189+}
190+
191+func (p *echoPredictor) sendInterval() time.Duration {
192+ interval := (p.srtt + 1) / 2
193+ return min(max(interval, echoSendIntervalMin), echoSendIntervalMax)
194+}
195+
196+// enabled reports whether the mirror describes a screen the engine can
197+// predict on: a visible cursor on a usable grid. The mirror holds the
198+// daemon's composed frame, which never enters the alternate screen, so
199+// full-screen pane programs rely on epoch validation, as in mosh.
200+func (p *echoPredictor) enabled() bool {
201+ return p.mode != domain.EchoPredictNever && p.screen.CursorVisible() &&
202+ p.screen.Columns() > 1 && p.screen.Rows() > 0
203+}
204+
205+func (p *echoPredictor) active() bool {
206+ if p.cursor.active {
207+ return true
208+ }
209+ for i := range p.cells {
210+ if p.cells[i].active {
211+ return true
212+ }
213+ }
214+ return false
215+}
216+
217+func (p *echoPredictor) displaying() bool {
218+ switch p.mode {
219+ case domain.EchoPredictNever:
220+ return false
221+ case domain.EchoPredictAlways:
222+ return true
223+ }
224+ return p.srttOn || p.glitch > 0
225+}
226+
227+func (p *echoPredictor) becomeTentative() { p.predEpoch++ }
228+
229+// reset drops every guess. Drawn cells stay recorded so render repaints them.
230+func (p *echoPredictor) reset() {
231+ for i := range p.cells {
232+ p.cells[i].reset()
233+ }
234+ p.cursor = echoCursor{}
235+ p.becomeTentative()
236+}
237+
238+// forget drops every guess and the drawn record, for when something else
239+// repaints or owns the terminal (a full frame, an overlay).
240+func (p *echoPredictor) forget() {
241+ p.reset()
242+ p.drawn = p.drawn[:0]
243+ p.cursorMove = false
244+}
245+
246+func (p *echoPredictor) killEpoch(epoch uint64) {
247+ for i := range p.cells {
248+ if p.cells[i].active && p.cells[i].epoch >= epoch {
249+ p.cells[i].reset()
250+ }
251+ }
252+ if p.cursor.active && p.cursor.epoch >= epoch {
253+ p.cursor = echoCursor{}
254+ }
255+ p.becomeTentative()
256+}
257+
258+// cull validates guesses against the mirror and updates the display
259+// triggers, as mosh's PredictionEngine::cull.
260+func (p *echoPredictor) cull(now time.Time) {
261+ if p.mode == domain.EchoPredictNever {
262+ return
263+ }
264+ if !p.enabled() {
265+ p.reset()
266+ return
267+ }
268+ if p.srttKnown {
269+ interval := p.sendInterval()
270+ if interval > echoSRTTTriggerHigh {
271+ p.srttOn = true
272+ } else if p.srttOn && interval <= echoSRTTTriggerLow && !p.active() {
273+ p.srttOn = false
274+ }
275+ if interval > echoFlagTriggerHigh {
276+ p.flagging = true
277+ } else if interval <= echoFlagTriggerLow {
278+ p.flagging = false
279+ }
280+ }
281+ if p.glitch > echoGlitchRepairCount {
282+ p.flagging = true
283+ }
284+ if p.row >= p.screen.Rows() || len(p.cells) != p.screen.Columns() {
285+ p.cells = nil
286+ }
287+ for col := range p.cells {
288+ cell := &p.cells[col]
289+ switch p.cellValidity(col) {
290+ case echoIncorrect:
291+ if cell.tentative(p.confEpoch) {
292+ p.killEpoch(cell.epoch)
293+ continue
294+ }
295+ p.reset()
296+ return
297+ case echoCorrect:
298+ p.confEpoch = max(p.confEpoch, cell.epoch)
299+ if now.Sub(cell.predictedAt) < echoGlitchThreshold && p.glitch > 0 && now.Sub(p.lastQuick) >= echoGlitchRepairMinInterval {
300+ p.glitch--
301+ p.lastQuick = now
302+ }
303+ // Later guesses take the confirmed style.
304+ style := p.screen.Cell(col, p.row).Style
305+ for k := col; k < len(p.cells); k++ {
306+ p.cells[k].replacement.Style = style
307+ }
308+ cell.reset()
309+ case echoCorrectNoCredit:
310+ cell.reset()
311+ case echoPending:
312+ pending := now.Sub(cell.predictedAt)
313+ if pending >= echoGlitchFlagThreshold {
314+ p.glitch = 2 * echoGlitchRepairCount
315+ } else if pending >= echoGlitchThreshold && p.glitch < echoGlitchRepairCount {
316+ p.glitch = echoGlitchRepairCount
317+ }
318+ }
319+ }
320+ if p.cursor.active && p.echo >= p.cursor.expires {
321+ if p.screen.CursorRow() != p.cursor.row || p.screen.CursorCol() != p.cursor.col {
322+ p.reset()
323+ return
324+ }
325+ p.cursor = echoCursor{}
326+ }
327+}
328+
329+func (p *echoPredictor) cellValidity(col int) echoValidity {
330+ cell := &p.cells[col]
331+ if !cell.active {
332+ return echoInactive
333+ }
334+ if p.echo < cell.expires {
335+ return echoPending
336+ }
337+ if cell.unknown || isBlankRune(cell.replacement.Rune) {
338+ return echoCorrectNoCredit
339+ }
340+ current := p.screen.Cell(col, p.row)
341+ if current.Rune != cell.replacement.Rune {
342+ return echoIncorrect
343+ }
344+ for _, original := range cell.originals {
345+ if original == cell.replacement.Rune {
346+ return echoCorrectNoCredit
347+ }
348+ }
349+ return echoCorrect
350+}
351+
352+func isBlankRune(r rune) bool { return r == 0 || r == ' ' }
353+
354+// input records the Input numbered seq and predicts its bytes.
355+func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) {
356+ if seq == 0 || p.mode == domain.EchoPredictNever {
357+ return
358+ }
359+ if len(p.sent) == maxEchoSent {
360+ p.sent = append(p.sent[:0], p.sent[1:]...)
361+ }
362+ p.sent = append(p.sent, echoSent{seq: seq, at: now})
363+ p.cull(now)
364+ if !p.enabled() {
365+ return
366+ }
367+ // A bracketed paste arrives as one Input: it is not typing.
368+ if bytes.Contains(data, []byte("\x1b[200~")) {
369+ p.becomeTentative()
370+ return
371+ }
372+ for len(data) > 0 {
373+ if data[0] == 0x1b {
374+ data = data[escapeLength(data):]
375+ p.becomeTentative()
376+ continue
377+ }
378+ r, size := utf8.DecodeRune(data)
379+ data = data[size:]
380+ switch {
381+ case r == utf8.RuneError && size == 1:
382+ p.becomeTentative()
383+ case r == 0x7f:
384+ p.backspace(seq, now)
385+ case r == '\r':
386+ // The next row's state is only known once the daemon answers.
387+ p.becomeTentative()
388+ p.cursor = echoCursor{}
389+ p.holdUntil = seq
390+ case vt.RuneWidth(r) != 1:
391+ p.becomeTentative()
392+ default:
393+ p.print(r, seq, now)
394+ }
395+ }
396+}
397+
398+// escapeLength is the length of the escape sequence at the start of data, or
399+// all of data when it is incomplete.
400+func escapeLength(data []byte) int {
401+ if len(data) < 2 {
402+ return len(data)
403+ }
404+ switch data[1] {
405+ case '[':
406+ for i := 2; i < len(data); i++ {
407+ if data[i] >= 0x40 && data[i] <= 0x7e {
408+ return i + 1
409+ }
410+ }
411+ return len(data)
412+ case 'O':
413+ return min(3, len(data))
414+ }
415+ return 2
416+}
417+
418+// initCursor starts the cursor prediction from the mirror, and the cell row
419+// with it. It reports false while the cursor row is not known yet.
420+func (p *echoPredictor) initCursor() bool {
421+ if p.echo < p.holdUntil {
422+ return false
423+ }
424+ if !p.cursor.active {
425+ row, col := p.screen.CursorRow(), p.screen.CursorCol()
426+ if row < 0 || row >= p.screen.Rows() || col < 0 || col >= p.screen.Columns() {
427+ return false
428+ }
429+ p.cursor = echoCursor{active: true, row: row, col: col, epoch: p.predEpoch}
430+ }
431+ if p.cells == nil || p.row != p.cursor.row || len(p.cells) != p.screen.Columns() {
432+ p.row = p.cursor.row
433+ p.cells = make([]echoCell, p.screen.Columns())
434+ }
435+ return true
436+}
437+
438+// effective is the cell the user will see at col: a known guess, else the
439+// mirror.
440+func (p *echoPredictor) effective(col int) (vt.Cell, bool) {
441+ cell := &p.cells[col]
442+ if cell.active {
443+ if cell.unknown {
444+ return vt.Cell{}, false
445+ }
446+ return cell.replacement, true
447+ }
448+ return p.screen.Cell(col, p.row), true
449+}
450+
451+func (p *echoPredictor) moveCursor(col int, seq uint64) {
452+ p.cursor.col = col
453+ p.cursor.expires = seq
454+ p.cursor.epoch = p.predEpoch
455+}
456+
457+// print predicts one width-1 rune in insert mode. The shift stops at the
458+// first blank, so a split layout's borders to the right never move.
459+func (p *echoPredictor) print(r rune, seq uint64, now time.Time) {
460+ if !p.initCursor() {
461+ p.becomeTentative()
462+ return
463+ }
464+ width := len(p.cells)
465+ col := p.cursor.col
466+ if col+1 >= width {
467+ // mosh: the last column is tricky (wrap markers, shells differ).
468+ p.becomeTentative()
469+ }
470+ end := col
471+ for end < width-1 {
472+ cell, known := p.effective(end)
473+ if known && isBlankRune(cell.Rune) {
474+ break
475+ }
476+ end++
477+ }
478+ for i := end; i > col; i-- {
479+ cell := &p.cells[i]
480+ prev, known := p.effective(i - 1)
481+ original := p.screen.Cell(i, p.row).Rune
482+ cell.resetWithOriginal()
483+ cell.arm(seq, p.predEpoch, now)
484+ cell.originals = append(cell.originals, original)
485+ if i == width-1 || !known {
486+ cell.unknown = true
487+ } else {
488+ cell.replacement = prev
489+ }
490+ }
491+ style := vt.DefaultStyle()
492+ if col > 0 {
493+ if left, known := p.effective(col - 1); known {
494+ style = left.Style
495+ }
496+ }
497+ cell := &p.cells[col]
498+ original := p.screen.Cell(col, p.row).Rune
499+ cell.resetWithOriginal()
500+ cell.arm(seq, p.predEpoch, now)
501+ cell.replacement = vt.Cell{Rune: r, Style: style}
502+ cell.originals = append(cell.originals, original)
503+ if col < width-1 {
504+ p.moveCursor(col+1, seq)
505+ } else {
506+ p.becomeTentative()
507+ p.moveCursor(col, seq)
508+ }
509+}
510+
511+// backspace predicts DEL: the cursor moves left and the text after it moves
512+// left up to the first blank.
513+func (p *echoPredictor) backspace(seq uint64, now time.Time) {
514+ if !p.initCursor() {
515+ p.becomeTentative()
516+ return
517+ }
518+ if p.cursor.col == 0 {
519+ return
520+ }
521+ width := len(p.cells)
522+ col := p.cursor.col - 1
523+ p.moveCursor(col, seq)
524+ for i := col; i < width; i++ {
525+ cell := &p.cells[i]
526+ original := p.screen.Cell(i, p.row).Rune
527+ var next vt.Cell
528+ known := false
529+ if i+2 < width {
530+ next, known = p.effective(i + 1)
531+ }
532+ blank := known && isBlankRune(next.Rune)
533+ cell.resetWithOriginal()
534+ cell.arm(seq, p.predEpoch, now)
535+ cell.originals = append(cell.originals, original)
536+ if !known {
537+ cell.unknown = true
538+ } else {
539+ cell.replacement = next
540+ }
541+ if blank {
542+ break
543+ }
544+ }
545+}
546+
547+// needsTick reports whether a pending guess still waits on a timing trigger
548+// (mosh's timing_tests_necessary), so the caller re-culls without output.
549+func (p *echoPredictor) needsTick() bool {
550+ return p.mode == domain.EchoPredictAdaptive && p.active() && !(p.glitch > 0 && p.flagging)
551+}
552+
553+// render returns the terminal bytes that bring the screen from the last
554+// render to the current guesses: drawn cells that no longer show a guess are
555+// repainted from the mirror, shown guesses are drawn again (a daemon frame
556+// may have overwritten them), and the cursor goes to the predicted position
557+// or back to the real one.
558+func (p *echoPredictor) render() []byte {
559+ var out bytes.Buffer
560+ show := p.displaying() && p.enabled()
561+ var want []int
562+ if show && p.cells != nil && p.row < p.screen.Rows() {
563+ for col := range p.cells {
564+ cell := &p.cells[col]
565+ if !cell.active || cell.unknown || cell.tentative(p.confEpoch) {
566+ continue
567+ }
568+ if p.screen.Cell(col, p.row).Equal(cell.replacement) && !p.flagging {
569+ continue
570+ }
571+ want = append(want, col)
572+ }
573+ }
574+ for _, col := range p.drawn {
575+ if p.drawnRow == p.row && slices.Contains(want, col) {
576+ continue
577+ }
578+ if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() {
579+ writeEchoCell(&out, p.drawnRow, col, p.screen.Cell(col, p.drawnRow), false)
580+ }
581+ }
582+ for _, col := range want {
583+ cell := p.cells[col].replacement
584+ flag := p.flagging && !(isBlankRune(cell.Rune) && isBlankRune(p.screen.Cell(col, p.row).Rune))
585+ writeEchoCell(&out, p.row, col, cell, flag)
586+ }
587+ p.drawn = append(p.drawn[:0], want...)
588+ p.drawnRow = p.row
589+
590+ row, col := p.screen.CursorRow(), p.screen.CursorCol()
591+ moved := false
592+ if show && p.cursor.active && p.cursor.epoch <= p.confEpoch {
593+ moved = p.cursor.row != row || p.cursor.col != col
594+ row, col = p.cursor.row, p.cursor.col
595+ }
596+ if out.Len() > 0 || moved || p.cursorMove {
597+ out.WriteString("\x1b[0m")
598+ writeEchoCUP(&out, row, col)
599+ }
600+ p.cursorMove = moved
601+ return out.Bytes()
602+}
603+
604+func writeEchoCUP(out *bytes.Buffer, row, col int) {
605+ out.WriteString("\x1b[")
606+ out.WriteString(strconv.Itoa(row + 1))
607+ out.WriteByte(';')
608+ out.WriteString(strconv.Itoa(col + 1))
609+ out.WriteByte('H')
610+}
611+
612+// writeEchoCell draws one cell. Mirror styles come from daemon output already
613+// projected to this terminal's color profile, so they are written as is.
614+func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) {
615+ writeEchoCUP(out, row, col)
616+ out.WriteString(echoSGR(cell.Style, underline))
617+ r := cell.Rune
618+ if cell.Continuation || r == 0 || vt.RuneWidth(r) != 1 {
619+ // A wide rune's halves cannot be drawn alone; a blank is the safe
620+ // stand-in until the daemon's next frame.
621+ r = ' '
622+ }
623+ out.WriteRune(r)
624+}
625+
626+func echoSGR(style vt.Style, underline bool) string {
627+ var b strings.Builder
628+ b.WriteString("\x1b[0")
629+ if style.Bold {
630+ b.WriteString(";1")
631+ }
632+ if style.Attrs&vt.AttrDim != 0 {
633+ b.WriteString(";2")
634+ }
635+ if style.Italic {
636+ b.WriteString(";3")
637+ }
638+ if underline || style.Attrs&vt.AttrUnderline != 0 {
639+ b.WriteString(";4")
640+ }
641+ if style.Attrs&vt.AttrBlink != 0 {
642+ b.WriteString(";5")
643+ }
644+ if style.Inverse {
645+ b.WriteString(";7")
646+ }
647+ if style.Attrs&vt.AttrStrikethrough != 0 {
648+ b.WriteString(";9")
649+ }
650+ writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90)
651+ writeEchoColor(&b, style.HasBackgroundRGB, style.BackgroundRGB, style.Background, 48, 40, 100)
652+ b.WriteByte('m')
653+ return b.String()
654+}
655+
656+func writeEchoColor(b *strings.Builder, hasRGB bool, rgb vt.RGB, index, extended, normal, bright int) {
657+ switch {
658+ case hasRGB:
659+ b.WriteString(";" + strconv.Itoa(extended) + ";2;" + strconv.Itoa(int(rgb.R)) + ";" + strconv.Itoa(int(rgb.G)) + ";" + strconv.Itoa(int(rgb.B)))
660+ case index < 0:
661+ case index < 8:
662+ b.WriteString(";" + strconv.Itoa(normal+index))
663+ case index < 16:
664+ b.WriteString(";" + strconv.Itoa(bright+index-8))
665+ case index < 256:
666+ b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index))
667+ }
668+}
A internal/usecase/client/predictive_echo_test.go +320 -0
@@ -0,0 +1,320 @@
1+package client
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+var echoTestStart = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
13+
14+// echoHarness drives one predictor against a 20x3 mirror whose prompt "$ "
15+// leaves the cursor at row 0, column 2.
16+type echoHarness struct {
17+ t *testing.T
18+ p *echoPredictor
19+ now time.Time
20+ seq uint64
21+}
22+
23+func newEchoHarness(t *testing.T, mode domain.EchoPredictMode) *echoHarness {
24+ t.Helper()
25+ h := &echoHarness{t: t, p: newEchoPredictor(mode), now: echoTestStart}
26+ 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)
27+ return h
28+}
29+
30+// warm sends one empty input and acknowledges it after rtt of network time.
31+func (h *echoHarness) warm(rtt time.Duration) {
32+ h.seq++
33+ h.p.input(h.seq, nil, h.now)
34+ h.now = h.now.Add(rtt + echoServerDelay)
35+ h.p.applyOutput(protocol.Output{Epoch: 1, Echo: h.seq}, h.now)
36+}
37+
38+func (h *echoHarness) typed(data string) []byte {
39+ h.seq++
40+ h.p.input(h.seq, []byte(data), h.now)
41+ return h.p.render()
42+}
43+
44+// daemon applies a state frame that writes data and acknowledges echo.
45+func (h *echoHarness) daemon(data string, echo uint64) []byte {
46+ h.now = h.now.Add(time.Millisecond)
47+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Echo: echo, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(data)}, h.now)
48+ return h.p.render()
49+}
50+
51+// confirm types a and lets the daemon echo it, which confirms the first epoch.
52+func (h *echoHarness) confirm() {
53+ h.t.Helper()
54+ if got := h.typed("a"); len(got) != 0 {
55+ h.t.Fatalf("first guess is tentative, rendered %q", got)
56+ }
57+ h.daemon("\x1b[1;3Ha", h.seq)
58+ if h.p.confEpoch == 0 {
59+ h.t.Fatal("echoed guess did not confirm its epoch")
60+ }
61+}
62+
63+func TestEchoPredictorDisplay(t *testing.T) {
64+ t.Parallel()
65+ tests := []struct {
66+ name string
67+ mode domain.EchoPredictMode
68+ rtt time.Duration
69+ want string
70+ }{
71+ {name: "fast link hides guesses", mode: domain.EchoPredictAdaptive, rtt: 10 * time.Millisecond, want: ""},
72+ {name: "slow link shows guesses", mode: domain.EchoPredictAdaptive, rtt: 100 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"},
73+ {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"},
74+ {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"},
75+ {name: "never predicts", mode: domain.EchoPredictNever, rtt: 200 * time.Millisecond, want: ""},
76+ }
77+ for _, tt := range tests {
78+ t.Run(tt.name, func(t *testing.T) {
79+ t.Parallel()
80+ h := newEchoHarness(t, tt.mode)
81+ h.warm(tt.rtt)
82+ if tt.mode != domain.EchoPredictNever {
83+ h.confirm()
84+ }
85+ if got := string(h.typed("b")); got != tt.want {
86+ t.Fatalf("render = %q, want %q", got, tt.want)
87+ }
88+ })
89+ }
90+}
91+
92+func TestEchoPredictorConfirmationRepaintsFromMirror(t *testing.T) {
93+ t.Parallel()
94+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
95+ h.warm(200 * time.Millisecond)
96+ h.confirm()
97+ if got := string(h.typed("b")); !strings.Contains(got, "\x1b[0;4mb") {
98+ t.Fatalf("pending guess is not underlined: %q", got)
99+ }
100+ got := string(h.daemon("\x1b[1;4Hb", h.seq))
101+ if want := "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"; got != want {
102+ t.Fatalf("confirmed render = %q, want %q", got, want)
103+ }
104+ if h.p.active() {
105+ t.Fatal("confirmed guess is still active")
106+ }
107+}
108+
109+func TestEchoPredictorWrongEchoErasesGuess(t *testing.T) {
110+ t.Parallel()
111+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
112+ h.warm(100 * time.Millisecond)
113+ h.confirm()
114+ if got := string(h.typed("p")); !strings.Contains(got, "p") {
115+ t.Fatalf("guess not drawn: %q", got)
116+ }
117+ // A password prompt echoes nothing.
118+ got := string(h.daemon("", h.seq))
119+ if want := "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
120+ t.Fatalf("render after wrong echo = %q, want %q", got, want)
121+ }
122+ if h.p.active() {
123+ t.Fatal("wrong guess survived")
124+ }
125+ // The reset makes the next guess tentative again.
126+ if got := h.typed("q"); len(got) != 0 {
127+ t.Fatalf("guess after reset rendered %q", got)
128+ }
129+}
130+
131+func TestEchoPredictorBackspace(t *testing.T) {
132+ t.Parallel()
133+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
134+ h.warm(100 * time.Millisecond)
135+ h.confirm()
136+ got := string(h.typed("\x7f"))
137+ if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
138+ t.Fatalf("backspace render = %q, want %q", got, want)
139+ }
140+ got = string(h.daemon("\x1b[1;3H \x1b[1;3H", h.seq))
141+ if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
142+ t.Fatalf("confirmed backspace render = %q, want %q", got, want)
143+ }
144+ if h.p.active() {
145+ t.Fatal("confirmed backspace is still active")
146+ }
147+}
148+
149+func TestEchoPredictorInsertShiftsText(t *testing.T) {
150+ t.Parallel()
151+ h := newEchoHarness(t, domain.EchoPredictAlways)
152+ // "$ ac" with the cursor on c.
153+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("ac\x1b[1;4H")}, h.now)
154+ h.p.confEpoch = h.p.predEpoch
155+ got := string(h.typed("b"))
156+ if want := "\x1b[1;4H\x1b[0mb\x1b[1;5H\x1b[0mc\x1b[0m\x1b[1;5H"; got != want {
157+ t.Fatalf("insert render = %q, want %q", got, want)
158+ }
159+}
160+
161+func TestEchoPredictorUnknownInputIsTentative(t *testing.T) {
162+ t.Parallel()
163+ tests := []struct {
164+ name string
165+ input string
166+ }{
167+ {name: "control byte", input: "\x03"},
168+ {name: "escape sequence", input: "\x1b[A"},
169+ {name: "kitty CSI-u key", input: "\x1b[97;5u"},
170+ {name: "carriage return", input: "\r"},
171+ {name: "wide rune", input: "界"},
172+ {name: "bracketed paste", input: "\x1b[200~text\x1b[201~"},
173+ }
174+ for _, tt := range tests {
175+ t.Run(tt.name, func(t *testing.T) {
176+ t.Parallel()
177+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
178+ h.warm(100 * time.Millisecond)
179+ h.confirm()
180+ h.typed(tt.input)
181+ if got := h.typed("z"); len(got) != 0 {
182+ t.Fatalf("guess after %s rendered %q", tt.name, got)
183+ }
184+ })
185+ }
186+}
187+
188+func TestEchoPredictorWrongTentativeGuessKillsItsEpoch(t *testing.T) {
189+ t.Parallel()
190+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
191+ h.warm(100 * time.Millisecond)
192+ h.confirm()
193+ confirmed := h.p.confEpoch
194+ h.typed("\x03z")
195+ // The daemon cleared the line instead of echoing z.
196+ h.daemon("\x1b[1;1H\x1b[2K$ ", h.seq)
197+ if h.p.active() {
198+ t.Fatal("killed epoch left guesses")
199+ }
200+ if h.p.confEpoch != confirmed {
201+ t.Fatalf("confirmed epoch = %d, want %d", h.p.confEpoch, confirmed)
202+ }
203+}
204+
205+func TestEchoPredictorHiddenCursorDisables(t *testing.T) {
206+ t.Parallel()
207+ h := newEchoHarness(t, domain.EchoPredictAlways)
208+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[?25l")}, h.now)
209+ h.p.confEpoch = h.p.predEpoch
210+ if got := h.typed("b"); len(got) != 0 {
211+ t.Fatalf("hidden cursor rendered %q", got)
212+ }
213+ if h.p.active() {
214+ t.Fatal("hidden cursor kept guesses")
215+ }
216+}
217+
218+func TestEchoPredictorSRTTHysteresis(t *testing.T) {
219+ t.Parallel()
220+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
221+ h.warm(200 * time.Millisecond)
222+ if !h.p.srttOn || !h.p.flagging {
223+ t.Fatalf("slow link: srttOn=%v flagging=%v, want both on", h.p.srttOn, h.p.flagging)
224+ }
225+ sawMiddle := false
226+ for range 64 {
227+ h.warm(0)
228+ interval := h.p.sendInterval()
229+ if interval > echoSRTTTriggerLow && interval <= echoSRTTTriggerHigh {
230+ sawMiddle = true
231+ if !h.p.srttOn {
232+ t.Fatalf("display turned off at send interval %v", interval)
233+ }
234+ }
235+ if interval <= echoFlagTriggerLow && h.p.flagging {
236+ t.Fatalf("still flagging at send interval %v", interval)
237+ }
238+ if interval <= echoSRTTTriggerLow {
239+ break
240+ }
241+ }
242+ if !sawMiddle {
243+ t.Fatal("never sampled the hysteresis band")
244+ }
245+ if h.p.srttOn {
246+ t.Fatal("display still on after the link recovered")
247+ }
248+}
249+
250+func TestEchoPredictorSRTTStaysOnWhileGuessesShow(t *testing.T) {
251+ t.Parallel()
252+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
253+ h.warm(100 * time.Millisecond)
254+ h.confirm()
255+ h.typed("b")
256+ // Fast acknowledgements of other input keep the b guess pending.
257+ h.p.srtt = 0
258+ h.p.cull(h.now)
259+ if !h.p.srttOn {
260+ t.Fatal("display turned off while a guess is shown")
261+ }
262+}
263+
264+func TestEchoPredictorGlitchShowsAndFlags(t *testing.T) {
265+ t.Parallel()
266+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
267+ h.warm(0)
268+ h.confirm()
269+ if got := h.typed("b"); len(got) != 0 {
270+ t.Fatalf("fast link rendered %q", got)
271+ }
272+ if !h.p.needsTick() {
273+ t.Fatal("pending guess needs no timing tick")
274+ }
275+ h.now = h.now.Add(echoGlitchThreshold)
276+ h.p.cull(h.now)
277+ if got := string(h.p.render()); got != "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H" {
278+ t.Fatalf("glitch render = %q", got)
279+ }
280+ h.now = h.now.Add(echoGlitchFlagThreshold)
281+ h.p.cull(h.now)
282+ h.p.cull(h.now)
283+ if !h.p.flagging {
284+ t.Fatal("long glitch does not underline")
285+ }
286+ if h.p.needsTick() {
287+ t.Fatal("timing tick still armed once every trigger fired")
288+ }
289+}
290+
291+func TestEchoPredictorResizeForgetsGuesses(t *testing.T) {
292+ t.Parallel()
293+ h := newEchoHarness(t, domain.EchoPredictAlways)
294+ h.warm(100 * time.Millisecond)
295+ h.confirm()
296+ h.typed("b")
297+ 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)
298+ if h.p.active() || len(h.p.drawn) != 0 {
299+ t.Fatal("resize kept guesses")
300+ }
301+ if got := h.p.render(); len(got) != 0 {
302+ t.Fatalf("render after resize = %q", got)
303+ }
304+}
305+
306+func TestEchoSGR(t *testing.T) {
307+ t.Parallel()
308+ style := newEchoHarness(t, domain.EchoPredictAlways).p.screen.Cell(0, 0).Style
309+ style.Bold = true
310+ style.Foreground = 9
311+ style.Background = 200
312+ if got, want := echoSGR(style, true), "\x1b[0;1;4;91;48;5;200m"; got != want {
313+ t.Fatalf("echoSGR = %q, want %q", got, want)
314+ }
315+ style.HasForegroundRGB = true
316+ style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B = 1, 2, 3
317+ if got, want := echoSGR(style, false), "\x1b[0;1;38;2;1;2;3;48;5;200m"; got != want {
318+ t.Fatalf("echoSGR rgb = %q, want %q", got, want)
319+ }
320+}
@@ -0,0 +1,320 @@
1+package client
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+var echoTestStart = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
13+
14+// echoHarness drives one predictor against a 20x3 mirror whose prompt "$ "
15+// leaves the cursor at row 0, column 2.
16+type echoHarness struct {
17+ t *testing.T
18+ p *echoPredictor
19+ now time.Time
20+ seq uint64
21+}
22+
23+func newEchoHarness(t *testing.T, mode domain.EchoPredictMode) *echoHarness {
24+ t.Helper()
25+ h := &echoHarness{t: t, p: newEchoPredictor(mode), now: echoTestStart}
26+ 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)
27+ return h
28+}
29+
30+// warm sends one empty input and acknowledges it after rtt of network time.
31+func (h *echoHarness) warm(rtt time.Duration) {
32+ h.seq++
33+ h.p.input(h.seq, nil, h.now)
34+ h.now = h.now.Add(rtt + echoServerDelay)
35+ h.p.applyOutput(protocol.Output{Epoch: 1, Echo: h.seq}, h.now)
36+}
37+
38+func (h *echoHarness) typed(data string) []byte {
39+ h.seq++
40+ h.p.input(h.seq, []byte(data), h.now)
41+ return h.p.render()
42+}
43+
44+// daemon applies a state frame that writes data and acknowledges echo.
45+func (h *echoHarness) daemon(data string, echo uint64) []byte {
46+ h.now = h.now.Add(time.Millisecond)
47+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Echo: echo, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(data)}, h.now)
48+ return h.p.render()
49+}
50+
51+// confirm types a and lets the daemon echo it, which confirms the first epoch.
52+func (h *echoHarness) confirm() {
53+ h.t.Helper()
54+ if got := h.typed("a"); len(got) != 0 {
55+ h.t.Fatalf("first guess is tentative, rendered %q", got)
56+ }
57+ h.daemon("\x1b[1;3Ha", h.seq)
58+ if h.p.confEpoch == 0 {
59+ h.t.Fatal("echoed guess did not confirm its epoch")
60+ }
61+}
62+
63+func TestEchoPredictorDisplay(t *testing.T) {
64+ t.Parallel()
65+ tests := []struct {
66+ name string
67+ mode domain.EchoPredictMode
68+ rtt time.Duration
69+ want string
70+ }{
71+ {name: "fast link hides guesses", mode: domain.EchoPredictAdaptive, rtt: 10 * time.Millisecond, want: ""},
72+ {name: "slow link shows guesses", mode: domain.EchoPredictAdaptive, rtt: 100 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"},
73+ {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"},
74+ {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"},
75+ {name: "never predicts", mode: domain.EchoPredictNever, rtt: 200 * time.Millisecond, want: ""},
76+ }
77+ for _, tt := range tests {
78+ t.Run(tt.name, func(t *testing.T) {
79+ t.Parallel()
80+ h := newEchoHarness(t, tt.mode)
81+ h.warm(tt.rtt)
82+ if tt.mode != domain.EchoPredictNever {
83+ h.confirm()
84+ }
85+ if got := string(h.typed("b")); got != tt.want {
86+ t.Fatalf("render = %q, want %q", got, tt.want)
87+ }
88+ })
89+ }
90+}
91+
92+func TestEchoPredictorConfirmationRepaintsFromMirror(t *testing.T) {
93+ t.Parallel()
94+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
95+ h.warm(200 * time.Millisecond)
96+ h.confirm()
97+ if got := string(h.typed("b")); !strings.Contains(got, "\x1b[0;4mb") {
98+ t.Fatalf("pending guess is not underlined: %q", got)
99+ }
100+ got := string(h.daemon("\x1b[1;4Hb", h.seq))
101+ if want := "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"; got != want {
102+ t.Fatalf("confirmed render = %q, want %q", got, want)
103+ }
104+ if h.p.active() {
105+ t.Fatal("confirmed guess is still active")
106+ }
107+}
108+
109+func TestEchoPredictorWrongEchoErasesGuess(t *testing.T) {
110+ t.Parallel()
111+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
112+ h.warm(100 * time.Millisecond)
113+ h.confirm()
114+ if got := string(h.typed("p")); !strings.Contains(got, "p") {
115+ t.Fatalf("guess not drawn: %q", got)
116+ }
117+ // A password prompt echoes nothing.
118+ got := string(h.daemon("", h.seq))
119+ if want := "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
120+ t.Fatalf("render after wrong echo = %q, want %q", got, want)
121+ }
122+ if h.p.active() {
123+ t.Fatal("wrong guess survived")
124+ }
125+ // The reset makes the next guess tentative again.
126+ if got := h.typed("q"); len(got) != 0 {
127+ t.Fatalf("guess after reset rendered %q", got)
128+ }
129+}
130+
131+func TestEchoPredictorBackspace(t *testing.T) {
132+ t.Parallel()
133+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
134+ h.warm(100 * time.Millisecond)
135+ h.confirm()
136+ got := string(h.typed("\x7f"))
137+ if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
138+ t.Fatalf("backspace render = %q, want %q", got, want)
139+ }
140+ got = string(h.daemon("\x1b[1;3H \x1b[1;3H", h.seq))
141+ if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
142+ t.Fatalf("confirmed backspace render = %q, want %q", got, want)
143+ }
144+ if h.p.active() {
145+ t.Fatal("confirmed backspace is still active")
146+ }
147+}
148+
149+func TestEchoPredictorInsertShiftsText(t *testing.T) {
150+ t.Parallel()
151+ h := newEchoHarness(t, domain.EchoPredictAlways)
152+ // "$ ac" with the cursor on c.
153+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("ac\x1b[1;4H")}, h.now)
154+ h.p.confEpoch = h.p.predEpoch
155+ got := string(h.typed("b"))
156+ if want := "\x1b[1;4H\x1b[0mb\x1b[1;5H\x1b[0mc\x1b[0m\x1b[1;5H"; got != want {
157+ t.Fatalf("insert render = %q, want %q", got, want)
158+ }
159+}
160+
161+func TestEchoPredictorUnknownInputIsTentative(t *testing.T) {
162+ t.Parallel()
163+ tests := []struct {
164+ name string
165+ input string
166+ }{
167+ {name: "control byte", input: "\x03"},
168+ {name: "escape sequence", input: "\x1b[A"},
169+ {name: "kitty CSI-u key", input: "\x1b[97;5u"},
170+ {name: "carriage return", input: "\r"},
171+ {name: "wide rune", input: "界"},
172+ {name: "bracketed paste", input: "\x1b[200~text\x1b[201~"},
173+ }
174+ for _, tt := range tests {
175+ t.Run(tt.name, func(t *testing.T) {
176+ t.Parallel()
177+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
178+ h.warm(100 * time.Millisecond)
179+ h.confirm()
180+ h.typed(tt.input)
181+ if got := h.typed("z"); len(got) != 0 {
182+ t.Fatalf("guess after %s rendered %q", tt.name, got)
183+ }
184+ })
185+ }
186+}
187+
188+func TestEchoPredictorWrongTentativeGuessKillsItsEpoch(t *testing.T) {
189+ t.Parallel()
190+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
191+ h.warm(100 * time.Millisecond)
192+ h.confirm()
193+ confirmed := h.p.confEpoch
194+ h.typed("\x03z")
195+ // The daemon cleared the line instead of echoing z.
196+ h.daemon("\x1b[1;1H\x1b[2K$ ", h.seq)
197+ if h.p.active() {
198+ t.Fatal("killed epoch left guesses")
199+ }
200+ if h.p.confEpoch != confirmed {
201+ t.Fatalf("confirmed epoch = %d, want %d", h.p.confEpoch, confirmed)
202+ }
203+}
204+
205+func TestEchoPredictorHiddenCursorDisables(t *testing.T) {
206+ t.Parallel()
207+ h := newEchoHarness(t, domain.EchoPredictAlways)
208+ h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[?25l")}, h.now)
209+ h.p.confEpoch = h.p.predEpoch
210+ if got := h.typed("b"); len(got) != 0 {
211+ t.Fatalf("hidden cursor rendered %q", got)
212+ }
213+ if h.p.active() {
214+ t.Fatal("hidden cursor kept guesses")
215+ }
216+}
217+
218+func TestEchoPredictorSRTTHysteresis(t *testing.T) {
219+ t.Parallel()
220+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
221+ h.warm(200 * time.Millisecond)
222+ if !h.p.srttOn || !h.p.flagging {
223+ t.Fatalf("slow link: srttOn=%v flagging=%v, want both on", h.p.srttOn, h.p.flagging)
224+ }
225+ sawMiddle := false
226+ for range 64 {
227+ h.warm(0)
228+ interval := h.p.sendInterval()
229+ if interval > echoSRTTTriggerLow && interval <= echoSRTTTriggerHigh {
230+ sawMiddle = true
231+ if !h.p.srttOn {
232+ t.Fatalf("display turned off at send interval %v", interval)
233+ }
234+ }
235+ if interval <= echoFlagTriggerLow && h.p.flagging {
236+ t.Fatalf("still flagging at send interval %v", interval)
237+ }
238+ if interval <= echoSRTTTriggerLow {
239+ break
240+ }
241+ }
242+ if !sawMiddle {
243+ t.Fatal("never sampled the hysteresis band")
244+ }
245+ if h.p.srttOn {
246+ t.Fatal("display still on after the link recovered")
247+ }
248+}
249+
250+func TestEchoPredictorSRTTStaysOnWhileGuessesShow(t *testing.T) {
251+ t.Parallel()
252+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
253+ h.warm(100 * time.Millisecond)
254+ h.confirm()
255+ h.typed("b")
256+ // Fast acknowledgements of other input keep the b guess pending.
257+ h.p.srtt = 0
258+ h.p.cull(h.now)
259+ if !h.p.srttOn {
260+ t.Fatal("display turned off while a guess is shown")
261+ }
262+}
263+
264+func TestEchoPredictorGlitchShowsAndFlags(t *testing.T) {
265+ t.Parallel()
266+ h := newEchoHarness(t, domain.EchoPredictAdaptive)
267+ h.warm(0)
268+ h.confirm()
269+ if got := h.typed("b"); len(got) != 0 {
270+ t.Fatalf("fast link rendered %q", got)
271+ }
272+ if !h.p.needsTick() {
273+ t.Fatal("pending guess needs no timing tick")
274+ }
275+ h.now = h.now.Add(echoGlitchThreshold)
276+ h.p.cull(h.now)
277+ if got := string(h.p.render()); got != "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H" {
278+ t.Fatalf("glitch render = %q", got)
279+ }
280+ h.now = h.now.Add(echoGlitchFlagThreshold)
281+ h.p.cull(h.now)
282+ h.p.cull(h.now)
283+ if !h.p.flagging {
284+ t.Fatal("long glitch does not underline")
285+ }
286+ if h.p.needsTick() {
287+ t.Fatal("timing tick still armed once every trigger fired")
288+ }
289+}
290+
291+func TestEchoPredictorResizeForgetsGuesses(t *testing.T) {
292+ t.Parallel()
293+ h := newEchoHarness(t, domain.EchoPredictAlways)
294+ h.warm(100 * time.Millisecond)
295+ h.confirm()
296+ h.typed("b")
297+ 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)
298+ if h.p.active() || len(h.p.drawn) != 0 {
299+ t.Fatal("resize kept guesses")
300+ }
301+ if got := h.p.render(); len(got) != 0 {
302+ t.Fatalf("render after resize = %q", got)
303+ }
304+}
305+
306+func TestEchoSGR(t *testing.T) {
307+ t.Parallel()
308+ style := newEchoHarness(t, domain.EchoPredictAlways).p.screen.Cell(0, 0).Style
309+ style.Bold = true
310+ style.Foreground = 9
311+ style.Background = 200
312+ if got, want := echoSGR(style, true), "\x1b[0;1;4;91;48;5;200m"; got != want {
313+ t.Fatalf("echoSGR = %q, want %q", got, want)
314+ }
315+ style.HasForegroundRGB = true
316+ style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B = 1, 2, 3
317+ if got, want := echoSGR(style, false), "\x1b[0;1;38;2;1;2;3;48;5;200m"; got != want {
318+ t.Fatalf("echoSGR rgb = %q, want %q", got, want)
319+ }
320+}
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

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