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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
17 files · +1310 -22
M AGENTS.md +1 -1
| @@ -43,7 +43,7 @@ Layer map: | |||
| 43 | 43 | - `internal/ports`: typed application connections and infrastructure-facing use-case seams. | |
| 44 | 44 | - `internal/adapters/sessionwire`: typed message ↔ raw frame adaptation. | |
| 45 | 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 | 47 | - `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, clock, and observability implementations. | |
| 48 | 48 | ||
| 49 | 49 | 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 | |||
| 93 | 93 | ||
| 94 | 94 | ### 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 | 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 | 100 | ## 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 focus | 94 | ### 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 composition | 100 | ## Broker-owned hosts and daemon composition | ||
M docs/configuration.md +4 -0
| @@ -46,6 +46,10 @@ keyboard.kitty-protocol = on | |||
| 46 | 46 | # Outer terminal colors: auto (detect), truecolor, 256, 16, or mono. | |
| 47 | 47 | 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 | 53 | # Rebindable actions. Leave a line out to keep its built-in binding. | |
| 50 | 54 | open-palette = alt+space | |
| 51 | 55 | 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 = auto | 47 | 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+space | 54 | open-palette = alt+space | ||
| 51 | toggle-floating-pane = alt+f | 55 | toggle-floating-pane = alt+f | ||
M docs/protocol.md +5 -2
| @@ -162,8 +162,11 @@ base. `ValidateOutput` enforces the epoch/base/new chain | |||
| 162 | 162 | - The client coalesces ACKs (`client.go:cumulativeAckQueue`) and keeps | |
| 163 | 163 | applying + acknowledging daemon frames even while a picker lease owns | |
| 164 | 164 | 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 | |
| 167 | 170 | operations and `UIReceipt` reports the processed boundary | |
| 168 | 171 | (`Processed` requires epoch/state/publication; `Unavailable` carries | |
| 169 | 172 | none). | |
| @@ -162,8 +162,11 @@ base. `ValidateOutput` enforces the epoch/base/new chain | |||||
| 162 | - The client coalesces ACKs (`client.go:cumulativeAckQueue`) and keeps | 162 | - The client coalesces ACKs (`client.go:cumulativeAckQueue`) and keeps | ||
| 163 | applying + acknowledging daemon frames even while a picker lease owns | 163 | 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 UI | 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 | |||
| 167 | operations and `UIReceipt` reports the processed boundary | 170 | operations and `UIReceipt` reports the processed boundary | ||
| 168 | (`Processed` requires epoch/state/publication; `Unavailable` carries | 171 | (`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 | |||
| 32 | 32 | ||
| 33 | 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 | 39 | ## Connection states | |
| 36 | 40 | ||
| 37 | 41 | | 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 states | 39 | ## 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) { | |||
| 38 | 38 | seenTabsKeys := make(map[string]bool) | |
| 39 | 39 | seenEphemeralKeys := make(map[string]bool) | |
| 40 | 40 | seenKeyboardKeys := make(map[string]bool) | |
| 41 | + | seenEchoKeys := make(map[string]bool) | |
| 41 | 42 | seenTerminalKeys := make(map[string]bool) | |
| 42 | 43 | seenScrollbackKeys := make(map[string]bool) | |
| 43 | 44 | seenWebKeys := make(map[string]bool) | |
| @@ -173,6 +174,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 173 | 174 | continue | |
| 174 | 175 | } | |
| 175 | 176 | 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 | 185 | case key == "terminal.colors": | |
| 177 | 186 | warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo) | |
| 178 | 187 | mode, auto, err := terminalcap.ParseColorMode(value) | |
| @@ -340,6 +349,18 @@ func fileStamp(path string) (stamp, error) { | |||
| 340 | 349 | 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 | 364 | func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning { | |
| 344 | 365 | if seen[key] { | |
| 345 | 366 | 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 | continue | 174 | continue | ||
| 174 | } | 175 | } | ||
| 175 | cfg.Keyboard.KittyProtocol = on | 176 | 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}, nil | 349 | 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) { | |||
| 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 | 615 | func TestParseNavigationOverflow(t *testing.T) { | |
| 567 | 616 | t.Parallel() | |
| 568 | 617 | ||
| @@ -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 | |||
| 698 | 698 | env := userTerminalAttachmentEnvironment(log, cfg.Terminal) | |
| 699 | 699 | env.ProbeTerminal = true | |
| 700 | 700 | env.KittyKeyboard = cfg.Keyboard.KittyProtocol | |
| 701 | + | env.EchoPredict = cfg.Echo.Predict | |
| 701 | 702 | return env | |
| 702 | 703 | } | |
| 703 | 704 | ||
| @@ -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 = true | 699 | env.ProbeTerminal = true | ||
| 700 | env.KittyKeyboard = cfg.Keyboard.KittyProtocol | 700 | env.KittyKeyboard = cfg.Keyboard.KittyProtocol | ||
| 701 | + | env.EchoPredict = cfg.Echo.Predict | |||
| 701 | return env | 702 | return env | ||
| 702 | } | 703 | } | ||
| 703 | 704 | ||||
M internal/domain/config.go +20 -0
| @@ -102,6 +102,25 @@ type KeyboardConfig struct { | |||
| 102 | 102 | 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 | 124 | // TerminalConfig contains client-side outer-terminal settings. It is read by | |
| 106 | 125 | // each client at startup and never applied by the daemon, so a change takes | |
| 107 | 126 | // effect on the next client process. | |
| @@ -153,6 +172,7 @@ type Config struct { | |||
| 153 | 172 | Tabs TabsConfig | |
| 154 | 173 | Ephemeral EphemeralConfig | |
| 155 | 174 | Keyboard KeyboardConfig | |
| 175 | + | Echo EchoConfig | |
| 156 | 176 | Terminal TerminalConfig | |
| 157 | 177 | Scrollback ScrollbackConfig | |
| 158 | 178 | } | |
| @@ -102,6 +102,25 @@ type KeyboardConfig struct { | |||||
| 102 | KittyProtocol bool | 102 | 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 by | 124 | // 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 takes | 125 | // 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 TabsConfig | 172 | Tabs TabsConfig | ||
| 154 | Ephemeral EphemeralConfig | 173 | Ephemeral EphemeralConfig | ||
| 155 | Keyboard KeyboardConfig | 174 | Keyboard KeyboardConfig | ||
| 175 | + | Echo EchoConfig | |||
| 156 | Terminal TerminalConfig | 176 | Terminal TerminalConfig | ||
| 157 | Scrollback ScrollbackConfig | 177 | 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 { | |||
| 33 | 33 | fg AttachmentForeground | |
| 34 | 34 | stream ports.BrokerLogicalConnection | |
| 35 | 35 | picker *attachmentMovePicker | |
| 36 | + | echo *attachmentEcho | |
| 36 | 37 | clock ports.Clock | |
| 37 | 38 | seq uint64 | |
| 38 | 39 | ||
| @@ -99,12 +100,13 @@ func (d *paletteDeadline) fired(kind paletteGenerationEventKind) paletteGenerati | |||
| 99 | 100 | 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 | 104 | in := &attachmentInput{ | |
| 104 | 105 | worker: w, | |
| 105 | 106 | fg: fg, | |
| 106 | 107 | stream: stream, | |
| 107 | 108 | picker: picker, | |
| 109 | + | echo: echo, | |
| 108 | 110 | clock: w.cfg.Clock, | |
| 109 | 111 | wake: make(chan struct{}, 1), | |
| 110 | 112 | themes: w.cfg.Theme, | |
| @@ -311,6 +313,11 @@ func (in *attachmentInput) flush(ctx context.Context) error { | |||
| 311 | 313 | if err := in.send(ctx, message); err != nil { | |
| 312 | 314 | 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 | 322 | return nil | |
| 316 | 323 | } | |
| @@ -33,6 +33,7 @@ type attachmentInput struct { | |||||
| 33 | fg AttachmentForeground | 33 | fg AttachmentForeground | ||
| 34 | stream ports.BrokerLogicalConnection | 34 | stream ports.BrokerLogicalConnection | ||
| 35 | picker *attachmentMovePicker | 35 | picker *attachmentMovePicker | ||
| 36 | + | echo *attachmentEcho | |||
| 36 | clock ports.Clock | 37 | clock ports.Clock | ||
| 37 | seq uint64 | 38 | seq uint64 | ||
| 38 | 39 | ||||
| @@ -99,12 +100,13 @@ func (d *paletteDeadline) fired(kind paletteGenerationEventKind) paletteGenerati | |||||
| 99 | return event | 100 | 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 err | 314 | 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 nil | 322 | return nil | ||
| 316 | } | 323 | } | ||
M internal/usecase/client/attachment_session.go +37 -18
| @@ -99,6 +99,8 @@ type sessionAttachmentConfig struct { | |||
| 99 | 99 | Tab attachmentTab | |
| 100 | 100 | // Tabs retains this client's committed tab per authority and session. | |
| 101 | 101 | Tabs *sessionTabMemory | |
| 102 | + | // EchoPredict selects predictive local echo on remote attachments. | |
| 103 | + | EchoPredict domain.EchoPredictMode | |
| 102 | 104 | } | |
| 103 | 105 | ||
| 104 | 106 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | |
| @@ -191,35 +193,35 @@ func (w *sessionAttachmentWorker) Run(ctx context.Context, fg AttachmentForegrou | |||
| 191 | 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 | 197 | if event != nil { | |
| 196 | 198 | 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 | 204 | // 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) { | |
| 206 | 208 | state := outputApplyState{} | |
| 207 | 209 | for { | |
| 208 | 210 | message, err := w.awaitServer(ctx, fg, incoming) | |
| 209 | 211 | if err != nil { | |
| 210 | 212 | event := w.settle(ctx, fg, stream, token, err) | |
| 211 | - | return state, &event | |
| 213 | + | return state, protocol.Output{}, &event | |
| 212 | 214 | } | |
| 213 | 215 | switch typed := message.(type) { | |
| 214 | 216 | case protocol.Output: | |
| 215 | 217 | next, ok := state.next(typed) | |
| 216 | 218 | if !ok { | |
| 217 | 219 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentPublication} | |
| 218 | - | return state, &event | |
| 220 | + | return state, protocol.Output{}, &event | |
| 219 | 221 | } | |
| 220 | 222 | if err := w.validatePublication(typed); err != nil { | |
| 221 | 223 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | |
| 222 | - | return state, &event | |
| 224 | + | return state, protocol.Output{}, &event | |
| 223 | 225 | } | |
| 224 | 226 | state = next | |
| 225 | 227 | w.noteCommitted(fg, state.context) | |
| @@ -229,11 +231,11 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||
| 229 | 231 | // that write, flush, and commit succeeded. | |
| 230 | 232 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusConnecting), typed.Data); err != nil { | |
| 231 | 233 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing initial output: %w", err)} | |
| 232 | - | return state, &event | |
| 234 | + | return state, protocol.Output{}, &event | |
| 233 | 235 | } | |
| 234 | 236 | if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil { | |
| 235 | 237 | event := w.settle(ctx, fg, stream, token, err) | |
| 236 | - | return state, &event | |
| 238 | + | return state, protocol.Output{}, &event | |
| 237 | 239 | } | |
| 238 | 240 | // Attachment is the committed publication. Complete the deadline | |
| 239 | 241 | // boundary before presenting attached, so attached always implies the | |
| @@ -241,14 +243,14 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||
| 241 | 243 | if w.cfg.BeforeAttached != nil { | |
| 242 | 244 | if err := w.cfg.BeforeAttached(); err != nil { | |
| 243 | 245 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | |
| 244 | - | return state, &event | |
| 246 | + | return state, protocol.Output{}, &event | |
| 245 | 247 | } | |
| 246 | 248 | } | |
| 247 | 249 | // A refused marker means the grant was revoked underneath us, so the | |
| 248 | 250 | // run ends as a failure. | |
| 249 | 251 | if !fg.MarkAttached() { | |
| 250 | 252 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentForegroundRevoked} | |
| 251 | - | return state, &event | |
| 253 | + | return state, protocol.Output{}, &event | |
| 252 | 254 | } | |
| 253 | 255 | // The attached presentation is published last, once the committed frame and | |
| 254 | 256 | // 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 | 259 | // drained. | |
| 258 | 260 | if err := fg.PublishAttached(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached)); err != nil { | |
| 259 | 261 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing attached presentation: %w", err)} | |
| 260 | - | return state, &event | |
| 262 | + | return state, protocol.Output{}, &event | |
| 261 | 263 | } | |
| 262 | - | return state, nil | |
| 264 | + | return state, typed, nil | |
| 263 | 265 | case protocol.ErrorMsg: | |
| 264 | 266 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | |
| 265 | - | return state, &event | |
| 267 | + | return state, protocol.Output{}, &event | |
| 266 | 268 | case protocol.Detached: | |
| 267 | 269 | // The daemon ended the attempt before publishing anything: it | |
| 268 | 270 | // never attached. | |
| 269 | 271 | event := AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | |
| 270 | - | return state, &event | |
| 272 | + | return state, protocol.Output{}, &event | |
| 271 | 273 | default: | |
| 272 | 274 | // Everything else the daemon may publish before the initial frame | |
| 273 | 275 | // stays outside this slice's scope and is ignored. | |
| @@ -301,7 +303,7 @@ var ( | |||
| 301 | 303 | ||
| 302 | 304 | // pumpAttached publishes output and concurrently forwards the foreground's | |
| 303 | 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 | 307 | pumpCtx, cancel := context.WithCancel(ctx) | |
| 306 | 308 | defer cancel() | |
| 307 | 309 | // 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 | 331 | samePeerUI := attachmentSamePeerUI(fg) | |
| 330 | 332 | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.Color)} | |
| 331 | 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 | 338 | defer input.close() | |
| 334 | 339 | if err := input.start(ctx); err != nil { | |
| 335 | 340 | return w.settle(ctx, fg, stream, token, err) | |
| @@ -437,6 +442,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 437 | 442 | if err := input.completionFired(ctx); err != nil { | |
| 438 | 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 | 449 | case <-repaint: | |
| 441 | 450 | // An overlay released the terminal: the picker box is still on screen | |
| 442 | 451 | // 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 | 500 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | |
| 492 | 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 | 507 | if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil { | |
| 495 | 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 | 639 | event.message = protocol.Input{InputSeq: input.nextSeq(), ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)} | |
| 627 | 640 | } | |
| 628 | 641 | if resize, ok := event.message.(protocol.Resize); ok { | |
| 642 | + | echo.resized() | |
| 629 | 643 | if err := picker.resize(ctx, state, resize.Size); err != nil { | |
| 630 | 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 | 659 | if err := await(from, event.input.actionID); err != nil { | |
| 646 | 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 | 668 | if event.action != 0 { | |
| 650 | 669 | // The lifecycle message must reach the daemon before the | |
| @@ -99,6 +99,8 @@ type sessionAttachmentConfig struct { | |||||
| 99 | Tab attachmentTab | 99 | 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 *sessionTabMemory | 101 | 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 logical | 106 | // 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 *event | 198 | 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 full | 204 | // awaitInitialPublication consumes server messages until the first valid full | ||
| 203 | - | // output frame is committed. It returns the output state to continue with, or | 205 | + | // 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, &event | 213 | + | 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, &event | 220 | + | 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, &event | 224 | + | return state, protocol.Output{}, &event |
| 223 | } | 225 | } | ||
| 224 | state = next | 226 | 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, &event | 234 | + | 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, &event | 238 | + | return state, protocol.Output{}, &event |
| 237 | } | 239 | } | ||
| 238 | // Attachment is the committed publication. Complete the deadline | 240 | // Attachment is the committed publication. Complete the deadline | ||
| 239 | // boundary before presenting attached, so attached always implies the | 241 | // 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, &event | 246 | + | return state, protocol.Output{}, &event |
| 245 | } | 247 | } | ||
| 246 | } | 248 | } | ||
| 247 | // A refused marker means the grant was revoked underneath us, so the | 249 | // 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, &event | 253 | + | return state, protocol.Output{}, &event |
| 252 | } | 254 | } | ||
| 253 | // The attached presentation is published last, once the committed frame and | 255 | // The attached presentation is published last, once the committed frame and | ||
| 254 | // the attached marker are in place. It carries the real action generation | 256 | // 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, &event | 262 | + | return state, protocol.Output{}, &event |
| 261 | } | 263 | } | ||
| 262 | - | return state, nil | 264 | + | 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, &event | 267 | + | return state, protocol.Output{}, &event |
| 266 | case protocol.Detached: | 268 | case protocol.Detached: | ||
| 267 | // The daemon ended the attempt before publishing anything: it | 269 | // 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, &event | 272 | + | return state, protocol.Output{}, &event |
| 271 | default: | 273 | default: | ||
| 272 | // Everything else the daemon may publish before the initial frame | 274 | // 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's | 304 | // 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 link | 309 | // 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 screen | 450 | // An overlay released the terminal: the picker box is still on screen | ||
| 442 | // and suppressed frames were never written, so ask the daemon for an | 451 | // 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 the | 669 | // 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 { | |||
| 997 | 997 | 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 | 1047 | // attachmentRun is one in-flight worker generation owned by the supervisor. | |
| 1001 | 1048 | type attachmentRun struct { | |
| 1002 | 1049 | host *attachmentHost | |
| @@ -997,6 +997,53 @@ func (f *attachmentForeground) writeTerminalQuery(data []byte) error { | |||||
| 997 | return err | 997 | 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 *attachmentHost | 1049 | 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 { | |||
| 396 | 396 | // KittyKeyboard enables the kitty keyboard protocol when the probe finds | |
| 397 | 397 | // it, which makes Ctrl+1..9 recent-session switching available. | |
| 398 | 398 | KittyKeyboard bool | |
| 399 | + | // EchoPredict selects predictive local echo on remote attachments. | |
| 400 | + | EchoPredict domain.EchoPredictMode | |
| 399 | 401 | } | |
| 400 | 402 | ||
| 401 | 403 | // AttachmentLifecycleAction is an explicit process/attachment decision. | |
| @@ -396,6 +396,8 @@ type AttachmentEnvironment struct { | |||||
| 396 | // KittyKeyboard enables the kitty keyboard protocol when the probe finds | 396 | // 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 bool | 398 | 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, | |||
| 357 | 357 | Clipboard: s.cfg.Clipboard, | |
| 358 | 358 | Tab: tab, | |
| 359 | 359 | Tabs: &s.tabs, | |
| 360 | + | EchoPredict: environment.EchoPredict, | |
| 360 | 361 | }) | |
| 361 | 362 | if err != nil { | |
| 362 | 363 | 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, err | 363 | return nil, err | ||