detached dee642a

Merge remote-tracking branch 'origin/main' into feat/rnt-rename-tab

commit
dee642a691160b8c4f8f624de5753f0cdcf15ef8
author
brice · · 2026-07-09 18:18 UTC
merge of
fb3dc13 + 7cc07d9 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
183 files · +32553 -2030
A .github/workflows/ci.yml +34 -0
@@ -0,0 +1,34 @@
1+name: ci
2+
3+on:
4+ push:
5+ branches: [main]
6+ pull_request:
7+
8+concurrency:
9+ group: ${{ github.workflow }}-${{ github.ref }}
10+ cancel-in-progress: true
11+
12+permissions:
13+ contents: read
14+
15+jobs:
16+ test:
17+ name: Test
18+ runs-on: ubuntu-latest
19+ timeout-minutes: 15
20+ steps:
21+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
22+ with:
23+ persist-credentials: false
24+ - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
25+ with:
26+ go-version-file: go.mod
27+ - name: Install goimports
28+ run: go install golang.org/x/tools/cmd/goimports@v0.47.0
29+ - name: Build
30+ run: go build -o vev .
31+ - name: Lint
32+ run: make lint
33+ - name: Test
34+ run: make test
M .mockery.yml +7 -0
@@ -28,3 +28,10 @@ packages:
2828 dir: internal/ports/mocks
2929 filename: mocks.go
3030 pkgname: portsmocks
31+ github.com/bnema/vev/internal/usecase/command:
32+ interfaces:
33+ Context:
34+ config:
35+ dir: internal/usecase/command
36+ filename: context_mock_test.go
37+ pkgname: command
A AGENTS.md +78 -0
@@ -0,0 +1,78 @@
1+# vev agent guide
2+
3+## Project
4+
5+vev is a Linux terminal multiplexer written in Go. It has a per-user daemon, a thin client, server-side rendering, and minimal socket/SSH diffs.
6+
7+## Commands
8+
9+```sh
10+go build -o vev .
11+make test
12+go test ./internal/usecase/daemon -race -run TestName
13+make lint
14+make mocks
15+go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem
16+```
17+
18+- Format with **goimports**.
19+- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v2@latest`.
20+- `make test` runs `go test ./... -race`.
21+- `make lint` checks `goimports -l`, then `go vet`.
22+- Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`.
23+- Set `VEV_LOG=debug|warn|error` to change log verbosity (default info).
24+- Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset.
25+- Use feature worktrees under `.worktrees/<branch-name>`. Remove the worktree once its branch is merged or abandoned (`git worktree remove <path>`).
26+- Create vev-owned private directories with `pkg/safedir.EnsurePrivate`, not `os.MkdirAll`.
27+
28+## Architecture rules
29+
30+Hexagonal boundaries are enforced by `boundary_test.go`.
31+
32+- `pkg/` is reusable and must not import `internal/`.
33+- `internal/usecase/` must not import `internal/adapters/`.
34+- Usecases depend on `internal/ports` and `internal/domain`.
35+
36+Layer map:
37+
38+- `main.go` → `internal/app`: CLI parsing, wiring, daemon startup, hidden subcommands.
39+- `internal/ports`: interfaces and wire protocol.
40+- `internal/usecase/daemon`: sessions, tabs, panes, VT screens, renderer shadows, daemon features.
41+- `internal/usecase/client`: raw-mode thin client; writes output bytes verbatim and interprets nothing.
42+- `internal/adapters`: IPC, SSH stdio, PTY, terminal, and clock implementations.
43+
44+Before touching daemon teardown paths, read the lock-ordering notes at the top of `internal/usecase/daemon/client.go`.
45+
46+## Ports and mocks
47+
48+- Define cross-layer interfaces only in `internal/ports`.
49+- Adapters implement ports; usecases and `internal/app` consume ports.
50+- Tests should prefer generated `portsmocks.MockX` with `.EXPECT()`.
51+- Hand-written fakes are okay only when mocks make the test harder to understand.
52+- After changing a port, run `make mocks` and commit the generated updates.
53+- Time-dependent daemon code must use `ports.Clock`/`ports.Timer`, not direct wall-clock APIs.
54+
55+## Tests
56+
57+- Write Go tests as table-driven tests by default.
58+- Keep wire tests byte-for-byte and include truncated-prefix and trailing-garbage coverage.
59+- Prefer fake clocks over sleeps.
60+
61+## Wire protocol
62+
63+Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
64+
65+- IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
66+- Client message types are `1–15`; server message types are `16+`.
67+- Version negotiation requires strict equality.
68+- `Hello.Version` must stay first so `PeekHelloVersion` works.
69+- Bump `ProtocolVersion` for any message layout change.
70+
71+## Session flow
72+
73+- Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted.
74+- Named sessions survive headless and persist across daemon restarts.
75+- The daemon starts on first use and exits when the last session ends.
76+- Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.
77+- Remote attach runs `ssh -- host vev _stdio [session]` and proxies the same protocol over stdio.
78+- Only one client may attach to a session; a new attach replaces the old client.
A CLAUDE.md +1 -0
@@ -0,0 +1,1 @@
1+AGENTS.md⏎̸
M Makefile +4 -0
@@ -2,6 +2,10 @@
22
33 install:
44 go install .
5+ @bin="$$(go env GOBIN)"; \
6+ if [ -z "$$bin" ]; then bin="$$(go env GOPATH)/bin"; fi; \
7+ mkdir -p "$$bin"; \
8+ install -m 0755 scripts/vev-bar-top-right scripts/vev-bar-bottom-right "$$bin"
59
610 test:
711 go test ./... -race
M README.md +156 -35
@@ -1,6 +1,10 @@
11 # vev
2+***Norwegian: web, weave, tissue***
23
3-A terminal multiplexer for Linux, written in Go with no runtime dependencies beyond `golang.org/x/sys` and `golang.org/x/term`. Sessions run in a per-user daemon; the client is a thin pipe. Rendering happens server-side with damage tracking, so only minimal diffs cross the socket (or the SSH link).
4+There are many terminal multiplexers, but this one is mine. Minimal aesthetic, purely focused on performance, rendering high-output of vertical text, agentic coding, and remote work over SSH. Built in Go with no external deps as much as possible.
5+
6+> [!IMPORTANT]
7+> This is totally in alpha.
48
59 ## Install
610
@@ -8,71 +12,192 @@ A terminal multiplexer for Linux, written in Go with no runtime dependencies bey
812 go install github.com/bnema/vev@latest
913 ```
1014
11-Or from a checkout: `go build -o vev .`
15+From a checkout:
16+
17+```sh
18+make install # install vev and default bar scripts to Go's bin directory
19+go build -o vev . # local binary
20+```
21+
22+`make install` places `vev-bar-top-right` and `vev-bar-bottom-right` beside the installed `vev` binary. If you install another way, copy the scripts from `scripts/` into a directory on `PATH` or replace the bar commands in config.
1223
1324 ## Usage
1425
15-```
26+```text
1627 vev attach to (or create) an ephemeral session
1728 vev new <name> create and attach to a named session
18-vev attach <name> attach to an existing named session (alias: a)
29+vev attach <name> attach to an existing session (alias: a)
1930 vev attach user@host[:session] attach to a remote vev daemon over SSH
20-vev ls list sessions
21-vev kill <name> kill a named session
31+vev ls list sessions (alias: list)
32+vev kill <name> kill a session
33+vev kill -- <name> kill a name that starts with -
2234 vev kill --all kill all sessions and stop the daemon
2335 vev kill --daemon stop the active vev daemon
24-vev --help show help
25-vev --version show version
36+vev --help show help (aliases: -h, help)
37+vev --version show version (alias: version)
38+```
39+
40+Ephemeral sessions get a number (`0`, `1`, `2`, ...) and are temporary daemon-retained sessions. They survive detach and transient client connection loss while the daemon is running, but they are not persisted to disk and disappear when the daemon is killed, exits, or the machine restarts. Named sessions are persisted, survive daemon restarts, and can be resumed later. New and renamed session names must start with a letter or number and may contain only letters, numbers, dots, underscores, and dashes.
41+
42+The daemon starts on first use and exits when no sessions remain. Only one client is attached to a session at a time; attaching again displaces the old client.
43+
44+## Configuration
45+
46+vev reads `$XDG_CONFIG_HOME/vev/config`, or `~/.config/vev/config` when `XDG_CONFIG_HOME` is unset. Missing files use defaults. The daemon reloads mtime/size changes while running, polling about every two seconds.
47+
48+```text
49+# Theme: auto follows the client; dark/light force built-in palettes.
50+theme = auto
51+
52+# Rebindable actions. Leave a line out to keep its built-in binding.
53+open-palette = alt+space
54+jump-attention = alt+a
55+focus-pane-left = alt+h
56+focus-pane-right = alt+l
57+focus-pane-up = alt+k
58+focus-pane-down = alt+j
59+switch-tab-1 = alt+1
60+switch-tab-2 = alt+2
61+switch-tab-3 = alt+3
62+switch-tab-4 = alt+4
63+switch-tab-5 = alt+5
64+switch-tab-6 = alt+6
65+switch-tab-7 = alt+7
66+switch-tab-8 = alt+8
67+switch-tab-9 = alt+9
68+
69+# Named session snapshots restore layout, cwd, visible content, scrollback,
70+# and foreground processes from this allowlist. Omit the key for these defaults;
71+# set it empty to disable process relaunch.
72+snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode
73+
74+# Right bar anchors. Empty command values disable the matching anchor.
75+bar.top-right = vev-bar-top-right
76+bar.bottom-right = vev-bar-bottom-right
77+bar.interval = 5s
78+
79+# Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits.
80+code.new-tab = CNT
81+code.new-session = CNS
82+code.close-tab = CLT
83+code.split-right = SPR
84+code.split-left = SPL
85+code.split-up = SPU
86+code.split-down = SPD
87+code.stack-pane = STP
88+code.toggle-stack = TST
89+code.close-pane = CLP
90+code.focus-pane-left = FPL
91+code.focus-pane-right = FPR
92+code.focus-pane-up = FPU
93+code.focus-pane-down = FPD
94+code.next-tab = NXT
95+code.previous-tab = PVT
96+code.back-session = BSK
97+code.forward-session = FSK
98+code.session-picker = SSP
99+code.visual-mode = VIS
100+code.rename-session = RNS
101+code.detach = DET
26102 ```
27103
28-Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last tab closes. The daemon starts on first use and exits when no sessions remain.
104+Configuring an action replaces all of its built-in aliases. In the example above, the focus-pane lines keep Alt+h/j/k/l but not the Alt+Arrow aliases; omit those lines to keep all built-ins.
29105
30-Remote attach runs `ssh user@host vev _stdio ...` under the hood, so vev must be installed on the remote host and reachable via your normal SSH config. Each remote attach opens one ssh connection. To make reconnects near-instant, enable OpenSSH connection reuse for your hosts in `~/.ssh/config`:
106+Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit key specs support `alt+1` through `alt+9`, not `alt+0`. `jump-attention` first opens the oldest attention tab in the current session; if none exists, it opens the oldest attention tab in another session. Invalid entries are logged as warnings and skipped where possible; duplicate config keys use the last value, binding conflicts keep the later action's defaults, and command-code conflicts drop the conflicting override.
31107
108+### Bar right anchors
109+
110+The top and bottom bar right anchors run configurable commands on the daemon host. By default, `bar.top-right = vev-bar-top-right` prints CPU, memory, then hour text (for example `C 12% M 48% 09:41`), and `bar.bottom-right = vev-bar-bottom-right` prints compact git state for the focused pane's `VEV_PANE_CWD`. Set either value empty to disable that anchor, or set it to another command to replace it.
111+
112+Both anchors share `bar.interval`, which defaults to `5s`; values below `1s` are clamped to `1s`. vev also refreshes around attach, resize, focus, and copy-feedback changes. Commands are bounded by a timeout and output limit. On failure or timeout, vev keeps the last good value; if there is none, the anchor is empty.
113+
114+Scripts receive these environment variables when available: `VEV_ANCHOR` (`top-right` or `bottom-right`), `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`. vev reads stdout only, uses the first line only, treats it as plain UTF-8 display text, and strips ANSI escape sequences and other control characters. Unicode and Nerd Font glyphs are supported. The v1 API does not support colors, styles, JSON, streaming output, or per-anchor intervals. The default git script runs `git status --porcelain` each refresh, so increase `bar.interval` or replace the command if that is too expensive for a large repository.
115+
116+## Remote attach
117+
118+```sh
119+vev attach user@host
120+vev attach user@host:session
32121 ```
33-Host *
34- ControlMaster auto
35- ControlPath ~/.ssh/cm-%r@%h:%p
36- ControlPersist 10m
37-```
38122
39-Omitting `:session` opens an ephemeral session on the remote.
123+Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Remote sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes, while the daemon still has the session. Named sessions additionally persist across daemon restarts.
124+
125+vev's resilient remote attach work is inspired by [mosh](https://mosh.org/)'s mobile-shell approach to UDP-based recovery across flaky networks. vev uses its own wire protocol and implementation; it is not compatible with mosh clients or servers and does not copy mosh protocol code.
126+
127+The default remote transport uses UDP. Its proxy binds a port in a fixed range on the remote (default `61000-61023`), so a host firewall that only allows SSH will drop the session datagrams and attach fails with `remote UDP transport unavailable: probe UDP transport`. Allow inbound UDP for that range on the remote to fix it — for example `sudo ufw allow 61000:61023/udp`, or on a trusted Tailscale network `sudo ufw allow in on tailscale0`. Override the range with `VEV_UDP_PORT_RANGE` on the remote (e.g. `VEV_UDP_PORT_RANGE=51000-51009`, a single `VEV_UDP_PORT_RANGE=51000`, or `VEV_UDP_PORT_RANGE=0` for a random ephemeral port).
128+
129+Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH stdio compatibility mode on networks where direct UDP is blocked or the SSH target is only reachable through jump-host or SSH-config-only routing. Stdio mode keeps all traffic inside SSH and does not require inbound UDP, but it may not notice sleep or Wi-Fi loss promptly. See `docs/remote-resilience.md` for concise operational notes.
130+
131+## Terminal color
132+
133+When the attaching client reports truecolor support, vev advertises newly spawned panes as `TERM=xterm-direct` and exports `COLORTERM=truecolor` to child processes. Otherwise, panes use the conservative `TERM=xterm-256color` and do not receive `COLORTERM`. This works for local and remote attach because the client capability is carried inside vev's protocol rather than relying on SSH `SendEnv`/`AcceptEnv`.
134+
135+Applications that use terminfo can detect direct color from `xterm-direct`; applications that use the common environment convention can use `COLORTERM=truecolor` when it is present. Panes restored from a saved snapshot start conservatively before any client attaches; after attach or resume, newly spawned panes use that client's reported capability.
136+
137+## Terminal compatibility
138+
139+vev keeps a server-side VT screen for each pane. It tracks scroll regions with DECOM origin mode, ANSI insert mode, cursor-position and mode-query reports, alternate-screen state, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard set requests, and double-width cells. Resize and edit operations repair double-width cells so CJK or emoji glyph halves are not left behind.
40140
41141 ## Keys
42142
43-All bindings use the Alt modifier directly, no prefix key.
143+All bindings use Alt directly; there is no prefix key.
44144
45145 | Key | Action |
46146 |---|---|
47147 | Alt+Space | open the command palette |
48148 | Alt+1 .. Alt+9 | switch to tab by number |
149+| Alt+h/j/k/l | focus pane left/down/up/right |
150+| Alt+Arrow | focus pane by direction |
151+| Alt+a | jump to a session with attention |
49152
50153 Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`; in those terminals, the palette binding depends on whether the terminal can send `ESC Space` for Alt+Space.
51154
52155 ## Command palette
53156
54-Open the palette with Alt+Space, then type a command code or fuzzy-match its label. Use Up/Down or Ctrl-N/Ctrl-P to move through matches, Enter to run the selected command, and Esc to close the palette.
157+Open the palette with Alt+Space. Type a command code or fuzzy-match the label/description. Use Up/Down or Ctrl-N/Ctrl-P to move, Enter to run, and Esc/Ctrl-C to close.
55158
56-The status bar marks ephemeral sessions with `*`, for example `0*`. Ephemeral sessions are removed when their client detaches. Renaming an ephemeral session makes it a persistent named session and removes the marker.
159+Successful commands are promoted near the top the next time the palette opens.
57160
58161 | Code | Action |
59162 |---|---|
60163 | CNT | create new tab |
61164 | CNS | create and switch to a named session |
62165 | CLT | close current tab |
63-| NXT | switch to next tab |
64-| PVT | switch to previous tab |
65-| SSP | switch session or tab |
166+| SPR / SPL / SPU / SPD | split pane right / left / up / down |
167+| STP | stack a new pane |
168+| TST | toggle focused stack |
169+| CLP | close focused pane |
170+| FPL / FPR / FPU / FPD | focus pane left / right / up / down |
171+| NXT / PVT | switch to next / previous tab |
172+| BSK / FSK | move back / forward through recent sessions |
173+| SSP | open session picker |
66174 | VIS | enter visual mode |
67175 | RNS | rename session |
68176 | RNT | rename current tab |
69177 | DET | detach |
70178
71-Rename prompts are prefilled with the current session or tab name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty session names and names already used by another session are rejected; the prompt stays open and shows the validation error. Custom tab names are saved for named sessions and are not persisted for ephemeral sessions.
179+Rename prompts are prefilled with the current session name. Empty names and names already used by another session are rejected.
72180
73-## Visual mode
181+## Session picker
74182
75-Visual mode freezes a view over history while the program keeps running underneath. Enter it with the `VIS` palette command or by scrolling up with the mouse wheel. The status bar shows `[VISUAL]`; when a line selection is active, selected rows are highlighted, the status bar shows `[SELECT]`, and a visible line cursor marks the active row.
183+Open it with `SSP` in the palette. It lists sessions and tabs, previews the selected target when space allows, and marks stopped sessions.
184+
185+| Key | Action |
186+|---|---|
187+| j / k, Up / Down | move selection |
188+| Enter | switch to or resume selected target |
189+| x | kill/delete selected target |
190+| q, Esc, Ctrl-C | close picker |
191+
192+## Panes and tabs
193+
194+Tabs hold pane layouts. Panes can be split left/right/up/down, stacked, focused by direction, and closed from the command palette. Clicking a pane focuses it.
195+
196+The top bar shows numbered tabs. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.
197+
198+## Scrollback mode and copy
199+
200+Scrollback mode freezes a view over history while the program keeps running underneath. Enter it with the `VIS` palette command or by scrolling up with the mouse wheel. Passive browsing is labeled `[SCROLL]`; selecting lines changes the status to `[SELECT]`. Scrolling back to the bottom exits to the live pane.
76201
77202 | Key | Action |
78203 |---|---|
@@ -83,18 +208,14 @@ Visual mode freezes a view over history while the program keeps running undernea
83208 | y or Enter | copy selection and exit |
84209 | q, Esc, Ctrl-C | exit without copying |
85210
86-Drag with the mouse to select lines in visual mode. Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. After a successful copy, the right side of the status bar reports `copied N chars to clipboard` until the next screen update. If the selection exceeds the OSC 52 copy limit, the status bar reports `selection too large to copy` instead.
87-
88-## Mouse
89-
90-vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In visual mode, the wheel scrolls the frozen view and left-button drag selects lines. On the normal screen, left-button drag enters visual mode and selects the dragged lines; a simple click is left alone. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child.
91-
92-vev follows the child application's cursor visibility and style requests and appends a hardware cursor update after each paint. Visible cursors blink as requested by the terminal/application state.
211+Copy uses OSC 52 clipboard sequences when supported by your terminal. After a successful copy, the bottom bar briefly shows `copied N chars to clipboard`.
93212
94213 ## Development
95214
96215 ```sh
97-go test ./... -race
216+go install golang.org/x/tools/cmd/goimports@latest
217+go install github.com/vektra/mockery/v2@latest
218+make test # go test ./... -race
219+make lint # goimports check, then go vet ./...
220+make mocks # regenerate mocks
98221 ```
99-
100-`pkg/` (VT emulator, renderer) never imports `internal/`; a boundary test enforces this. Format Go code with `goimports`. Benchmark notes live in `docs/performance.md`.
M boundary_test.go +5 -0
@@ -29,6 +29,11 @@ func TestImportBoundaries(t *testing.T) {
2929 root: "internal/usecase",
3030 disallowedPrefix: "github.com/bnema/vev/internal/adapters",
3131 },
32+ {
33+ name: "usecase must not import platform",
34+ root: "internal/usecase",
35+ disallowedPrefix: "github.com/bnema/vev/internal/platform",
36+ },
3237 }
3338
3439 for _, rule := range rules {
M docs/performance.md +3 -2
@@ -5,7 +5,7 @@ Enable daemon pprof explicitly with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`
55 Run microbenchmarks with:
66
77 ```sh
8-go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem
8+go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem
99 ```
1010
1111 Demo flood workloads are scripted in `scripts/bench-workloads.sh` for `yes`, `seq`, and `cat` styles for raw/tmux runs. vev does not currently support `vev new <name> -- command`, so automated vev workload command overrides are intentionally not documented. Capture bytes/frame and syscalls/frame externally (for example with `strace -c` around the client/daemon) and allocations with `-benchmem`/pprof.
@@ -24,12 +24,13 @@ strace -ff -c -o tmux-cat.strace tmux new-session -d -s perf-cat 'timeout 30s sh
2424
2525 Record host CPU, OS/kernel, terminal emulator, vev commit, tmux version, terminal size, workload duration, raw command lines, syscall summaries, bytes written per rendered frame, and heap profiles or allocation counts before comparing.
2626
27-Microbenchmarks executed locally for this review using `go test ./internal/adapters/ipc -run '^$' -bench=. -benchmem`:
27+Microbenchmarks executed locally for this review using `go test ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem`:
2828
2929 | benchmark | methodology |
3030 | --- | --- |
3131 | BenchmarkTransportSend | writes encoded frames to an in-memory discard `net.Conn`; allocations reported here are Send-side frame assembly costs. |
3232 | BenchmarkTransportRecvReuse | reads a pre-encoded frame from a looping in-memory reader; no `Send` call, goroutine, channel, or frame production work runs inside the measured loop, so allocations reported here are Recv-side costs only. |
33+| BenchmarkPaintCachedSinglePaneDamage | writes a small damage update into one pane, paints through the daemon compose→diff→send path, and advances the output ACK each iteration so the measured loop does not hit the unacked-output bailout. |
3334
3435 | workload | vev baseline | tmux baseline | notes |
3536 | --- | --- | --- | --- |
A docs/remote-resilience.md +37 -0
@@ -0,0 +1,37 @@
1+# Remote resilience
2+
3+Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers.
4+
5+## Operational model
6+
7+- SSH bootstrap starts or finds the remote UDP endpoint.
8+- UDP carries normal session traffic after bootstrap.
9+- Named sessions remain recoverable across daemon restarts; ephemeral sessions remain recoverable only while the daemon retains them.
10+- `VEV_REMOTE_TRANSPORT=stdio` keeps all traffic inside SSH when direct UDP is blocked.
11+
12+## Recovery states
13+
14+Resilience work uses these user-facing states:
15+
16+| State | Meaning |
17+| --- | --- |
18+| `connected` | authenticated UDP traffic is flowing |
19+| `degraded` | traffic is delayed or incomplete, but recovery is still expected |
20+| `probing` | vev is trying alternate UDP paths before using SSH again |
21+| `resuming` | a replacement transport is attaching to the same session |
22+| `offline` | no path currently works, but retries continue |
23+| `expired` | the session or recovery window is no longer available |
24+
25+When a terminal is in raw mode, recovery messages should appear as vev status text rather than as bytes written into the remote program's output stream.
26+
27+## Manual checks
28+
29+Useful scenarios for validating resilience behavior:
30+
31+- block and unblock the configured UDP port range;
32+- move between networks or change VPN state;
33+- suspend and resume the client machine;
34+- retry `vev attach user@host:session` for the same named session;
35+- compare logs for UDP silence, probing, SSH bootstrap failure, proxy expiry, and missing sessions.
36+
37+Keep hostnames, usernames, and local paths out of shared logs and screenshots.
A docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md +40 -0
@@ -0,0 +1,40 @@
1+# Clipboard image transfer (local → remote pane)
2+
3+Date: 2026-07-04 · Status: approved (approach A)
4+
5+## Problem
6+
7+When attached to a remote session, pasting an image for a TUI coding agent (pi, claude) is impossible: the agent's Ctrl+V handler reads the *remote* clipboard, which doesn't exist on a headless host (pi's chain — `wl-paste` → `xclip` → native — returns null in every branch without `WAYLAND_DISPLAY`/`DISPLAY`; no OSC 52 image path exists). The image lives on the laptop.
8+
9+## Approach
10+
11+Replicate the *end state* of pi's local Ctrl+V, which is: write the image to a temp file and insert the literal file path as text at the editor cursor (pi-mono `interactive-mode.ts:2536-2555`). vev does the same across the wire:
12+
13+1. **Client (remote attach only):** intercept `0x16` (Ctrl+V) on stdin. Run `wl-paste --list-types` (1s timeout). If an `image/*` type is present, pick by preference `png > jpeg > webp > gif` (same order as pi), read it with `wl-paste --type <mime> --no-newline`, and send one `ImagePush` frame. If no image / wl-paste missing / any error: forward `0x16` verbatim (today's behavior).
14+2. **Daemon:** on `ImagePush`, write the bytes to `os.TempDir()/vev-clip-<unixts>.<ext>` with mode 0600, remember the path on the session, and inject the path text into the focused pane's PTY — wrapped in `\x1b[200~…\x1b[201~` iff the pane's `Screen.BracketedPasteMode()` is true (first production use of the mode-2004 tracking), bare text otherwise. Injection goes through the same write path as regular input (`writeToPane`).
15+3. **Cleanup:** files recorded on the session are removed when the session ends (same lifecycle as the session teardown path). Best-effort; `/tmp` is the backstop.
16+
17+## Wire protocol
18+
19+- New client-originated message `MsgImagePush` (next free type in the 1–15 range) in `internal/ports`: `{ Mime string, Data []byte }`, hand-marshalled big-endian like the rest of `wire.go`.
20+- Bump `ProtocolVersion` (strict-equality negotiation; `Hello.Version` stays first field).
21+- Byte-for-byte golden tests + truncated-prefix / trailing-garbage invariants in `wire_test.go`, per repo protocol rules.
22+- Caps: client refuses images > 10 MiB (notifies via stderr/status); frame limit is 16 MiB so one frame always suffices. Daemon independently rejects > 10 MiB payloads (defense against old/foreign clients).
23+
24+## Scope decisions
25+
26+- **Remote attaches only.** On local attach, `0x16` passes through untouched — the agent reads the local clipboard natively and its own temp-file flow works.
27+- **Wayland only** on the client side (user's environment). The clipboard read sits behind a small client-side seam (interface in the client package or `ports` if it must cross layers) so `xclip`/OSC-52 backends can be added later without protocol changes.
28+- **No image conversion.** Bytes are transferred as-is; unsupported exotic formats are the agent's problem (pi converts BMP itself locally; that path simply isn't reachable here).
29+- **Known caveat:** while an image is on the local clipboard, Ctrl+V won't reach remote pane apps that bind it themselves (e.g. vim visual block). Text/empty clipboard → passthrough unchanged.
30+
31+## Testing
32+
33+- Client: fake clipboard runner (seam above) — image present → one ImagePush frame, no `0x16` forwarded; no image → `0x16` forwarded; oversized → refused + `0x16` not swallowed silently.
34+- Ports: golden wire tests as above.
35+- Daemon: ImagePush → file created 0600 with exact bytes, path injected with/without bracketed-paste wrapping depending on pane mode; session end removes the file; oversized rejected.
36+- Manual: end-to-end against the scratchpad harness (fake-ssh `_stdio` path) with a real PNG.
37+
38+## Out of scope
39+
40+Remote→local image transfer, multi-image batching, X11 client support, progress UI for large images.
A internal/adapters/clipboard/wlpaste.go +113 -0
@@ -0,0 +1,113 @@
1+// Package clipboard implements ports.ClipboardReader over Wayland's
2+// wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote
3+// attach forward a locally clipped image to the daemon (see
4+// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md); an
5+// xclip or OSC-52 backend could implement the same interface later without
6+// touching the wire protocol.
7+package clipboard
8+
9+import (
10+ "bytes"
11+ "context"
12+ "os/exec"
13+ "strings"
14+ "time"
15+
16+ "github.com/bnema/vev/internal/ports"
17+)
18+
19+// mimePreference orders candidate image MIME types the same way pi (the
20+// reference TUI agent whose local Ctrl+V behavior this replicates) does:
21+// png first, then the other common web/image formats.
22+var mimePreference = []string{"image/png", "image/jpeg", "image/webp", "image/gif"}
23+
24+// listTypesTimeout bounds the "what's on the clipboard" probe so a hung or
25+// missing wl-paste never stalls the stdin pump for long.
26+const listTypesTimeout = 1 * time.Second
27+
28+// defaultReadTimeout bounds the actual clipboard data read. Without this, a
29+// wl-paste that hangs mid-read (as opposed to during the --list-types probe,
30+// which listTypesTimeout already covers) would stall the client's stdin pump
31+// indefinitely, since ReadImage is called with context.Background() from the
32+// interceptor. Keep this short so a slow clipboard read cannot noticeably
33+// freeze typing for longer than the image-transfer MVP's small payload cap.
34+const defaultReadTimeout = time.Second
35+
36+// WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It
37+// holds no state and is safe for concurrent use; every call runs a fresh
38+// subprocess.
39+type WlPaste struct {
40+ // execCommand constructs the command to run; overridable in tests so
41+ // command construction can be verified without requiring wl-paste (or
42+ // even Wayland) to be installed.
43+ execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd
44+ // readTimeout bounds the data-read command; zero means defaultReadTimeout
45+ // (a zero-value WlPaste{execCommand: ...}, as tests construct directly,
46+ // still gets a sane default). Tests needing a short deadline set it
47+ // explicitly.
48+ readTimeout time.Duration
49+}
50+
51+// New returns a WlPaste clipboard reader that shells out to the real
52+// wl-paste binary.
53+func New() *WlPaste {
54+ return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout}
55+}
56+
57+func (w *WlPaste) readTimeoutOrDefault() time.Duration {
58+ if w.readTimeout <= 0 {
59+ return defaultReadTimeout
60+ }
61+ return w.readTimeout
62+}
63+
64+// ReadImage implements ports.ClipboardReader. It lists the clipboard's
65+// offered MIME types, picks the best supported image type (if any), and
66+// reads it. Any failure (wl-paste missing, no image on the clipboard, a
67+// read error or timeout) is reported as ports.ErrNoClipboardImage so callers
68+// uniformly fall back to forwarding the keystroke that triggered the read.
69+func (w *WlPaste) ReadImage(ctx context.Context) (string, []byte, error) {
70+ listCtx, cancel := context.WithTimeout(ctx, listTypesTimeout)
71+ defer cancel()
72+
73+ listCmd := w.execCommand(listCtx, "wl-paste", "--list-types")
74+ out, err := listCmd.Output()
75+ if err != nil {
76+ return "", nil, ports.ErrNoClipboardImage
77+ }
78+
79+ mime := pickImageMime(string(out))
80+ if mime == "" {
81+ return "", nil, ports.ErrNoClipboardImage
82+ }
83+
84+ readCtx, readCancel := context.WithTimeout(ctx, w.readTimeoutOrDefault())
85+ defer readCancel()
86+
87+ readCmd := w.execCommand(readCtx, "wl-paste", "--type", mime, "--no-newline")
88+ var buf bytes.Buffer
89+ readCmd.Stdout = &buf
90+ if err := readCmd.Run(); err != nil {
91+ return "", nil, ports.ErrNoClipboardImage
92+ }
93+ return mime, buf.Bytes(), nil
94+}
95+
96+// pickImageMime scans wl-paste --list-types output (one MIME type per line)
97+// and returns the first match in mimePreference order, or "" if the
98+// clipboard offers no supported image type.
99+func pickImageMime(listTypesOutput string) string {
100+ offered := make(map[string]bool)
101+ for _, line := range strings.Split(listTypesOutput, "\n") {
102+ line = strings.TrimSpace(line)
103+ if line != "" {
104+ offered[line] = true
105+ }
106+ }
107+ for _, mime := range mimePreference {
108+ if offered[mime] {
109+ return mime
110+ }
111+ }
112+ return ""
113+}
A internal/adapters/clipboard/wlpaste_test.go +183 -0
@@ -0,0 +1,183 @@
1+package clipboard
2+
3+import (
4+ "context"
5+ "errors"
6+ "os/exec"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestPickImageMimePrefersPngOverOtherImageTypes(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ output string
17+ want string
18+ }{
19+ {"no types", "", ""},
20+ {"text only", "text/plain\nUTF8_STRING\n", ""},
21+ {"png present", "text/plain\nimage/png\n", "image/png"},
22+ {"prefers png over jpeg", "image/jpeg\nimage/png\n", "image/png"},
23+ {"falls back to jpeg", "image/jpeg\nimage/webp\n", "image/jpeg"},
24+ {"falls back to webp", "image/webp\nimage/gif\n", "image/webp"},
25+ {"falls back to gif", "text/plain\nimage/gif\n", "image/gif"},
26+ {"unsupported image type only", "image/bmp\n", ""},
27+ {"ignores blank lines and whitespace", "\n image/png \n\n", "image/png"},
28+ }
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ if got := pickImageMime(tt.output); got != tt.want {
32+ t.Fatalf("pickImageMime(%q) = %q, want %q", tt.output, got, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+// fakeExecCommand returns a substitute execCommand that never shells out to
39+// wl-paste itself: it inspects the requested command/args and swaps in a
40+// safe, universally available binary (printf) that produces the same
41+// stdout a real wl-paste invocation would, so command construction and the
42+// read pipeline can be verified without wl-paste installed.
43+func fakeExecCommand(t *testing.T, listTypesOutput, imageData string) (execCommand func(context.Context, string, ...string) *exec.Cmd, calls *[][]string) {
44+ t.Helper()
45+ var got [][]string
46+ fn := func(ctx context.Context, name string, args ...string) *exec.Cmd {
47+ call := append([]string{name}, args...)
48+ got = append(got, call)
49+ if name != "wl-paste" {
50+ return exec.CommandContext(ctx, "false")
51+ }
52+ if len(args) > 0 && args[0] == "--list-types" {
53+ return exec.CommandContext(ctx, "printf", "%s", listTypesOutput)
54+ }
55+ return exec.CommandContext(ctx, "printf", "%s", imageData)
56+ }
57+ return fn, &got
58+}
59+
60+func TestReadImageConstructsExpectedCommandsAndReturnsData(t *testing.T) {
61+ execCommand, calls := fakeExecCommand(t, "text/plain\nimage/png\nimage/jpeg\n", "PNGBYTES")
62+ w := &WlPaste{execCommand: execCommand}
63+
64+ mime, data, err := w.ReadImage(context.Background())
65+ if err != nil {
66+ t.Fatalf("ReadImage() error = %v", err)
67+ }
68+ if mime != "image/png" {
69+ t.Fatalf("mime = %q, want image/png", mime)
70+ }
71+ if string(data) != "PNGBYTES" {
72+ t.Fatalf("data = %q, want PNGBYTES", data)
73+ }
74+
75+ want := [][]string{
76+ {"wl-paste", "--list-types"},
77+ {"wl-paste", "--type", "image/png", "--no-newline"},
78+ }
79+ got := *calls
80+ if len(got) != len(want) {
81+ t.Fatalf("calls = %#v, want %#v", got, want)
82+ }
83+ for i := range want {
84+ if len(got[i]) != len(want[i]) {
85+ t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i])
86+ }
87+ for j := range want[i] {
88+ if got[i][j] != want[i][j] {
89+ t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i])
90+ }
91+ }
92+ }
93+}
94+
95+func TestReadImageNoImageOnClipboard(t *testing.T) {
96+ execCommand, _ := fakeExecCommand(t, "text/plain\nUTF8_STRING\n", "")
97+ w := &WlPaste{execCommand: execCommand}
98+
99+ _, _, err := w.ReadImage(context.Background())
100+ if !errors.Is(err, ports.ErrNoClipboardImage) {
101+ t.Fatalf("err = %v, want ErrNoClipboardImage", err)
102+ }
103+}
104+
105+func TestReadImageListTypesFailureReturnsNoClipboardImage(t *testing.T) {
106+ execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
107+ return exec.CommandContext(ctx, "false") // exits 1: simulates wl-paste missing/failing
108+ }
109+ w := &WlPaste{execCommand: execCommand}
110+
111+ _, _, err := w.ReadImage(context.Background())
112+ if !errors.Is(err, ports.ErrNoClipboardImage) {
113+ t.Fatalf("err = %v, want ErrNoClipboardImage", err)
114+ }
115+}
116+
117+// TestReadImageDataReadCommandHasDeadline proves the actual clipboard-data
118+// read (not just the --list-types probe) runs under a context carrying a
119+// deadline: called with context.Background() (as the client interceptor
120+// does), a wl-paste that hangs mid-read must not stall the caller forever.
121+func TestReadImageDataReadCommandHasDeadline(t *testing.T) {
122+ var readCtx context.Context
123+ execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
124+ if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" {
125+ return exec.CommandContext(ctx, "printf", "%s", "image/png\n")
126+ }
127+ readCtx = ctx
128+ return exec.CommandContext(ctx, "printf", "%s", "PNGBYTES")
129+ }
130+ w := &WlPaste{execCommand: execCommand}
131+
132+ _, _, err := w.ReadImage(context.Background())
133+ if err != nil {
134+ t.Fatalf("ReadImage() error = %v", err)
135+ }
136+ if readCtx == nil {
137+ t.Fatal("data read command was never constructed")
138+ }
139+ if _, ok := readCtx.Deadline(); !ok {
140+ t.Fatal("data read command's context must carry a deadline, even when ReadImage is called with context.Background()")
141+ }
142+}
143+
144+// TestReadImageHungDataReadTimesOut proves a wl-paste that hangs while
145+// streaming the actual clipboard bytes (as opposed to during --list-types,
146+// covered by listTypesTimeout) is bounded by readTimeout instead of stalling
147+// the caller indefinitely.
148+func TestReadImageHungDataReadTimesOut(t *testing.T) {
149+ execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
150+ if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" {
151+ return exec.CommandContext(ctx, "printf", "%s", "image/png\n")
152+ }
153+ return exec.CommandContext(ctx, "sleep", "5") // simulates a hung read
154+ }
155+ w := &WlPaste{execCommand: execCommand, readTimeout: 50 * time.Millisecond}
156+
157+ start := time.Now()
158+ _, _, err := w.ReadImage(context.Background())
159+ elapsed := time.Since(start)
160+
161+ if !errors.Is(err, ports.ErrNoClipboardImage) {
162+ t.Fatalf("err = %v, want ErrNoClipboardImage", err)
163+ }
164+ if elapsed > 2*time.Second {
165+ t.Fatalf("ReadImage() took %v; a hung read must be bounded by readTimeout, not context.Background()", elapsed)
166+ }
167+}
168+
169+// TestReadImageAgainstRealWlPaste is a best-effort integration check against
170+// the real binary; it skips itself when wl-paste (or a Wayland clipboard) is
171+// not available, which is the common case in CI/sandbox environments.
172+func TestReadImageAgainstRealWlPaste(t *testing.T) {
173+ if _, err := exec.LookPath("wl-paste"); err != nil {
174+ t.Skip("wl-paste not installed")
175+ }
176+ w := New()
177+ _, _, err := w.ReadImage(context.Background())
178+ // No clipboard image is a perfectly valid outcome in CI; just confirm
179+ // the call doesn't panic and errors are of the expected shape.
180+ if err != nil && !errors.Is(err, ports.ErrNoClipboardImage) {
181+ t.Fatalf("ReadImage() unexpected error = %v", err)
182+ }
183+}
A internal/adapters/clock/clock_test.go +56 -0
@@ -0,0 +1,56 @@
1+package clock
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestRealClockNow(t *testing.T) {
9+ c := New()
10+ before := time.Now().Add(-time.Second)
11+ got := c.Now()
12+ after := time.Now().Add(time.Second)
13+
14+ if got.Before(before) || got.After(after) {
15+ t.Fatalf("Now() = %v, want between %v and %v", got, before, after)
16+ }
17+}
18+
19+func TestRealClockTimerFires(t *testing.T) {
20+ c := New()
21+ timer := c.NewTimer(10 * time.Millisecond)
22+ defer timer.Stop()
23+
24+ select {
25+ case <-timer.C():
26+ case <-time.After(5 * time.Second):
27+ t.Fatal("timer did not fire before deadline")
28+ }
29+}
30+
31+func TestRealClockTimerStopKeepsChannelQuiet(t *testing.T) {
32+ c := New()
33+ timer := c.NewTimer(time.Hour)
34+ _ = timer.Stop()
35+
36+ select {
37+ case <-timer.C():
38+ t.Fatal("stopped timer fired")
39+ case <-time.After(25 * time.Millisecond):
40+ }
41+}
42+
43+func TestRealClockTimerResetRearms(t *testing.T) {
44+ c := New()
45+ timer := c.NewTimer(time.Hour)
46+ defer timer.Stop()
47+
48+ _ = timer.Stop()
49+ timer.Reset(10 * time.Millisecond)
50+
51+ select {
52+ case <-timer.C():
53+ case <-time.After(5 * time.Second):
54+ t.Fatal("reset timer did not fire before deadline")
55+ }
56+}
A internal/adapters/config/config.go +277 -0
@@ -0,0 +1,277 @@
1+package config
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "regexp"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+)
18+
19+const pollInterval = 2 * time.Second
20+
21+var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
22+
23+// Parse reads vev's flat action = value config format. Duplicate action keys
24+// are accepted with a warning; the last value wins while first-seen action order
25+// is preserved for binding conflict resolution.
26+func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
27+ cfg := domain.Defaults()
28+ var warnings []domain.Warning
29+ seenBindingKeys := make(map[string]bool)
30+
31+ scanner := bufio.NewScanner(r)
32+ lineNo := 0
33+ for scanner.Scan() {
34+ lineNo++
35+ line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r"))
36+ if line == "" || strings.HasPrefix(line, "#") {
37+ continue
38+ }
39+ line = stripInlineComment(line)
40+ if line == "" {
41+ continue
42+ }
43+
44+ key, value, ok := strings.Cut(line, "=")
45+ if !ok {
46+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
47+ continue
48+ }
49+ key = strings.TrimSpace(key)
50+ value = strings.TrimSpace(value)
51+ if key == "" {
52+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"})
53+ continue
54+ }
55+
56+ switch {
57+ case key == "theme":
58+ mode, ok := parseTheme(value)
59+ if !ok {
60+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)})
61+ continue
62+ }
63+ cfg.Theme = mode
64+ case key == "bar.top-right":
65+ command, ok := parseBarCommand(value)
66+ if !ok {
67+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.top-right %q", value)})
68+ continue
69+ }
70+ cfg.Bar.TopRight = command
71+ case key == "bar.bottom-right":
72+ command, ok := parseBarCommand(value)
73+ if !ok {
74+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.bottom-right %q", value)})
75+ continue
76+ }
77+ cfg.Bar.BottomRight = command
78+ case key == "bar.interval":
79+ interval, err := time.ParseDuration(value)
80+ if err != nil {
81+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
82+ continue
83+ }
84+ if interval < domain.MinBarInterval {
85+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", domain.MinBarInterval)})
86+ interval = domain.MinBarInterval
87+ }
88+ cfg.Bar.Interval = interval
89+ case strings.HasPrefix(key, "code."):
90+ codeKey := strings.TrimPrefix(key, "code.")
91+ if codeKey == "" {
92+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"})
93+ continue
94+ }
95+ if _, exists := cfg.Codes[codeKey]; exists {
96+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
97+ }
98+ cfg.Codes[codeKey] = value
99+ case key == "snapshot.restore_processes":
100+ var processWarnings []domain.Warning
101+ cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo)
102+ warnings = append(warnings, processWarnings...)
103+ cfg.Snapshot.RestoreProcessesSet = true
104+ default:
105+ if seenBindingKeys[key] {
106+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
107+ updateBindingEntry(cfg.BindingEntries, key, value)
108+ } else {
109+ seenBindingKeys[key] = true
110+ cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
111+ }
112+ }
113+ }
114+ if err := scanner.Err(); err != nil {
115+ return cfg, warnings, err
116+ }
117+ return cfg, warnings, nil
118+}
119+
120+// Load reads path and parses it. Missing config files are treated as defaults.
121+func Load(path string) (domain.Config, []domain.Warning, error) {
122+ f, err := os.Open(path)
123+ if errors.Is(err, os.ErrNotExist) {
124+ return domain.Defaults(), nil, nil
125+ }
126+ if err != nil {
127+ return domain.Defaults(), nil, err
128+ }
129+ cfg, warnings, parseErr := Parse(f)
130+ if closeErr := f.Close(); parseErr == nil && closeErr != nil {
131+ return cfg, warnings, closeErr
132+ }
133+ return cfg, warnings, parseErr
134+}
135+
136+// Watch polls path for mtime/size changes every two seconds and calls onChange
137+// with each successfully loaded config. Missing files are allowed and reload to
138+// defaults when the watched state changes.
139+func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error {
140+ last, err := fileStamp(path)
141+ lastErrMsg := ""
142+ if err != nil {
143+ lastErrMsg = err.Error()
144+ onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
145+ }
146+ timer := clock.NewTimer(pollInterval)
147+ defer timer.Stop()
148+
149+ for {
150+ select {
151+ case <-ctx.Done():
152+ return ctx.Err()
153+ case <-timer.C():
154+ current, statErr := fileStamp(path)
155+ if statErr != nil {
156+ if statErr.Error() != lastErrMsg {
157+ lastErrMsg = statErr.Error()
158+ onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
159+ }
160+ } else if current != last || lastErrMsg != "" {
161+ lastErrMsg = ""
162+ last = current
163+ cfg, warnings, err := Load(path)
164+ if err != nil {
165+ cfg = domain.Defaults()
166+ warnings = append(warnings, domain.Warning{Msg: err.Error()})
167+ }
168+ onChange(cfg, warnings)
169+ }
170+ timer.Reset(pollInterval)
171+ }
172+ }
173+}
174+
175+type stamp struct {
176+ modTime time.Time
177+ size int64
178+ exists bool
179+}
180+
181+func fileStamp(path string) (stamp, error) {
182+ st, err := os.Stat(path)
183+ if errors.Is(err, os.ErrNotExist) {
184+ return stamp{}, nil
185+ }
186+ if err != nil {
187+ return stamp{}, err
188+ }
189+ return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
190+}
191+
192+func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
193+ for i := range entries {
194+ if entries[i].Key == key {
195+ entries[i].Value = value
196+ return
197+ }
198+ }
199+}
200+
201+func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
202+ if strings.TrimSpace(value) == "" {
203+ return nil, nil
204+ }
205+ parts := strings.Split(value, ",")
206+ out := make([]string, 0, len(parts))
207+ warnings := make([]domain.Warning, 0)
208+ seen := make(map[string]struct{}, len(parts))
209+ for _, part := range parts {
210+ item := strings.TrimSpace(part)
211+ if item == "" {
212+ continue
213+ }
214+ if !processNamePattern.MatchString(item) {
215+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)})
216+ continue
217+ }
218+ if _, ok := seen[item]; ok {
219+ continue
220+ }
221+ seen[item] = struct{}{}
222+ out = append(out, item)
223+ }
224+ return out, warnings
225+}
226+
227+func parseBarCommand(value string) (string, bool) {
228+ value = strings.TrimSpace(value)
229+ if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
230+ unquoted, err := strconv.Unquote(value)
231+ if err != nil {
232+ return value, false
233+ }
234+ return unquoted, true
235+ }
236+ return value, true
237+}
238+
239+func parseTheme(value string) (domain.ThemeMode, bool) {
240+ switch strings.ToLower(strings.TrimSpace(value)) {
241+ case "auto", "":
242+ return domain.ThemeAuto, true
243+ case "dark":
244+ return domain.ThemeDark, true
245+ case "light":
246+ return domain.ThemeLight, true
247+ default:
248+ return domain.ThemeAuto, false
249+ }
250+}
251+
252+func stripInlineComment(line string) string {
253+ inQuote := false
254+ escaped := false
255+ for i, r := range line {
256+ if escaped {
257+ escaped = false
258+ continue
259+ }
260+ if inQuote && r == '\\' {
261+ escaped = true
262+ continue
263+ }
264+ if r == '"' {
265+ inQuote = !inQuote
266+ continue
267+ }
268+ if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
269+ return strings.TrimSpace(line[:i])
270+ }
271+ }
272+ return line
273+}
274+
275+func isSpace(r rune) bool {
276+ return r == ' ' || r == '\t' || r == '\r' || r == '\n'
277+}
A internal/adapters/config/config_test.go +523 -0
@@ -0,0 +1,523 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestParse(t *testing.T) {
20+ t.Parallel()
21+
22+ tests := []struct {
23+ name string
24+ input string
25+ want domain.Config
26+ wantWarnings []domain.Warning
27+ }{
28+ {
29+ name: "comments and inline comments",
30+ input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n",
31+ want: domain.Config{
32+ Theme: domain.ThemeAuto,
33+ BindingEntries: []domain.ConfigEntry{
34+ {Key: "new-tab", Value: "alt+t"},
35+ {Key: "detach", Value: "alt+#"},
36+ },
37+ Codes: map[string]string{},
38+ },
39+ },
40+ {
41+ name: "crlf and code prefix",
42+ input: "theme = light\r\ncode.detach = dt\r\n",
43+ want: domain.Config{
44+ Theme: domain.ThemeLight,
45+ BindingEntries: []domain.ConfigEntry{},
46+ Codes: map[string]string{"detach": "dt"},
47+ },
48+ },
49+ {
50+ name: "invalid theme warns and keeps default",
51+ input: "theme = blue\n",
52+ want: domain.Config{
53+ Theme: domain.ThemeAuto,
54+ BindingEntries: []domain.ConfigEntry{},
55+ Codes: map[string]string{},
56+ },
57+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid theme \"blue\""}},
58+ },
59+ {
60+ name: "unknown binding key stored",
61+ input: "unknown.action = alt+x\n",
62+ want: domain.Config{
63+ Theme: domain.ThemeAuto,
64+ BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},
65+ Codes: map[string]string{},
66+ },
67+ },
68+ {
69+ name: "duplicates warn and last wins",
70+ input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",
71+ want: domain.Config{
72+ Theme: domain.ThemeAuto,
73+ BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},
74+ Codes: map[string]string{"detach": "DX"},
75+ },
76+ wantWarnings: []domain.Warning{
77+ {Line: 2, Msg: "duplicate key \"new-tab\""},
78+ {Line: 4, Msg: "duplicate key \"code.detach\""},
79+ },
80+ },
81+ {
82+ name: "invalid lines report line numbers",
83+ input: "\nno equals\n = value\ntheme = dark\n",
84+ want: domain.Config{
85+ Theme: domain.ThemeDark,
86+ BindingEntries: []domain.ConfigEntry{},
87+ Codes: map[string]string{},
88+ },
89+ wantWarnings: []domain.Warning{
90+ {Line: 2, Msg: "missing '='"},
91+ {Line: 3, Msg: "missing key"},
92+ },
93+ },
94+ {
95+ name: "bar settings custom values",
96+ input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",
97+ want: domain.Config{
98+ Theme: domain.ThemeAuto,
99+ Bar: domain.BarConfig{
100+ TopRight: "custom-top",
101+ BottomRight: "custom-bottom",
102+ Interval: 2 * time.Second,
103+ },
104+ BindingEntries: []domain.ConfigEntry{},
105+ Codes: map[string]string{},
106+ },
107+ },
108+ {
109+ name: "bar settings quoted commands unquote",
110+ input: "bar.top-right = \"date +%H:%M\"\nbar.bottom-right = \"\"\n",
111+ want: domain.Config{
112+ Theme: domain.ThemeAuto,
113+ Bar: domain.BarConfig{
114+ TopRight: "date +%H:%M",
115+ BottomRight: "",
116+ Interval: 5 * time.Second,
117+ },
118+ BindingEntries: []domain.ConfigEntry{},
119+ Codes: map[string]string{},
120+ },
121+ },
122+ {
123+ name: "bar settings quoted commands preserve hash",
124+ input: "bar.top-right = \"echo foo #1\" # inline comment\n",
125+ want: domain.Config{
126+ Theme: domain.ThemeAuto,
127+ Bar: domain.BarConfig{
128+ TopRight: "echo foo #1",
129+ BottomRight: "vev-bar-bottom-right",
130+ Interval: 5 * time.Second,
131+ },
132+ BindingEntries: []domain.ConfigEntry{},
133+ Codes: map[string]string{},
134+ },
135+ },
136+ {
137+ name: "bar settings malformed quoted command warns and keeps default",
138+ input: "bar.top-right = \"bad\\q\"\n",
139+ want: domain.Config{
140+ Theme: domain.ThemeAuto,
141+ Bar: domain.BarConfig{
142+ TopRight: "vev-bar-top-right",
143+ BottomRight: "vev-bar-bottom-right",
144+ Interval: 5 * time.Second,
145+ },
146+ BindingEntries: []domain.ConfigEntry{},
147+ Codes: map[string]string{},
148+ },
149+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.top-right \"\\\"bad\\\\q\\\"\""}},
150+ },
151+ {
152+ name: "bar settings empty commands disable anchors",
153+ input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
154+ want: domain.Config{
155+ Theme: domain.ThemeAuto,
156+ Bar: domain.BarConfig{
157+ TopRight: "",
158+ BottomRight: "",
159+ Interval: 5 * time.Second,
160+ },
161+ BindingEntries: []domain.ConfigEntry{},
162+ Codes: map[string]string{},
163+ },
164+ },
165+ {
166+ name: "bar interval invalid warns and keeps default",
167+ input: "bar.interval = nope\n",
168+ want: domain.Config{
169+ Theme: domain.ThemeAuto,
170+ Bar: domain.BarConfig{
171+ TopRight: "vev-bar-top-right",
172+ BottomRight: "vev-bar-bottom-right",
173+ Interval: 5 * time.Second,
174+ },
175+ BindingEntries: []domain.ConfigEntry{},
176+ Codes: map[string]string{},
177+ },
178+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}},
179+ },
180+ {
181+ name: "bar interval below minimum clamps and warns",
182+ input: "bar.interval = 100ms\n",
183+ want: domain.Config{
184+ Theme: domain.ThemeAuto,
185+ Bar: domain.BarConfig{
186+ TopRight: "vev-bar-top-right",
187+ BottomRight: "vev-bar-bottom-right",
188+ Interval: time.Second,
189+ },
190+ BindingEntries: []domain.ConfigEntry{},
191+ Codes: map[string]string{},
192+ },
193+ wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}},
194+ },
195+ {
196+ name: "snapshot restore processes",
197+ input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",
198+ want: domain.Config{
199+ Theme: domain.ThemeAuto,
200+ BindingEntries: []domain.ConfigEntry{},
201+ Codes: map[string]string{},
202+ Snapshot: domain.SnapshotConfig{
203+ RestoreProcesses: []string{"claude", "codex", "pi", "opencode", "btop"},
204+ RestoreProcessesSet: true,
205+ },
206+ },
207+ },
208+ {
209+ name: "snapshot restore processes empty disables",
210+ input: "snapshot.restore_processes =\n",
211+ want: domain.Config{
212+ Theme: domain.ThemeAuto,
213+ BindingEntries: []domain.ConfigEntry{},
214+ Codes: map[string]string{},
215+ Snapshot: domain.SnapshotConfig{
216+ RestoreProcessesSet: true,
217+ },
218+ },
219+ },
220+ {
221+ name: "snapshot restore processes warns on malformed entries",
222+ input: "snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n",
223+ want: domain.Config{
224+ Theme: domain.ThemeAuto,
225+ BindingEntries: []domain.ConfigEntry{},
226+ Codes: map[string]string{},
227+ Snapshot: domain.SnapshotConfig{
228+ RestoreProcesses: []string{"claude", "custom-tool", "zsh"},
229+ RestoreProcessesSet: true,
230+ },
231+ },
232+ wantWarnings: []domain.Warning{
233+ {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""},
234+ {Line: 1, Msg: "invalid snapshot restore process \":all:\""},
235+ {Line: 1, Msg: "invalid snapshot restore process \"bad name\""},
236+ },
237+ },
238+ }
239+
240+ for _, tt := range tests {
241+ t.Run(tt.name, func(t *testing.T) {
242+ t.Parallel()
243+
244+ if tt.want.Bar.Interval == 0 {
245+ tt.want.Bar = domain.Defaults().Bar
246+ }
247+ if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
248+ tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
249+ }
250+ got, warnings, err := Parse(strings.NewReader(tt.input))
251+ if err != nil {
252+ t.Fatalf("Parse() error = %v", err)
253+ }
254+ if !reflect.DeepEqual(got, tt.want) {
255+ t.Fatalf("Parse() config = %#v, want %#v", got, tt.want)
256+ }
257+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
258+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
259+ }
260+ })
261+ }
262+}
263+
264+func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
265+ t.Parallel()
266+
267+ first := domain.Defaults()
268+ first.Snapshot.RestoreProcesses[0] = "mutated"
269+ second := domain.Defaults()
270+ require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
271+}
272+
273+func TestParsePreservesBindingFileOrder(t *testing.T) {
274+ t.Parallel()
275+
276+ cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n"))
277+ if err != nil {
278+ t.Fatalf("Parse() error = %v", err)
279+ }
280+ if len(warnings) != 0 {
281+ t.Fatalf("Parse() warnings = %#v, want none", warnings)
282+ }
283+ want := []domain.ConfigEntry{
284+ {Key: "open-palette", Value: "alt+p"},
285+ {Key: "focus-pane-left", Value: "alt+p"},
286+ }
287+ if !reflect.DeepEqual(cfg.BindingEntries, want) {
288+ t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want)
289+ }
290+}
291+
292+func TestLoadMissingFileReturnsDefaults(t *testing.T) {
293+ t.Parallel()
294+
295+ cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
296+ if err != nil {
297+ t.Fatalf("Load() error = %v", err)
298+ }
299+ if len(warnings) != 0 {
300+ t.Fatalf("Load() warnings = %#v, want none", warnings)
301+ }
302+ if !reflect.DeepEqual(cfg, domain.Defaults()) {
303+ t.Fatalf("Load() config = %#v, want defaults", cfg)
304+ }
305+}
306+
307+func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
308+ t.Parallel()
309+
310+ path := filepath.Join(t.TempDir(), "config")
311+ if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
312+ t.Fatal(err)
313+ }
314+
315+ clk := newFakeClock(time.Unix(0, 0))
316+ ctx, cancel := context.WithCancel(context.Background())
317+ defer cancel()
318+
319+ type change struct {
320+ cfg domain.Config
321+ warnings []domain.Warning
322+ }
323+ changes := make(chan change, 2)
324+ done := make(chan error, 1)
325+ go func() {
326+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
327+ changes <- change{cfg: cfg, warnings: warnings}
328+ })
329+ }()
330+
331+ clk.waitForTimers(t, 1)
332+ if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {
333+ t.Fatal(err)
334+ }
335+ clk.advance(2 * time.Second)
336+
337+ var got change
338+ select {
339+ case got = <-changes:
340+ case <-time.After(2 * time.Second):
341+ t.Fatal("timed out waiting for config reload")
342+ }
343+ if got.cfg.Theme != domain.ThemeLight {
344+ t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight)
345+ }
346+ if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) {
347+ t.Fatalf("Watch() warnings = %#v", got.warnings)
348+ }
349+
350+ cancel()
351+ if err := <-done; !errors.Is(err, context.Canceled) {
352+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
353+ }
354+}
355+
356+func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
357+ t.Parallel()
358+
359+ path := filepath.Join(t.TempDir(), "config")
360+ if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
361+ t.Fatal(err)
362+ }
363+
364+ clk := newFakeClock(time.Unix(0, 0))
365+ ctx, cancel := context.WithCancel(context.Background())
366+ defer cancel()
367+
368+ type change struct {
369+ cfg domain.Config
370+ warnings []domain.Warning
371+ }
372+ changes := make(chan change, 2)
373+ done := make(chan error, 1)
374+ go func() {
375+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
376+ changes <- change{cfg: cfg, warnings: warnings}
377+ })
378+ }()
379+
380+ clk.waitForTimers(t, 1)
381+ tooLong := strings.Repeat("x", 70*1024)
382+ if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {
383+ t.Fatal(err)
384+ }
385+ clk.advance(2 * time.Second)
386+
387+ got := <-changes
388+ require.Equal(t, domain.Defaults(), got.cfg)
389+ require.NotEmpty(t, got.warnings)
390+
391+ cancel()
392+ if err := <-done; !errors.Is(err, context.Canceled) {
393+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
394+ }
395+}
396+
397+func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
398+ t.Parallel()
399+
400+ path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
401+ clk := newFakeClock(time.Unix(0, 0))
402+ ctx, cancel := context.WithCancel(context.Background())
403+ defer cancel()
404+
405+ changes := make(chan []domain.Warning, 4)
406+ done := make(chan error, 1)
407+ go func() {
408+ done <- Watch(ctx, clk, path, func(_ domain.Config, warnings []domain.Warning) {
409+ changes <- warnings
410+ })
411+ }()
412+
413+ first := <-changes
414+ require.Len(t, first, 1)
415+ clk.waitForTimers(t, 1)
416+ clk.advance(2 * time.Second)
417+ clk.advance(2 * time.Second)
418+
419+ select {
420+ case extra := <-changes:
421+ t.Fatalf("Watch() repeated persistent stat error: %#v", extra)
422+ case <-time.After(100 * time.Millisecond):
423+ }
424+
425+ cancel()
426+ if err := <-done; !errors.Is(err, context.Canceled) {
427+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
428+ }
429+}
430+
431+var _ ports.Clock = (*fakeClock)(nil)
432+var _ ports.Timer = (*fakeTimer)(nil)
433+
434+type fakeClock struct {
435+ mu sync.Mutex
436+ now time.Time
437+ timers []*fakeTimer
438+ notify chan struct{}
439+}
440+
441+func newFakeClock(now time.Time) *fakeClock {
442+ return &fakeClock{now: now, notify: make(chan struct{}, 16)}
443+}
444+
445+func (c *fakeClock) Now() time.Time {
446+ c.mu.Lock()
447+ defer c.mu.Unlock()
448+ return c.now
449+}
450+
451+func (c *fakeClock) NewTimer(d time.Duration) ports.Timer {
452+ c.mu.Lock()
453+ defer c.mu.Unlock()
454+
455+ t := &fakeTimer{clock: c, ch: make(chan time.Time, 1), next: c.now.Add(d), active: true}
456+ c.timers = append(c.timers, t)
457+ c.notify <- struct{}{}
458+ return t
459+}
460+
461+func (c *fakeClock) waitForTimers(t *testing.T, n int) {
462+ t.Helper()
463+ deadline := time.After(2 * time.Second)
464+ for c.timerCount() < n {
465+ select {
466+ case <-c.notify:
467+ case <-deadline:
468+ t.Fatalf("timed out waiting for %d timers, got %d", n, c.timerCount())
469+ }
470+ }
471+}
472+
473+func (c *fakeClock) timerCount() int {
474+ c.mu.Lock()
475+ defer c.mu.Unlock()
476+ return len(c.timers)
477+}
478+
479+func (c *fakeClock) advance(d time.Duration) {
480+ c.mu.Lock()
481+ defer c.mu.Unlock()
482+
483+ c.now = c.now.Add(d)
484+ for _, timer := range c.timers {
485+ timer.mu.Lock()
486+ if timer.active && !timer.next.After(c.now) {
487+ timer.ch <- c.now
488+ timer.active = false
489+ }
490+ timer.mu.Unlock()
491+ }
492+}
493+
494+type fakeTimer struct {
495+ clock *fakeClock
496+ mu sync.Mutex
497+ ch chan time.Time
498+ next time.Time
499+ active bool
500+}
501+
502+func (t *fakeTimer) C() <-chan time.Time { return t.ch }
503+
504+func (t *fakeTimer) Reset(d time.Duration) bool {
505+ t.clock.mu.Lock()
506+ defer t.clock.mu.Unlock()
507+ t.mu.Lock()
508+ defer t.mu.Unlock()
509+
510+ wasActive := t.active
511+ t.active = true
512+ t.next = t.clock.now.Add(d)
513+ return wasActive
514+}
515+
516+func (t *fakeTimer) Stop() bool {
517+ t.mu.Lock()
518+ defer t.mu.Unlock()
519+
520+ wasActive := t.active
521+ t.active = false
522+ return wasActive
523+}
A internal/adapters/dgram/proxy.go +201 -0
@@ -0,0 +1,201 @@
1+package dgram
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "log/slog"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+const (
14+ DefaultProxyIdleTTL = 30 * time.Second
15+ DefaultProxyRetryBackoff = 100 * time.Millisecond
16+)
17+
18+type ProxyRuntime struct {
19+ Client ports.Transport
20+ Daemon ports.Transport
21+ Log *slog.Logger
22+ IdleTTL time.Duration
23+ RetryBackoff time.Duration
24+ Clock ports.Clock
25+}
26+
27+type proxyCopyErrKind uint8
28+
29+const (
30+ proxyClientRecv proxyCopyErrKind = iota
31+ proxyDaemonRecv
32+ proxyClientSend
33+ proxyDaemonSend
34+)
35+
36+type proxyCopyErr struct {
37+ kind proxyCopyErrKind
38+ err error
39+}
40+
41+type proxyCopier struct {
42+ ctx context.Context
43+ errCh chan<- proxyCopyErr
44+ retryBackoff time.Duration
45+ clk ports.Clock
46+}
47+
48+type proxyCopyDirection struct {
49+ src ports.Transport
50+ dst ports.Transport
51+ recvKind proxyCopyErrKind
52+ sendKind proxyCopyErrKind
53+ retryRecoverable bool
54+}
55+
56+func (p ProxyRuntime) Run(ctx context.Context) error {
57+ defer func() {
58+ _ = p.Client.Close()
59+ _ = p.Daemon.Close()
60+ }()
61+ copyCtx, cancelCopies := context.WithCancel(ctx)
62+ defer cancelCopies()
63+
64+ idleTTL := p.IdleTTL
65+ if idleTTL <= 0 {
66+ idleTTL = DefaultProxyIdleTTL
67+ }
68+ retryBackoff := p.RetryBackoff
69+ if retryBackoff <= 0 {
70+ retryBackoff = DefaultProxyRetryBackoff
71+ }
72+ clk := p.Clock
73+ if clk == nil {
74+ clk = realClock{}
75+ }
76+
77+ errCh := make(chan proxyCopyErr, 2)
78+ copier := proxyCopier{ctx: copyCtx, errCh: errCh, retryBackoff: retryBackoff, clk: clk}
79+ clientToDaemon := proxyCopyDirection{src: p.Client, dst: p.Daemon, recvKind: proxyClientRecv, sendKind: proxyDaemonSend}
80+ daemonToClient := proxyCopyDirection{src: p.Daemon, dst: p.Client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true}
81+ go copier.copyFrames(clientToDaemon)
82+ go copier.copyFrames(daemonToClient)
83+
84+ var linkEvents <-chan ports.LinkEvent
85+ if reporter, ok := p.Client.(ports.LinkStateReporter); ok {
86+ linkEvents = reporter.LinkEvents()
87+ }
88+ var idle <-chan time.Time
89+ var timer ports.Timer
90+ defer func() {
91+ if timer != nil {
92+ timer.Stop()
93+ }
94+ }()
95+ startIdle := func() {
96+ if idle != nil {
97+ return
98+ }
99+ if timer == nil {
100+ timer = clk.NewTimer(idleTTL)
101+ } else {
102+ timer.Reset(idleTTL)
103+ }
104+ idle = timer.C()
105+ }
106+ stopIdle := func() {
107+ if timer != nil {
108+ timer.Stop()
109+ }
110+ idle = nil
111+ }
112+ for {
113+ select {
114+ case <-ctx.Done():
115+ return ctx.Err()
116+ case ev, ok := <-linkEvents:
117+ if !ok {
118+ linkEvents = nil
119+ continue
120+ }
121+ switch ev.State {
122+ case ports.LinkStateDegraded, ports.LinkStateProbing, ports.LinkStateOffline, ports.LinkStateDead:
123+ startIdle()
124+ case ports.LinkStateConnected:
125+ stopIdle()
126+ }
127+ case <-idle:
128+ return ErrLinkDead
129+ case ce := <-errCh:
130+ switch ce.kind {
131+ case proxyDaemonRecv:
132+ if errors.Is(ce.err, io.EOF) {
133+ return nil
134+ }
135+ return ce.err
136+ case proxyClientRecv, proxyClientSend:
137+ if errors.Is(ce.err, ErrLinkDead) {
138+ startIdle()
139+ continue
140+ }
141+ return ce.err
142+ default:
143+ // Recoverable client-side errors are retried inside the copier
144+ // without dropping the frame; anything that surfaces here (a
145+ // daemon send failure, or a non-recoverable client send failure)
146+ // is fatal.
147+ return ce.err
148+ }
149+ }
150+ }
151+}
152+
153+func (c proxyCopier) copyFrames(dir proxyCopyDirection) {
154+ for {
155+ f, err := dir.src.Recv()
156+ if err != nil {
157+ c.errCh <- proxyCopyErr{kind: dir.recvKind, err: err}
158+ return
159+ }
160+ // Retry the same frame in place while the destination link error stays
161+ // recoverable (congestion, transient path loss). The frame is never
162+ // dropped — on a reliable stream a gap desynchronizes the client screen.
163+ for {
164+ err := dir.dst.Send(f)
165+ if err == nil {
166+ break
167+ }
168+ if dir.retryRecoverable && recoverableClientLink(dir.dst, err) {
169+ t := c.clk.NewTimer(c.retryBackoff)
170+ select {
171+ case <-t.C():
172+ t.Stop()
173+ continue
174+ case <-c.ctx.Done():
175+ t.Stop()
176+ return
177+ }
178+ }
179+ c.errCh <- proxyCopyErr{kind: dir.sendKind, err: err}
180+ return
181+ }
182+ }
183+}
184+
185+func recoverableClientLink(t ports.Transport, err error) bool {
186+ if errors.Is(err, ErrLinkDead) {
187+ return false
188+ }
189+ reporter, ok := t.(ports.LinkStateReporter)
190+ if !ok {
191+ return false
192+ }
193+ switch reporter.LinkState() {
194+ case ports.LinkStateConnected:
195+ return errors.Is(err, ErrPendingFull)
196+ case ports.LinkStateDegraded, ports.LinkStateProbing, ports.LinkStateOffline:
197+ return true
198+ default:
199+ return false
200+ }
201+}
A internal/adapters/dgram/proxy_registry.go +152 -0
@@ -0,0 +1,152 @@
1+package dgram
2+
3+import (
4+ "encoding/base64"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "syscall"
11+ "time"
12+
13+ "github.com/bnema/vev/pkg/safedir"
14+)
15+
16+type ProxyRecord struct {
17+ Session string `json:"session"`
18+ PID int `json:"pid"`
19+ Port int `json:"port"`
20+ Key string `json:"key"`
21+ Created time.Time `json:"created"`
22+}
23+
24+func (r ProxyRecord) ReadyLine() string { return fmt.Sprintf("VEV-UDP %d %s\n", r.Port, r.Key) }
25+
26+type ProxyRegistry struct{ dir string }
27+
28+func NewProxyRegistry(dir string) *ProxyRegistry { return &ProxyRegistry{dir: dir} }
29+
30+func (r *ProxyRegistry) Lookup(session string) (ProxyRecord, bool) {
31+ var rec ProxyRecord
32+ ok := false
33+ _ = r.withLock(func() error {
34+ var err error
35+ rec, err = r.read(session)
36+ if err != nil {
37+ return nil
38+ }
39+ if !processAlive(rec.PID) {
40+ _ = r.removeLocked(session)
41+ return nil
42+ }
43+ if rec.PID != os.Getpid() {
44+ return nil
45+ }
46+ ok = true
47+ return nil
48+ })
49+ return rec, ok
50+}
51+
52+func (r *ProxyRegistry) Publish(rec ProxyRecord) error {
53+ if rec.Created.IsZero() {
54+ rec.Created = time.Now()
55+ }
56+ return r.withLock(func() error {
57+ b, err := json.Marshal(rec)
58+ if err != nil {
59+ return err
60+ }
61+ tmp, err := os.CreateTemp(r.dir, ".udp-proxy-*.tmp")
62+ if err != nil {
63+ return err
64+ }
65+ tmpName := tmp.Name()
66+ defer func() { _ = os.Remove(tmpName) }()
67+ if _, err := tmp.Write(b); err != nil {
68+ _ = tmp.Close()
69+ return err
70+ }
71+ if err := tmp.Close(); err != nil {
72+ return err
73+ }
74+ return os.Rename(tmpName, r.path(rec.Session))
75+ })
76+}
77+
78+func (r *ProxyRegistry) Remove(session string) error {
79+ return r.withLock(func() error { return r.removeLocked(session) })
80+}
81+
82+func (r *ProxyRegistry) RemoveOwned(rec ProxyRecord) error {
83+ return r.withLock(func() error {
84+ cur, err := r.read(rec.Session)
85+ if errors.Is(err, os.ErrNotExist) {
86+ return nil
87+ }
88+ if err != nil {
89+ return err
90+ }
91+ if cur.PID != rec.PID || cur.Port != rec.Port || cur.Key != rec.Key {
92+ return nil
93+ }
94+ return r.removeLocked(rec.Session)
95+ })
96+}
97+
98+func (r *ProxyRegistry) withLock(fn func() error) error {
99+ if err := safedir.EnsurePrivate(r.dir); err != nil {
100+ return err
101+ }
102+ lock, err := os.OpenFile(filepath.Join(r.dir, ".lock"), os.O_CREATE|os.O_RDWR, 0o600)
103+ if err != nil {
104+ return err
105+ }
106+ defer func() { _ = lock.Close() }()
107+ if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
108+ return err
109+ }
110+ defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) }()
111+ return fn()
112+}
113+
114+func (r *ProxyRegistry) removeLocked(session string) error {
115+ err := os.Remove(r.path(session))
116+ if errors.Is(err, os.ErrNotExist) {
117+ return nil
118+ }
119+ return err
120+}
121+
122+func (r *ProxyRegistry) read(session string) (ProxyRecord, error) {
123+ b, err := os.ReadFile(r.path(session))
124+ if err != nil {
125+ return ProxyRecord{}, err
126+ }
127+ var rec ProxyRecord
128+ if err := json.Unmarshal(b, &rec); err != nil {
129+ return ProxyRecord{}, err
130+ }
131+ return rec, nil
132+}
133+
134+func (r *ProxyRegistry) path(session string) string {
135+ if session == "" {
136+ return filepath.Join(r.dir, "udp-proxy-empty.json")
137+ }
138+ name := base64.RawURLEncoding.EncodeToString([]byte(session))
139+ return filepath.Join(r.dir, "udp-proxy-"+name+".json")
140+}
141+
142+func processAlive(pid int) bool {
143+ if pid <= 0 {
144+ return false
145+ }
146+ p, err := os.FindProcess(pid)
147+ if err != nil {
148+ return false
149+ }
150+ err = p.Signal(syscall.Signal(0))
151+ return err == nil || errors.Is(err, syscall.EPERM)
152+}
A internal/adapters/dgram/proxy_registry_test.go +104 -0
@@ -0,0 +1,104 @@
1+package dgram
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func privateDir(t *testing.T) string {
10+ t.Helper()
11+ return filepath.Join(t.TempDir(), "vev")
12+}
13+
14+func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
15+ t.Run("reuses live matching record", func(t *testing.T) {
16+ r := NewProxyRegistry(privateDir(t))
17+ want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}
18+ if err := r.Publish(want); err != nil {
19+ t.Fatal(err)
20+ }
21+ got, ok := r.Lookup("work")
22+ if !ok {
23+ t.Fatal("Lookup ok=false, want true")
24+ }
25+ if got.Port != want.Port || got.Key != want.Key || got.PID != want.PID {
26+ t.Fatalf("Lookup()=%+v, want %+v", got, want)
27+ }
28+ })
29+ t.Run("removes stale record", func(t *testing.T) {
30+ r := NewProxyRegistry(privateDir(t))
31+ if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}); err != nil {
32+ t.Fatal(err)
33+ }
34+ if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key":"abc"}`), 0o600); err != nil {
35+ t.Fatal(err)
36+ }
37+ if _, ok := r.Lookup("work"); ok {
38+ t.Fatal("Lookup ok=true for stale pid")
39+ }
40+ if _, err := os.Stat(r.path("work")); !os.IsNotExist(err) {
41+ t.Fatalf("stale record stat err=%v, want not exist", err)
42+ }
43+ })
44+ t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) {
45+ r := NewProxyRegistry(privateDir(t))
46+ old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "old"}
47+ if err := r.Publish(old); err != nil {
48+ t.Fatal(err)
49+ }
50+ newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, Key: "new"}
51+ if err := r.Publish(newRec); err != nil {
52+ t.Fatal(err)
53+ }
54+ if err := r.RemoveOwned(old); err != nil {
55+ t.Fatal(err)
56+ }
57+ got, ok := r.Lookup("work")
58+ if !ok {
59+ t.Fatal("new live record removed by stale owner")
60+ }
61+ if got.Port != newRec.Port || got.Key != newRec.Key {
62+ t.Fatalf("Lookup()=%+v, want %+v", got, newRec)
63+ }
64+ if err := r.RemoveOwned(newRec); err != nil {
65+ t.Fatal(err)
66+ }
67+ if _, ok := r.Lookup("work"); ok {
68+ t.Fatal("owned remove left registry record")
69+ }
70+ })
71+}
72+
73+func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
74+ r := NewProxyRegistry(privateDir(t))
75+ tests := []struct {
76+ a string
77+ b string
78+ }{
79+ {a: "", b: "default"},
80+ {a: "a/b", b: "a_b"},
81+ {a: `a\b`, b: "a_b"},
82+ }
83+ for _, tt := range tests {
84+ if r.path(tt.a) == r.path(tt.b) {
85+ t.Fatalf("path(%q) collides with path(%q): %s", tt.a, tt.b, r.path(tt.a))
86+ }
87+ first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, Key: "first"}
88+ second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, Key: "second"}
89+ if err := r.Publish(first); err != nil {
90+ t.Fatal(err)
91+ }
92+ if err := r.Publish(second); err != nil {
93+ t.Fatal(err)
94+ }
95+ gotFirst, ok := r.Lookup(tt.a)
96+ if !ok || gotFirst.Key != first.Key {
97+ t.Fatalf("Lookup(%q)=%+v ok=%v, want first record", tt.a, gotFirst, ok)
98+ }
99+ gotSecond, ok := r.Lookup(tt.b)
100+ if !ok || gotSecond.Key != second.Key {
101+ t.Fatalf("Lookup(%q)=%+v ok=%v, want second record", tt.b, gotSecond, ok)
102+ }
103+ }
104+}
A internal/adapters/dgram/proxy_test.go +439 -0
@@ -0,0 +1,439 @@
1+package dgram
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+type fakeTransport struct {
15+ recv chan recvResult
16+ sent chan ports.Frame
17+ sendErr chan error
18+ linkState ports.LinkState
19+ linkEvents chan ports.LinkEvent
20+ closed chan struct{}
21+ closeOnce sync.Once
22+}
23+
24+type recvResult struct {
25+ frame ports.Frame
26+ err error
27+}
28+
29+func newFakeTransport() *fakeTransport {
30+ return &fakeTransport{recv: make(chan recvResult, 4), sent: make(chan ports.Frame, 4), sendErr: make(chan error, 4), linkEvents: make(chan ports.LinkEvent, 4), linkState: ports.LinkStateConnected, closed: make(chan struct{})}
31+}
32+func (f *fakeTransport) Send(fr ports.Frame) error {
33+ select {
34+ case err := <-f.sendErr:
35+ return err
36+ default:
37+ }
38+ f.sent <- fr
39+ return nil
40+}
41+func (f *fakeTransport) Recv() (ports.Frame, error) {
42+ select {
43+ case r := <-f.recv:
44+ return r.frame, r.err
45+ case <-f.closed:
46+ return ports.Frame{}, io.EOF
47+ }
48+}
49+func (f *fakeTransport) Close() error {
50+ f.closeOnce.Do(func() { close(f.closed) })
51+ return nil
52+}
53+func (f *fakeTransport) LinkState() ports.LinkState { return f.linkState }
54+func (f *fakeTransport) LinkEvents() <-chan ports.LinkEvent { return f.linkEvents }
55+
56+func TestProxyRuntimeCopiesFramesAndDaemonEOFIsTerminal(t *testing.T) {
57+ client := newFakeTransport()
58+ daemon := newFakeTransport()
59+ errCh := make(chan error, 1)
60+ go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
61+ client.recv <- recvResult{frame: ports.Frame{Type: ports.MsgInput, Payload: []byte("x")}}
62+ select {
63+ case got := <-daemon.sent:
64+ if got.Type != ports.MsgInput {
65+ t.Fatalf("daemon got type %v", got.Type)
66+ }
67+ case <-time.After(time.Second):
68+ t.Fatal("timeout waiting for copied frame")
69+ }
70+ daemon.recv <- recvResult{err: io.EOF}
71+ select {
72+ case err := <-errCh:
73+ if err != nil {
74+ t.Fatalf("Run err=%v, want nil", err)
75+ }
76+ case <-time.After(time.Second):
77+ t.Fatal("timeout waiting for terminal EOF")
78+ }
79+}
80+
81+func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) {
82+ tests := []struct {
83+ name string
84+ state ports.LinkState
85+ err error
86+ }{
87+ {name: "connected pending full", state: ports.LinkStateConnected, err: ErrPendingFull},
88+ {name: "degraded transient error", state: ports.LinkStateDegraded, err: errors.New("degraded write drop")},
89+ }
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ client := newFakeTransport()
93+ daemon := newFakeTransport()
94+ // No link event is emitted, so the idle timer is never armed; the
95+ // retry alone must recover. IdleTTL is large to make that explicit.
96+ client.linkState = tt.state
97+ errCh := make(chan error, 1)
98+ go func() {
99+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour, RetryBackoff: time.Nanosecond}.Run(t.Context())
100+ }()
101+ client.sendErr <- tt.err
102+ daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("preserved during outage")}}
103+ select {
104+ case got := <-client.sent:
105+ if got.Type != ports.MsgOutput || string(got.Payload) != "preserved during outage" {
106+ t.Fatalf("client got %+v, want retried output", got)
107+ }
108+ case <-time.After(time.Second):
109+ t.Fatal("timeout waiting for recovered daemon output")
110+ }
111+ daemon.recv <- recvResult{err: io.EOF}
112+ select {
113+ case err := <-errCh:
114+ if err != nil {
115+ t.Fatalf("Run err=%v, want nil", err)
116+ }
117+ case <-time.After(time.Second):
118+ t.Fatal("timeout waiting for daemon EOF")
119+ }
120+ })
121+ }
122+}
123+
124+// TestProxyRuntimeSurvivesPendingFullAndPreservesOrder is the congestion
125+// regression: a persistently pending-full client window must not tear the proxy
126+// down (the old idle TTL killed it after 30s) and every frame must arrive in
127+// order. IdleTTL is a nanosecond: if a retry armed the idle timer the proxy
128+// would die at once, so surviving proves retries no longer arm it.
129+func TestProxyRuntimeSurvivesPendingFullAndPreservesOrder(t *testing.T) {
130+ client := newFakeTransport()
131+ daemon := newFakeTransport()
132+ client.linkState = ports.LinkStateConnected
133+ errCh := make(chan error, 1)
134+ go func() {
135+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Nanosecond, RetryBackoff: time.Nanosecond}.Run(t.Context())
136+ }()
137+ const frames = 4
138+ for i := range frames {
139+ client.sendErr <- ErrPendingFull
140+ daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}}
141+ select {
142+ case got := <-client.sent:
143+ if got.Type != ports.MsgOutput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
144+ t.Fatalf("frame %d delivered out of order or dropped: %+v", i, got)
145+ }
146+ case <-time.After(time.Second):
147+ t.Fatalf("frame %d dropped or proxy killed under pending-full", i)
148+ }
149+ }
150+ select {
151+ case err := <-errCh:
152+ t.Fatalf("proxy returned under sustained pending-full: %v", err)
153+ case <-time.After(20 * time.Millisecond):
154+ }
155+ daemon.recv <- recvResult{err: io.EOF}
156+ select {
157+ case err := <-errCh:
158+ if err != nil {
159+ t.Fatalf("Run err=%v, want nil", err)
160+ }
161+ case <-time.After(time.Second):
162+ t.Fatal("timeout waiting for daemon EOF")
163+ }
164+}
165+
166+func TestProxyRuntimeOfflineLinkEventExpiresViaIdleTTL(t *testing.T) {
167+ client := newFakeTransport()
168+ daemon := newFakeTransport()
169+ client.linkState = ports.LinkStateOffline
170+ errCh := make(chan error, 1)
171+ go func() {
172+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: 20 * time.Millisecond}.Run(t.Context())
173+ }()
174+ client.linkEvents <- ports.LinkEvent{State: ports.LinkStateOffline}
175+ select {
176+ case err := <-errCh:
177+ if !errors.Is(err, ErrLinkDead) {
178+ t.Fatalf("Run err=%v, want ErrLinkDead", err)
179+ }
180+ case <-time.After(time.Second):
181+ t.Fatal("timeout waiting for idle expiry after offline link event")
182+ }
183+}
184+
185+type proxyManualClock struct {
186+ mu sync.Mutex
187+ now time.Time
188+ timers chan *proxyManualTimer
189+}
190+
191+func newProxyManualClock(now time.Time) *proxyManualClock {
192+ return &proxyManualClock{now: now, timers: make(chan *proxyManualTimer, 4)}
193+}
194+
195+func (c *proxyManualClock) Now() time.Time {
196+ c.mu.Lock()
197+ defer c.mu.Unlock()
198+ return c.now
199+}
200+
201+func (c *proxyManualClock) NewTimer(d time.Duration) ports.Timer {
202+ tm := &proxyManualTimer{c: make(chan time.Time, 1), d: d}
203+ c.timers <- tm
204+ return tm
205+}
206+
207+func (c *proxyManualClock) advance(d time.Duration) {
208+ c.mu.Lock()
209+ c.now = c.now.Add(d)
210+ now := c.now
211+ c.mu.Unlock()
212+ for {
213+ select {
214+ case tm := <-c.timers:
215+ if tm.duration() <= d {
216+ tm.fire(now)
217+ } else {
218+ c.timers <- tm
219+ return
220+ }
221+ default:
222+ return
223+ }
224+ }
225+}
226+
227+type proxyManualTimer struct {
228+ mu sync.Mutex
229+ c chan time.Time
230+ d time.Duration
231+}
232+
233+func (t *proxyManualTimer) C() <-chan time.Time { return t.c }
234+func (t *proxyManualTimer) Reset(d time.Duration) bool {
235+ t.mu.Lock()
236+ defer t.mu.Unlock()
237+ t.d = d
238+ return true
239+}
240+func (t *proxyManualTimer) Stop() bool { return true }
241+func (t *proxyManualTimer) duration() time.Duration {
242+ t.mu.Lock()
243+ defer t.mu.Unlock()
244+ return t.d
245+}
246+func (t *proxyManualTimer) fire(now time.Time) {
247+ select {
248+ case t.c <- now:
249+ default:
250+ }
251+}
252+
253+func waitForProxyManualTimers(t *testing.T, clk *proxyManualClock, n int) {
254+ t.Helper()
255+ deadline := time.Now().Add(time.Second)
256+ for time.Now().Before(deadline) {
257+ if len(clk.timers) >= n {
258+ return
259+ }
260+ time.Sleep(time.Millisecond)
261+ }
262+ t.Fatalf("manual timers=%d, want at least %d", len(clk.timers), n)
263+}
264+
265+func TestProxyRuntimeClientDeadWaitsForIdleTTL(t *testing.T) {
266+ client := newFakeTransport()
267+ daemon := newFakeTransport()
268+ clk := newProxyManualClock(time.Unix(0, 0))
269+ errCh := make(chan error, 1)
270+ go func() {
271+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Minute, Clock: clk}.Run(t.Context())
272+ }()
273+
274+ client.linkEvents <- ports.LinkEvent{State: ports.LinkStateDead}
275+ waitForProxyManualTimers(t, clk, 1)
276+ select {
277+ case err := <-errCh:
278+ t.Fatalf("Run returned before IdleTTL after client LinkStateDead: %v", err)
279+ default:
280+ }
281+
282+ clk.advance(time.Minute)
283+ select {
284+ case err := <-errCh:
285+ if !errors.Is(err, ErrLinkDead) {
286+ t.Fatalf("Run err=%v, want ErrLinkDead", err)
287+ }
288+ case <-time.After(time.Second):
289+ t.Fatal("timeout waiting for idle expiry after client LinkStateDead")
290+ }
291+}
292+
293+func TestProxyRuntimeClientRecvDeadWaitsForIdleTTL(t *testing.T) {
294+ client := newFakeTransport()
295+ daemon := newFakeTransport()
296+ clk := newProxyManualClock(time.Unix(0, 0))
297+ errCh := make(chan error, 1)
298+ go func() {
299+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Minute, Clock: clk}.Run(t.Context())
300+ }()
301+
302+ client.recv <- recvResult{err: ErrLinkDead}
303+ waitForProxyManualTimers(t, clk, 1)
304+ select {
305+ case err := <-errCh:
306+ t.Fatalf("Run returned before IdleTTL after client Recv ErrLinkDead: %v", err)
307+ default:
308+ }
309+
310+ clk.advance(time.Minute)
311+ select {
312+ case err := <-errCh:
313+ if !errors.Is(err, ErrLinkDead) {
314+ t.Fatalf("Run err=%v, want ErrLinkDead", err)
315+ }
316+ case <-time.After(time.Second):
317+ t.Fatal("timeout waiting for idle expiry after client Recv ErrLinkDead")
318+ }
319+}
320+
321+func TestProxyRuntimeClientSendDeadWaitsForIdleTTL(t *testing.T) {
322+ client := newFakeTransport()
323+ daemon := newFakeTransport()
324+ clk := newProxyManualClock(time.Unix(0, 0))
325+ errCh := make(chan error, 1)
326+ go func() {
327+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Minute, Clock: clk}.Run(t.Context())
328+ }()
329+
330+ client.sendErr <- ErrLinkDead
331+ daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("client is gone")}}
332+ waitForProxyManualTimers(t, clk, 1)
333+ select {
334+ case err := <-errCh:
335+ t.Fatalf("Run returned before IdleTTL after client Send ErrLinkDead: %v", err)
336+ default:
337+ }
338+
339+ clk.advance(time.Minute)
340+ select {
341+ case err := <-errCh:
342+ if !errors.Is(err, ErrLinkDead) {
343+ t.Fatalf("Run err=%v, want ErrLinkDead", err)
344+ }
345+ case <-time.After(time.Second):
346+ t.Fatal("timeout waiting for idle expiry after client Send ErrLinkDead")
347+ }
348+}
349+
350+func TestProxyRuntimeDaemonRecvDeadTearsDown(t *testing.T) {
351+ client := newFakeTransport()
352+ daemon := newFakeTransport()
353+ errCh := make(chan error, 1)
354+ go func() {
355+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context())
356+ }()
357+ daemon.recv <- recvResult{err: ErrLinkDead}
358+ select {
359+ case err := <-errCh:
360+ if !errors.Is(err, ErrLinkDead) {
361+ t.Fatalf("Run err=%v, want ErrLinkDead", err)
362+ }
363+ case <-time.After(time.Second):
364+ t.Fatal("daemon recv dead did not tear down proxy")
365+ }
366+}
367+
368+func TestProxyRuntimeClosedLinkEventsDoNotSpin(t *testing.T) {
369+ client := newFakeTransport()
370+ daemon := newFakeTransport()
371+ close(client.linkEvents)
372+ errCh := make(chan error, 1)
373+ go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
374+ daemon.recv <- recvResult{err: io.EOF}
375+ select {
376+ case err := <-errCh:
377+ if err != nil {
378+ t.Fatalf("Run err=%v, want nil", err)
379+ }
380+ case <-time.After(time.Second):
381+ t.Fatal("timeout waiting for daemon EOF")
382+ }
383+}
384+
385+type retryingOfflineTransport struct {
386+ closed chan struct{}
387+ firstSend chan struct{}
388+ closeOnce sync.Once
389+ firstSendOnce sync.Once
390+}
391+
392+func newRetryingOfflineTransport() *retryingOfflineTransport {
393+ return &retryingOfflineTransport{closed: make(chan struct{}), firstSend: make(chan struct{})}
394+}
395+
396+func (t *retryingOfflineTransport) Send(ports.Frame) error {
397+ t.firstSendOnce.Do(func() { close(t.firstSend) })
398+ return errors.New("offline")
399+}
400+
401+func (t *retryingOfflineTransport) Recv() (ports.Frame, error) {
402+ <-t.closed
403+ return ports.Frame{}, io.EOF
404+}
405+
406+func (t *retryingOfflineTransport) Close() error {
407+ t.closeOnce.Do(func() { close(t.closed) })
408+ return nil
409+}
410+
411+func (t *retryingOfflineTransport) LinkState() ports.LinkState { return ports.LinkStateOffline }
412+func (t *retryingOfflineTransport) LinkEvents() <-chan ports.LinkEvent {
413+ return nil
414+}
415+
416+func TestProxyCopierStopsRetryingWhenContextCanceled(t *testing.T) {
417+ ctx, cancel := context.WithCancel(t.Context())
418+ client := newRetryingOfflineTransport()
419+ daemon := newFakeTransport()
420+ done := make(chan struct{})
421+ copier := proxyCopier{ctx: ctx, errCh: make(chan<- proxyCopyErr), retryBackoff: time.Hour, clk: realClock{}}
422+ go func() {
423+ copier.copyFrames(proxyCopyDirection{src: daemon, dst: client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true})
424+ close(done)
425+ }()
426+
427+ daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("retry until cancel")}}
428+ select {
429+ case <-client.firstSend:
430+ case <-time.After(time.Second):
431+ t.Fatal("timeout waiting for proxy send attempt")
432+ }
433+ cancel()
434+ select {
435+ case <-done:
436+ case <-time.After(time.Second):
437+ t.Fatal("retrying copier did not stop after context cancellation")
438+ }
439+}
A internal/adapters/dgram/ssh_dialer.go +302 -0
@@ -0,0 +1,302 @@
1+package dgram
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/base64"
7+ "fmt"
8+ "io"
9+ "log/slog"
10+ "net"
11+ "os/exec"
12+ "strconv"
13+ "strings"
14+ "time"
15+
16+ "github.com/bnema/vev/internal/adapters/sshstdio"
17+ "github.com/bnema/vev/internal/ports"
18+ pdgram "github.com/bnema/vev/pkg/dgram"
19+)
20+
21+const (
22+ maxBootstrapStderr = 64 * 1024
23+ defaultBootstrapTimeout = 10 * time.Second
24+ defaultProbeTimeout = 3 * time.Second
25+)
26+
27+type bootstrapProcess interface {
28+ StdoutPipe() (io.ReadCloser, error)
29+ Start() error
30+ Kill() error
31+ Wait() error
32+}
33+
34+type execBootstrapProcess struct{ cmd *exec.Cmd }
35+
36+func (p execBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.cmd.StdoutPipe() }
37+func (p execBootstrapProcess) Start() error { return p.cmd.Start() }
38+func (p execBootstrapProcess) Kill() error { return p.cmd.Process.Kill() }
39+func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() }
40+
41+var (
42+ startUDPBootstrap = func(ctx context.Context, target, session string, stderr io.Writer) bootstrapProcess {
43+ spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", session)
44+ cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
45+ cmd.Stderr = stderr
46+ return execBootstrapProcess{cmd: cmd}
47+ }
48+ listenUDPPacket = func(ctx context.Context) (net.PacketConn, error) {
49+ var lc net.ListenConfig
50+ return lc.ListenPacket(ctx, "udp", ":0")
51+ }
52+ probeUDPTransport = func(ctx context.Context, t *Transport) error { return t.Probe(ctx) }
53+)
54+
55+// RemoteDialFailureKind classifies which part of UDP remote bootstrap failed
56+// so client-side recovery policy and tests do not have to parse human text.
57+type RemoteDialFailureKind uint8
58+
59+const (
60+ RemoteDialBootstrapUnavailable RemoteDialFailureKind = iota + 1
61+ RemoteDialProbeUnreachable
62+)
63+
64+// RemoteDialError wraps a UDP remote bootstrap failure with a stable machine
65+// classification while preserving the existing human-facing error text.
66+type RemoteDialError struct {
67+ Kind RemoteDialFailureKind
68+ Action string
69+ Err error
70+ Hint string
71+}
72+
73+func (e *RemoteDialError) Error() string {
74+ base := fmt.Sprintf("remote UDP transport unavailable: %s: %v", e.Action, e.Err)
75+ if e.Hint != "" {
76+ return base + e.Hint
77+ }
78+ return base
79+}
80+
81+func (e *RemoteDialError) Unwrap() error { return e.Err }
82+
83+type limitedBuffer struct {
84+ buf []byte
85+}
86+
87+func (b *limitedBuffer) Write(p []byte) (int, error) {
88+ if len(b.buf) < maxBootstrapStderr {
89+ keep := min(len(p), maxBootstrapStderr-len(b.buf))
90+ b.buf = append(b.buf, p[:keep]...)
91+ }
92+ return len(p), nil
93+}
94+
95+func (b *limitedBuffer) String() string { return string(b.buf) }
96+
97+// RemoteDialer connects to the authenticated datagram bootstrap for UDP remote
98+// attach. SSH stdio is selected only through the explicit stdio transport mode.
99+type RemoteDialer struct {
100+ Target string
101+ Session string
102+ BootstrapTimeout time.Duration
103+ ProbeTimeout time.Duration
104+ Log *slog.Logger
105+}
106+
107+func NewRemoteDialer(target, session string) RemoteDialer {
108+ return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout}
109+}
110+
111+func NewRemoteDialerWithLogger(target, session string, log *slog.Logger) RemoteDialer {
112+ return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout, Log: log}
113+}
114+
115+func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
116+ if err := ctx.Err(); err != nil {
117+ return nil, err
118+ }
119+ bootstrapTimeout := d.BootstrapTimeout
120+ if bootstrapTimeout <= 0 {
121+ bootstrapTimeout = defaultBootstrapTimeout
122+ }
123+ probeTimeout := d.ProbeTimeout
124+ if probeTimeout <= 0 {
125+ probeTimeout = defaultProbeTimeout
126+ }
127+ bootstrapCtx, bootstrapCancel := context.WithTimeout(ctx, bootstrapTimeout)
128+ defer bootstrapCancel()
129+
130+ var stderr limitedBuffer
131+ proc := startUDPBootstrap(bootstrapCtx, d.Target, d.Session, &stderr)
132+ stdout, err := proc.StdoutPipe()
133+ if err != nil {
134+ return nil, udpUnavailable("bootstrap stdout", err, &stderr)
135+ }
136+ if err := proc.Start(); err != nil {
137+ _ = stdout.Close()
138+ return nil, udpUnavailable("start bootstrap", err, &stderr)
139+ }
140+ cleanup := true
141+ waited := false
142+ defer func() {
143+ if cleanup {
144+ _ = proc.Kill()
145+ }
146+ if !waited {
147+ _ = proc.Wait()
148+ }
149+ _ = stdout.Close()
150+ }()
151+
152+ ready, err := readUDPReadyContext(bootstrapCtx, stdout)
153+ if err != nil {
154+ _ = proc.Kill()
155+ _ = proc.Wait()
156+ waited = true
157+ cleanup = false
158+ return nil, udpUnavailable("read bootstrap readiness", err, &stderr)
159+ }
160+ if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {
161+ waited = true
162+ cleanup = false
163+ return nil, udpUnavailable("wait bootstrap", err, &stderr)
164+ }
165+ waited = true
166+ cleanup = false
167+ peer, err := resolveUDPPeer(bootstrapCtx, d.Target, ready.port)
168+ if err != nil {
169+ return nil, udpUnavailable("resolve UDP peer", err, &stderr)
170+ }
171+ pc, err := listenUDPPacket(ctx)
172+ if err != nil {
173+ return nil, udpUnavailable("listen UDP", err, &stderr)
174+ }
175+ // RebindPacketConn heals NAT rebinds / stale conntrack paths by hopping to a
176+ // fresh local UDP socket once the link is silent long enough to probe. The
177+ // bind reuses listenUDPPacket so tests can stub it.
178+ t, err := NewTransportWithOptions(pc, peer, ready.key, 1, 2, Options{
179+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
180+ return listenUDPPacket(ctx)
181+ },
182+ })
183+ if err != nil {
184+ _ = pc.Close()
185+ return nil, udpUnavailable("create UDP transport", err, &stderr)
186+ }
187+ probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
188+ defer cancel()
189+ if err := probeUDPTransport(probeCtx, t); err != nil {
190+ _ = t.Close()
191+ return nil, udpProbeUnreachable(d.Target, err, &stderr)
192+ }
193+ cleanup = false
194+ return t, nil
195+}
196+
197+func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {
198+ type result struct {
199+ ready udpReady
200+ err error
201+ }
202+ ch := make(chan result, 1)
203+ go func() {
204+ ready, err := readUDPReady(r)
205+ ch <- result{ready: ready, err: err}
206+ }()
207+ select {
208+ case res := <-ch:
209+ return res.ready, res.err
210+ case <-ctx.Done():
211+ return udpReady{}, ctx.Err()
212+ }
213+}
214+
215+func waitBootstrapContext(ctx context.Context, proc bootstrapProcess) error {
216+ ch := make(chan error, 1)
217+ go func() { ch <- proc.Wait() }()
218+ select {
219+ case err := <-ch:
220+ return err
221+ case <-ctx.Done():
222+ _ = proc.Kill()
223+ return ctx.Err()
224+ }
225+}
226+
227+func resolveUDPPeer(ctx context.Context, target string, port int) (*net.UDPAddr, error) {
228+ host := sshTargetHost(target)
229+ if ip := net.ParseIP(host); ip != nil {
230+ return &net.UDPAddr{IP: ip, Port: port}, nil
231+ }
232+ addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
233+ if err != nil {
234+ return nil, err
235+ }
236+ if len(addrs) == 0 {
237+ return nil, fmt.Errorf("no UDP peer addresses for %q", host)
238+ }
239+ return &net.UDPAddr{IP: addrs[0].IP, Zone: addrs[0].Zone, Port: port}, nil
240+}
241+
242+type udpReady struct {
243+ port int
244+ key []byte
245+}
246+
247+func readUDPReady(r io.Reader) (udpReady, error) {
248+ line, err := bufio.NewReader(r).ReadString('\n')
249+ if err != nil && line == "" {
250+ return udpReady{}, err
251+ }
252+ fields := strings.Fields(line)
253+ if len(fields) != 3 || fields[0] != "VEV-UDP" {
254+ return udpReady{}, fmt.Errorf("malformed readiness line %q", strings.TrimSpace(line))
255+ }
256+ port, err := strconv.Atoi(fields[1])
257+ if err != nil || port < 1 || port > 65535 {
258+ return udpReady{}, fmt.Errorf("invalid UDP port %q", fields[1])
259+ }
260+ key, err := base64.StdEncoding.DecodeString(fields[2])
261+ if err != nil {
262+ return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err)
263+ }
264+ if len(key) != pdgram.KeySize {
265+ return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key))
266+ }
267+ return udpReady{port: port, key: key}, nil
268+}
269+
270+func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
271+ return udpDialFailure(RemoteDialBootstrapUnavailable, action, err, stderr, "")
272+}
273+
274+func udpDialFailure(kind RemoteDialFailureKind, action string, err error, stderr fmt.Stringer, hint string) error {
275+ if msg := strings.TrimSpace(stderr.String()); msg != "" {
276+ hint = fmt.Sprintf(" (stderr: %s)%s", msg, hint)
277+ }
278+ return &RemoteDialError{Kind: kind, Action: action, Err: err, Hint: hint}
279+}
280+
281+// udpProbeUnreachable wraps a probe failure with actionable guidance. A failed
282+// probe almost always means a firewall on the remote is dropping datagrams to
283+// vev's UDP proxy rather than a vev bug, so name the two real fixes instead of
284+// surfacing a bare deadline error.
285+func udpProbeUnreachable(target string, err error, stderr fmt.Stringer) error {
286+ return udpDialFailure(RemoteDialProbeUnreachable, "probe UDP transport", err, stderr, fmt.Sprintf("\n"+
287+ " the remote host is not reachable over UDP; a firewall is likely dropping the datagrams.\n"+
288+ " open UDP on the remote (e.g. `sudo ufw allow in on tailscale0`) or attach over SSH with `VEV_REMOTE_TRANSPORT=stdio vev attach %s`", target))
289+}
290+
291+func sshTargetHost(target string) string {
292+ if at := strings.LastIndexByte(target, '@'); at >= 0 {
293+ target = target[at+1:]
294+ }
295+ if h, _, err := net.SplitHostPort(target); err == nil {
296+ return h
297+ }
298+ if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
299+ return strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[")
300+ }
301+ return target
302+}
A internal/adapters/dgram/ssh_dialer_test.go +240 -0
@@ -0,0 +1,240 @@
1+package dgram
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "io"
8+ "net"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ pdgram "github.com/bnema/vev/pkg/dgram"
14+)
15+
16+type fakeBootstrapProcess struct {
17+ stdout io.ReadCloser
18+ startErr error
19+ waitErr error
20+ killed bool
21+ waited bool
22+}
23+
24+type closeTrackReadCloser struct {
25+ io.Reader
26+ closed *bool
27+}
28+
29+func (r closeTrackReadCloser) Close() error { *r.closed = true; return nil }
30+
31+func (p *fakeBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.stdout, nil }
32+func (p *fakeBootstrapProcess) Start() error { return p.startErr }
33+func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }
34+func (p *fakeBootstrapProcess) Wait() error { p.waited = true; return p.waitErr }
35+
36+func withBootstrapStarter(t *testing.T, fn func(context.Context, string, string, io.Writer) bootstrapProcess) {
37+ t.Helper()
38+ old := startUDPBootstrap
39+ startUDPBootstrap = fn
40+ t.Cleanup(func() { startUDPBootstrap = old })
41+}
42+
43+func withListenUDP(t *testing.T, fn func(context.Context) (net.PacketConn, error)) {
44+ t.Helper()
45+ old := listenUDPPacket
46+ listenUDPPacket = fn
47+ t.Cleanup(func() { listenUDPPacket = old })
48+}
49+
50+func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
51+ key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
52+ tests := []struct {
53+ name string
54+ stdout string
55+ start error
56+ listen error
57+ want string
58+ killed bool
59+ waited bool
60+ wantStdoutClosed bool
61+ }{
62+ {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
63+ {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true},
64+ {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},
65+ {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ stdoutClosed := false
70+ proc := &fakeBootstrapProcess{stdout: closeTrackReadCloser{Reader: strings.NewReader(tt.stdout), closed: &stdoutClosed}, startErr: tt.start}
71+ if tt.name == "bootstrap wait failure" {
72+ proc.waitErr = errors.New("exit status 255")
73+ }
74+ withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc })
75+ if tt.listen != nil {
76+ withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen })
77+ }
78+ tr, err := NewRemoteDialer("127.0.0.1", "work").Dial(context.Background())
79+ if err == nil || !strings.Contains(err.Error(), tt.want) {
80+ t.Fatalf("Dial() error = %v, want containing %q", err, tt.want)
81+ }
82+ var dialErr *RemoteDialError
83+ if !errors.As(err, &dialErr) {
84+ t.Fatalf("Dial() error = %T, want *RemoteDialError", err)
85+ }
86+ if dialErr.Kind != RemoteDialBootstrapUnavailable {
87+ t.Fatalf("Dial() error kind = %v, want bootstrap unavailable", dialErr.Kind)
88+ }
89+ if tr != nil {
90+ t.Fatalf("Dial() transport = %T, want nil", tr)
91+ }
92+ if proc.killed != tt.killed {
93+ t.Fatalf("killed=%v, want %v", proc.killed, tt.killed)
94+ }
95+ if proc.waited != tt.waited {
96+ t.Fatalf("waited=%v, want %v", proc.waited, tt.waited)
97+ }
98+ if stdoutClosed != tt.wantStdoutClosed {
99+ t.Fatalf("stdout closed=%v, want %v", stdoutClosed, tt.wantStdoutClosed)
100+ }
101+ })
102+ }
103+}
104+
105+func TestListenUDPPacketUsesDualStackWildcard(t *testing.T) {
106+ pc, err := listenUDPPacket(context.Background())
107+ if err != nil {
108+ t.Fatal(err)
109+ }
110+ defer func() { _ = pc.Close() }()
111+ addr, ok := pc.LocalAddr().(*net.UDPAddr)
112+ if !ok {
113+ t.Fatalf("local addr = %T, want *net.UDPAddr", pc.LocalAddr())
114+ }
115+ if !addr.IP.IsUnspecified() {
116+ t.Fatalf("listen IP = %v, want unspecified wildcard", addr.IP)
117+ }
118+}
119+
120+func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
121+ key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
122+ proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}
123+ withBootstrapStarter(t, func(_ context.Context, target, session string, _ io.Writer) bootstrapProcess {
124+ if target != "127.0.0.1" || session != "work" {
125+ t.Fatalf("bootstrap target/session = %q/%q", target, session)
126+ }
127+ return proc
128+ })
129+ var lc net.ListenConfig
130+ pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ closed := false
135+ withListenUDP(t, func(context.Context) (net.PacketConn, error) {
136+ return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil
137+ })
138+ d := NewRemoteDialer("127.0.0.1", "work")
139+ d.ProbeTimeout = time.Nanosecond
140+ tr, err := d.Dial(context.Background())
141+ if err == nil || !strings.Contains(err.Error(), "remote UDP transport unavailable: probe UDP transport") {
142+ t.Fatalf("Dial() error = %v, want probe unavailable", err)
143+ }
144+ var dialErr *RemoteDialError
145+ if !errors.As(err, &dialErr) {
146+ t.Fatalf("Dial() error = %T, want *RemoteDialError", err)
147+ }
148+ if dialErr.Kind != RemoteDialProbeUnreachable {
149+ t.Fatalf("Dial() error kind = %v, want probe unreachable", dialErr.Kind)
150+ }
151+ for _, want := range []string{"firewall", "VEV_REMOTE_TRANSPORT=stdio vev attach 127.0.0.1"} {
152+ if !strings.Contains(err.Error(), want) {
153+ t.Fatalf("Dial() error = %q, want actionable hint %q", err, want)
154+ }
155+ }
156+ if tr != nil {
157+ t.Fatalf("transport=%T, want nil", tr)
158+ }
159+ if !closed {
160+ t.Fatal("packet conn not closed after probe failure")
161+ }
162+ if proc.killed {
163+ t.Fatal("bootstrap process killed after it was already waited")
164+ }
165+ if !proc.waited {
166+ t.Fatal("bootstrap process not waited after probe failure")
167+ }
168+}
169+
170+type closeTrackPacketConn struct {
171+ net.PacketConn
172+ closed *bool
173+}
174+
175+func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() }
176+
177+func TestReadUDPReady(t *testing.T) {
178+ key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
179+ tests := []struct {
180+ name, line string
181+ wantPort int
182+ wantErr string
183+ }{
184+ {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234},
185+ {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed readiness"},
186+ {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: "invalid UDP port"},
187+ {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"},
188+ }
189+ for _, tt := range tests {
190+ t.Run(tt.name, func(t *testing.T) {
191+ got, err := readUDPReady(strings.NewReader(tt.line))
192+ if tt.wantErr != "" {
193+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
194+ t.Fatalf("err=%v, want %q", err, tt.wantErr)
195+ }
196+ return
197+ }
198+ if err != nil {
199+ t.Fatal(err)
200+ }
201+ if got.port != tt.wantPort || len(got.key) != pdgram.KeySize {
202+ t.Fatalf("ready=%+v keylen=%d", got, len(got.key))
203+ }
204+ })
205+ }
206+}
207+
208+func TestLimitedBufferCapsBootstrapStderr(t *testing.T) {
209+ var b limitedBuffer
210+ chunk := strings.Repeat("x", maxBootstrapStderr+1024)
211+ n, err := b.Write([]byte(chunk))
212+ if err != nil {
213+ t.Fatal(err)
214+ }
215+ if n != len(chunk) {
216+ t.Fatalf("n=%d, want %d", n, len(chunk))
217+ }
218+ if got := len(b.String()); got != maxBootstrapStderr {
219+ t.Fatalf("captured=%d, want %d", got, maxBootstrapStderr)
220+ }
221+}
222+
223+func TestSSHTargetHost(t *testing.T) {
224+ tests := []struct {
225+ target string
226+ want string
227+ }{
228+ {target: "example.com", want: "example.com"},
229+ {target: "user@example.com", want: "example.com"},
230+ {target: "example.com:2222", want: "example.com"},
231+ {target: "user@[2001:db8::1]:2222", want: "2001:db8::1"},
232+ }
233+ for _, tt := range tests {
234+ t.Run(tt.target, func(t *testing.T) {
235+ if got := sshTargetHost(tt.target); got != tt.want {
236+ t.Fatalf("sshTargetHost(%q) = %q, want %q", tt.target, got, tt.want)
237+ }
238+ })
239+ }
240+}
A internal/adapters/dgram/transport.go +900 -0
@@ -0,0 +1,900 @@
1+// Package dgram adapts authenticated UDP-style packet connections to ports.Transport.
2+package dgram
3+
4+import (
5+ "context"
6+ "encoding/binary"
7+ "errors"
8+ "net"
9+ "slices"
10+ "sync"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ pdgram "github.com/bnema/vev/pkg/dgram"
15+)
16+
17+const (
18+ recData byte = 1
19+ recAck byte = 2
20+ recProbe byte = 3
21+ recPong byte = 4
22+
23+ defaultMTU = pdgram.DefaultMTU
24+ defaultResend = 250 * time.Millisecond
25+ defaultMaxResendAfter = 2 * time.Second
26+ defaultMaxResendPerTick = 64
27+ defaultWriteTimeout = 250 * time.Millisecond
28+ defaultHeartbeat = 3 * time.Second
29+ defaultDegraded = 10 * time.Second
30+ defaultProbe = 20 * time.Second
31+ defaultOffline = 30 * time.Second
32+ defaultDead = 60 * time.Second
33+ defaultMaxPending = 1024
34+ defaultMaxPendingWait = 50 * time.Millisecond
35+ defaultMaxRecvBuf = 1024
36+ maxRecvBuffer = defaultMaxRecvBuf
37+ linkEventBufferSize = 16
38+ probeReplyBufferSize = 16
39+)
40+
41+var (
42+ ErrPendingFull = errors.New("dgram: pending reliable queue full")
43+ ErrLinkDead = errors.New("dgram: link dead")
44+)
45+
46+type Options struct {
47+ MTU int
48+ ResendAfter time.Duration
49+ MaxResendAfter time.Duration
50+ MaxResendPerTick int
51+ WriteTimeout time.Duration
52+ Heartbeat time.Duration
53+ DegradedAfter time.Duration
54+ ProbeAfter time.Duration
55+ OfflineAfter time.Duration
56+ DeadAfter time.Duration
57+ MaxPending int
58+ MaxPendingWait time.Duration
59+ MaxRecvBuffer int
60+ Clock ports.Clock
61+ // RebindPacketConn is an optional client-side hook used to hop the local UDP
62+ // socket when the peer is offline. Server/proxy transports should leave it nil.
63+ RebindPacketConn func(net.PacketConn) (net.PacketConn, error)
64+}
65+
66+type realClock struct{}
67+
68+func (realClock) Now() time.Time { return time.Now() }
69+func (realClock) NewTimer(d time.Duration) ports.Timer {
70+ return realTimer{t: time.NewTimer(d)}
71+}
72+
73+type realTimer struct{ t *time.Timer }
74+
75+func (r realTimer) C() <-chan time.Time { return r.t.C }
76+func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
77+func (r realTimer) Stop() bool { return r.t.Stop() }
78+
79+func normalizeOptions(opts Options) Options {
80+ if opts.MTU <= 0 {
81+ opts.MTU = defaultMTU
82+ }
83+ if opts.ResendAfter <= 0 {
84+ opts.ResendAfter = defaultResend
85+ }
86+ if opts.MaxResendAfter <= 0 {
87+ opts.MaxResendAfter = defaultMaxResendAfter
88+ }
89+ if opts.MaxResendAfter < opts.ResendAfter {
90+ opts.MaxResendAfter = opts.ResendAfter
91+ }
92+ if opts.MaxResendPerTick <= 0 {
93+ opts.MaxResendPerTick = defaultMaxResendPerTick
94+ }
95+ if opts.WriteTimeout <= 0 {
96+ opts.WriteTimeout = defaultWriteTimeout
97+ }
98+ if opts.Heartbeat <= 0 {
99+ opts.Heartbeat = defaultHeartbeat
100+ }
101+ if opts.DegradedAfter <= 0 {
102+ opts.DegradedAfter = defaultDegraded
103+ }
104+ if opts.ProbeAfter <= 0 {
105+ opts.ProbeAfter = defaultProbe
106+ }
107+ if opts.OfflineAfter <= 0 {
108+ opts.OfflineAfter = defaultOffline
109+ }
110+ if opts.DeadAfter <= 0 {
111+ opts.DeadAfter = defaultDead
112+ }
113+ if opts.MaxPending <= 0 {
114+ opts.MaxPending = defaultMaxPending
115+ }
116+ if opts.MaxPendingWait <= 0 {
117+ opts.MaxPendingWait = defaultMaxPendingWait
118+ }
119+ if opts.MaxRecvBuffer <= 0 {
120+ opts.MaxRecvBuffer = defaultMaxRecvBuf
121+ }
122+ if opts.Clock == nil {
123+ opts.Clock = realClock{}
124+ }
125+ return opts
126+}
127+
128+type Transport struct {
129+ pc net.PacketConn
130+ codec *pdgram.Codec
131+ sendDir, recvDir uint32
132+ mtu int
133+
134+ mu sync.Mutex
135+ peer net.Addr
136+ ctr uint64
137+ seq uint64
138+ probeSeq uint64
139+ pending map[uint64]*pending
140+ closed bool
141+ closeErr error
142+ lastHeard time.Time
143+ heartbeat time.Duration
144+ resendAfter time.Duration
145+ maxResendAfter time.Duration
146+ maxResendPerTick int
147+ srtt time.Duration
148+ rttvar time.Duration
149+ rto time.Duration
150+ writeTimeout time.Duration
151+ degradedAfter time.Duration
152+ probeAfter time.Duration
153+ offlineAfter time.Duration
154+ deadAfter time.Duration
155+ maxPending int
156+ maxPendingWait time.Duration
157+ maxRecvBuffer int
158+ clock ports.Clock
159+ linkState ports.LinkState
160+ linkEvents chan ports.LinkEvent
161+ probeWait map[uint64]chan struct{}
162+ sendWake chan struct{}
163+ probeReply chan uint64
164+ rebind func(net.PacketConn) (net.PacketConn, error)
165+ hoppedOffline bool
166+
167+ recvMu sync.Mutex
168+ replay *pdgram.ReplayWindow
169+ reasm *pdgram.Reassembler
170+ in chan ports.Frame
171+ done chan struct{}
172+
173+ writeMu sync.Mutex
174+
175+ deliverMu sync.Mutex
176+ deliverCond *sync.Cond
177+ nextRecvSeq uint64
178+ recvBuf map[uint64]ports.Frame
179+}
180+
181+type pending struct {
182+ frame ports.Frame
183+ first time.Time
184+ last time.Time
185+ attempts int
186+ retransmitted bool
187+}
188+
189+func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) {
190+ return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{})
191+}
192+
193+func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, opts Options) (*Transport, error) {
194+ c, err := pdgram.NewCodec(key)
195+ if err != nil {
196+ return nil, err
197+ }
198+ opts = normalizeOptions(opts)
199+ t := &Transport{
200+ pc: pc,
201+ codec: c,
202+ sendDir: sendDir,
203+ recvDir: recvDir,
204+ mtu: opts.MTU,
205+ peer: peer,
206+ pending: make(map[uint64]*pending),
207+ replay: pdgram.NewReplayWindow(),
208+ reasm: pdgram.NewReassembler(),
209+ in: make(chan ports.Frame, 32),
210+ done: make(chan struct{}),
211+ lastHeard: opts.Clock.Now(),
212+ heartbeat: opts.Heartbeat,
213+ resendAfter: opts.ResendAfter,
214+ maxResendAfter: opts.MaxResendAfter,
215+ maxResendPerTick: opts.MaxResendPerTick,
216+ rto: opts.ResendAfter,
217+ writeTimeout: opts.WriteTimeout,
218+ degradedAfter: opts.DegradedAfter,
219+ probeAfter: opts.ProbeAfter,
220+ offlineAfter: opts.OfflineAfter,
221+ deadAfter: opts.DeadAfter,
222+ maxPending: opts.MaxPending,
223+ maxPendingWait: opts.MaxPendingWait,
224+ maxRecvBuffer: opts.MaxRecvBuffer,
225+ clock: opts.Clock,
226+ linkState: ports.LinkStateConnected,
227+ linkEvents: make(chan ports.LinkEvent, linkEventBufferSize),
228+ probeWait: make(map[uint64]chan struct{}),
229+ rebind: opts.RebindPacketConn,
230+ nextRecvSeq: 1,
231+ recvBuf: make(map[uint64]ports.Frame),
232+ }
233+ t.sendWake = make(chan struct{})
234+ t.probeReply = make(chan uint64, probeReplyBufferSize)
235+ t.deliverCond = sync.NewCond(&t.deliverMu)
236+ go t.readLoop(pc)
237+ go t.resendLoop()
238+ go t.deliveryLoop()
239+ go t.probeReplyLoop()
240+ return t, nil
241+}
242+
243+func (t *Transport) Send(f ports.Frame) error {
244+ // Until full ACK-driven terminal state sync exists, every frame is reliable;
245+ // dropping MsgOutput can permanently desynchronize the client screen.
246+ reliable := true
247+ t.mu.Lock()
248+ var pendingWaitStarted time.Time
249+ for reliable && len(t.pending) >= t.maxPending && !t.closed {
250+ if t.linkState != ports.LinkStateConnected {
251+ t.mu.Unlock()
252+ return ErrPendingFull
253+ }
254+ if pendingWaitStarted.IsZero() {
255+ pendingWaitStarted = t.clock.Now()
256+ }
257+ remaining := t.maxPendingWait - t.clock.Now().Sub(pendingWaitStarted)
258+ if remaining <= 0 {
259+ t.mu.Unlock()
260+ return ErrPendingFull
261+ }
262+ wake := t.sendWake
263+ timer := t.clock.NewTimer(remaining)
264+ t.mu.Unlock()
265+ select {
266+ case <-timer.C():
267+ case <-wake:
268+ case <-t.done:
269+ }
270+ timer.Stop()
271+ t.mu.Lock()
272+ }
273+ if t.closed {
274+ err := t.closeErr
275+ t.mu.Unlock()
276+ if err != nil {
277+ return err
278+ }
279+ return errors.New("dgram: closed")
280+ }
281+ t.seq++
282+ seq := t.seq
283+ if reliable {
284+ now := t.clock.Now()
285+ t.pending[seq] = &pending{frame: f, first: now, last: now}
286+ }
287+ t.mu.Unlock()
288+ if err := t.sendData(seq, reliable, f); err != nil {
289+ if reliable {
290+ t.mu.Lock()
291+ if _, ok := t.pending[seq]; ok {
292+ delete(t.pending, seq)
293+ t.notifySendWaitersLocked()
294+ }
295+ t.mu.Unlock()
296+ }
297+ return err
298+ }
299+ return nil
300+}
301+
302+func (t *Transport) Recv() (ports.Frame, error) {
303+ select {
304+ case f := <-t.in:
305+ return f, nil
306+ case <-t.done:
307+ t.mu.Lock()
308+ err := t.closeErr
309+ t.mu.Unlock()
310+ if err != nil {
311+ return ports.Frame{}, err
312+ }
313+ return ports.Frame{}, errors.New("dgram: closed")
314+ }
315+}
316+func (t *Transport) Close() error {
317+ t.closeWithError(errors.New("dgram: closed"))
318+ t.mu.Lock()
319+ pc := t.pc
320+ t.mu.Unlock()
321+ return pc.Close()
322+}
323+
324+func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
325+
326+func (t *Transport) LinkState() ports.LinkState {
327+ t.mu.Lock()
328+ defer t.mu.Unlock()
329+ return t.linkState
330+}
331+
332+func (t *Transport) LinkEvents() <-chan ports.LinkEvent { return t.linkEvents }
333+
334+// Probe sends an authenticated datagram and waits for the peer to authenticate a
335+// response or for ctx to expire. It is intended for UDP bootstrap reachability checks.
336+func (t *Transport) Probe(ctx context.Context) error {
337+ id, ch, err := t.registerProbe()
338+ if err != nil {
339+ return err
340+ }
341+ defer t.unregisterProbe(id)
342+ if err := t.sendProbe(recProbe, id); err != nil {
343+ return err
344+ }
345+ select {
346+ case <-ch:
347+ return nil
348+ case <-ctx.Done():
349+ return ctx.Err()
350+ case <-t.done:
351+ t.mu.Lock()
352+ err := t.closeErr
353+ t.mu.Unlock()
354+ if err != nil {
355+ return err
356+ }
357+ return errors.New("dgram: closed")
358+ }
359+}
360+
361+func (t *Transport) registerProbe() (uint64, chan struct{}, error) {
362+ t.mu.Lock()
363+ defer t.mu.Unlock()
364+ if t.closed {
365+ if t.closeErr != nil {
366+ return 0, nil, t.closeErr
367+ }
368+ return 0, nil, errors.New("dgram: closed")
369+ }
370+ t.probeSeq++
371+ id := t.probeSeq
372+ ch := make(chan struct{})
373+ t.probeWait[id] = ch
374+ return id, ch, nil
375+}
376+
377+func (t *Transport) unregisterProbe(id uint64) {
378+ t.mu.Lock()
379+ delete(t.probeWait, id)
380+ t.mu.Unlock()
381+}
382+
383+func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error {
384+ p := encodeData(seq, reliable, f)
385+ return t.sendPayload(p)
386+}
387+func (t *Transport) sendAck(seq uint64) {
388+ var b [9]byte
389+ b[0] = recAck
390+ binary.BigEndian.PutUint64(b[1:], seq)
391+ _ = t.sendPayload(b[:])
392+}
393+func (t *Transport) sendProbe(kind byte, id uint64) error {
394+ var b [9]byte
395+ b[0] = kind
396+ binary.BigEndian.PutUint64(b[1:], id)
397+ return t.sendPayload(b[:])
398+}
399+func (t *Transport) sendPayload(p []byte) error {
400+ frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead())
401+ if err != nil {
402+ return err
403+ }
404+ for _, f := range frags {
405+ raw, err := pdgram.MarshalFragment(f)
406+ if err != nil {
407+ return err
408+ }
409+ pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil)
410+ t.mu.Lock()
411+ peer := t.peer
412+ closed := t.closed
413+ closeErr := t.closeErr
414+ t.mu.Unlock()
415+ if closed {
416+ if closeErr != nil {
417+ return closeErr
418+ }
419+ return errors.New("dgram: closed")
420+ }
421+ if peer == nil {
422+ return errors.New("dgram: no peer")
423+ }
424+ if err := t.writePacket(pkt, peer); err != nil {
425+ return err
426+ }
427+ }
428+ return nil
429+}
430+
431+func (t *Transport) writePacket(pkt []byte, peer net.Addr) error {
432+ t.writeMu.Lock()
433+ defer t.writeMu.Unlock()
434+
435+ t.mu.Lock()
436+ pc := t.pc
437+ writeTimeout := t.writeTimeout
438+ t.mu.Unlock()
439+
440+ if writeTimeout > 0 {
441+ if err := pc.SetWriteDeadline(t.clock.Now().Add(writeTimeout)); err != nil {
442+ return err
443+ }
444+ defer func() { _ = pc.SetWriteDeadline(time.Time{}) }()
445+ }
446+ _, err := pc.WriteTo(pkt, peer)
447+ return err
448+}
449+
450+func (t *Transport) nextCounter() uint64 { t.mu.Lock(); defer t.mu.Unlock(); t.ctr++; return t.ctr }
451+
452+func (t *Transport) readLoop(pc net.PacketConn) {
453+ buf := make([]byte, 64*1024)
454+ for {
455+ n, addr, err := pc.ReadFrom(buf)
456+ if err != nil {
457+ t.mu.Lock()
458+ current := t.pc == pc
459+ closed := t.closed
460+ t.mu.Unlock()
461+ if current && !closed {
462+ t.closeWithError(err)
463+ }
464+ return
465+ }
466+ t.recvMu.Lock()
467+ _, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay)
468+ if err != nil {
469+ t.recvMu.Unlock()
470+ continue
471+ }
472+ frag, err := pdgram.UnmarshalFragment(pt)
473+ if err != nil {
474+ t.recvMu.Unlock()
475+ continue
476+ }
477+ payload, ok, err := t.reasm.Add(frag)
478+ t.recvMu.Unlock()
479+ if err != nil || !ok {
480+ continue
481+ }
482+ t.updatePeerFromAuthenticated(addr)
483+ t.handleRecord(payload)
484+ }
485+}
486+
487+func (t *Transport) updatePeerFromAuthenticated(addr net.Addr) {
488+ t.mu.Lock()
489+ t.peer = addr
490+ t.lastHeard = t.clock.Now()
491+ t.hoppedOffline = false
492+ t.mu.Unlock()
493+ t.setLinkState(ports.LinkStateConnected, nil)
494+}
495+
496+func (t *Transport) handleRecord(p []byte) {
497+ if len(p) < 1 {
498+ return
499+ }
500+ switch p[0] {
501+ case recAck:
502+ if len(p) != 9 {
503+ return
504+ }
505+ seq := binary.BigEndian.Uint64(p[1:])
506+ t.mu.Lock()
507+ acked := false
508+ for pendingSeq, p := range t.pending {
509+ if pendingSeq > seq {
510+ continue
511+ }
512+ if pendingSeq == seq && p != nil && !p.retransmitted {
513+ t.updateRTTLocked(t.clock.Now().Sub(p.first))
514+ }
515+ delete(t.pending, pendingSeq)
516+ acked = true
517+ }
518+ if acked {
519+ t.notifySendWaitersLocked()
520+ }
521+ t.mu.Unlock()
522+ case recProbe:
523+ if len(p) != 9 {
524+ return
525+ }
526+ select {
527+ case t.probeReply <- binary.BigEndian.Uint64(p[1:]):
528+ default:
529+ }
530+ case recPong:
531+ if len(p) != 9 {
532+ return
533+ }
534+ id := binary.BigEndian.Uint64(p[1:])
535+ t.mu.Lock()
536+ ch := t.probeWait[id]
537+ if ch != nil {
538+ delete(t.probeWait, id)
539+ close(ch)
540+ }
541+ t.mu.Unlock()
542+ case recData:
543+ seq, reliable, f, ok := decodeData(p)
544+ if !ok {
545+ return
546+ }
547+ if reliable {
548+ ackSeq, ack, queued := t.enqueueReliable(seq, f)
549+ if ack {
550+ t.sendAck(ackSeq)
551+ }
552+ if queued {
553+ t.signalDelivery()
554+ }
555+ return
556+ }
557+ t.deliver(f)
558+ }
559+}
560+
561+func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ackSeq uint64, ack bool, queued bool) {
562+ t.deliverMu.Lock()
563+ defer t.deliverMu.Unlock()
564+ if seq < t.nextRecvSeq {
565+ return t.highestContiguousRecvLocked(), true, false
566+ }
567+ if _, exists := t.recvBuf[seq]; exists {
568+ return t.highestContiguousRecvLocked(), true, false
569+ }
570+ if len(t.recvBuf) >= t.maxRecvBuffer && seq != t.nextRecvSeq {
571+ return 0, false, false
572+ }
573+ t.recvBuf[seq] = f
574+ return t.highestContiguousRecvLocked(), true, true
575+}
576+
577+func (t *Transport) highestContiguousRecvLocked() uint64 {
578+ seq := t.nextRecvSeq
579+ for {
580+ if _, ok := t.recvBuf[seq]; !ok {
581+ break
582+ }
583+ seq++
584+ }
585+ return seq - 1
586+}
587+
588+func (t *Transport) signalDelivery() {
589+ t.deliverMu.Lock()
590+ t.deliverCond.Signal()
591+ t.deliverMu.Unlock()
592+}
593+
594+func (t *Transport) deliveryLoop() {
595+ for {
596+ t.deliverMu.Lock()
597+ f, ok := t.recvBuf[t.nextRecvSeq]
598+ for !ok {
599+ if t.isClosed() {
600+ t.deliverMu.Unlock()
601+ return
602+ }
603+ t.deliverCond.Wait()
604+ f, ok = t.recvBuf[t.nextRecvSeq]
605+ }
606+ delete(t.recvBuf, t.nextRecvSeq)
607+ t.nextRecvSeq++
608+ t.deliverMu.Unlock()
609+ t.deliver(f)
610+ }
611+}
612+
613+func (t *Transport) isClosed() bool {
614+ t.mu.Lock()
615+ defer t.mu.Unlock()
616+ return t.closed
617+}
618+
619+func (t *Transport) deliver(f ports.Frame) {
620+ select {
621+ case t.in <- f:
622+ case <-t.done:
623+ }
624+}
625+
626+func (t *Transport) probeReplyLoop() {
627+ for {
628+ select {
629+ case id := <-t.probeReply:
630+ _ = t.sendProbe(recPong, id)
631+ case <-t.done:
632+ return
633+ }
634+ }
635+}
636+
637+func (t *Transport) notifySendWaitersLocked() {
638+ if t.sendWake == nil {
639+ return
640+ }
641+ close(t.sendWake)
642+ t.sendWake = make(chan struct{})
643+}
644+
645+func (t *Transport) resendLoop() {
646+ resend := t.clock.NewTimer(t.resendAfter)
647+ defer resend.Stop()
648+ heartbeat := t.clock.NewTimer(t.heartbeat)
649+ defer heartbeat.Stop()
650+ for {
651+ select {
652+ case <-resend.C():
653+ t.resendPending()
654+ t.checkSilence()
655+ t.checkSendStall()
656+ resend.Reset(t.resendAfter)
657+ case <-heartbeat.C():
658+ _ = t.sendProbe(recProbe, 0)
659+ t.checkSilence()
660+ t.checkSendStall()
661+ heartbeat.Reset(t.heartbeat)
662+ case <-t.done:
663+ return
664+ }
665+ }
666+}
667+
668+func (t *Transport) resendPending() {
669+ now := t.clock.Now()
670+ var resend []struct {
671+ seq uint64
672+ f ports.Frame
673+ }
674+ t.mu.Lock()
675+ seqs := make([]uint64, 0, len(t.pending))
676+ for seq := range t.pending {
677+ seqs = append(seqs, seq)
678+ }
679+ slices.Sort(seqs)
680+ limit := t.maxResendPerTick
681+ for _, seq := range seqs {
682+ if limit <= 0 {
683+ break
684+ }
685+ p := t.pending[seq]
686+ if p == nil || now.Sub(p.last) < t.resendDelayLocked(p) {
687+ continue
688+ }
689+ p.last = now
690+ p.attempts++
691+ p.retransmitted = true
692+ resend = append(resend, struct {
693+ seq uint64
694+ f ports.Frame
695+ }{seq, p.frame})
696+ limit--
697+ }
698+ t.mu.Unlock()
699+ for _, r := range resend {
700+ _ = t.sendData(r.seq, true, r.f)
701+ }
702+}
703+
704+func (t *Transport) resendDelayLocked(p *pending) time.Duration {
705+ d := t.rto
706+ if d <= 0 {
707+ d = t.resendAfter
708+ }
709+ for i := 0; i < p.attempts && d < t.maxResendAfter; i++ {
710+ d *= 2
711+ if d > t.maxResendAfter {
712+ return t.maxResendAfter
713+ }
714+ }
715+ return d
716+}
717+
718+func (t *Transport) updateRTTLocked(sample time.Duration) {
719+ if sample <= 0 {
720+ return
721+ }
722+ if t.srtt <= 0 {
723+ t.srtt = sample
724+ t.rttvar = sample / 2
725+ } else {
726+ delta := t.srtt - sample
727+ if delta < 0 {
728+ delta = -delta
729+ }
730+ t.rttvar = (3*t.rttvar + delta) / 4
731+ t.srtt = (7*t.srtt + sample) / 8
732+ }
733+ variation := 4 * t.rttvar
734+ if variation < time.Millisecond {
735+ variation = time.Millisecond
736+ }
737+ rto := t.srtt + variation
738+ if rto < t.resendAfter {
739+ rto = t.resendAfter
740+ }
741+ if rto > t.maxResendAfter {
742+ rto = t.maxResendAfter
743+ }
744+ t.rto = rto
745+}
746+
747+func (t *Transport) hopPacketConnOnce() {
748+ t.mu.Lock()
749+ if t.rebind == nil || t.hoppedOffline || t.closed {
750+ t.mu.Unlock()
751+ return
752+ }
753+ old := t.pc
754+ rebind := t.rebind
755+ t.hoppedOffline = true
756+ t.mu.Unlock()
757+ pc, err := rebind(old)
758+ if err != nil {
759+ t.mu.Lock()
760+ if t.pc == old {
761+ t.hoppedOffline = false
762+ }
763+ t.mu.Unlock()
764+ return
765+ }
766+ t.mu.Lock()
767+ if t.closed || t.pc != old {
768+ t.mu.Unlock()
769+ _ = pc.Close()
770+ return
771+ }
772+ t.pc = pc
773+ t.mu.Unlock()
774+ go t.readLoop(pc)
775+ _ = old.Close()
776+}
777+
778+func (t *Transport) checkSilence() {
779+ t.mu.Lock()
780+ last := t.lastHeard
781+ closed := t.closed
782+ degradedAfter := t.degradedAfter
783+ probeAfter := t.probeAfter
784+ offlineAfter := t.offlineAfter
785+ deadAfter := t.deadAfter
786+ now := t.clock.Now()
787+ t.mu.Unlock()
788+ if closed {
789+ return
790+ }
791+ silentFor := now.Sub(last)
792+ switch {
793+ case deadAfter > 0 && silentFor >= deadAfter:
794+ t.setLinkState(ports.LinkStateDead, ErrLinkDead)
795+ t.closeWithError(ErrLinkDead)
796+ case offlineAfter > 0 && silentFor >= offlineAfter:
797+ t.setLinkState(ports.LinkStateOffline, nil)
798+ case probeAfter > 0 && silentFor >= probeAfter:
799+ t.setLinkState(ports.LinkStateProbing, nil)
800+ t.hopPacketConnOnce()
801+ case degradedAfter > 0 && silentFor >= degradedAfter:
802+ t.setLinkState(ports.LinkStateDegraded, nil)
803+ }
804+}
805+
806+// checkSendStall declares the link dead when the oldest unacked reliable frame
807+// has gone unacknowledged for deadAfter despite at least one retransmit. The
808+// receive-silence check in checkSilence misses one-way-dead links whose receive
809+// side stays fresh from peer heartbeats; this catches them at the layer that
810+// owns death. first is set once when a frame is enqueued and never refreshed,
811+// so acks (which delete pending entries) keep the oldest age bounded on a
812+// progressing link — only a frame that is never acked crosses the threshold.
813+func (t *Transport) checkSendStall() {
814+ t.mu.Lock()
815+ if t.closed || t.deadAfter <= 0 {
816+ t.mu.Unlock()
817+ return
818+ }
819+ deadAfter := t.deadAfter
820+ now := t.clock.Now()
821+ var oldest time.Time
822+ var attempts int
823+ found := false
824+ for _, p := range t.pending {
825+ if p == nil || p.first.IsZero() {
826+ continue
827+ }
828+ if !found || p.first.Before(oldest) {
829+ oldest = p.first
830+ attempts = p.attempts
831+ found = true
832+ }
833+ }
834+ t.mu.Unlock()
835+ if !found || attempts < 1 || now.Sub(oldest) < deadAfter {
836+ return
837+ }
838+ t.setLinkState(ports.LinkStateDead, ErrLinkDead)
839+ t.closeWithError(ErrLinkDead)
840+}
841+
842+func (t *Transport) setLinkState(state ports.LinkState, err error) {
843+ now := t.clock.Now()
844+ t.mu.Lock()
845+ if t.linkState == state {
846+ t.mu.Unlock()
847+ return
848+ }
849+ t.linkState = state
850+ if state == ports.LinkStateConnected {
851+ for _, p := range t.pending {
852+ if p != nil {
853+ p.attempts = 0
854+ }
855+ }
856+ }
857+ t.notifySendWaitersLocked()
858+ t.mu.Unlock()
859+ event := ports.LinkEvent{State: state, At: now, Err: err}
860+ select {
861+ case t.linkEvents <- event:
862+ default:
863+ }
864+}
865+
866+func (t *Transport) closeWithError(err error) {
867+ t.mu.Lock()
868+ if t.closed {
869+ t.mu.Unlock()
870+ return
871+ }
872+ t.closed = true
873+ t.closeErr = err
874+ t.probeWait = make(map[uint64]chan struct{})
875+ close(t.done)
876+ t.notifySendWaitersLocked()
877+ t.mu.Unlock()
878+ t.deliverMu.Lock()
879+ t.deliverCond.Broadcast()
880+ t.deliverMu.Unlock()
881+}
882+
883+func encodeData(seq uint64, reliable bool, f ports.Frame) []byte {
884+ b := make([]byte, 12+len(f.Payload))
885+ b[0] = recData
886+ binary.BigEndian.PutUint64(b[1:9], seq)
887+ if reliable {
888+ b[9] = 1
889+ }
890+ b[10] = byte(f.Type)
891+ b[11] = 0
892+ copy(b[12:], f.Payload)
893+ return b
894+}
895+func decodeData(b []byte) (uint64, bool, ports.Frame, bool) {
896+ if len(b) < 12 || b[0] != recData {
897+ return 0, false, ports.Frame{}, false
898+ }
899+ return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[12:]...)}, true
900+}
A internal/adapters/dgram/transport_test.go +1210 -0
@@ -0,0 +1,1210 @@
1+package dgram
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/binary"
7+ "errors"
8+ "net"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/vev/internal/ports"
15+ pdgram "github.com/bnema/vev/pkg/dgram"
16+)
17+
18+type testAddr string
19+
20+func (a testAddr) Network() string { return "mem" }
21+func (a testAddr) String() string { return string(a) }
22+
23+type packet struct {
24+ b []byte
25+ addr net.Addr
26+}
27+type fakePC struct {
28+ addr net.Addr
29+ mu sync.Mutex
30+ closed bool
31+ in chan packet
32+ peers map[string]*fakePC
33+ drop func([]byte, net.Addr) bool
34+}
35+
36+func newPair() (*fakePC, *fakePC) {
37+ a := &fakePC{addr: testAddr("a"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
38+ b := &fakePC{addr: testAddr("b"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
39+ a.peers["b"] = b
40+ b.peers["a"] = a
41+ return a, b
42+}
43+func (p *fakePC) ReadFrom(b []byte) (int, net.Addr, error) {
44+ q, ok := <-p.in
45+ if !ok {
46+ return 0, nil, errors.New("closed")
47+ }
48+ copy(b, q.b)
49+ return len(q.b), q.addr, nil
50+}
51+func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) {
52+ p.mu.Lock()
53+ closed := p.closed
54+ p.mu.Unlock()
55+ if closed {
56+ return 0, errors.New("closed")
57+ }
58+ if p.drop != nil && p.drop(b, addr) {
59+ return len(b), nil
60+ }
61+ peer := p.peers[addr.String()]
62+ peer.mu.Lock()
63+ defer peer.mu.Unlock()
64+ if peer.closed {
65+ return 0, errors.New("peer closed")
66+ }
67+ peer.in <- packet{append([]byte(nil), b...), p.addr}
68+ return len(b), nil
69+}
70+func (p *fakePC) Close() error {
71+ p.mu.Lock()
72+ defer p.mu.Unlock()
73+ if !p.closed {
74+ p.closed = true
75+ close(p.in)
76+ }
77+ return nil
78+}
79+func (p *fakePC) LocalAddr() net.Addr { return p.addr }
80+func (p *fakePC) SetDeadline(time.Time) error { return nil }
81+func (p *fakePC) SetReadDeadline(time.Time) error { return nil }
82+func (p *fakePC) SetWriteDeadline(time.Time) error { return nil }
83+
84+func key() []byte { return bytes.Repeat([]byte{1}, pdgram.KeySize) }
85+
86+type timeoutErr struct{}
87+
88+func (timeoutErr) Error() string { return "i/o timeout" }
89+func (timeoutErr) Timeout() bool { return true }
90+
91+type blockingWritePC struct {
92+ addr net.Addr
93+
94+ mu sync.Mutex
95+ closed bool
96+ deadline time.Time
97+ done chan struct{}
98+}
99+
100+func newBlockingWritePC() *blockingWritePC {
101+ return &blockingWritePC{addr: testAddr("blocked"), done: make(chan struct{})}
102+}
103+
104+func (p *blockingWritePC) ReadFrom([]byte) (int, net.Addr, error) {
105+ <-p.done
106+ return 0, nil, errors.New("closed")
107+}
108+
109+func (p *blockingWritePC) WriteTo([]byte, net.Addr) (int, error) {
110+ for {
111+ p.mu.Lock()
112+ closed := p.closed
113+ deadline := p.deadline
114+ p.mu.Unlock()
115+ if closed {
116+ return 0, errors.New("closed")
117+ }
118+ if !deadline.IsZero() {
119+ wait := time.Until(deadline)
120+ if wait > 0 {
121+ timer := time.NewTimer(wait)
122+ select {
123+ case <-timer.C:
124+ case <-p.done:
125+ timer.Stop()
126+ return 0, errors.New("closed")
127+ }
128+ }
129+ return 0, timeoutErr{}
130+ }
131+ select {
132+ case <-time.After(time.Millisecond):
133+ case <-p.done:
134+ return 0, errors.New("closed")
135+ }
136+ }
137+}
138+
139+func (p *blockingWritePC) Close() error {
140+ p.mu.Lock()
141+ defer p.mu.Unlock()
142+ if !p.closed {
143+ p.closed = true
144+ close(p.done)
145+ }
146+ return nil
147+}
148+
149+func (p *blockingWritePC) LocalAddr() net.Addr { return p.addr }
150+func (p *blockingWritePC) SetDeadline(t time.Time) error {
151+ p.mu.Lock()
152+ p.deadline = t
153+ p.mu.Unlock()
154+ return nil
155+}
156+func (p *blockingWritePC) SetReadDeadline(time.Time) error { return nil }
157+func (p *blockingWritePC) SetWriteDeadline(t time.Time) error {
158+ p.mu.Lock()
159+ p.deadline = t
160+ p.mu.Unlock()
161+ return nil
162+}
163+
164+type fixedClock struct{ now time.Time }
165+
166+func (c fixedClock) Now() time.Time { return c.now }
167+func (c fixedClock) NewTimer(d time.Duration) ports.Timer {
168+ return realTimer{t: time.NewTimer(d)}
169+}
170+
171+type manualClock struct {
172+ mu sync.Mutex
173+ now time.Time
174+ timers chan *manualTimer
175+}
176+
177+func newManualClock(now time.Time) *manualClock {
178+ return &manualClock{now: now, timers: make(chan *manualTimer, 16)}
179+}
180+
181+func (c *manualClock) Now() time.Time {
182+ c.mu.Lock()
183+ defer c.mu.Unlock()
184+ return c.now
185+}
186+
187+func (c *manualClock) NewTimer(d time.Duration) ports.Timer {
188+ tm := &manualTimer{c: make(chan time.Time, 1), d: d}
189+ c.timers <- tm
190+ return tm
191+}
192+
193+func (c *manualClock) advance(d time.Duration) {
194+ c.mu.Lock()
195+ c.now = c.now.Add(d)
196+ now := c.now
197+ c.mu.Unlock()
198+ for {
199+ select {
200+ case tm := <-c.timers:
201+ tm.fire(now)
202+ default:
203+ return
204+ }
205+ }
206+}
207+
208+type manualTimer struct {
209+ c chan time.Time
210+ d time.Duration
211+}
212+
213+func (t *manualTimer) C() <-chan time.Time { return t.c }
214+func (t *manualTimer) Reset(d time.Duration) bool {
215+ t.d = d
216+ return true
217+}
218+func (t *manualTimer) Stop() bool { return true }
219+func (t *manualTimer) fire(now time.Time) {
220+ select {
221+ case t.c <- now:
222+ default:
223+ }
224+}
225+
226+func waitForManualTimers(t *testing.T, clk *manualClock, n int) {
227+ t.Helper()
228+ deadline := time.Now().Add(time.Second)
229+ for time.Now().Before(deadline) {
230+ if len(clk.timers) >= n {
231+ return
232+ }
233+ time.Sleep(time.Millisecond)
234+ }
235+ t.Fatalf("manual timers=%d, want at least %d", len(clk.timers), n)
236+}
237+
238+type deadlineCapturePC struct {
239+ addr net.Addr
240+ deadline atomic.Value
241+ done chan struct{}
242+}
243+
244+func (p *deadlineCapturePC) ReadFrom([]byte) (int, net.Addr, error) {
245+ <-p.done
246+ return 0, nil, errors.New("closed")
247+}
248+func (p *deadlineCapturePC) WriteTo(b []byte, _ net.Addr) (int, error) { return len(b), nil }
249+func (p *deadlineCapturePC) Close() error {
250+ select {
251+ case <-p.done:
252+ default:
253+ close(p.done)
254+ }
255+ return nil
256+}
257+func (p *deadlineCapturePC) LocalAddr() net.Addr { return p.addr }
258+func (p *deadlineCapturePC) SetDeadline(time.Time) error { return nil }
259+func (p *deadlineCapturePC) SetReadDeadline(time.Time) error { return nil }
260+func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
261+ if !t.IsZero() {
262+ p.deadline.Store(t)
263+ }
264+ return nil
265+}
266+
267+func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) {
268+ pc := newBlockingWritePC()
269+ tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{
270+ WriteTimeout: 10 * time.Millisecond,
271+ ResendAfter: time.Hour,
272+ Heartbeat: time.Hour,
273+ })
274+ if err != nil {
275+ t.Fatal(err)
276+ }
277+ defer func() { _ = tr.Close() }()
278+
279+ done := make(chan error, 1)
280+ go func() { done <- tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("blocked")}) }()
281+ select {
282+ case err := <-done:
283+ if err == nil {
284+ t.Fatal("Send returned nil, want write timeout")
285+ }
286+ var timeout interface{ Timeout() bool }
287+ if !errors.As(err, &timeout) || !timeout.Timeout() {
288+ t.Fatalf("Send err=%v, want timeout error", err)
289+ }
290+ tr.mu.Lock()
291+ pending := len(tr.pending)
292+ tr.mu.Unlock()
293+ if pending != 0 {
294+ t.Fatalf("pending after failed initial send=%d, want 0", pending)
295+ }
296+ case <-time.After(250 * time.Millisecond):
297+ t.Fatal("Send blocked without honoring WriteTimeout")
298+ }
299+}
300+
301+func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) {
302+ aPC, bPC := newPair()
303+ var droppedAck atomic.Bool
304+ bPC.drop = func(_ []byte, addr net.Addr) bool {
305+ return addr.String() == "a" && droppedAck.CompareAndSwap(false, true)
306+ }
307+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
308+ if err != nil {
309+ t.Fatal(err)
310+ }
311+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
312+ if err != nil {
313+ t.Fatal(err)
314+ }
315+ defer func() {
316+ if err := a.Close(); err != nil {
317+ t.Errorf("close a: %v", err)
318+ }
319+ }()
320+ defer func() {
321+ if err := b.Close(); err != nil {
322+ t.Errorf("close b: %v", err)
323+ }
324+ }()
325+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
326+ t.Fatal(err)
327+ }
328+ got := recvWithin(t, b, time.Second)
329+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !droppedAck.Load() {
330+ t.Fatalf("got=%+v droppedAck=%v", got, droppedAck.Load())
331+ }
332+ if got, ok := recvMaybe(b, 3*defaultResend); ok {
333+ t.Fatalf("duplicate reliable frame delivered: %+v", got)
334+ }
335+}
336+
337+func TestReliableInputRetransmitsUntilAck(t *testing.T) {
338+ aPC, bPC := newPair()
339+ var dropped atomic.Bool
340+ aPC.drop = func(_ []byte, addr net.Addr) bool {
341+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
342+ }
343+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
344+ if err != nil {
345+ t.Fatal(err)
346+ }
347+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
348+ if err != nil {
349+ t.Fatal(err)
350+ }
351+ defer func() {
352+ if err := a.Close(); err != nil {
353+ t.Errorf("close a: %v", err)
354+ }
355+ }()
356+ defer func() {
357+ if err := b.Close(); err != nil {
358+ t.Errorf("close b: %v", err)
359+ }
360+ }()
361+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
362+ t.Fatal(err)
363+ }
364+ got := recvWithin(t, b, time.Second)
365+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !dropped.Load() {
366+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
367+ }
368+}
369+
370+func TestAuthenticatedPeerChangeUpdatesPeerAndEmitsConnected(t *testing.T) {
371+ aPC, bPC := newPair()
372+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
373+ if err != nil {
374+ t.Fatal(err)
375+ }
376+ defer func() {
377+ if err := a.Close(); err != nil {
378+ t.Errorf("close a: %v", err)
379+ }
380+ }()
381+ evilPC := &fakePC{addr: testAddr("evil"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
382+ aPC.peers["evil"] = evilPC
383+ _ = bPC
384+ old := a.Peer().String()
385+ a.setLinkState(ports.LinkStateOffline, nil)
386+ <-a.LinkEvents()
387+ aPC.in <- packet{[]byte("not authenticated"), testAddr("evil")}
388+ if a.Peer().String() != old {
389+ t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())
390+ }
391+ c, _ := pdgram.NewCodec(key())
392+ rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing})
393+ frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)
394+ raw, _ := pdgram.MarshalFragment(frags[0])
395+ aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}
396+ waitPeer(t, a, "evil")
397+ select {
398+ case ev := <-a.LinkEvents():
399+ if ev.State != ports.LinkStateConnected {
400+ t.Fatalf("event state=%v, want connected", ev.State)
401+ }
402+ case <-time.After(time.Second):
403+ t.Fatal("missing connected event after authenticated rehome")
404+ }
405+ select {
406+ case pkt := <-evilPC.in:
407+ if pkt.addr.String() != "a" {
408+ t.Fatalf("ack source=%v, want a", pkt.addr)
409+ }
410+ case <-time.After(time.Second):
411+ t.Fatal("missing ACK to authenticated new peer")
412+ }
413+}
414+
415+func TestPortHopPreservesPendingReliableMessages(t *testing.T) {
416+ aPC, bPC := newPair()
417+ var hopped atomic.Bool
418+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
419+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
420+ ResendAfter: 20 * time.Millisecond,
421+ ProbeAfter: time.Millisecond,
422+ DeadAfter: time.Hour,
423+ RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
424+ newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
425+ bPC.peers["a2"] = newPC
426+ hopped.Store(true)
427+ return newPC, nil
428+ },
429+ })
430+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
431+ defer func() { _ = a.Close() }()
432+ defer func() { _ = b.Close() }()
433+
434+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("survive")}); err != nil {
435+ t.Fatal(err)
436+ }
437+ a.mu.Lock()
438+ a.lastHeard = time.Now().Add(-time.Second)
439+ a.mu.Unlock()
440+ a.checkSilence()
441+ if !hopped.Load() {
442+ t.Fatal("expected packet conn rebind")
443+ }
444+ aPC.drop = nil
445+ got := recvWithin(t, b, time.Second)
446+ if got.Type != ports.MsgInput || string(got.Payload) != "survive" {
447+ t.Fatalf("got=%+v, want pending reliable message", got)
448+ }
449+ select {
450+ case <-a.done:
451+ t.Fatal("transport closed during port hop")
452+ default:
453+ }
454+}
455+
456+func TestPortHopRetriesAfterRebindFailure(t *testing.T) {
457+ aPC, bPC := newPair()
458+ var attempts atomic.Int32
459+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
460+ ResendAfter: time.Hour,
461+ Heartbeat: time.Hour,
462+ ProbeAfter: time.Millisecond,
463+ DeadAfter: time.Hour,
464+ RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
465+ if attempts.Add(1) == 1 {
466+ return nil, errors.New("temporary rebind failure")
467+ }
468+ newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
469+ bPC.peers["a2"] = newPC
470+ return newPC, nil
471+ },
472+ })
473+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
474+ defer func() { _ = a.Close() }()
475+ defer func() { _ = b.Close() }()
476+
477+ a.mu.Lock()
478+ a.lastHeard = time.Now().Add(-time.Second)
479+ a.mu.Unlock()
480+ a.checkSilence()
481+ a.mu.Lock()
482+ firstPC := a.pc
483+ a.mu.Unlock()
484+ if attempts.Load() != 1 || firstPC != aPC {
485+ t.Fatalf("first failed hop attempts=%d pc=%v, want one attempt and original pc", attempts.Load(), firstPC.LocalAddr())
486+ }
487+
488+ a.checkSilence()
489+ a.mu.Lock()
490+ secondPC := a.pc
491+ a.mu.Unlock()
492+ if attempts.Load() != 2 || secondPC == aPC {
493+ t.Fatalf("second hop attempts=%d pc=%v, want retry with new pc", attempts.Load(), secondPC.LocalAddr())
494+ }
495+}
496+
497+func TestServerWithoutRebindDoesNotHopPorts(t *testing.T) {
498+ aPC, bPC := newPair()
499+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ProbeAfter: time.Millisecond, DeadAfter: time.Hour})
500+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
501+ defer func() { _ = a.Close() }()
502+ defer func() { _ = b.Close() }()
503+ a.mu.Lock()
504+ a.lastHeard = time.Now().Add(-time.Second)
505+ a.mu.Unlock()
506+ a.checkSilence()
507+ if a.pc != aPC {
508+ t.Fatal("server/proxy transport hopped without rebind hook")
509+ }
510+}
511+
512+func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {
513+ tr := &Transport{maxRecvBuffer: 1, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
514+ _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")})
515+ if !ack || !queued {
516+ t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued)
517+ }
518+ _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
519+ if !ack || !queued {
520+ t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued)
521+ }
522+ if _, ok := tr.recvBuf[2]; !ok {
523+ t.Fatal("acked future frame was dropped when next frame arrived")
524+ }
525+}
526+
527+func TestOutputRetransmitsUntilAck(t *testing.T) {
528+ aPC, bPC := newPair()
529+ var dropped atomic.Bool
530+ aPC.drop = func(_ []byte, addr net.Addr) bool {
531+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
532+ }
533+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
534+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
535+ defer func() {
536+ if err := a.Close(); err != nil {
537+ t.Errorf("close a: %v", err)
538+ }
539+ }()
540+ defer func() {
541+ if err := b.Close(); err != nil {
542+ t.Errorf("close b: %v", err)
543+ }
544+ }()
545+ if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil {
546+ t.Fatal(err)
547+ }
548+ got := recvWithin(t, b, time.Second)
549+ if got.Type != ports.MsgOutput || string(got.Payload) != "state1" || !dropped.Load() {
550+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
551+ }
552+}
553+
554+func TestReliableDeliveryPreservesSequenceOrder(t *testing.T) {
555+ aPC, bPC := newPair()
556+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
557+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
558+ defer func() { _ = a.Close() }()
559+ defer func() { _ = b.Close() }()
560+
561+ for i := range 100 {
562+ if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
563+ t.Fatal(err)
564+ }
565+ }
566+ for i := range 100 {
567+ got := recvWithin(t, b, time.Second)
568+ if got.Type != ports.MsgOutput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
569+ t.Fatalf("frame %d delivered out of order: %+v", i, got)
570+ }
571+ }
572+}
573+
574+func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) {
575+ aPC, bPC := newPair()
576+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
577+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
578+ defer func() { _ = a.Close() }()
579+ defer func() { _ = b.Close() }()
580+
581+ for i := range cap(b.in) + 20 {
582+ if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
583+ t.Fatal(err)
584+ }
585+ }
586+ deadline := time.Now().Add(time.Second)
587+ for time.Now().Before(deadline) {
588+ a.mu.Lock()
589+ pending := len(a.pending)
590+ a.mu.Unlock()
591+ if pending == 0 {
592+ return
593+ }
594+ time.Sleep(10 * time.Millisecond)
595+ }
596+ a.mu.Lock()
597+ pending := len(a.pending)
598+ a.mu.Unlock()
599+ t.Fatalf("acks blocked behind undrained consumer; pending=%d", pending)
600+}
601+
602+func newResendTestTransport(t *testing.T, maxResendPerTick int) (*Transport, *atomic.Int32) {
603+ t.Helper()
604+ aPC, _ := newPair()
605+ codec, err := pdgram.NewCodec(key())
606+ if err != nil {
607+ t.Fatal(err)
608+ }
609+ var writes atomic.Int32
610+ aPC.drop = func(_ []byte, addr net.Addr) bool {
611+ if addr.String() == "b" {
612+ writes.Add(1)
613+ return true
614+ }
615+ return false
616+ }
617+ return &Transport{
618+ pc: aPC,
619+ peer: testAddr("b"),
620+ codec: codec,
621+ sendDir: 1,
622+ mtu: pdgram.DefaultMTU,
623+ pending: make(map[uint64]*pending),
624+ resendAfter: 100 * time.Millisecond,
625+ maxResendAfter: 500 * time.Millisecond,
626+ maxResendPerTick: maxResendPerTick,
627+ rto: 100 * time.Millisecond,
628+ clock: realClock{},
629+ }, &writes
630+}
631+
632+func TestResendPendingCapsBurst(t *testing.T) {
633+ tr, writes := newResendTestTransport(t, 2)
634+ now := tr.clock.Now()
635+ for i := range 5 {
636+ tr.pending[uint64(i+1)] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}, last: now.Add(-time.Second)}
637+ }
638+
639+ tr.resendPending()
640+ if got := writes.Load(); got != 2 {
641+ t.Fatalf("resend writes=%d, want capped burst of 2", got)
642+ }
643+}
644+
645+func TestResendPendingUsesExponentialBackoffAndMaxDelay(t *testing.T) {
646+ tr, writes := newResendTestTransport(t, 10)
647+ now := tr.clock.Now()
648+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("too-soon")}, last: now.Add(-150 * time.Millisecond), attempts: 1}
649+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("ready")}, last: now.Add(-250 * time.Millisecond), attempts: 1}
650+ tr.pending[3] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("capped")}, last: now.Add(-time.Second), attempts: 10}
651+
652+ tr.resendPending()
653+ if got := writes.Load(); got != 2 {
654+ t.Fatalf("resend writes=%d, want ready and capped frames only", got)
655+ }
656+ if attempts := tr.pending[1].attempts; attempts != 1 {
657+ t.Fatalf("too-soon attempts=%d, want unchanged", attempts)
658+ }
659+ if attempts := tr.pending[2].attempts; attempts != 2 {
660+ t.Fatalf("ready attempts=%d, want incremented", attempts)
661+ }
662+ if attempts := tr.pending[3].attempts; attempts != 11 {
663+ t.Fatalf("capped attempts=%d, want incremented", attempts)
664+ }
665+}
666+
667+func TestProbeTimeoutAndSuccess(t *testing.T) {
668+ aPC, bPC := newPair()
669+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
670+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
671+ defer func() { _ = a.Close() }()
672+ defer func() { _ = b.Close() }()
673+
674+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
675+ if err := a.Probe(ctx); err != nil {
676+ cancel()
677+ t.Fatalf("probe success: %v", err)
678+ }
679+ cancel()
680+
681+ bPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "a" }
682+ ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
683+ defer cancel()
684+ if err := a.Probe(ctx); !errors.Is(err, context.DeadlineExceeded) {
685+ t.Fatalf("probe timeout err=%v, want context deadline", err)
686+ }
687+}
688+
689+func TestRecvUnblocksWhenPeerDeadAfter(t *testing.T) {
690+ aPC, bPC := newPair()
691+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: 25 * time.Millisecond, DeadAfter: 75 * time.Millisecond})
692+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
693+ defer func() { _ = a.Close() }()
694+ defer func() { _ = b.Close() }()
695+ a.mu.Lock()
696+ a.lastHeard = time.Now().Add(-time.Second)
697+ a.mu.Unlock()
698+
699+ _, err := recvErrWithin(t, a, time.Second)
700+ if !errors.Is(err, ErrLinkDead) {
701+ t.Fatalf("Recv err=%v, want ErrLinkDead", err)
702+ }
703+}
704+
705+func TestCumulativeAckReleasesAllEarlierPendingFrames(t *testing.T) {
706+ tr, _ := newResendTestTransport(t, 10)
707+ now := tr.clock.Now()
708+ for seq := uint64(1); seq <= 3; seq++ {
709+ tr.pending[seq] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(seq)}}, first: now, last: now}
710+ }
711+
712+ tr.handleRecord(ackRecord(3))
713+
714+ if got := len(tr.pending); got != 0 {
715+ t.Fatalf("pending after cumulative ack=%d, want 0", got)
716+ }
717+}
718+
719+func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
720+ tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
721+
722+ ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")})
723+ if !ack || !queued {
724+ t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued)
725+ }
726+ if got, want := ackSeq, uint64(0); got != want {
727+ t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want)
728+ }
729+
730+ ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")})
731+ if !ack || !queued {
732+ t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued)
733+ }
734+ if got, want := ackSeq, uint64(1); got != want {
735+ t.Fatalf("first contiguous ack=%d, want %d", got, want)
736+ }
737+
738+ ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")})
739+ if !ack || !queued {
740+ t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued)
741+ }
742+ if got, want := ackSeq, uint64(3); got != want {
743+ t.Fatalf("filled gap ack=%d, want %d", got, want)
744+ }
745+}
746+
747+func TestRTTEstimatorUpdatesRTOFromAckSample(t *testing.T) {
748+ clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
749+ tr, writes := newResendTestTransport(t, 10)
750+ tr.clock = clk
751+ tr.resendAfter = 100 * time.Millisecond
752+ tr.maxResendAfter = time.Second
753+ tr.rto = tr.resendAfter
754+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-200 * time.Millisecond)}
755+
756+ tr.handleRecord(ackRecord(1))
757+
758+ if tr.rto <= tr.resendAfter {
759+ t.Fatalf("rto=%v, want above initial resend after RTT sample", tr.rto)
760+ }
761+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now, last: clk.now.Add(-(tr.rto - time.Millisecond))}
762+ tr.resendPending()
763+ if got := writes.Load(); got != 0 {
764+ t.Fatalf("writes before rto=%d, want 0", got)
765+ }
766+ tr.pending[2].last = clk.now.Add(-tr.rto)
767+ tr.resendPending()
768+ if got := writes.Load(); got != 1 {
769+ t.Fatalf("writes at rto=%d, want 1", got)
770+ }
771+}
772+
773+func TestRTTEstimatorIgnoresRetransmittedFramesAfterRecovery(t *testing.T) {
774+ clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
775+ tr, _ := newResendTestTransport(t, 10)
776+ tr.clock = clk
777+ tr.resendAfter = 100 * time.Millisecond
778+ tr.maxResendAfter = time.Second
779+ tr.rto = tr.resendAfter
780+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-tr.resendAfter)}
781+ tr.resendPending()
782+ tr.linkState = ports.LinkStateOffline
783+
784+ tr.setLinkState(ports.LinkStateConnected, nil)
785+ tr.handleRecord(ackRecord(1))
786+
787+ if tr.rto != tr.resendAfter || tr.srtt != 0 || tr.rttvar != 0 {
788+ t.Fatalf("rtt sampled retransmitted frame after recovery: rto=%v srtt=%v rttvar=%v", tr.rto, tr.srtt, tr.rttvar)
789+ }
790+}
791+
792+func TestPendingReliableQueueBackpressuresUntilAck(t *testing.T) {
793+ aPC, bPC := newPair()
794+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
795+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1})
796+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
797+ defer func() { _ = a.Close() }()
798+ defer func() { _ = b.Close() }()
799+
800+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
801+ t.Fatal(err)
802+ }
803+ done := make(chan error, 1)
804+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
805+
806+ select {
807+ case err := <-done:
808+ t.Fatalf("send returned before an ack released pending capacity: %v", err)
809+ case <-time.After(25 * time.Millisecond):
810+ }
811+
812+ a.handleRecord(ackRecord(1))
813+ select {
814+ case err := <-done:
815+ if err != nil {
816+ t.Fatalf("send after ack: %v", err)
817+ }
818+ case <-time.After(time.Second):
819+ t.Fatal("send did not resume after ack released pending capacity")
820+ }
821+ a.mu.Lock()
822+ pending := len(a.pending)
823+ a.mu.Unlock()
824+ if pending > 1 {
825+ t.Fatalf("pending=%d, want <= 1", pending)
826+ }
827+}
828+
829+func TestPendingReliableQueueReturnsErrPendingFullWhileConnected(t *testing.T) {
830+ aPC, bPC := newPair()
831+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
832+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
833+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: 50 * time.Millisecond, Clock: clk})
834+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
835+ defer func() { _ = a.Close() }()
836+ defer func() { _ = b.Close() }()
837+
838+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
839+ t.Fatal(err)
840+ }
841+ done := make(chan error, 1)
842+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
843+ waitForManualTimers(t, clk, 3)
844+
845+ clk.advance(50 * time.Millisecond)
846+ select {
847+ case err := <-done:
848+ if !errors.Is(err, ErrPendingFull) {
849+ t.Fatalf("send err=%v, want ErrPendingFull", err)
850+ }
851+ case <-time.After(time.Second):
852+ t.Fatal("send did not return after clock advanced past MaxPendingWait")
853+ }
854+}
855+
856+func TestPendingAttemptsResetWhenLinkRecovers(t *testing.T) {
857+ tr, _ := newResendTestTransport(t, 10)
858+ tr.linkState = ports.LinkStateDegraded
859+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 4}
860+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 7}
861+
862+ tr.setLinkState(ports.LinkStateConnected, nil)
863+
864+ for seq, p := range tr.pending {
865+ if p.attempts != 0 {
866+ t.Fatalf("pending[%d] attempts=%d, want reset on connected recovery", seq, p.attempts)
867+ }
868+ }
869+}
870+
871+func TestWritePacketDeadlineUsesTransportClock(t *testing.T) {
872+ wantNow := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
873+ pc := &deadlineCapturePC{addr: testAddr("a"), done: make(chan struct{})}
874+ tr, err := NewTransportWithOptions(pc, testAddr("b"), key(), 1, 2, Options{
875+ Clock: fixedClock{now: wantNow},
876+ WriteTimeout: 250 * time.Millisecond,
877+ ResendAfter: time.Hour,
878+ Heartbeat: time.Hour,
879+ })
880+ if err != nil {
881+ t.Fatal(err)
882+ }
883+ defer func() { _ = tr.Close() }()
884+
885+ if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("deadline")}); err != nil {
886+ t.Fatal(err)
887+ }
888+ got, _ := pc.deadline.Load().(time.Time)
889+ if want := wantNow.Add(250 * time.Millisecond); !got.Equal(want) {
890+ t.Fatalf("write deadline=%v, want transport clock deadline %v", got, want)
891+ }
892+}
893+
894+func TestProbeReplyDoesNotBlockOnWriteMu(t *testing.T) {
895+ aPC, bPC := newPair()
896+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
897+ if err != nil {
898+ t.Fatal(err)
899+ }
900+ defer func() { _ = tr.Close() }()
901+ defer func() { _ = bPC.Close() }()
902+
903+ tr.writeMu.Lock()
904+ done := make(chan struct{})
905+ go func() {
906+ for id := uint64(1); id <= probeReplyBufferSize*4; id++ {
907+ tr.handleRecord(probeRecord(recProbe, id))
908+ }
909+ close(done)
910+ }()
911+
912+ select {
913+ case <-done:
914+ case <-time.After(time.Second):
915+ tr.writeMu.Unlock()
916+ <-done
917+ t.Fatal("probe reply burst blocked behind writeMu")
918+ }
919+ tr.writeMu.Unlock()
920+}
921+
922+func TestPendingReliableQueueReturnsWhenLinkDegrades(t *testing.T) {
923+ aPC, bPC := newPair()
924+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
925+ clk := newManualClock(time.Now())
926+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: time.Hour, Clock: clk})
927+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
928+ defer func() { _ = a.Close() }()
929+ defer func() { _ = b.Close() }()
930+
931+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
932+ t.Fatal(err)
933+ }
934+ done := make(chan error, 2)
935+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
936+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("third")}) }()
937+ waitForManualTimers(t, clk, 2)
938+ a.setLinkState(ports.LinkStateDegraded, nil)
939+ for i := range 2 {
940+ select {
941+ case err := <-done:
942+ if !errors.Is(err, ErrPendingFull) {
943+ t.Fatalf("send %d err=%v, want ErrPendingFull", i, err)
944+ }
945+ case <-time.After(time.Second):
946+ t.Fatalf("send %d did not unblock after link degraded", i)
947+ }
948+ }
949+}
950+
951+func ackRecord(seq uint64) []byte {
952+ var b [9]byte
953+ b[0] = recAck
954+ binary.BigEndian.PutUint64(b[1:], seq)
955+ return b[:]
956+}
957+
958+func probeRecord(kind byte, id uint64) []byte {
959+ var b [9]byte
960+ b[0] = kind
961+ binary.BigEndian.PutUint64(b[1:], id)
962+ return b[:]
963+}
964+
965+func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
966+ tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}
967+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
968+ for i := range maxRecvBuffer + 100 {
969+ tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}})
970+ }
971+ if got := len(tr.recvBuf); got != maxRecvBuffer {
972+ t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer)
973+ }
974+ tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
975+ if _, ok := tr.recvBuf[1]; !ok {
976+ t.Fatalf("next expected sequence was dropped when far-future buffer was full")
977+ }
978+ if got := len(tr.recvBuf); got > maxRecvBuffer+1 {
979+ t.Fatalf("recvBuf len=%d, want <= %d", got, maxRecvBuffer+1)
980+ }
981+}
982+
983+func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {
984+ aPC, bPC := newPair()
985+ codec, err := pdgram.NewCodec(key())
986+ if err != nil {
987+ t.Fatal(err)
988+ }
989+ tr := &Transport{pc: aPC, peer: testAddr("b"), codec: codec, sendDir: 1, mtu: pdgram.DefaultMTU, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, clock: realClock{}, done: make(chan struct{})}
990+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
991+ defer func() { _ = aPC.Close() }()
992+ defer func() { _ = bPC.Close() }()
993+
994+ for i := range maxRecvBuffer {
995+ tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}
996+ }
997+ tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")}))
998+ select {
999+ case pkt := <-bPC.in:
1000+ t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b)
1001+ case <-time.After(25 * time.Millisecond):
1002+ }
1003+
1004+ tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}))
1005+ select {
1006+ case <-bPC.in:
1007+ case <-time.After(time.Second):
1008+ t.Fatal("expected ACK for buffered contiguous frame")
1009+ }
1010+}
1011+
1012+func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) {
1013+ ch := make(chan ports.Frame, 1)
1014+ go func() { f, _ := tr.Recv(); ch <- f }()
1015+ select {
1016+ case f := <-ch:
1017+ return f, true
1018+ case <-time.After(d):
1019+ return ports.Frame{}, false
1020+ }
1021+}
1022+
1023+func recvWithin(t *testing.T, tr *Transport, d time.Duration) ports.Frame {
1024+ t.Helper()
1025+ ch := make(chan ports.Frame, 1)
1026+ go func() { f, _ := tr.Recv(); ch <- f }()
1027+ select {
1028+ case f := <-ch:
1029+ return f
1030+ case <-time.After(d):
1031+ t.Fatal("timeout")
1032+ return ports.Frame{}
1033+ }
1034+}
1035+
1036+func waitPeer(t *testing.T, tr *Transport, want string) {
1037+ t.Helper()
1038+ deadline := time.Now().Add(time.Second)
1039+ for time.Now().Before(deadline) {
1040+ if tr.Peer().String() == want {
1041+ return
1042+ }
1043+ time.Sleep(time.Millisecond)
1044+ }
1045+ t.Fatalf("peer=%v, want %s", tr.Peer(), want)
1046+}
1047+
1048+func recvErrWithin(t *testing.T, tr *Transport, d time.Duration) (ports.Frame, error) {
1049+ t.Helper()
1050+ type result struct {
1051+ f ports.Frame
1052+ err error
1053+ }
1054+ ch := make(chan result, 1)
1055+ go func() {
1056+ f, err := tr.Recv()
1057+ ch <- result{f: f, err: err}
1058+ }()
1059+ select {
1060+ case r := <-ch:
1061+ return r.f, r.err
1062+ case <-time.After(d):
1063+ t.Fatal("timeout")
1064+ return ports.Frame{}, nil
1065+ }
1066+}
1067+
1068+func TestLinkStateTransitionsFromConfigurableSilence(t *testing.T) {
1069+ aPC, bPC := newPair()
1070+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: 4 * time.Second})
1071+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1072+ defer func() { _ = a.Close() }()
1073+ defer func() { _ = b.Close() }()
1074+
1075+ tests := []struct {
1076+ name string
1077+ age time.Duration
1078+ want ports.LinkState
1079+ }{
1080+ {"degraded", time.Second, ports.LinkStateDegraded},
1081+ {"probing", 2 * time.Second, ports.LinkStateProbing},
1082+ {"offline", 3 * time.Second, ports.LinkStateOffline},
1083+ }
1084+ for _, tt := range tests {
1085+ t.Run(tt.name, func(t *testing.T) {
1086+ a.mu.Lock()
1087+ a.lastHeard = time.Now().Add(-tt.age)
1088+ a.mu.Unlock()
1089+ a.checkSilence()
1090+ if got := a.LinkState(); got != tt.want {
1091+ t.Fatalf("LinkState()=%v, want %v", got, tt.want)
1092+ }
1093+ select {
1094+ case ev := <-a.LinkEvents():
1095+ if ev.State != tt.want {
1096+ t.Fatalf("event state=%v, want %v", ev.State, tt.want)
1097+ }
1098+ default:
1099+ t.Fatalf("missing link event for %v", tt.want)
1100+ }
1101+ })
1102+ }
1103+}
1104+
1105+func TestSendStallDeathRequiresOldRetransmittedFrame(t *testing.T) {
1106+ tests := []struct {
1107+ name string
1108+ age time.Duration
1109+ attempts int
1110+ wantDead bool
1111+ }{
1112+ {name: "recent frame stays alive", age: time.Second, attempts: 5, wantDead: false},
1113+ {name: "old but never retransmitted stays alive", age: 61 * time.Second, attempts: 0, wantDead: false},
1114+ {name: "old retransmitted frame dies", age: 61 * time.Second, attempts: 3, wantDead: true},
1115+ }
1116+ for _, tt := range tests {
1117+ t.Run(tt.name, func(t *testing.T) {
1118+ aPC, bPC := newPair()
1119+ // Drop everything a sends so the frame never gets acked, and keep the
1120+ // receive side fresh so only the send-stall check can declare death.
1121+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
1122+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
1123+ ResendAfter: time.Hour, Heartbeat: time.Hour, DeadAfter: 60 * time.Second,
1124+ })
1125+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1126+ defer func() { _ = a.Close() }()
1127+ defer func() { _ = b.Close() }()
1128+
1129+ now := time.Now()
1130+ a.mu.Lock()
1131+ a.lastHeard = now
1132+ a.pending[1] = &pending{
1133+ frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stuck")},
1134+ first: now.Add(-tt.age),
1135+ last: now,
1136+ attempts: tt.attempts,
1137+ }
1138+ a.mu.Unlock()
1139+
1140+ a.checkSendStall()
1141+ if dead := a.LinkState() == ports.LinkStateDead; dead != tt.wantDead {
1142+ t.Fatalf("dead=%v, want %v", dead, tt.wantDead)
1143+ }
1144+ select {
1145+ case <-a.done:
1146+ if !tt.wantDead {
1147+ t.Fatal("transport closed without a send stall")
1148+ }
1149+ default:
1150+ if tt.wantDead {
1151+ t.Fatal("transport not closed after send stall")
1152+ }
1153+ }
1154+ })
1155+ }
1156+}
1157+
1158+func TestHopFiresAtProbingNotDegraded(t *testing.T) {
1159+ aPC, bPC := newPair()
1160+ var hopped atomic.Bool
1161+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
1162+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
1163+ DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: time.Hour,
1164+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
1165+ newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
1166+ bPC.peers["a2"] = newPC
1167+ hopped.Store(true)
1168+ return newPC, nil
1169+ },
1170+ })
1171+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1172+ defer func() { _ = a.Close() }()
1173+ defer func() { _ = b.Close() }()
1174+
1175+ a.mu.Lock()
1176+ a.lastHeard = time.Now().Add(-time.Second)
1177+ a.mu.Unlock()
1178+ a.checkSilence()
1179+ if hopped.Load() {
1180+ t.Fatal("socket hopped at degraded silence")
1181+ }
1182+
1183+ a.mu.Lock()
1184+ a.lastHeard = time.Now().Add(-2 * time.Second)
1185+ a.mu.Unlock()
1186+ a.checkSilence()
1187+ if !hopped.Load() {
1188+ t.Fatal("socket did not hop at probing silence")
1189+ }
1190+}
1191+
1192+func TestShortSilenceDoesNotKillTransport(t *testing.T) {
1193+ aPC, bPC := newPair()
1194+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: 50 * time.Millisecond, DeadAfter: time.Hour})
1195+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1196+ defer func() { _ = a.Close() }()
1197+ defer func() { _ = b.Close() }()
1198+ a.mu.Lock()
1199+ a.lastHeard = time.Now().Add(-time.Second)
1200+ a.mu.Unlock()
1201+ a.checkSilence()
1202+ if got := a.LinkState(); got != ports.LinkStateDegraded {
1203+ t.Fatalf("LinkState()=%v, want degraded", got)
1204+ }
1205+ select {
1206+ case <-a.done:
1207+ t.Fatal("transport closed before DeadAfter")
1208+ default:
1209+ }
1210+}
M internal/adapters/ipc/listener.go +3 -2
@@ -9,6 +9,7 @@ import (
99 "syscall"
1010
1111 "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/pkg/safedir"
1213 )
1314
1415 // socketFileName is the fixed name of the daemon's listening socket within
@@ -35,8 +36,8 @@ type unixListener struct {
3536 // unlinked and bind is retried once. A successful dial means a live daemon
3637 // owns the socket, and Listen returns ErrDaemonRunning.
3738 func Listen(dir string) (ports.Listener, error) {
38- if err := os.MkdirAll(dir, 0o700); err != nil {
39- return nil, fmt.Errorf("ipc: creating socket directory: %w", err)
39+ if err := safedir.EnsurePrivate(dir); err != nil {
40+ return nil, fmt.Errorf("ipc: securing socket directory: %w", err)
4041 }
4142
4243 sockPath := filepath.Join(dir, socketFileName)
M internal/adapters/ipc/listener_test.go +26 -3
@@ -6,13 +6,14 @@ import (
66 "os"
77 "path/filepath"
88 "strconv"
9+ "strings"
910 "testing"
1011
1112 "github.com/bnema/vev/internal/ports"
1213 )
1314
1415 func TestListenAcceptRoundTrip(t *testing.T) {
15- dir := t.TempDir()
16+ dir := filepath.Join(t.TempDir(), "vev")
1617
1718 ln, err := Listen(dir)
1819 if err != nil {
@@ -85,8 +86,30 @@ func TestListenMkdirsSocketDir(t *testing.T) {
8586 }
8687 }
8788
89+func TestListenRejectsHostileSocketDir(t *testing.T) {
90+ dir := filepath.Join(t.TempDir(), "socketdir")
91+ if err := os.MkdirAll(dir, 0o777); err != nil {
92+ t.Fatalf("mkdir hostile socket dir: %v", err)
93+ }
94+ if err := os.Chmod(dir, 0o777); err != nil {
95+ t.Fatalf("chmod hostile socket dir: %v", err)
96+ }
97+
98+ ln, err := Listen(dir)
99+ if err == nil {
100+ _ = ln.Close()
101+ t.Fatal("Listen() error = nil, want hostile directory rejection")
102+ }
103+ if !strings.Contains(err.Error(), dir) {
104+ t.Fatalf("Listen() error = %q, want mention of %q", err, dir)
105+ }
106+}
107+
88108 func TestListenStaleSocketCleanedUp(t *testing.T) {
89- dir := t.TempDir()
109+ dir := filepath.Join(t.TempDir(), "vev")
110+ if err := os.Mkdir(dir, 0o700); err != nil {
111+ t.Fatalf("mkdir safe socket dir: %v", err)
112+ }
90113 sockPath := filepath.Join(dir, "daemon.sock")
91114
92115 // Create a socket file with nobody listening behind it: bind, then
@@ -117,7 +140,7 @@ func TestListenStaleSocketCleanedUp(t *testing.T) {
117140 }
118141
119142 func TestListenLiveDaemonRejected(t *testing.T) {
120- dir := t.TempDir()
143+ dir := filepath.Join(t.TempDir(), "vev")
121144
122145 first, err := Listen(dir)
123146 if err != nil {
M internal/adapters/pty/pty_linux.go +20 -0
@@ -161,6 +161,26 @@ func (p *linuxPTY) Pid() int {
161161 return p.cmd.Process.Pid
162162 }
163163
164+// ForegroundPgid reports the process group currently in the foreground for the
165+// pty's controlling terminal.
166+func (p *linuxPTY) ForegroundPgid() (int, error) {
167+ rc, err := p.master.SyscallConn()
168+ if err != nil {
169+ return 0, fmt.Errorf("pty: foreground pgid: %w", err)
170+ }
171+ var pgid int
172+ var ioctlErr error
173+ if err := rc.Control(func(fd uintptr) {
174+ pgid, ioctlErr = unix.IoctlGetInt(int(fd), unix.TIOCGPGRP)
175+ }); err != nil {
176+ return 0, fmt.Errorf("pty: foreground pgid: %w", err)
177+ }
178+ if ioctlErr != nil {
179+ return 0, fmt.Errorf("pty: foreground pgid: %w", ioctlErr)
180+ }
181+ return pgid, nil
182+}
183+
164184 // Close terminates the child and releases the master. It signals the child's
165185 // process group with SIGHUP, waits up to killGracePeriod for it to exit, then
166186 // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie,
M internal/adapters/pty/pty_linux_test.go +14 -0
@@ -98,6 +98,20 @@ func TestOpen_EchoRoundtrip(t *testing.T) {
9898 require.NoError(t, p.Close())
9999 }
100100
101+func TestForegroundPgid(t *testing.T) {
102+ if testing.Short() {
103+ t.Skip("skipping pty integration test in -short mode")
104+ }
105+ f := newFactory()
106+ p, err := f.Open("sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
107+ require.NoError(t, err)
108+ t.Cleanup(func() { _ = p.Close() })
109+
110+ pgid, err := p.ForegroundPgid()
111+ require.NoError(t, err)
112+ require.Greater(t, pgid, 0)
113+}
114+
101115 func TestResize_SttySize(t *testing.T) {
102116 if testing.Short() {
103117 t.Skip("skipping pty integration test in -short mode")
A internal/adapters/remote/remote_factory.go +45 -0
@@ -0,0 +1,45 @@
1+package remote
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+
8+ "github.com/bnema/vev/internal/adapters/dgram"
9+ "github.com/bnema/vev/internal/adapters/sshstdio"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// DialerFactory selects the requested remote transport adapter.
14+type DialerFactory struct{}
15+
16+var _ ports.RemoteDialerFactory = DialerFactory{}
17+
18+func NewDialerFactory() DialerFactory { return DialerFactory{} }
19+
20+func (DialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
21+ switch mode {
22+ case ports.RemoteTransportUDP:
23+ if log != nil {
24+ log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
25+ }
26+ return dgram.NewRemoteDialerWithLogger(target, session, log), nil
27+ case ports.RemoteTransportStdio:
28+ if log != nil {
29+ log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
30+ }
31+ return stdioDialer{target: target, session: session, log: log}, nil
32+ default:
33+ return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
34+ }
35+}
36+
37+type stdioDialer struct {
38+ target string
39+ session string
40+ log *slog.Logger
41+}
42+
43+func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) {
44+ return sshstdio.DialContext(ctx, d.target, d.session, d.log)
45+}
A internal/adapters/remote/remote_factory_test.go +78 -0
@@ -0,0 +1,78 @@
1+package remote
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/adapters/dgram"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func TestDialerFactorySelectsExplicitModes(t *testing.T) {
13+ factory := NewDialerFactory()
14+
15+ tests := []struct {
16+ name string
17+ mode ports.RemoteTransportMode
18+ wantType any
19+ }{
20+ {name: "udp", mode: ports.RemoteTransportUDP, wantType: dgram.RemoteDialer{}},
21+ {name: "stdio", mode: ports.RemoteTransportStdio, wantType: stdioDialer{}},
22+ }
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ dialer, err := factory.DialerForRemote("remote.example", "work", tt.mode, nil)
26+ if err != nil {
27+ t.Fatalf("DialerForRemote() error = %v", err)
28+ }
29+ switch tt.wantType.(type) {
30+ case dgram.RemoteDialer:
31+ got, ok := dialer.(dgram.RemoteDialer)
32+ if !ok {
33+ t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{})
34+ }
35+ if got.Target != "remote.example" || got.Session != "work" {
36+ t.Fatalf("dialer = %+v, want target/session copied", got)
37+ }
38+ case stdioDialer:
39+ got, ok := dialer.(stdioDialer)
40+ if !ok {
41+ t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{})
42+ }
43+ if got.target != "remote.example" || got.session != "work" {
44+ t.Fatalf("dialer = %+v, want target/session copied", got)
45+ }
46+ }
47+ })
48+ }
49+}
50+
51+func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {
52+ factory := NewDialerFactory()
53+
54+ dialer, err := factory.DialerForRemote("remote.example", "work", ports.RemoteTransportMode("serial"), nil)
55+ if err == nil {
56+ t.Fatal("DialerForRemote() error = nil, want unsupported mode error")
57+ }
58+ if dialer != nil {
59+ t.Fatalf("DialerForRemote() dialer = %T, want nil", dialer)
60+ }
61+ if got, want := err.Error(), "vev: unsupported remote transport \"serial\""; got != want {
62+ t.Fatalf("error = %q, want %q", got, want)
63+ }
64+}
65+
66+func TestStdioDialerUsesContextBeforeStartingSSH(t *testing.T) {
67+ dialer := stdioDialer{target: "remote.example", session: "work"}
68+ ctx, cancel := context.WithCancel(context.Background())
69+ cancel()
70+
71+ transport, err := dialer.Dial(ctx)
72+ if !errors.Is(err, context.Canceled) {
73+ t.Fatalf("Dial() error = %v, want context canceled", err)
74+ }
75+ if transport != nil {
76+ t.Fatalf("Dial() transport = %T, want nil", transport)
77+ }
78+}
A internal/adapters/shellcmd/runner.go +63 -0
@@ -0,0 +1,63 @@
1+package shellcmd
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "os/exec"
8+ "syscall"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// Runner executes bounded shell commands for bar scripts and similar usecases.
14+type Runner struct{}
15+
16+// New returns a production shell command runner.
17+func New() Runner { return Runner{} }
18+
19+// Run executes spec.Command through sh -c, capturing bounded stdout and discarding stderr.
20+func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
21+ cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)
22+ cmd.Env = spec.Env
23+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
24+ cmd.WaitDelay = spec.Timeout
25+ cmd.Cancel = func() error {
26+ if cmd.Process == nil {
27+ return nil
28+ }
29+ return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
30+ }
31+ var stdout boundedBuffer
32+ stdout.limit = spec.StdoutLimit
33+ cmd.Stdout = &stdout
34+ cmd.Stderr = io.Discard
35+ if err := cmd.Run(); err != nil {
36+ return stdout.Bytes(), err
37+ }
38+ return stdout.Bytes(), nil
39+}
40+
41+type boundedBuffer struct {
42+ buf bytes.Buffer
43+ limit int
44+}
45+
46+func (b *boundedBuffer) Write(p []byte) (int, error) {
47+ if b.limit <= 0 {
48+ return len(p), nil
49+ }
50+ remaining := b.limit - b.buf.Len()
51+ if remaining > 0 {
52+ if len(p) < remaining {
53+ remaining = len(p)
54+ }
55+ _, _ = b.buf.Write(p[:remaining])
56+ }
57+ return len(p), nil
58+}
59+
60+func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() }
61+
62+var _ ports.ShellCommandRunner = Runner{}
63+var _ io.Writer = (*boundedBuffer)(nil)
A internal/adapters/shellcmd/runner_test.go +107 -0
@@ -0,0 +1,107 @@
1+package shellcmd
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestRunnerRun(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ command string
17+ limit int
18+ want string
19+ }{
20+ {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"},
21+ {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"},
22+ {name: "stderr discarded", command: "printf out; printf err >&2", limit: 1024, want: "out"},
23+ {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""},
24+ {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
29+ defer cancel()
30+
31+ got, err := New().Run(ctx, ports.CommandSpec{Command: tt.command, Timeout: time.Second, StdoutLimit: tt.limit})
32+ if err != nil {
33+ t.Fatalf("Run() error = %v", err)
34+ }
35+ if string(got) != tt.want {
36+ t.Fatalf("Run() = %q, want %q", string(got), tt.want)
37+ }
38+ })
39+ }
40+}
41+
42+func TestRunnerRunReturnsPartialStdoutOnError(t *testing.T) {
43+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
44+ defer cancel()
45+
46+ got, err := New().Run(ctx, ports.CommandSpec{Command: "printf 'abcdef'; exit 7", Timeout: time.Second, StdoutLimit: 3})
47+ if err == nil {
48+ t.Fatal("Run() error = nil, want non-zero exit error")
49+ }
50+ if string(got) != "abc" {
51+ t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got), "abc")
52+ }
53+}
54+
55+func TestRunnerRunTimeoutReturnsPromptly(t *testing.T) {
56+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
57+ defer cancel()
58+ start := time.Now()
59+
60+ _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
61+
62+ if err == nil {
63+ t.Fatal("Run() error = nil, want timeout or wait error")
64+ }
65+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
66+ t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
67+ }
68+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
69+ t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
70+ }
71+}
72+
73+func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) {
74+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
75+ defer cancel()
76+ start := time.Now()
77+
78+ _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1 &", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
79+
80+ if err == nil {
81+ t.Fatal("Run() error = nil, want timeout or wait error")
82+ }
83+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
84+ t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
85+ }
86+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
87+ t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
88+ }
89+}
90+
91+func TestRunnerRunUsesProvidedEnvironment(t *testing.T) {
92+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
93+ defer cancel()
94+
95+ got, err := New().Run(ctx, ports.CommandSpec{
96+ Command: "printf '%s' \"$VEV_TEST_VALUE\"",
97+ Env: []string{"PATH=/bin:/usr/bin", "VEV_TEST_VALUE=from-env"},
98+ Timeout: time.Second,
99+ StdoutLimit: 1024,
100+ })
101+ if err != nil {
102+ t.Fatalf("Run() error = %v", err)
103+ }
104+ if strings.TrimSpace(string(got)) != "from-env" {
105+ t.Fatalf("Run() env output = %q, want from-env", string(got))
106+ }
107+}
A internal/adapters/snapshot/store.go +168 -0
@@ -0,0 +1,168 @@
1+package snapshot
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "log/slog"
10+ "os"
11+ "path/filepath"
12+ "regexp"
13+ "strings"
14+
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/pkg/safedir"
17+)
18+
19+var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`)
20+
21+const maxSnapshotFileSize = 16 + (256 << 20)
22+
23+// Store persists raw session snapshot blobs on disk.
24+type Store struct {
25+ dir string
26+ log *slog.Logger
27+}
28+
29+var _ ports.SnapshotStore = (*Store)(nil)
30+
31+// NewStore creates a file-backed snapshot store rooted at dir.
32+func NewStore(dir string) *Store {
33+ return &Store{dir: dir, log: slog.Default()}
34+}
35+
36+// Write atomically writes data for name. The previous snapshot remains in place
37+// until the final rename succeeds.
38+func (s *Store) Write(name string, data []byte) error {
39+ if err := safedir.EnsurePrivate(s.dir); err != nil {
40+ return fmt.Errorf("create snapshot dir: %w", err)
41+ }
42+
43+ tmp, err := os.CreateTemp(s.dir, ".tmp-*")
44+ if err != nil {
45+ return fmt.Errorf("create temp snapshot: %w", err)
46+ }
47+ tmpName := tmp.Name()
48+ keepTmp := false
49+ defer func() {
50+ if !keepTmp {
51+ _ = os.Remove(tmpName)
52+ }
53+ }()
54+
55+ if err := tmp.Chmod(0o600); err != nil {
56+ _ = tmp.Close()
57+ return fmt.Errorf("chmod temp snapshot: %w", err)
58+ }
59+ if _, err := tmp.Write(data); err != nil {
60+ _ = tmp.Close()
61+ return fmt.Errorf("write temp snapshot: %w", err)
62+ }
63+ if err := tmp.Sync(); err != nil {
64+ _ = tmp.Close()
65+ return fmt.Errorf("sync temp snapshot: %w", err)
66+ }
67+ if err := tmp.Close(); err != nil {
68+ return fmt.Errorf("close temp snapshot: %w", err)
69+ }
70+
71+ path := filepath.Join(s.dir, filenameForName(name))
72+ if err := os.Rename(tmpName, path); err != nil {
73+ return fmt.Errorf("rename snapshot: %w", err)
74+ }
75+ keepTmp = true
76+ syncDir(s.dir)
77+ return nil
78+}
79+
80+// Load returns all raw .snap blobs. It does not validate the blob format.
81+func (s *Store) Load() ([]ports.SnapshotBlob, error) {
82+ entries, err := os.ReadDir(s.dir)
83+ if errors.Is(err, os.ErrNotExist) {
84+ return nil, nil
85+ }
86+ if err != nil {
87+ return nil, fmt.Errorf("read snapshot dir: %w", err)
88+ }
89+
90+ blobs := make([]ports.SnapshotBlob, 0, len(entries))
91+ for _, entry := range entries {
92+ base := entry.Name()
93+ path := filepath.Join(s.dir, base)
94+ if strings.HasPrefix(base, ".tmp-") {
95+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
96+ s.log.Warn("remove stale snapshot temp", "path", path, "err", err)
97+ }
98+ continue
99+ }
100+ if entry.IsDir() || !strings.HasSuffix(base, ".snap") {
101+ continue
102+ }
103+ data, err := readSnapshotFileBounded(path)
104+ if err != nil {
105+ s.log.Warn("read snapshot", "path", path, "err", err)
106+ continue
107+ }
108+ name := strings.TrimSuffix(base, ".snap")
109+ if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {
110+ name = ""
111+ }
112+ blobs = append(blobs, ports.SnapshotBlob{Name: name, Data: data})
113+ }
114+ return blobs, nil
115+}
116+
117+// Delete removes the deterministic snapshot file for name. Missing files are ignored.
118+func (s *Store) Delete(name string) error {
119+ err := os.Remove(filepath.Join(s.dir, filenameForName(name)))
120+ if errors.Is(err, os.ErrNotExist) {
121+ return nil
122+ }
123+ if err != nil {
124+ return fmt.Errorf("delete snapshot: %w", err)
125+ }
126+ syncDir(s.dir)
127+ return nil
128+}
129+
130+func readSnapshotFileBounded(path string) ([]byte, error) {
131+ f, err := os.Open(path)
132+ if err != nil {
133+ return nil, err
134+ }
135+ defer func() { _ = f.Close() }()
136+ info, err := f.Stat()
137+ if err != nil {
138+ return nil, err
139+ }
140+ if info.Size() > maxSnapshotFileSize {
141+ return nil, fmt.Errorf("snapshot too large: %d bytes", info.Size())
142+ }
143+ data, err := io.ReadAll(io.LimitReader(f, maxSnapshotFileSize+1))
144+ if err != nil {
145+ return nil, err
146+ }
147+ if len(data) > maxSnapshotFileSize {
148+ return nil, fmt.Errorf("snapshot too large: %d bytes", len(data))
149+ }
150+ return data, nil
151+}
152+
153+func filenameForName(name string) string {
154+ if safeNameRE.MatchString(name) {
155+ return name + ".snap"
156+ }
157+ sum := sha256.Sum256([]byte(name))
158+ return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
159+}
160+
161+func syncDir(dir string) {
162+ f, err := os.Open(dir)
163+ if err != nil {
164+ return
165+ }
166+ defer func() { _ = f.Close() }()
167+ _ = f.Sync()
168+}
A internal/adapters/snapshot/store_test.go +192 -0
@@ -0,0 +1,192 @@
1+package snapshot
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "runtime"
10+ "sort"
11+ "strings"
12+ "testing"
13+)
14+
15+func privateDir(t *testing.T) string {
16+ t.Helper()
17+ return filepath.Join(t.TempDir(), "vev")
18+}
19+
20+func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) {
21+ t.Parallel()
22+ dir := privateDir(t)
23+ store := NewStore(dir)
24+
25+ if err := store.Write("alpha", []byte("first")); err != nil {
26+ t.Fatalf("Write first: %v", err)
27+ }
28+ if err := store.Write("alpha", []byte("second")); err != nil {
29+ t.Fatalf("Write second: %v", err)
30+ }
31+
32+ got := loadMap(t, store)
33+ if string(got["alpha"]) != "second" {
34+ t.Fatalf("loaded alpha = %q, want second", got["alpha"])
35+ }
36+ info, err := os.Stat(filepath.Join(dir, "alpha.snap"))
37+ if err != nil {
38+ t.Fatalf("stat snapshot: %v", err)
39+ }
40+ if gotMode := info.Mode().Perm(); gotMode != 0o600 {
41+ t.Fatalf("snapshot mode = %v, want 0600", gotMode)
42+ }
43+ if dirInfo, err := os.Stat(dir); err != nil {
44+ t.Fatalf("stat dir: %v", err)
45+ } else if gotMode := dirInfo.Mode().Perm(); gotMode != 0o700 {
46+ t.Fatalf("dir mode = %v, want 0700", gotMode)
47+ }
48+}
49+
50+func TestStoreDelete(t *testing.T) {
51+ t.Parallel()
52+ dir := privateDir(t)
53+ store := NewStore(dir)
54+
55+ if err := store.Write("gone", []byte("data")); err != nil {
56+ t.Fatalf("Write: %v", err)
57+ }
58+ if err := store.Delete("gone"); err != nil {
59+ t.Fatalf("Delete existing: %v", err)
60+ }
61+ if err := store.Delete("gone"); err != nil {
62+ t.Fatalf("Delete missing: %v", err)
63+ }
64+ if got := loadMap(t, store); len(got) != 0 {
65+ t.Fatalf("Load after delete = %#v, want empty", got)
66+ }
67+}
68+
69+func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) {
70+ t.Parallel()
71+ dir := privateDir(t)
72+ store := NewStore(dir)
73+ name := "../unsafe/session name"
74+
75+ if err := store.Write(name, []byte("raw")); err != nil {
76+ t.Fatalf("Write unsafe: %v", err)
77+ }
78+ sum := sha256.Sum256([]byte(name))
79+ wantBase := "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
80+ if _, err := os.Stat(filepath.Join(dir, wantBase)); err != nil {
81+ t.Fatalf("stat hashed snapshot %q: %v", wantBase, err)
82+ }
83+ loaded, err := store.Load()
84+ if err != nil {
85+ t.Fatalf("Load: %v", err)
86+ }
87+ if len(loaded) != 1 || loaded[0].Name != "" || string(loaded[0].Data) != "raw" {
88+ t.Fatalf("Load unsafe = %#v, want one nameless raw blob", loaded)
89+ }
90+}
91+
92+func TestStoreMissingDirAndStaleTmpCleanup(t *testing.T) {
93+ t.Parallel()
94+ dir := filepath.Join(t.TempDir(), "missing")
95+ store := NewStore(dir)
96+
97+ loaded, err := store.Load()
98+ if err != nil {
99+ t.Fatalf("Load missing dir: %v", err)
100+ }
101+ if len(loaded) != 0 {
102+ t.Fatalf("Load missing dir len = %d, want 0", len(loaded))
103+ }
104+ if err := os.MkdirAll(dir, 0o700); err != nil {
105+ t.Fatalf("MkdirAll: %v", err)
106+ }
107+ if err := os.WriteFile(filepath.Join(dir, ".tmp-stale"), []byte("tmp"), 0o600); err != nil {
108+ t.Fatalf("write stale tmp: %v", err)
109+ }
110+ if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("ignore"), 0o600); err != nil {
111+ t.Fatalf("write ignored: %v", err)
112+ }
113+ if err := os.WriteFile(filepath.Join(dir, "beta.snap"), []byte("beta"), 0o600); err != nil {
114+ t.Fatalf("write snap: %v", err)
115+ }
116+
117+ got := loadMap(t, store)
118+ if string(got["beta"]) != "beta" || len(got) != 1 {
119+ t.Fatalf("Load = %#v, want only beta", got)
120+ }
121+ if _, err := os.Stat(filepath.Join(dir, ".tmp-stale")); !errors.Is(err, os.ErrNotExist) {
122+ t.Fatalf("stale tmp stat err = %v, want not exist", err)
123+ }
124+}
125+
126+func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) {
127+ t.Parallel()
128+ dir := privateDir(t)
129+ store := NewStore(dir)
130+ if err := os.MkdirAll(dir, 0o700); err != nil {
131+ t.Fatalf("MkdirAll: %v", err)
132+ }
133+ if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
134+ t.Fatalf("write ok: %v", err)
135+ }
136+ tooLarge := filepath.Join(dir, "huge.snap")
137+ f, err := os.Create(tooLarge)
138+ if err != nil {
139+ t.Fatalf("create huge: %v", err)
140+ }
141+ if err := f.Truncate(maxSnapshotFileSize + 1); err != nil {
142+ _ = f.Close()
143+ t.Fatalf("truncate huge: %v", err)
144+ }
145+ if err := f.Close(); err != nil {
146+ t.Fatalf("close huge: %v", err)
147+ }
148+
149+ got := loadMap(t, store)
150+ if len(got) != 1 || string(got["ok"]) != "ok" {
151+ t.Fatalf("Load = %#v, want only ok", got)
152+ }
153+}
154+
155+func TestStoreLoadSkipsUnreadableFile(t *testing.T) {
156+ if runtime.GOOS == "windows" || os.Geteuid() == 0 {
157+ t.Skip("permission bits do not make file unreadable on this platform/user")
158+ }
159+ t.Parallel()
160+ dir := privateDir(t)
161+ store := NewStore(dir)
162+ if err := os.MkdirAll(dir, 0o700); err != nil {
163+ t.Fatalf("MkdirAll: %v", err)
164+ }
165+ if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
166+ t.Fatalf("write ok: %v", err)
167+ }
168+ bad := filepath.Join(dir, "bad.snap")
169+ if err := os.WriteFile(bad, []byte("bad"), 0o000); err != nil {
170+ t.Fatalf("write bad: %v", err)
171+ }
172+ t.Cleanup(func() { _ = os.Chmod(bad, 0o600) })
173+
174+ got := loadMap(t, store)
175+ if len(got) != 1 || string(got["ok"]) != "ok" {
176+ t.Fatalf("Load = %#v, want only ok", got)
177+ }
178+}
179+
180+func loadMap(t *testing.T, store *Store) map[string][]byte {
181+ t.Helper()
182+ loaded, err := store.Load()
183+ if err != nil {
184+ t.Fatalf("Load: %v", err)
185+ }
186+ sort.Slice(loaded, func(i, j int) bool { return strings.Compare(loaded[i].Name, loaded[j].Name) < 0 })
187+ out := make(map[string][]byte, len(loaded))
188+ for _, blob := range loaded {
189+ out[blob.Name] = blob.Data
190+ }
191+ return out
192+}
M internal/adapters/sshstdio/transport.go +119 -27
@@ -6,10 +6,12 @@ package sshstdio
66
77 import (
88 "bytes"
9+ "context"
910 "encoding/binary"
1011 "errors"
1112 "fmt"
1213 "io"
14+ "log/slog"
1315 "os/exec"
1416 "strings"
1517 "sync"
@@ -30,19 +32,25 @@ var (
3032 )
3133
3234 type closeFunc func() error
35+type eofErrFunc func() error
3336
3437 // NewTransport wraps separate reader/writer streams as a framed Transport.
35-func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport {
36- if close == nil {
37- close = func() error { return nil }
38+func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc) ports.Transport {
39+ return newTransport(r, w, closeFn, nil)
40+}
41+
42+func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) ports.Transport {
43+ if closeFn == nil {
44+ closeFn = func() error { return nil }
3845 }
39- return &transport{r: r, w: w, close: close}
46+ return &transport{r: r, w: w, close: closeFn, eofErr: eofErr}
4047 }
4148
4249 type transport struct {
43- r io.Reader
44- w io.Writer
45- close closeFunc
50+ r io.Reader
51+ w io.Writer
52+ close closeFunc
53+ eofErr eofErrFunc
4654
4755 mu sync.Mutex
4856 readBuf []byte
@@ -68,7 +76,7 @@ func (t *transport) Send(f ports.Frame) error {
6876 func (t *transport) Recv() (ports.Frame, error) {
6977 var hdr [frameHeaderLen]byte
7078 if _, err := io.ReadFull(t.r, hdr[:]); err != nil {
71- return ports.Frame{}, err
79+ return ports.Frame{}, t.mapEOFError(err)
7280 }
7381
7482 n := binary.BigEndian.Uint32(hdr[:])
@@ -85,39 +93,98 @@ func (t *transport) Recv() (ports.Frame, error) {
8593 t.readBuf = t.readBuf[:n]
8694 }
8795 if _, err := io.ReadFull(t.r, t.readBuf); err != nil {
88- return ports.Frame{}, err
96+ return ports.Frame{}, t.mapEOFError(err)
8997 }
9098
9199 payload := append([]byte(nil), t.readBuf[1:]...)
92100 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
93101 }
94102
103+func (t *transport) mapEOFError(err error) error {
104+ if t.eofErr == nil || (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) {
105+ return err
106+ }
107+ if sshErr := t.eofErr(); sshErr != nil {
108+ return sshErr
109+ }
110+ return err
111+}
112+
95113 func (t *transport) Close() error { return t.close() }
96114
97-func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration) closeFunc {
98- return func() error {
99- _ = stdin.Close()
115+type processWaiter struct {
116+ cmd *exec.Cmd
117+ stdin io.Closer
118+ stderr *bytes.Buffer
119+ timeout time.Duration
120+ log *slog.Logger
121+ target string
122+ session string
123+
124+ waitOnce sync.Once
125+ waitErr error
126+}
127+
128+func newProcessWaiter(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) *processWaiter {
129+ w := &processWaiter{
130+ cmd: cmd,
131+ stdin: stdin,
132+ stderr: stderr,
133+ timeout: timeout,
134+ log: log,
135+ target: target,
136+ session: session,
137+ }
138+ return w
139+}
140+
141+func (w *processWaiter) close() error {
142+ _ = w.stdin.Close()
143+ return w.wait(w.timeout)
144+}
100145
101- waitErr := make(chan error, 1)
102- go func() { waitErr <- cmd.Wait() }()
146+func (w *processWaiter) eofErr() error {
147+ return w.wait(sshCloseTimeout)
148+}
103149
104- var err error
150+func (w *processWaiter) wait(timeout time.Duration) error {
151+ w.waitOnce.Do(func() {
152+ // cmd.Wait must start only after stdout is no longer being read. os/exec
153+ // requires callers to finish reading StdoutPipe before Wait, so DialContext
154+ // calls this from transport Close or after Recv has already observed EOF.
155+ waitCh := make(chan error, 1)
156+ go func() { waitCh <- w.cmd.Wait() }()
105157 select {
106- case err = <-waitErr:
158+ case w.waitErr = <-waitCh:
107159 case <-time.After(timeout):
108- _ = cmd.Process.Kill()
109- err = <-waitErr
110- }
111- if err == nil {
112- return nil
160+ _ = w.cmd.Process.Kill()
161+ w.waitErr = <-waitCh
113162 }
163+ w.waitErr = formatProcessWaitError(w.waitErr, w.stderr, w.log, w.target, w.session)
164+ })
165+ return w.waitErr
166+}
114167
115- stderrText := strings.TrimSpace(stderr.String())
168+func formatProcessWaitError(err error, stderr *bytes.Buffer, log *slog.Logger, target, session string) error {
169+ if err == nil {
170+ return nil
171+ }
172+ stderrText := strings.TrimSpace(stderr.String())
173+ if log != nil {
174+ attrs := []any{"target", target, "session", session, "err", err}
116175 if stderrText != "" {
117- return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)
176+ attrs = append(attrs, "stderr", stderrText)
118177 }
119- return fmt.Errorf("sshstdio: ssh exited: %w", err)
178+ log.Warn("ssh exited non-cleanly", attrs...)
120179 }
180+ if stderrText != "" {
181+ return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)
182+ }
183+ return fmt.Errorf("sshstdio: ssh exited: %w", err)
184+}
185+
186+func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) closeFunc {
187+ return newProcessWaiter(cmd, stdin, stderr, timeout, log, target, session).close
121188 }
122189
123190 // CommandSpec is the exact ssh argv vev will execute locally.
@@ -130,7 +197,13 @@ type CommandSpec struct {
130197 // local shell. OpenSSH sends the remote command as one string for the remote
131198 // user's shell to interpret, so every remote argv word is POSIX single-quoted.
132199 func BuildCommand(target, session string) CommandSpec {
133- remote := []string{shellQuote("vev"), shellQuote("_stdio")}
200+ return BuildCommandForMode(target, "_stdio", session)
201+}
202+
203+// BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev
204+// remote mode such as _stdio or _udp-bootstrap.
205+func BuildCommandForMode(target, mode, session string) CommandSpec {
206+ remote := []string{shellQuote("vev"), shellQuote(mode)}
134207 if session != "" {
135208 remote = append(remote, shellQuote(session))
136209 }
@@ -146,8 +219,23 @@ func shellQuote(s string) string {
146219 // child process' stdio. The subprocess is started with exec.Command argv, never
147220 // through a shell.
148221 func Dial(target, session string) (ports.Transport, error) {
222+ return DialContext(context.Background(), target, session)
223+}
224+
225+// DialContext is like Dial, but the context is propagated to ssh startup so a
226+// canceled attach attempt interrupts the local ssh process. Callers may pass a
227+// logger to record ssh start failures and non-clean exits without logging the
228+// generated command line.
229+func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) {
230+ if err := ctx.Err(); err != nil {
231+ return nil, err
232+ }
233+ var log *slog.Logger
234+ if len(logger) > 0 {
235+ log = logger[0]
236+ }
149237 spec := BuildCommand(target, session)
150- cmd := exec.Command(spec.Path, spec.Args...)
238+ cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
151239 stdin, err := cmd.StdinPipe()
152240 if err != nil {
153241 return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err)
@@ -159,8 +247,12 @@ func Dial(target, session string) (ports.Transport, error) {
159247 var stderr bytes.Buffer
160248 cmd.Stderr = &stderr
161249 if err := cmd.Start(); err != nil {
250+ if log != nil {
251+ log.Error("ssh start failed", "target", target, "session", session, "err", err)
252+ }
162253 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
163254 }
164255
165- return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout)), nil
256+ waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)
257+ return newTransport(stdout, stdin, waiter.close, waiter.eofErr), nil
166258 }
M internal/adapters/sshstdio/transport_test.go +96 -1
@@ -2,8 +2,10 @@ package sshstdio
22
33 import (
44 "bytes"
5+ "context"
56 "errors"
67 "io"
8+ "log/slog"
79 "os/exec"
810 "strings"
911 "testing"
@@ -13,6 +15,8 @@ import (
1315 )
1416
1517 func TestBuildCommandUsesExecArgs(t *testing.T) {
18+ // BuildCommand deliberately starts `ssh -- target 'vev' '_stdio' [session]`.
19+ // Terminal color capability is carried in vev's Hello message, not in ssh env.
1620 tests := []struct {
1721 name string
1822 target string
@@ -43,6 +47,19 @@ func TestBuildCommandUsesExecArgs(t *testing.T) {
4347 }
4448 }
4549
50+func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) {
51+ got := BuildCommandForMode("user@example.com", "_udp-bootstrap", "work")
52+ want := []string{"--", "user@example.com", "'vev' '_udp-bootstrap' 'work'"}
53+ if got.Path != "ssh" {
54+ t.Fatalf("Path = %q, want ssh", got.Path)
55+ }
56+ for i := range want {
57+ if got.Args[i] != want[i] {
58+ t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], want[i], got.Args)
59+ }
60+ }
61+}
62+
4663 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
4764 clientRead, serverWrite := io.Pipe()
4865 serverRead, clientWrite := io.Pipe()
@@ -102,6 +119,84 @@ func TestTransportRejectsZeroLengthFrame(t *testing.T) {
102119 }
103120 }
104121
122+func TestDialContextCanceledBeforeStart(t *testing.T) {
123+ ctx, cancel := context.WithCancel(context.Background())
124+ cancel()
125+ _, err := DialContext(ctx, "example.com", "work")
126+ if !errors.Is(err, context.Canceled) {
127+ t.Fatalf("DialContext error = %v, want context.Canceled", err)
128+ }
129+}
130+
131+func TestRecvMapsUnexpectedEOFDuringHeaderToSSHExit(t *testing.T) {
132+ sshErr := errors.New("ssh exited with status 255")
133+ tr := newTransport(strings.NewReader("\x00"), io.Discard, nil, func() error { return sshErr })
134+
135+ _, err := tr.Recv()
136+ if !errors.Is(err, sshErr) {
137+ t.Fatalf("Recv error = %v, want %v", err, sshErr)
138+ }
139+}
140+
141+func TestRecvReportsSSHExitWhenProcessClosesBeforeFrame(t *testing.T) {
142+ cmd := exec.CommandContext(context.Background(), "sh", "-c", "echo connection refused >&2; exit 255")
143+ stdin, err := cmd.StdinPipe()
144+ if err != nil {
145+ t.Fatalf("StdinPipe: %v", err)
146+ }
147+ stdout, err := cmd.StdoutPipe()
148+ if err != nil {
149+ t.Fatalf("StdoutPipe: %v", err)
150+ }
151+ var stderr bytes.Buffer
152+ cmd.Stderr = &stderr
153+ if err := cmd.Start(); err != nil {
154+ t.Fatalf("Start: %v", err)
155+ }
156+
157+ waiter := newProcessWaiter(cmd, stdin, &stderr, time.Second, nil, "user@example.com", "work")
158+ tr := newTransport(stdout, stdin, waiter.close, waiter.eofErr)
159+
160+ _, err = tr.Recv()
161+ if err == nil {
162+ t.Fatal("Recv error = nil, want ssh exit error")
163+ }
164+ for _, want := range []string{"sshstdio: ssh exited:", "connection refused"} {
165+ if !strings.Contains(err.Error(), want) {
166+ t.Fatalf("Recv error = %q, want substring %q", err, want)
167+ }
168+ }
169+}
170+
171+func TestProcessCloserLogsNonCleanExitStderr(t *testing.T) {
172+ cmd := exec.Command("sh", "-c", "echo remote failure >&2; exit 7")
173+ stdin, err := cmd.StdinPipe()
174+ if err != nil {
175+ t.Fatalf("StdinPipe: %v", err)
176+ }
177+ var stderr bytes.Buffer
178+ cmd.Stderr = &stderr
179+ if err := cmd.Start(); err != nil {
180+ t.Fatalf("Start: %v", err)
181+ }
182+
183+ var logBuf bytes.Buffer
184+ log := slog.New(slog.NewJSONHandler(&logBuf, nil))
185+ err = newProcessCloser(cmd, stdin, &stderr, time.Second, log, "user@example.com", "work")()
186+ if err == nil {
187+ t.Fatal("Close error = nil, want non-clean ssh exit")
188+ }
189+ entry := logBuf.String()
190+ for _, want := range []string{"ssh exited non-cleanly", "remote failure", "user@example.com", "work"} {
191+ if !strings.Contains(entry, want) {
192+ t.Fatalf("log entry = %q, want substring %q", entry, want)
193+ }
194+ }
195+ if strings.Contains(entry, "'vev' '_stdio'") || strings.Contains(entry, "-- user@example.com") {
196+ t.Fatalf("log entry includes generated command line: %q", entry)
197+ }
198+}
199+
105200 func TestProcessCloser(t *testing.T) {
106201 tests := []struct {
107202 name string
@@ -138,7 +233,7 @@ func TestProcessCloser(t *testing.T) {
138233 }
139234
140235 started := time.Now()
141- err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout)()
236+ err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout, nil, "", "")()
142237 elapsed := time.Since(started)
143238
144239 if err == nil {
M internal/adapters/term/pty_integration_test.go +1 -1
@@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
102102
103103 select {
104104 case got := <-captured:
105- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
105+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
106106 if string(got) != want {
107107 t.Fatalf("captured escapes = %q, want %q", got, want)
108108 }
M internal/adapters/term/terminal.go +27 -10
@@ -21,14 +21,18 @@ var _ ports.Terminal = (*Terminal)(nil)
2121 // Escape sequences for alt-screen and cursor visibility control. All
2222 // emissions go through the batched writer and are explicitly flushed.
2323 const (
24- altScreenEnter = "\x1b[?1049h"
25- altScreenExit = "\x1b[?1049l"
26- cursorHide = "\x1b[?25l"
27- cursorShow = "\x1b[?25h"
28- cursorStyleDefault = "\x1b[0 q"
29- mouseEnable = "\x1b[?1002h\x1b[?1006h"
30- mouseDisable = "\x1b[?1002l\x1b[?1006l"
31- oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
24+ altScreenEnter = "\x1b[?1049h"
25+ altScreenExit = "\x1b[?1049l"
26+ cursorHide = "\x1b[?25l"
27+ cursorShow = "\x1b[?25h"
28+ cursorStyleDefault = "\x1b[0 q"
29+ mouseEnable = "\x1b[?1002h\x1b[?1006h"
30+ mouseDisable = "\x1b[?1002l\x1b[?1006l"
31+ bracketedPasteEnable = "\x1b[?2004h"
32+ bracketedPasteDisable = "\x1b[?2004l"
33+ colorSchemeEnable = "\x1b[?2031h"
34+ colorSchemeDisable = "\x1b[?2031l"
35+ oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
3236 )
3337
3438 // bufSize is the batched writer's buffer capacity.
@@ -105,7 +109,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
105109 }
106110 t.orig = old
107111
108- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + oscColorQuery); err != nil {
112+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery); err != nil {
109113 _ = t.restoreRawLocked()
110114 return nil, fmt.Errorf("term: enter alt screen: %w", err)
111115 }
@@ -139,7 +143,7 @@ func (t *Terminal) restore() error {
139143 t.mu.Lock()
140144 defer t.mu.Unlock()
141145
142- _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + altScreenExit)
146+ _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit)
143147 ferr := t.bw.Flush()
144148
145149 rerr := t.restoreRawLocked()
@@ -232,6 +236,19 @@ func (t *Terminal) stopResizeLocked() {
232236 t.resizeWG.Wait()
233237 }
234238
239+// QueryColors re-emits OSC 10/11 queries for default foreground/background.
240+func (t *Terminal) QueryColors() error {
241+ t.mu.Lock()
242+ defer t.mu.Unlock()
243+ if _, err := t.bw.WriteString(oscColorQuery); err != nil {
244+ return fmt.Errorf("term: query colors: %w", err)
245+ }
246+ if err := t.bw.Flush(); err != nil {
247+ return fmt.Errorf("term: query colors: %w", err)
248+ }
249+ return nil
250+}
251+
235252 // In returns the reader for the controlling terminal's input.
236253 func (t *Terminal) In() io.Reader {
237254 return t.in
M internal/adapters/term/terminal_test.go +24 -2
@@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
6060 _ = outW.Close()
6161 <-done
6262
63- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
63+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
6464 if got := captured.String(); got != want {
6565 t.Fatalf("captured escapes = %q, want %q", got, want)
6666 }
@@ -101,12 +101,34 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
101101 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and
102102 // cursor/alt-screen exits exactly once, regardless of how many times
103103 // EnterRaw/restore were called.
104- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
104+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
105105 if got := captured.String(); got != want {
106106 t.Fatalf("captured escapes = %q, want %q", got, want)
107107 }
108108 }
109109
110+func TestTerminal_QueryColors(t *testing.T) {
111+ inR, inW, err := os.Pipe()
112+ if err != nil {
113+ t.Fatalf("os.Pipe(in): %v", err)
114+ }
115+ defer func() { _ = inW.Close() }()
116+ defer func() { _ = inR.Close() }()
117+
118+ outR, outW, captured, done := pipeCapture(t)
119+ defer func() { _ = outR.Close() }()
120+
121+ tm := NewWithFiles(inR, outW)
122+ if err := tm.QueryColors(); err != nil {
123+ t.Fatalf("QueryColors: %v", err)
124+ }
125+ _ = outW.Close()
126+ <-done
127+ if got := captured.String(); got != oscColorQuery {
128+ t.Fatalf("captured = %q, want %q", got, oscColorQuery)
129+ }
130+}
131+
110132 func TestTerminal_InOut(t *testing.T) {
111133 inR, inW, err := os.Pipe()
112134 if err != nil {
M internal/app/daemon_integration_test.go +85 -8
@@ -4,8 +4,10 @@ package app
44
55 import (
66 "context"
7+ "errors"
78 "io"
89 "log/slog"
10+ "path/filepath"
911 "strings"
1012 "testing"
1113 "time"
@@ -34,7 +36,11 @@ func discardLog() *slog.Logger {
3436 // The returned served channel receives Serve's result exactly once.
3537 func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) {
3638 t.Helper()
37- dir = t.TempDir()
39+ return startDaemonInDir(t, filepath.Join(t.TempDir(), "vev"), opts...)
40+}
41+
42+func startDaemonInDir(t *testing.T, dir string, opts ...daemon.Option) (string, <-chan error) {
43+ t.Helper()
3844 ln, err := ipc.Listen(dir)
3945 require.NoError(t, err)
4046
@@ -83,6 +89,36 @@ func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size)
8389 return tr, p
8490 }
8591
92+func listRemoteSessions(t *testing.T, dir string) ports.Sessions {
93+ t.Helper()
94+ tr, err := ipc.Dial(dir)
95+ require.NoError(t, err)
96+ defer func() { _ = tr.Close() }()
97+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))
98+ f, err := tr.Recv()
99+ require.NoError(t, err)
100+ require.Equal(t, ports.MsgSessions, f.Type)
101+ sessions, err := ports.UnmarshalSessions(f.Payload)
102+ require.NoError(t, err)
103+ return sessions
104+}
105+
106+func killAll(dir string) error {
107+ tr, err := ipc.Dial(dir)
108+ if err != nil {
109+ return err
110+ }
111+ defer func() { _ = tr.Close() }()
112+ if err := tr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}); err != nil {
113+ return err
114+ }
115+ _, err = tr.Recv()
116+ if errors.Is(err, io.EOF) {
117+ return nil
118+ }
119+ return err
120+}
121+
86122 // awaitText decodes MsgOutput frames into a fresh VT screen and returns once
87123 // the reconstructed grid contains want.
88124 func awaitText(t *testing.T, p *pump, sz domain.Size, want string) {
@@ -236,22 +272,63 @@ func TestIntegration_AltCWithoutPaletteDoesNotCreateTab(t *testing.T) {
236272 assertNoTextAfterInput(t, p, sz, " 1 2 ")
237273 }
238274
239-func TestIntegration_EphemeralGoneOnDetach(t *testing.T) {
275+func TestIntegration_EphemeralSurvivesDetachAndReattaches(t *testing.T) {
240276 sz := domain.Size{Cols: 80, Rows: 24}
241- dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf READY; sleep 30"}))
277+ dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf MARKER; sleep 30"}))
242278
243- tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)
244- awaitText(t, p, sz, "READY")
279+ tr1, p1 := attach(t, dir, ports.IntentEphemeral, "", sz)
280+ awaitText(t, p1, sz, "MARKER")
281+ require.NoError(t, tr1.Close())
245282
246- // Detach by dropping the connection: the ephemeral session must die, which
247- // (being the last session) shuts the daemon down.
283+ select {
284+ case err := <-served:
285+ t.Fatalf("daemon shut down while an ephemeral session was alive: %v", err)
286+ case <-time.After(300 * time.Millisecond):
287+ }
288+
289+ tr2, p2 := attach(t, dir, ports.IntentAttach, "0", sz)
290+ defer func() { _ = tr2.Close() }()
291+ awaitText(t, p2, sz, "MARKER")
292+
293+ require.NoError(t, killAll(dir))
294+ select {
295+ case err := <-served:
296+ require.NoError(t, err)
297+ case <-time.After(5 * time.Second):
298+ t.Fatal("daemon did not shut down after kill all")
299+ }
300+}
301+
302+func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
303+ sz := domain.Size{Cols: 80, Rows: 24}
304+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
305+ t.Setenv("XDG_STATE_HOME", stateRoot)
306+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
307+ dir := ipc.SocketDir()
308+
309+ _, served := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "printf TEMP; sleep 30"}))
310+ tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)
311+ awaitText(t, p, sz, "TEMP")
248312 require.NoError(t, tr.Close())
249313
314+ require.NoError(t, runKill(context.Background(), "", false, true))
250315 select {
251316 case err := <-served:
252317 require.NoError(t, err)
253318 case <-time.After(5 * time.Second):
254- t.Fatal("daemon did not shut down after the ephemeral session detached")
319+ t.Fatal("daemon did not stop after kill --daemon")
320+ }
321+
322+ _, served2 := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}))
323+ sessions := listRemoteSessions(t, dir)
324+ require.Empty(t, sessions.Sessions)
325+
326+ require.NoError(t, runKill(context.Background(), "", false, true))
327+ select {
328+ case err := <-served2:
329+ require.NoError(t, err)
330+ case <-time.After(5 * time.Second):
331+ t.Fatal("restarted daemon did not stop after kill --daemon")
255332 }
256333 }
257334
D internal/app/logging.go +0 -50
@@ -1,50 +0,0 @@
1-package app
2-
3-import (
4- "fmt"
5- "log/slog"
6- "os"
7- "path/filepath"
8- "strings"
9-
10- "github.com/bnema/vev/internal/platform"
11-)
12-
13-// logFileName is the fixed name of vev's shared log file, used by both the
14-// daemon and the raw-attached client (which must never log to the console).
15-const logFileName = "vev.log"
16-
17-// parseLogLevel maps VEV_LOG to a slog level, defaulting to info.
18-func parseLogLevel(s string) slog.Level {
19- switch strings.ToLower(strings.TrimSpace(s)) {
20- case "debug":
21- return slog.LevelDebug
22- case "warn", "warning":
23- return slog.LevelWarn
24- case "error":
25- return slog.LevelError
26- default:
27- return slog.LevelInfo
28- }
29-}
30-
31-// setupLogging installs a JSON slog handler writing to the shared log file
32-// and returns the open file so the caller can close it on exit. The level
33-// comes from VEV_LOG (default info). Both the daemon and the client route
34-// diagnostics here — the client because console output while the terminal is
35-// raw would corrupt the display.
36-//
37-// No log rotation is performed for the MVP (future work).
38-func setupLogging() (*os.File, error) {
39- dir := platform.StateDir()
40- if err := os.MkdirAll(dir, 0o700); err != nil {
41- return nil, fmt.Errorf("vev: creating state dir: %w", err)
42- }
43- f, err := os.OpenFile(filepath.Join(dir, logFileName), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
44- if err != nil {
45- return nil, fmt.Errorf("vev: opening log file: %w", err)
46- }
47- handler := slog.NewJSONHandler(f, &slog.HandlerOptions{Level: parseLogLevel(os.Getenv("VEV_LOG"))})
48- slog.SetDefault(slog.New(handler))
49- return f, nil
50-}
M internal/app/remote.go +16 -0
@@ -11,6 +11,22 @@ func parseRemoteAttachTarget(s string) (target, session string, ok bool) {
1111 return "", "", false
1212 }
1313 afterAt := s[at+1:]
14+ if strings.HasPrefix(afterAt, "[") {
15+ closeBracket := strings.IndexByte(afterAt, ']')
16+ if closeBracket < 0 {
17+ return "", "", false
18+ }
19+ suffix := afterAt[closeBracket+1:]
20+ switch {
21+ case suffix == "":
22+ return s, "", true
23+ case strings.HasPrefix(suffix, ":"):
24+ targetEnd := at + 1 + closeBracket + 1
25+ return s[:targetEnd], suffix[1:], true
26+ default:
27+ return "", "", false
28+ }
29+ }
1430 colon := strings.LastIndexByte(afterAt, ':')
1531 if colon < 0 {
1632 return s, "", true
M internal/app/remote_test.go +4 -0
@@ -17,6 +17,10 @@ func TestParseRemoteAttachTarget(t *testing.T) {
1717 {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true},
1818 {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true},
1919 {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true},
20+ {name: "bracketed ipv6 no session", input: "user@[2001:db8::1]", wantTarget: "user@[2001:db8::1]", wantOK: true},
21+ {name: "bracketed ipv6 with session", input: "user@[2001:db8::1]:work", wantTarget: "user@[2001:db8::1]", wantSession: "work", wantOK: true},
22+ {name: "malformed bracketed ipv6", input: "user@[2001:db8::1"},
23+ {name: "malformed suffix after bracket", input: "user@[2001:db8::1]work"},
2024 {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true},
2125 }
2226
M internal/app/run.go +464 -62
@@ -5,25 +5,40 @@
55 package app
66
77 import (
8+ "bufio"
89 "context"
10+ "crypto/rand"
11+ "encoding/base64"
912 "errors"
1013 "fmt"
1114 "io"
1215 "log/slog"
16+ "net"
1317 "net/http"
1418 _ "net/http/pprof"
1519 "os"
20+ "os/exec"
1621 "os/signal"
22+ "path/filepath"
23+ "strconv"
24+ "strings"
1725 "syscall"
1826 "text/tabwriter"
1927 "time"
2028
29+ "github.com/bnema/vev/internal/adapters/clipboard"
2130 "github.com/bnema/vev/internal/adapters/clock"
31+ "github.com/bnema/vev/internal/adapters/config"
32+ "github.com/bnema/vev/internal/adapters/dgram"
2233 "github.com/bnema/vev/internal/adapters/ipc"
2334 "github.com/bnema/vev/internal/adapters/pty"
35+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
36+ "github.com/bnema/vev/internal/adapters/shellcmd"
37+ snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
2438 "github.com/bnema/vev/internal/adapters/sshstdio"
2539 "github.com/bnema/vev/internal/adapters/term"
2640 "github.com/bnema/vev/internal/domain"
41+ "github.com/bnema/vev/internal/logging"
2742 "github.com/bnema/vev/internal/persist"
2843 "github.com/bnema/vev/internal/platform"
2944 "github.com/bnema/vev/internal/ports"
@@ -42,6 +57,8 @@ const (
4257 kindKill
4358 kindDaemon
4459 kindStdio
60+ kindUDPBootstrap
61+ kindUDPProxy
4562 kindHelp
4663 kindVersion
4764 )
@@ -72,11 +89,11 @@ const usageText = `vev — a terminal multiplexer
7289 usage:
7390 vev attach to (or create) an ephemeral session
7491 vev new <name> create and attach to a named session
75- vev attach <name> attach to an existing named session (alias: a)
92+ vev attach <name> attach to an existing session (alias: a)
7693 vev attach user@host[:session]
7794 attach through SSH to a remote vev daemon
7895 vev ls list sessions
79- vev kill <name> kill a named session
96+ vev kill <name> kill a session
8097 vev kill --all kill all sessions and stop the daemon
8198 vev kill --daemon stop the active vev daemon
8299 vev --help show this help
@@ -116,6 +133,24 @@ func parseArgs(args []string) (command, error) {
116133 cmd.name = args[1]
117134 }
118135 return cmd, nil
136+ case "_udp-bootstrap":
137+ if len(args) > 2 {
138+ return command{}, usagef("`_udp-bootstrap` accepts at most one session name")
139+ }
140+ cmd := command{kind: kindUDPBootstrap}
141+ if len(args) == 2 {
142+ cmd.name = args[1]
143+ }
144+ return cmd, nil
145+ case "_udp-proxy":
146+ if len(args) > 2 {
147+ return command{}, usagef("`_udp-proxy` accepts at most one session name")
148+ }
149+ cmd := command{kind: kindUDPProxy}
150+ if len(args) == 2 {
151+ cmd.name = args[1]
152+ }
153+ return cmd, nil
119154 case "new":
120155 if len(args) < 2 || args[1] == "" {
121156 return command{}, usagef("`new` requires a session name")
@@ -123,6 +158,9 @@ func parseArgs(args []string) (command, error) {
123158 if len(args) > 2 {
124159 return command{}, usagef("`new` does not support command overrides")
125160 }
161+ if err := domain.ValidateSessionName(args[1]); err != nil {
162+ return command{}, err
163+ }
126164 return command{kind: kindAttach, intent: ports.IntentNew, name: args[1]}, nil
127165 case "attach", "a":
128166 if len(args) < 2 || args[1] == "" {
@@ -185,6 +223,10 @@ func dispatch(ctx context.Context, cmd command) error {
185223 return runDaemon()
186224 case kindStdio:
187225 return runStdio(ctx)
226+ case kindUDPBootstrap:
227+ return runUDPBootstrap(ctx, cmd.name)
228+ case kindUDPProxy:
229+ return runUDPProxy(ctx, cmd.name, os.Stdout)
188230 case kindList:
189231 return runList(ctx)
190232 case kindKill:
@@ -196,19 +238,56 @@ func dispatch(ctx context.Context, cmd command) error {
196238 }
197239 }
198240
241+func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) {
242+ return logging.Setup(logging.Config{
243+ Dir: platform.StateDir(),
244+ Component: component,
245+ Level: logging.EnvLevel(),
246+ MaxBytes: logging.DefaultMaxBytes,
247+ RotateAtRuntime: rotateAtRuntime,
248+ })
249+}
250+
251+func logConfigWarnings(log *slog.Logger, warnings []domain.Warning) {
252+ for _, warning := range warnings {
253+ if warning.Line > 0 {
254+ log.Warn("config warning", "line", warning.Line, "msg", warning.Msg)
255+ continue
256+ }
257+ log.Warn("config warning", "msg", warning.Msg)
258+ }
259+}
260+
261+func snapshotDir() string {
262+ return filepath.Join(platform.StateDir(), "snapshots")
263+}
264+
265+func pprofAddrIsLoopback(addr string) bool {
266+ host, _, err := net.SplitHostPort(addr)
267+ if err != nil || host == "" {
268+ return false
269+ }
270+ if strings.EqualFold(host, "localhost") {
271+ return true
272+ }
273+ ip := net.ParseIP(host)
274+ return ip != nil && ip.IsLoopback()
275+}
276+
199277 // runDaemon runs the daemon in the foreground (the hidden --daemon path,
200278 // entered by an auto-spawned child): it sets up logging, binds the socket,
201279 // constructs the daemon, and serves until the last session exits or a
202280 // termination signal arrives (graceful shutdown notifies attached clients).
203281 func runDaemon() error {
204- logFile, err := setupLogging()
282+ log, logCloser, err := configureLogging(logging.Daemon, true)
205283 if err != nil {
206284 return err
207285 }
208- defer func() { _ = logFile.Close() }()
286+ defer func() { _ = logCloser.Close() }()
209287
210288 ln, err := ipc.Listen(ipc.SocketDir())
211289 if err != nil {
290+ log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)
212291 return fmt.Errorf("vev: daemon listen: %w", err)
213292 }
214293 defer func() { _ = ln.Close() }()
@@ -217,27 +296,54 @@ func runDaemon() error {
217296 defer stop()
218297
219298 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
299+ if !pprofAddrIsLoopback(addr) {
300+ log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr)
301+ }
220302 go func() {
221303 if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {
222- slog.Error("pprof server exited", "err", err)
304+ log.Error("pprof server exited", "err", err)
223305 }
224306 }()
225- slog.Info("pprof enabled", "addr", addr)
307+ log.Info("pprof enabled", "addr", addr)
226308 }
227309
228- slog.Info("daemon starting", "socket", ln.Addr())
310+ log.Info("daemon starting", "socket", ln.Addr())
229311 daemonOpts := []daemon.Option(nil)
230- if store, err := kv.Open(persist.StorePath(platform.StateDir())); err != nil {
231- slog.Warn("opening session store failed; persistence disabled", "err", err)
312+ configPath := platform.ConfigPath()
313+ cfg, warnings, err := config.Load(configPath)
314+ if err != nil {
315+ log.Warn("loading config failed; using defaults", "path", configPath, "err", err)
316+ cfg = domain.Defaults()
317+ }
318+ logConfigWarnings(log, warnings)
319+ daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
320+ daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
321+ daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
322+ daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
323+ storePath := persist.StorePath(platform.StateDir())
324+ if store, err := kv.Open(storePath); err != nil {
325+ log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)
232326 } else {
327+ log.Info("session persistence enabled", "path", storePath)
233328 daemonOpts = append(daemonOpts, daemon.WithStore(store))
234329 }
235- d := daemon.New(pty.NewFactory(), clock.New(), slog.Default(), daemonOpts...)
330+ clk := clock.New()
331+ d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)
332+ watchCtx, stopWatch := context.WithCancel(ctx)
333+ defer stopWatch()
334+ go func() {
335+ if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) {
336+ logConfigWarnings(log, warnings)
337+ d.ApplyConfig(cfg)
338+ }); err != nil && !errors.Is(err, context.Canceled) {
339+ log.Warn("config watcher stopped", "path", configPath, "err", err)
340+ }
341+ }()
236342 if err := d.Serve(ctx, ln); err != nil {
237- slog.Error("daemon exited", "err", err)
343+ log.Error("daemon exited", "err", err)
238344 return err
239345 }
240- slog.Info("daemon exited cleanly")
346+ log.Info("daemon exited cleanly")
241347 return nil
242348 }
243349
@@ -245,24 +351,56 @@ func runDaemon() error {
245351 // attach loop. Logging goes to the shared file: the client must never write
246352 // to the console while the terminal is raw.
247353 func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error {
248- logFile, err := setupLogging()
354+ log, logCloser, err := configureLogging(logging.Client, false)
249355 if err != nil {
250356 return err
251357 }
252- defer func() { _ = logFile.Close() }()
358+ defer func() { _ = logCloser.Close() }()
253359
254- return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{
255- attachLocal: attachLocalDaemon,
256- createDetached: createDetachedLocalSession,
360+ return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
361+ localDialer: defaultLocalDialer,
362+ remoteDialerFactory: defaultRemoteDialerFactory(),
363+ selectedRemoteTransport: os.Getenv(envRemoteTransport),
364+ runClient: client.Run,
365+ createDetached: createDetachedLocalSession,
366+ clipboard: clipboard.New(),
257367 })
258368 }
259369
370+const envRemoteTransport = "VEV_REMOTE_TRANSPORT"
371+
372+func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
373+
374+func defaultRemoteDialerFactory() ports.RemoteDialerFactory {
375+ return remoteadapter.NewDialerFactory()
376+}
377+
260378 type runAttachDeps struct {
261- attachLocal func(context.Context, uint8, string) error
262- createDetached func(context.Context, string) error
379+ attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
380+ localDialer func() ports.Dialer
381+ remoteDialerFactory ports.RemoteDialerFactory
382+ selectedRemoteTransport string
383+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
384+ createDetached func(context.Context, string) error
385+ // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
386+ // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
387+ // Only used for the remote-dialer branch below; local attaches never
388+ // intercept Ctrl+V regardless of this field.
389+ clipboard ports.ClipboardReader
263390 }
264391
265-func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, deps runAttachDeps) error {
392+func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {
393+ switch value {
394+ case "", string(ports.RemoteTransportUDP):
395+ return ports.RemoteTransportUDP, nil
396+ case string(ports.RemoteTransportStdio):
397+ return ports.RemoteTransportStdio, nil
398+ default:
399+ return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio)
400+ }
401+}
402+
403+func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
266404 if activeSession != "" {
267405 if remoteTarget == "" && intent == ports.IntentNew {
268406 return deps.createDetached(ctx, name)
@@ -270,18 +408,47 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
270408 return errors.New("vev: sessions should be nested with care; unset VEV to force")
271409 }
272410
411+ runClient := deps.runClient
412+ if runClient == nil {
413+ runClient = client.Run
414+ }
273415 if remoteTarget != "" {
274- transport, err := sshstdio.Dial(remoteTarget, name)
416+ mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
275417 if err != nil {
276418 return err
277419 }
278- // client.Attach owns and closes transport.
279- return client.Attach(ctx, transport, term.New(), intent, name)
420+ factory := deps.remoteDialerFactory
421+ if factory == nil {
422+ factory = defaultRemoteDialerFactory()
423+ }
424+ if log != nil {
425+ log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
426+ }
427+ dialer, err := factory.DialerForRemote(remoteTarget, name, mode, log)
428+ if err != nil {
429+ return err
430+ }
431+ return runClient(ctx, dialer, term.New(), clock.New(), intent, name, true, deps.clipboard, log)
280432 }
281433
434+ attachLocal := deps.attachLocal
435+ if attachLocal == nil {
436+ localDialer := deps.localDialer
437+ if localDialer == nil {
438+ localDialer = defaultLocalDialer
439+ }
440+ attachLocal = func(ctx context.Context, intent uint8, name string, log *slog.Logger) error {
441+ if log != nil {
442+ log.Info("attaching to local session", "intent", intent, "name", name)
443+ }
444+ return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil, log)
445+ }
446+ }
282447 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
283- confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
284- attach: deps.attachLocal,
448+ confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
449+ attach: func(ctx context.Context, intent uint8, name string) error {
450+ return attachLocal(ctx, intent, name, log)
451+ },
285452 killDaemon: requestDaemonStop,
286453 settleAfterKill: waitForDaemonStop,
287454 })
@@ -332,13 +499,23 @@ func isDaemonVersionDrift(err error) bool {
332499 return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal
333500 }
334501
335-func attachLocalDaemon(ctx context.Context, intent uint8, name string) error {
336- transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
337- if err != nil {
338- return err
502+type localDaemonDialer struct{ dir string }
503+
504+func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
505+ return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff)
506+}
507+
508+func detachedLocalHello(name, cwd string) ports.Hello {
509+ termEnv := os.Getenv("TERM")
510+ return ports.Hello{
511+ Version: ports.ProtocolVersion,
512+ Intent: ports.IntentNew,
513+ Name: name,
514+ Size: domain.Size{Cols: 80, Rows: 24},
515+ TermEnv: termEnv,
516+ Cwd: cwd,
517+ TrueColor: client.DetectTrueColor(termEnv, os.Getenv("COLORTERM")),
339518 }
340- // client.Attach owns and closes transport.
341- return client.Attach(ctx, transport, term.New(), intent, name)
342519 }
343520
344521 func createDetachedLocalSession(ctx context.Context, name string) error {
@@ -352,14 +529,7 @@ func createDetachedLocalSession(ctx context.Context, name string) error {
352529 if err != nil {
353530 cwd = ""
354531 }
355- hello := ports.Hello{
356- Version: ports.ProtocolVersion,
357- Intent: ports.IntentNew,
358- Name: name,
359- Size: domain.Size{Cols: 80, Rows: 24},
360- TermEnv: os.Getenv("TERM"),
361- Cwd: cwd,
362- }
532+ hello := detachedLocalHello(name, cwd)
363533 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
364534 return fmt.Errorf("vev: creating detached session: %w", err)
365535 }
@@ -452,6 +622,13 @@ func requestDaemonStop(ctx context.Context) error {
452622 // connects to the per-user daemon (auto-spawning it if needed) and proxies the
453623 // framed protocol between process stdio and the daemon socket.
454624 func runStdio(ctx context.Context) error {
625+ log, logCloser, err := configureLogging(logging.Stdio, false)
626+ if err != nil {
627+ return err
628+ }
629+ defer func() { _ = logCloser.Close() }()
630+ log.Debug("stdio proxy starting")
631+
455632 transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
456633 if err != nil {
457634 return err
@@ -459,36 +636,254 @@ func runStdio(ctx context.Context) error {
459636 defer func() { _ = transport.Close() }()
460637
461638 stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil)
462- return proxyTransports(ctx, stdio, transport)
639+ return proxyTransports(ctx, stdio, transport, log)
463640 }
464641
465-func proxyTransports(ctx context.Context, a, b ports.Transport) error {
466- errCh := make(chan error, 2)
467- copyFrames := func(dst, src ports.Transport) {
468- for {
469- f, err := src.Recv()
470- if err != nil {
471- errCh <- err
472- return
473- }
474- if err := dst.Send(f); err != nil {
475- errCh <- err
476- return
477- }
478- }
642+var (
643+ udpBootstrapTimeout = 5 * time.Second
644+ udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd {
645+ return exec.CommandContext(ctx, exe, args...)
646+ }
647+)
648+
649+// runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness
650+// line, and exits so SSH stdio can close without owning the UDP proxy lifetime.
651+func runUDPBootstrap(ctx context.Context, session string) error {
652+ // Always start a fresh proxy for a bootstrap request. A client attach begins
653+ // with MsgHello, which must reach the daemon handshake path; reusing an
654+ // already-running proxy would forward that Hello into the daemon connection's
655+ // post-handshake runConnLoop, where it is intentionally ignored. The new
656+ // proxy publishes itself and retires any older registry entry once ready.
657+ r, w, err := os.Pipe()
658+ if err != nil {
659+ return err
660+ }
661+ defer func() { _ = r.Close() }()
662+
663+ exe, err := os.Executable()
664+ if err != nil {
665+ _ = w.Close()
666+ return err
667+ }
668+ args := []string{"_udp-proxy"}
669+ if session != "" {
670+ args = append(args, session)
671+ }
672+ devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
673+ if err != nil {
674+ _ = w.Close()
675+ return err
479676 }
480- go copyFrames(b, a)
481- go copyFrames(a, b)
677+ defer func() { _ = devNull.Close() }()
482678
679+ cmd := udpProxyCommand(ctx, exe, args...)
680+ // _udp-proxy writes diagnostics through configureLogging(logging.Stdio, false);
681+ // stdio is detached here so the bootstrap SSH channel can close.
682+ cmd.Stdin = devNull
683+ cmd.Stdout = w
684+ cmd.Stderr = devNull
685+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
686+ if err := cmd.Start(); err != nil {
687+ _ = w.Close()
688+ return err
689+ }
690+ _ = w.Close()
691+
692+ readyCtx, cancel := context.WithTimeout(ctx, udpBootstrapTimeout)
693+ defer cancel()
694+ lineCh := make(chan string, 1)
695+ errCh := make(chan error, 1)
696+ go func() {
697+ line, err := bufio.NewReader(r).ReadString('\n')
698+ if err != nil {
699+ errCh <- err
700+ return
701+ }
702+ lineCh <- line
703+ }()
483704 select {
484- case <-ctx.Done():
485- return ctx.Err()
486- case err := <-errCh:
487- if errors.Is(err, io.EOF) {
488- return nil
705+ case line := <-lineCh:
706+ if err := cmd.Process.Release(); err != nil {
707+ return err
489708 }
709+ _, err := fmt.Fprint(os.Stdout, line)
710+ return err
711+ case err := <-errCh:
712+ _ = cmd.Wait()
713+ return err
714+ case <-readyCtx.Done():
715+ _ = cmd.Process.Kill()
716+ _ = cmd.Wait()
717+ return fmt.Errorf("vev: udp bootstrap readiness: %w", readyCtx.Err())
718+ }
719+}
720+
721+// runUDPProxy is the detached long-lived remote-side datagram proxy.
722+func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
723+ log, logCloser, err := configureLogging(logging.Stdio, false)
724+ if err != nil {
725+ return err
726+ }
727+ defer func() { _ = logCloser.Close() }()
728+ log.Debug("udp proxy starting", "session", session)
729+
730+ portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange))
731+ if err != nil {
490732 return err
491733 }
734+ conn, err := listenUDPInRange(ctx, portRange)
735+ if err != nil {
736+ return err
737+ }
738+ defer func() { _ = conn.Close() }()
739+ key := make([]byte, 32)
740+ if _, err := rand.Read(key); err != nil {
741+ return err
742+ }
743+ daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
744+ if err != nil {
745+ return err
746+ }
747+ defer func() { _ = daemonTr.Close() }()
748+ dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpProxyClientTransportOptions)
749+ if err != nil {
750+ return err
751+ }
752+ defer func() { _ = dg.Close() }()
753+
754+ addr, ok := conn.LocalAddr().(*net.UDPAddr)
755+ if !ok {
756+ return errors.New("vev: udp proxy did not get a UDP address")
757+ }
758+ keyText := base64.StdEncoding.EncodeToString(key)
759+ registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies"))
760+ record := dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, Key: keyText}
761+ if err := registry.Publish(record); err != nil {
762+ return err
763+ }
764+ defer func() { _ = registry.RemoveOwned(record) }()
765+ if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, keyText); err != nil {
766+ return err
767+ }
768+
769+ var dgramTr ports.Transport = dg
770+ if session != "" {
771+ dgramTr = preHelloNameTransport{Transport: dg, session: session}
772+ }
773+ return dgram.ProxyRuntime{Client: dgramTr, Daemon: daemonTr, Log: log, IdleTTL: udpProxyIdleTTL}.Run(ctx)
774+}
775+
776+const udpProxyIdleTTL = 15 * time.Minute
777+
778+var udpProxyClientTransportOptions = dgram.Options{
779+ MaxPending: 32,
780+ DeadAfter: 2 * udpProxyIdleTTL,
781+}
782+
783+const (
784+ // envUDPPortRange configures the remote UDP proxy's listen port range so a
785+ // host firewall can allow a known range instead of a random ephemeral port.
786+ // Format: "START-END" (inclusive), a single "PORT", or "0" for a random
787+ // ephemeral port. Unset uses the default range below.
788+ envUDPPortRange = "VEV_UDP_PORT_RANGE"
789+ defaultUDPPortStart = 61000
790+ defaultUDPPortEnd = 61023
791+)
792+
793+// udpPortRange is an inclusive [start, end] UDP port range. start == 0 means a
794+// random ephemeral port (the old ":0" behavior).
795+type udpPortRange struct {
796+ start int
797+ end int
798+}
799+
800+// parseUDPPortRange parses VEV_UDP_PORT_RANGE. Empty -> default range. "0" ->
801+// ephemeral. "N" -> single port N. "A-B" -> inclusive range. Ports must be in
802+// 1..65535 (or 0 for ephemeral) and end must be >= start.
803+func parseUDPPortRange(value string) (udpPortRange, error) {
804+ value = strings.TrimSpace(value)
805+ if value == "" {
806+ return udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}, nil
807+ }
808+ startStr, endStr, hasRange := strings.Cut(value, "-")
809+ start, err := parseUDPPort(startStr)
810+ if err != nil {
811+ return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
812+ }
813+ if start == 0 {
814+ if hasRange {
815+ return udpPortRange{}, fmt.Errorf("invalid %s %q: port 0 (ephemeral) cannot be combined with a range", envUDPPortRange, value)
816+ }
817+ return udpPortRange{start: 0, end: 0}, nil
818+ }
819+ end := start
820+ if hasRange {
821+ end, err = parseUDPPort(endStr)
822+ if err != nil {
823+ return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
824+ }
825+ }
826+ if end < start {
827+ return udpPortRange{}, fmt.Errorf("invalid %s %q: end %d before start %d", envUDPPortRange, value, end, start)
828+ }
829+ return udpPortRange{start: start, end: end}, nil
830+}
831+
832+func parseUDPPort(s string) (int, error) {
833+ p, err := strconv.Atoi(strings.TrimSpace(s))
834+ if err != nil {
835+ return 0, fmt.Errorf("port %q is not a number", strings.TrimSpace(s))
836+ }
837+ if p < 0 || p > 65535 {
838+ return 0, fmt.Errorf("port %d out of range 0-65535", p)
839+ }
840+ return p, nil
841+}
842+
843+// listenUDPInRange binds a UDP packet conn on the first free port in the range,
844+// skipping busy ports. A zero-start range binds a random ephemeral port.
845+func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, error) {
846+ var lc net.ListenConfig
847+ if r.start == 0 {
848+ return lc.ListenPacket(ctx, "udp", ":0")
849+ }
850+ if r.start < 0 || r.end < r.start {
851+ return nil, fmt.Errorf("invalid UDP port range %d-%d", r.start, r.end)
852+ }
853+ var lastErr error
854+ for port := r.start; port <= r.end; port++ {
855+ conn, err := lc.ListenPacket(ctx, "udp", fmt.Sprintf(":%d", port))
856+ if err == nil {
857+ return conn, nil
858+ }
859+ lastErr = err
860+ }
861+ return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)
862+}
863+
864+type preHelloNameTransport struct {
865+ ports.Transport
866+ session string
867+}
868+
869+func (t preHelloNameTransport) Recv() (ports.Frame, error) {
870+ f, err := t.Transport.Recv()
871+ if err != nil || f.Type != ports.MsgHello || t.session == "" {
872+ return f, err
873+ }
874+ h, err := ports.UnmarshalHello(f.Payload)
875+ if err != nil {
876+ return f, nil
877+ }
878+ if h.Name == "" {
879+ h.Name = t.session
880+ f.Payload = ports.MarshalHello(h)
881+ }
882+ return f, nil
883+}
884+
885+func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger) error {
886+ return dgram.ProxyRuntime{Client: a, Daemon: b, Log: log}.Run(ctx)
492887 }
493888
494889 // runList prints the daemon's session listing. With no daemon running, it
@@ -546,7 +941,10 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
546941 if s.Stopped {
547942 state = "stopped"
548943 tabs = "-"
549- } else if s.Attached {
944+ } else if s.Ephemeral {
945+ state = "temporary"
946+ }
947+ if s.Attached {
550948 attached = "yes"
551949 }
552950 _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached)
@@ -590,6 +988,10 @@ func runKill(_ context.Context, name string, all, daemon bool) error {
590988 if closeErr := p.Close(); closeErr != nil {
591989 return fmt.Errorf("vev: closing stored sessions: %w", closeErr)
592990 }
991+ snapshots := snapshotadapter.NewStore(snapshotDir())
992+ if deleteErr := snapshots.Delete(name); deleteErr != nil {
993+ return fmt.Errorf("vev: deleting session snapshot: %w", deleteErr)
994+ }
593995 printKillSuccess(name, all, daemon)
594996 return nil
595997 }
M internal/app/run_test.go +399 -4
@@ -5,18 +5,35 @@ import (
55 "context"
66 "errors"
77 "io"
8+ "log/slog"
9+ "net"
810 "os"
11+ "os/exec"
912 "path/filepath"
1013 "strings"
1114 "testing"
1215 "time"
1316
17+ "github.com/bnema/vev/internal/adapters/snapshot"
18+ "github.com/bnema/vev/internal/domain"
1419 "github.com/bnema/vev/internal/persist"
1520 "github.com/bnema/vev/internal/ports"
21+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1622 "github.com/bnema/vev/internal/usecase/client"
1723 "github.com/bnema/vev/internal/usecase/confirm"
24+ "github.com/stretchr/testify/mock"
25+ "github.com/stretchr/testify/require"
1826 )
1927
28+func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
29+ require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
30+}
31+
32+func TestRunUDPProxyClientDeadAfterExceedsIdleTTL(t *testing.T) {
33+ require.Positive(t, udpProxyIdleTTL)
34+ require.Greater(t, udpProxyClientTransportOptions.DeadAfter, udpProxyIdleTTL)
35+}
36+
2037 func TestParseArgs(t *testing.T) {
2138 tests := []struct {
2239 name string
@@ -36,6 +53,7 @@ func TestParseArgs(t *testing.T) {
3653 {name: "new empty name", args: []string{"new", ""}, wantErr: true},
3754 {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},
3855 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
56+ {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
3957 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
4058 {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
4159 {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
@@ -45,6 +63,7 @@ func TestParseArgs(t *testing.T) {
4563 {name: "ls", args: []string{"ls"}, wantKind: kindList},
4664 {name: "list", args: []string{"list"}, wantKind: kindList},
4765 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
66+ {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
4867 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
4968 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
5069 {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
@@ -56,7 +75,11 @@ func TestParseArgs(t *testing.T) {
5675 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
5776 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
5877 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
78+ {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},
5979 {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
80+ {name: "udp bootstrap", args: []string{"_udp-bootstrap", "work"}, wantKind: kindUDPBootstrap, wantName: "work"},
81+ {name: "udp proxy", args: []string{"_udp-proxy", "work"}, wantKind: kindUDPProxy, wantName: "work"},
82+ {name: "udp proxy too many args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
6083 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
6184 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
6285 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
@@ -101,6 +124,13 @@ func TestParseArgs(t *testing.T) {
101124 }
102125 }
103126
127+func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
128+ _, err := parseArgs([]string{"new", "my work"})
129+ if !errors.Is(err, domain.ErrInvalidSessionName) {
130+ t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName)
131+ }
132+}
133+
104134 func TestPrintSessionsShowsStoppedState(t *testing.T) {
105135 var out bytes.Buffer
106136 printSessions(&out, []ports.SessionInfo{
@@ -115,6 +145,19 @@ func TestPrintSessionsShowsStoppedState(t *testing.T) {
115145 }
116146 }
117147
148+func TestPrintSessionsMarksEphemeral(t *testing.T) {
149+ var buf bytes.Buffer
150+ printSessions(&buf, []ports.SessionInfo{
151+ {Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
152+ {Name: "work", Tabs: 2, Attached: true},
153+ {Name: "old", Stopped: true},
154+ })
155+ out := buf.String()
156+ for _, want := range []string{"0", "temporary", "work", "running", "old", "stopped"} {
157+ require.Contains(t, out, want)
158+ }
159+}
160+
118161 func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
119162 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
120163 t.Setenv("XDG_STATE_HOME", stateRoot)
@@ -174,6 +217,10 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
174217 if err := p.Close(); err != nil {
175218 t.Fatalf("Close error = %v", err)
176219 }
220+ snapshots := snapshot.NewStore(filepath.Join(stateRoot, "vev", "snapshots"))
221+ if err := snapshots.Write("stored", []byte("snapshot bytes")); err != nil {
222+ t.Fatalf("snapshot Write error = %v", err)
223+ }
177224
178225 got := captureStdout(t, func() {
179226 if err := runKill(context.Background(), "stored", false, false); err != nil {
@@ -190,6 +237,13 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
190237 if len(records) != 0 {
191238 t.Fatalf("records after kill = %#v, want none", records)
192239 }
240+ blobs, err := snapshots.Load()
241+ if err != nil {
242+ t.Fatalf("snapshot Load error = %v", err)
243+ }
244+ if len(blobs) != 0 {
245+ t.Fatalf("snapshots after kill = %#v, want none", blobs)
246+ }
193247 }
194248
195249 func captureStdout(t *testing.T, fn func()) string {
@@ -346,8 +400,8 @@ func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
346400
347401 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
348402 called := false
349- err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", runAttachDeps{
350- attachLocal: func(context.Context, uint8, string) error {
403+ err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
404+ attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
351405 called = true
352406 return nil
353407 },
@@ -369,8 +423,8 @@ func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
369423
370424 func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
371425 var gotName string
372- err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", runAttachDeps{
373- attachLocal: func(context.Context, uint8, string) error {
426+ err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{
427+ attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
374428 t.Fatal("nested new should not attach to the session")
375429 return nil
376430 },
@@ -386,3 +440,344 @@ func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
386440 t.Fatalf("detached name = %q, want scratch", gotName)
387441 }
388442 }
443+
444+func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
445+ t.Setenv("TERM", "xterm-direct")
446+ t.Setenv("COLORTERM", "")
447+
448+ hello := detachedLocalHello("scratch", "/tmp/work")
449+
450+ require.Equal(t, ports.IntentNew, hello.Intent)
451+ require.Equal(t, "scratch", hello.Name)
452+ require.Equal(t, "xterm-direct", hello.TermEnv)
453+ require.Equal(t, "/tmp/work", hello.Cwd)
454+ require.True(t, hello.TrueColor)
455+}
456+
457+type namedDialer struct{ name string }
458+
459+func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
460+ return nil, errors.New("not used")
461+}
462+
463+// fakeClipboardReader is a distinguishable ports.ClipboardReader used only to
464+// verify identity (that runAttachWithDeps threads the *same* reader through),
465+// never actually invoked in these wiring tests.
466+type fakeClipboardReader struct{ ports.ClipboardReader }
467+
468+func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
469+ tests := []struct {
470+ name string
471+ selectedTransport string
472+ wantMode ports.RemoteTransportMode
473+ }{
474+ {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
475+ {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
476+ }
477+
478+ for _, tt := range tests {
479+ t.Run(tt.name, func(t *testing.T) {
480+ var gotDialer string
481+ var gotRemote bool
482+ var gotClipboard ports.ClipboardReader
483+ clip := &fakeClipboardReader{}
484+ factory := portsmocks.NewMockRemoteDialerFactory(t)
485+ factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
486+
487+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
488+ remoteDialerFactory: factory,
489+ selectedRemoteTransport: tt.selectedTransport,
490+ clipboard: clip,
491+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
492+ nd, ok := d.(namedDialer)
493+ if !ok {
494+ t.Fatalf("dialer type = %T, want namedDialer", d)
495+ }
496+ gotDialer = nd.name
497+ gotRemote = remote
498+ gotClipboard = clipboard
499+ if intent != ports.IntentAttach || name != "work" {
500+ t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
501+ }
502+ return nil
503+ },
504+ })
505+ if err != nil {
506+ t.Fatalf("runAttachWithDeps returned error: %v", err)
507+ }
508+ if gotDialer != "remote" {
509+ t.Fatalf("remote dialer = %q, want remote", gotDialer)
510+ }
511+ if !gotRemote {
512+ t.Fatal("remote attach must pass remote=true to runClient")
513+ }
514+ if gotClipboard != ports.ClipboardReader(clip) {
515+ t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
516+ }
517+ })
518+ }
519+}
520+
521+func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
522+ factory := portsmocks.NewMockRemoteDialerFactory(t)
523+ runClientCalled := false
524+
525+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
526+ remoteDialerFactory: factory,
527+ selectedRemoteTransport: "serial",
528+ runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
529+ runClientCalled = true
530+ return nil
531+ },
532+ })
533+ if err == nil || err.Error() != `vev: invalid remote transport "serial" (want "udp" or "stdio")` {
534+ t.Fatalf("runAttachWithDeps error = %v, want invalid transport", err)
535+ }
536+ if runClientCalled {
537+ t.Fatal("runClient called after invalid remote transport")
538+ }
539+}
540+
541+func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
542+ factoryErr := errors.New("factory failed")
543+ factory := portsmocks.NewMockRemoteDialerFactory(t)
544+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
545+ runClientCalled := false
546+
547+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
548+ remoteDialerFactory: factory,
549+ runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
550+ runClientCalled = true
551+ return nil
552+ },
553+ })
554+ if !errors.Is(err, factoryErr) {
555+ t.Fatalf("runAttachWithDeps error = %v, want %v", err, factoryErr)
556+ }
557+ if runClientCalled {
558+ t.Fatal("runClient called after remote factory error")
559+ }
560+}
561+
562+func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
563+ var gotDialer string
564+ gotRemote := true
565+ gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
566+ factory := portsmocks.NewMockRemoteDialerFactory(t)
567+ err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
568+ localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
569+ remoteDialerFactory: factory,
570+ clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
571+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
572+ nd, ok := d.(namedDialer)
573+ if !ok {
574+ t.Fatalf("dialer type = %T, want namedDialer", d)
575+ }
576+ gotDialer = nd.name
577+ gotRemote = remote
578+ gotClipboard = clipboard
579+ if intent != ports.IntentEphemeral || name != "" {
580+ t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
581+ }
582+ return nil
583+ },
584+ })
585+ if err != nil {
586+ t.Fatalf("runAttachWithDeps returned error: %v", err)
587+ }
588+ if gotDialer != "local" {
589+ t.Fatalf("local dialer = %q, want local", gotDialer)
590+ }
591+ if gotRemote {
592+ t.Fatal("local attach must pass remote=false to runClient")
593+ }
594+ if gotClipboard != nil {
595+ t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
596+ }
597+}
598+
599+func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
600+ oldTimeout := udpBootstrapTimeout
601+ udpBootstrapTimeout = time.Second
602+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
603+ oldCommand := udpProxyCommand
604+ var gotCmd *exec.Cmd
605+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
606+ cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'")
607+ gotCmd = cmd
608+ return cmd
609+ }
610+ t.Cleanup(func() { udpProxyCommand = oldCommand })
611+
612+ got := captureStdout(t, func() {
613+ if err := runUDPBootstrap(context.Background(), "work"); err != nil {
614+ t.Fatalf("runUDPBootstrap() error = %v", err)
615+ }
616+ })
617+ if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" {
618+ t.Fatalf("readiness = %q", got)
619+ }
620+ if gotCmd.Stdin == nil || gotCmd.Stderr == nil {
621+ t.Fatalf("proxy stdio = stdin %T stderr %T, want detached from SSH channels", gotCmd.Stdin, gotCmd.Stderr)
622+ }
623+ if gotCmd.Stdout == os.Stdout || gotCmd.Stderr == os.Stderr || gotCmd.Stdin == os.Stdin {
624+ t.Fatalf("proxy inherited SSH stdio handles")
625+ }
626+}
627+
628+func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) {
629+ oldTimeout := udpBootstrapTimeout
630+ udpBootstrapTimeout = time.Second
631+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
632+ oldCommand := udpProxyCommand
633+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
634+ return exec.Command("/bin/sh", "-c", "exit 7")
635+ }
636+ t.Cleanup(func() { udpProxyCommand = oldCommand })
637+
638+ err := runUDPBootstrap(context.Background(), "work")
639+ if !errors.Is(err, io.EOF) {
640+ t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)
641+ }
642+}
643+
644+func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
645+ oldTimeout := udpBootstrapTimeout
646+ udpBootstrapTimeout = 10 * time.Millisecond
647+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
648+ oldCommand := udpProxyCommand
649+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
650+ return exec.Command("/bin/sh", "-c", "sleep 5")
651+ }
652+ t.Cleanup(func() { udpProxyCommand = oldCommand })
653+
654+ err := runUDPBootstrap(context.Background(), "work")
655+ if !errors.Is(err, context.DeadlineExceeded) {
656+ t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
657+ }
658+}
659+
660+func TestParseUDPPortRange(t *testing.T) {
661+ tests := []struct {
662+ name string
663+ value string
664+ want udpPortRange
665+ wantError bool
666+ }{
667+ {name: "empty uses default", value: "", want: udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}},
668+ {name: "zero is ephemeral", value: "0", want: udpPortRange{start: 0, end: 0}},
669+ {name: "zero range rejected", value: "0-100", wantError: true},
670+ {name: "single port", value: "61000", want: udpPortRange{start: 61000, end: 61000}},
671+ {name: "range", value: "61000-61023", want: udpPortRange{start: 61000, end: 61023}},
672+ {name: "range with spaces", value: " 61000 - 61023 ", want: udpPortRange{start: 61000, end: 61023}},
673+ {name: "reversed range", value: "61023-61000", wantError: true},
674+ {name: "non-numeric", value: "abc", wantError: true},
675+ {name: "missing end", value: "61000-", wantError: true},
676+ {name: "port too high", value: "70000", wantError: true},
677+ }
678+ for _, tt := range tests {
679+ t.Run(tt.name, func(t *testing.T) {
680+ got, err := parseUDPPortRange(tt.value)
681+ if tt.wantError {
682+ if err == nil {
683+ t.Fatalf("parseUDPPortRange(%q) error = nil, want error", tt.value)
684+ }
685+ return
686+ }
687+ if err != nil {
688+ t.Fatalf("parseUDPPortRange(%q) error = %v", tt.value, err)
689+ }
690+ if got != tt.want {
691+ t.Fatalf("parseUDPPortRange(%q) = %+v, want %+v", tt.value, got, tt.want)
692+ }
693+ })
694+ }
695+}
696+
697+func TestListenUDPInRange(t *testing.T) {
698+ // Occupy a wildcard UDP port, then assert listenUDPInRange skips it.
699+ var lc net.ListenConfig
700+ occupied, err := lc.ListenPacket(context.Background(), "udp", ":0")
701+ if err != nil {
702+ t.Fatal(err)
703+ }
704+ defer func() { _ = occupied.Close() }()
705+ udpPort := func(t *testing.T, conn net.PacketConn) int {
706+ t.Helper()
707+ addr, ok := conn.LocalAddr().(*net.UDPAddr)
708+ if !ok {
709+ t.Fatalf("LocalAddr() = %T, want *net.UDPAddr", conn.LocalAddr())
710+ }
711+ return addr.Port
712+ }
713+ busyPort := udpPort(t, occupied)
714+
715+ t.Run("skips busy port", func(t *testing.T) {
716+ conn, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort + 8})
717+ if err != nil {
718+ t.Fatalf("listenUDPInRange error = %v", err)
719+ }
720+ defer func() { _ = conn.Close() }()
721+ got := udpPort(t, conn)
722+ if got == busyPort {
723+ t.Fatalf("bound busy port %d, want a different port in range", busyPort)
724+ }
725+ if got < busyPort || got > busyPort+8 {
726+ t.Fatalf("bound port %d outside range %d-%d", got, busyPort, busyPort+8)
727+ }
728+ })
729+
730+ t.Run("exhausted range errors", func(t *testing.T) {
731+ _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort})
732+ if err == nil {
733+ t.Fatal("listenUDPInRange error = nil, want exhausted-range error")
734+ }
735+ if !strings.Contains(err.Error(), "no free UDP port in range") {
736+ t.Fatalf("error = %v, want 'no free UDP port in range'", err)
737+ }
738+ })
739+
740+ t.Run("invalid direct range errors", func(t *testing.T) {
741+ _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort + 8, end: busyPort})
742+ if err == nil {
743+ t.Fatal("listenUDPInRange error = nil, want invalid-range error")
744+ }
745+ if !strings.Contains(err.Error(), "invalid UDP port range") {
746+ t.Fatalf("error = %v, want 'invalid UDP port range'", err)
747+ }
748+ })
749+
750+ t.Run("ephemeral binds", func(t *testing.T) {
751+ conn, err := listenUDPInRange(context.Background(), udpPortRange{start: 0, end: 0})
752+ if err != nil {
753+ t.Fatalf("listenUDPInRange ephemeral error = %v", err)
754+ }
755+ defer func() { _ = conn.Close() }()
756+ if udpPort(t, conn) == 0 {
757+ t.Fatal("ephemeral bind returned port 0")
758+ }
759+ })
760+}
761+
762+func TestPprofAddrIsLoopback(t *testing.T) {
763+ tests := []struct {
764+ addr string
765+ want bool
766+ }{
767+ {addr: "127.0.0.1:6060", want: true},
768+ {addr: "localhost:6060", want: true},
769+ {addr: "[::1]:6060", want: true},
770+ {addr: ":6060", want: false},
771+ {addr: "0.0.0.0:6060", want: false},
772+ {addr: "192.168.1.5:6060", want: false},
773+ {addr: "garbage", want: false},
774+ }
775+
776+ for _, tt := range tests {
777+ t.Run(tt.addr, func(t *testing.T) {
778+ if got := pprofAddrIsLoopback(tt.addr); got != tt.want {
779+ t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
780+ }
781+ })
782+ }
783+}
M internal/app/spawn.go +12 -1
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "log/slog"
78 "os"
89 "os/exec"
910 "path/filepath"
@@ -13,6 +14,7 @@ import (
1314 "github.com/bnema/vev/internal/adapters/ipc"
1415 "github.com/bnema/vev/internal/platform"
1516 "github.com/bnema/vev/internal/ports"
17+ "github.com/bnema/vev/pkg/safedir"
1618 )
1719
1820 // ErrDaemonUnreachable is returned when the daemon socket never becomes
@@ -57,6 +59,7 @@ var defaultBackoff = backoffConfig{
5759 // backoff until the socket is live or the budget expires.
5860 func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {
5961 if t, err := dial(dir); err == nil {
62+ slog.Debug("daemon already reachable", "socket_dir", dir)
6063 return t, nil
6164 }
6265
@@ -69,9 +72,13 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun
6972 // dialable (or spawn fails) so late-arriving clients keep waiting
7073 // rather than spawning a second daemon.
7174 defer release()
75+ slog.Info("spawning daemon", "socket_dir", dir)
7276 if err := spawn(); err != nil {
77+ slog.Error("daemon spawn failed", "err", err)
7378 return nil, fmt.Errorf("vev: spawning daemon: %w", err)
7479 }
80+ } else {
81+ slog.Debug("waiting for daemon spawned by another process", "socket_dir", dir)
7582 }
7683
7784 return retryDial(ctx, dir, dial, cfg)
@@ -82,7 +89,7 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun
8289 // no-op release) when another process holds a fresh lock, and takes over a
8390 // lock older than staleLockAge (crash resilience).
8491 func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {
85- if err := os.MkdirAll(dir, 0o700); err != nil {
92+ if err := safedir.EnsurePrivate(dir); err != nil {
8693 return nil, false, err
8794 }
8895 path := filepath.Join(dir, spawnLockName)
@@ -95,6 +102,8 @@ func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {
95102
96103 // Lock exists: take it over only if it looks abandoned.
97104 if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > staleLockAge {
105+ age := time.Since(info.ModTime())
106+ slog.Warn("taking over stale daemon spawn lock", "path", path, "age", age)
98107 _ = os.Remove(path)
99108 if mkErr := os.Mkdir(path, 0o700); mkErr == nil {
100109 return func() { _ = os.Remove(path) }, true, nil
@@ -115,6 +124,7 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
115124 return t, nil
116125 }
117126 if !time.Now().Before(deadline) {
127+ slog.Error("daemon did not become reachable before retry budget expired", "socket_dir", dir, "budget", cfg.total)
118128 return nil, ErrDaemonUnreachable
119129 }
120130 select {
@@ -153,6 +163,7 @@ func realSpawn() error {
153163 if err := cmd.Start(); err != nil {
154164 return err
155165 }
166+ slog.Info("daemon process started", "pid", cmd.Process.Pid)
156167 // Detach: we neither wait for nor signal the daemon after launch.
157168 return cmd.Process.Release()
158169 }
M internal/app/spawn_test.go +7 -4
@@ -20,7 +20,7 @@ var errDialFailed = errors.New("dial failed")
2020 var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond}
2121
2222 func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) {
23- dir := t.TempDir()
23+ dir := filepath.Join(t.TempDir(), "vev")
2424 const racers = 16
2525
2626 var wins atomic.Int32
@@ -62,7 +62,10 @@ func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) {
6262 }
6363
6464 func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) {
65- dir := t.TempDir()
65+ dir := filepath.Join(t.TempDir(), "vev")
66+ if err := os.Mkdir(dir, 0o700); err != nil {
67+ t.Fatalf("seeding socket dir: %v", err)
68+ }
6669 lockPath := filepath.Join(dir, spawnLockName)
6770 if err := os.Mkdir(lockPath, 0o700); err != nil {
6871 t.Fatalf("seeding lock: %v", err)
@@ -126,7 +129,7 @@ func TestRetryDialContextCancelled(t *testing.T) {
126129 }
127130
128131 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
129- dir := t.TempDir()
132+ dir := filepath.Join(t.TempDir(), "vev")
130133 want := portsmocks.NewMockTransport(t)
131134 var dialCalls, spawnCalls atomic.Int32
132135 dial := func(string) (ports.Transport, error) {
@@ -180,7 +183,7 @@ func TestEnsureDaemonSpawnFailure(t *testing.T) {
180183 dial := func(string) (ports.Transport, error) { return nil, errDialFailed }
181184 spawn := func() error { return spawnErr }
182185
183- _, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)
186+ _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)
184187 if !errors.Is(err, spawnErr) {
185188 t.Fatalf("ensureDaemon error = %v, want wrapped %v", err, spawnErr)
186189 }
A internal/domain/config.go +83 -0
@@ -0,0 +1,83 @@
1+package domain
2+
3+import "time"
4+
5+// MinBarInterval is the minimum supported bar refresh interval.
6+const MinBarInterval = time.Second
7+
8+// ThemeMode selects how vev derives its color scheme.
9+type ThemeMode int
10+
11+const (
12+ // ThemeAuto follows the attached client's reported color scheme.
13+ ThemeAuto ThemeMode = iota
14+ // ThemeDark forces vev's built-in dark palette.
15+ ThemeDark
16+ // ThemeLight forces vev's built-in light palette.
17+ ThemeLight
18+)
19+
20+// ConfigEntry preserves one parsed config key/value in file order.
21+type ConfigEntry struct {
22+ Key string
23+ Value string
24+}
25+
26+// SnapshotConfig contains user-configurable snapshot restore settings.
27+type SnapshotConfig struct {
28+ RestoreProcesses []string
29+ RestoreProcessesSet bool
30+}
31+
32+// defaultSnapshotRestoreProcesses is the default allowlist for process restore.
33+var defaultSnapshotRestoreProcesses = []string{
34+ "vi", "vim", "nvim", "emacs", "man", "less", "more", "tail", "top", "htop", "btop",
35+ "claude", "codex", "pi", "opencode",
36+}
37+
38+// DefaultSnapshotRestoreProcesses returns a copy of the default process restore allowlist.
39+func DefaultSnapshotRestoreProcesses() []string {
40+ return append([]string(nil), defaultSnapshotRestoreProcesses...)
41+}
42+
43+// BarConfig contains user-configurable bar right-anchor settings.
44+type BarConfig struct {
45+ TopRight string
46+ BottomRight string
47+ Interval time.Duration
48+}
49+
50+// Config is the user-editable vev configuration after parsing. Unknown binding
51+// keys are preserved here (in BindingEntries, in file order) so the usecase
52+// layer can decide which actions it understands.
53+type Config struct {
54+ Theme ThemeMode
55+ Bar BarConfig
56+ BindingEntries []ConfigEntry
57+ Codes map[string]string
58+ Snapshot SnapshotConfig
59+}
60+
61+// Warning describes a non-fatal config problem. Parsers and reloaders should
62+// warn and continue with defaults rather than aborting startup.
63+type Warning struct {
64+ Line int
65+ Msg string
66+}
67+
68+// Defaults returns vev's default configuration.
69+func Defaults() Config {
70+ return Config{
71+ Theme: ThemeAuto,
72+ Bar: BarConfig{
73+ TopRight: "vev-bar-top-right",
74+ BottomRight: "vev-bar-bottom-right",
75+ Interval: 5 * time.Second,
76+ },
77+ BindingEntries: []ConfigEntry{},
78+ Codes: map[string]string{},
79+ Snapshot: SnapshotConfig{
80+ RestoreProcesses: DefaultSnapshotRestoreProcesses(),
81+ },
82+ }
83+}
A internal/domain/session_name.go +22 -0
@@ -0,0 +1,22 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "regexp"
6+)
7+
8+const sessionNamePattern = `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`
9+
10+var (
11+ // ErrInvalidSessionName is returned when a new or renamed session name is unsafe.
12+ ErrInvalidSessionName = errors.New("invalid session name: must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}")
13+ sessionNameRE = regexp.MustCompile(`^` + sessionNamePattern + `$`)
14+)
15+
16+// ValidateSessionName rejects unsafe names for newly-created or renamed sessions.
17+func ValidateSessionName(name string) error {
18+ if !sessionNameRE.MatchString(name) {
19+ return ErrInvalidSessionName
20+ }
21+ return nil
22+}
A internal/domain/session_name_test.go +34 -0
@@ -0,0 +1,34 @@
1+package domain
2+
3+import "testing"
4+
5+func TestValidateSessionName(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ wantErr bool
9+ }{
10+ {name: "a"},
11+ {name: "work"},
12+ {name: "Work_1.2-3"},
13+ {name: "a123456789012345678901234567890123456789012345678901234567890123"},
14+ {name: "", wantErr: true},
15+ {name: "my work", wantErr: true},
16+ {name: "-work", wantErr: true},
17+ {name: "_work", wantErr: true},
18+ {name: ".work", wantErr: true},
19+ {name: "work/one", wantErr: true},
20+ {name: "a1234567890123456789012345678901234567890123456789012345678901234", wantErr: true},
21+ }
22+
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ err := ValidateSessionName(tt.name)
26+ if tt.wantErr && err == nil {
27+ t.Fatalf("ValidateSessionName(%q) = nil, want error", tt.name)
28+ }
29+ if !tt.wantErr && err != nil {
30+ t.Fatalf("ValidateSessionName(%q) = %v, want nil", tt.name, err)
31+ }
32+ })
33+ }
34+}
A internal/logging/logging.go +99 -0
@@ -0,0 +1,99 @@
1+package logging
2+
3+import (
4+ "io"
5+ "log/slog"
6+ "os"
7+ "path/filepath"
8+ "runtime/debug"
9+ "strings"
10+ "sync"
11+
12+ "github.com/bnema/vev/pkg/safedir"
13+)
14+
15+type Component string
16+
17+const (
18+ Daemon Component = "daemon"
19+ Client Component = "client"
20+ Stdio Component = "stdio"
21+)
22+
23+const DefaultMaxBytes = 20 << 20
24+
25+type Config struct {
26+ Dir string
27+ Component Component
28+ Level slog.Level
29+ MaxBytes int64
30+ RotateAtRuntime bool
31+}
32+
33+func Setup(cfg Config) (*slog.Logger, io.Closer, error) {
34+ if err := safedir.EnsurePrivate(cfg.Dir); err != nil {
35+ return nil, nil, err
36+ }
37+
38+ component := cfg.Component
39+ logPath := filepath.Join(cfg.Dir, "vev-"+string(component)+".log")
40+ writer, err := newRotatingWriter(logPath, cfg.MaxBytes, cfg.RotateAtRuntime)
41+ if err != nil {
42+ return nil, nil, err
43+ }
44+
45+ crashPath := filepath.Join(cfg.Dir, "vev-"+string(component)+"-crash.log")
46+ crashFile, err := os.OpenFile(crashPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
47+ if err != nil {
48+ _ = writer.Close()
49+ return nil, nil, err
50+ }
51+ if err := debug.SetCrashOutput(crashFile, debug.CrashOptions{}); err != nil {
52+ _ = crashFile.Close()
53+ _ = writer.Close()
54+ return nil, nil, err
55+ }
56+
57+ logger := slog.New(slog.NewJSONHandler(writer, &slog.HandlerOptions{Level: cfg.Level})).With("component", string(component))
58+ slog.SetDefault(logger)
59+
60+ return logger, &setupCloser{log: writer, crash: crashFile}, nil
61+}
62+
63+func ParseLevel(s string) slog.Level {
64+ switch strings.ToLower(strings.TrimSpace(s)) {
65+ case "debug":
66+ return slog.LevelDebug
67+ case "warn", "warning":
68+ return slog.LevelWarn
69+ case "error":
70+ return slog.LevelError
71+ default:
72+ return slog.LevelInfo
73+ }
74+}
75+
76+func EnvLevel() slog.Level {
77+ return ParseLevel(os.Getenv("VEV_LOG"))
78+}
79+
80+type setupCloser struct {
81+ once sync.Once
82+ log io.Closer
83+ crash io.Closer
84+ err error
85+}
86+
87+func (c *setupCloser) Close() error {
88+ c.once.Do(func() {
89+ if c.log != nil {
90+ c.err = c.log.Close()
91+ }
92+ if c.crash != nil {
93+ if err := c.crash.Close(); c.err == nil {
94+ c.err = err
95+ }
96+ }
97+ })
98+ return c.err
99+}
A internal/logging/logging_test.go +264 -0
@@ -0,0 +1,264 @@
1+package logging
2+
3+import (
4+ "encoding/json"
5+ "log/slog"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+)
13+
14+func privateDir(t *testing.T) string {
15+ t.Helper()
16+ return filepath.Join(t.TempDir(), "vev")
17+}
18+
19+func TestParseLevelAndEnvLevel(t *testing.T) {
20+ tests := []struct {
21+ in string
22+ want slog.Level
23+ }{
24+ {"debug", slog.LevelDebug},
25+ {" DEBUG ", slog.LevelDebug},
26+ {"info", slog.LevelInfo},
27+ {"", slog.LevelInfo},
28+ {"warn", slog.LevelWarn},
29+ {"warning", slog.LevelWarn},
30+ {"error", slog.LevelError},
31+ {"bogus", slog.LevelInfo},
32+ }
33+ for _, tt := range tests {
34+ if got := ParseLevel(tt.in); got != tt.want {
35+ t.Fatalf("ParseLevel(%q) = %v, want %v", tt.in, got, tt.want)
36+ }
37+ }
38+
39+ t.Setenv("VEV_LOG", "debug")
40+ if got := EnvLevel(); got != slog.LevelDebug {
41+ t.Fatalf("EnvLevel() = %v, want %v", got, slog.LevelDebug)
42+ }
43+}
44+
45+func TestSetupWritesJSONWithComponent(t *testing.T) {
46+ dir := privateDir(t)
47+ logger, closer, err := Setup(Config{Dir: dir, Component: Client, Level: slog.LevelDebug})
48+ if err != nil {
49+ t.Fatalf("Setup() error = %v", err)
50+ }
51+ defer func() {
52+ if err := closer.Close(); err != nil {
53+ t.Errorf("Close() error = %v", err)
54+ }
55+ }()
56+
57+ logger.Info("hello", "answer", 42)
58+
59+ data, err := os.ReadFile(filepath.Join(dir, "vev-client.log"))
60+ if err != nil {
61+ t.Fatalf("ReadFile() error = %v", err)
62+ }
63+ var entry map[string]any
64+ if err := json.Unmarshal(data, &entry); err != nil {
65+ t.Fatalf("log entry is not JSON: %v; data=%q", err, data)
66+ }
67+ if entry["component"] != "client" {
68+ t.Fatalf("component = %v, want client; entry=%v", entry["component"], entry)
69+ }
70+ if entry["msg"] != "hello" || entry["answer"] != float64(42) {
71+ t.Fatalf("unexpected entry fields: %v", entry)
72+ }
73+ if _, err := os.Stat(filepath.Join(dir, "vev-client-crash.log")); err != nil {
74+ t.Fatalf("crash log not created: %v", err)
75+ }
76+}
77+
78+func TestRuntimeRotation(t *testing.T) {
79+ dir := t.TempDir()
80+ path := filepath.Join(dir, "vev-daemon.log")
81+ w, err := newRotatingWriter(path, 10, true)
82+ if err != nil {
83+ t.Fatalf("newRotatingWriter() error = %v", err)
84+ }
85+ defer func() {
86+ if err := w.Close(); err != nil {
87+ t.Errorf("Close() error = %v", err)
88+ }
89+ }()
90+
91+ if _, err := w.Write([]byte("0123456789")); err != nil {
92+ t.Fatalf("Write() error = %v", err)
93+ }
94+ if _, err := os.Stat(path + ".old"); err != nil {
95+ t.Fatalf("rotated file missing: %v", err)
96+ }
97+ live, err := os.ReadFile(path)
98+ if err != nil {
99+ t.Fatalf("read live file: %v", err)
100+ }
101+ if len(live) != 0 {
102+ t.Fatalf("live file length = %d, want 0 after rotation", len(live))
103+ }
104+ old, err := os.ReadFile(path + ".old")
105+ if err != nil {
106+ t.Fatalf("read old file: %v", err)
107+ }
108+ if string(old) != "0123456789" {
109+ t.Fatalf("old file = %q, want original write", old)
110+ }
111+}
112+
113+func TestStartupOnlyRotation(t *testing.T) {
114+ dir := t.TempDir()
115+ path := filepath.Join(dir, "vev-client.log")
116+ if err := os.WriteFile(path, []byte("already-too-large"), 0o600); err != nil {
117+ t.Fatalf("seed log: %v", err)
118+ }
119+ w, err := newRotatingWriter(path, 5, false)
120+ if err != nil {
121+ t.Fatalf("newRotatingWriter() error = %v", err)
122+ }
123+ defer func() {
124+ if err := w.Close(); err != nil {
125+ t.Errorf("Close() error = %v", err)
126+ }
127+ }()
128+
129+ if old, err := os.ReadFile(path + ".old"); err != nil || string(old) != "already-too-large" {
130+ t.Fatalf("startup rotation old = %q, err = %v", old, err)
131+ }
132+ if _, err := w.Write([]byte("first")); err != nil {
133+ t.Fatalf("Write(first) error = %v", err)
134+ }
135+ if _, err := w.Write([]byte("second")); err != nil {
136+ t.Fatalf("Write(second) error = %v", err)
137+ }
138+ if old, err := os.ReadFile(path + ".old"); err != nil || string(old) != "already-too-large" {
139+ t.Fatalf("startup-only mode rotated during writes; old = %q, err = %v", old, err)
140+ }
141+ live, err := os.ReadFile(path)
142+ if err != nil {
143+ t.Fatalf("read live file: %v", err)
144+ }
145+ if string(live) != "firstsecond" {
146+ t.Fatalf("live file = %q, want appended writes", live)
147+ }
148+}
149+
150+func TestCrashOutputCapturesPanicStack(t *testing.T) {
151+ dir := privateDir(t)
152+ cmd := exec.Command(os.Args[0], "-test.run=TestCrashOutputHelper")
153+ cmd.Env = append(os.Environ(), "VEV_CRASH_HELPER=1", "VEV_CRASH_DIR="+dir)
154+ if err := cmd.Run(); err == nil {
155+ t.Fatal("crash helper exited successfully, want panic failure")
156+ }
157+
158+ data, err := os.ReadFile(filepath.Join(dir, "vev-daemon-crash.log"))
159+ if err != nil {
160+ t.Fatalf("read crash log: %v", err)
161+ }
162+ if !strings.Contains(string(data), "intentional crash for SetCrashOutput test") || !strings.Contains(string(data), "goroutine") {
163+ t.Fatalf("crash log did not contain panic stack; data=%q", data)
164+ }
165+}
166+
167+func TestCrashOutputHelper(t *testing.T) {
168+ if os.Getenv("VEV_CRASH_HELPER") != "1" {
169+ return
170+ }
171+ if _, _, err := Setup(Config{Dir: os.Getenv("VEV_CRASH_DIR"), Component: Daemon, Level: slog.LevelInfo}); err != nil {
172+ panic(err)
173+ }
174+ panic("intentional crash for SetCrashOutput test")
175+}
176+
177+func TestStartupOnlyConcurrentRotationRaceIsIgnored(t *testing.T) {
178+ dir := t.TempDir()
179+ path := filepath.Join(dir, "vev-client.log")
180+ if err := os.WriteFile(path, []byte("already-too-large"), 0o600); err != nil {
181+ t.Fatalf("seed log: %v", err)
182+ }
183+
184+ var wg sync.WaitGroup
185+ errCh := make(chan error, 8)
186+ for i := 0; i < cap(errCh); i++ {
187+ wg.Add(1)
188+ go func() {
189+ defer wg.Done()
190+ w, err := newRotatingWriter(path, 5, false)
191+ if err != nil {
192+ errCh <- err
193+ return
194+ }
195+ if _, err := w.Write([]byte("x")); err != nil {
196+ errCh <- err
197+ }
198+ if err := w.Close(); err != nil {
199+ errCh <- err
200+ }
201+ }()
202+ }
203+ wg.Wait()
204+ close(errCh)
205+ for err := range errCh {
206+ if err != nil {
207+ t.Fatalf("concurrent startup writer failed: %v", err)
208+ }
209+ }
210+ if _, err := os.Stat(path); err != nil {
211+ t.Fatalf("live log missing after concurrent startup rotation: %v", err)
212+ }
213+ if _, err := os.Stat(path + ".old"); err != nil {
214+ t.Fatalf("rotated log missing after concurrent startup rotation: %v", err)
215+ }
216+}
217+
218+func TestDoubleClose(t *testing.T) {
219+ _, closer, err := Setup(Config{Dir: privateDir(t), Component: Stdio, Level: slog.LevelInfo})
220+ if err != nil {
221+ t.Fatalf("Setup() error = %v", err)
222+ }
223+ if err := closer.Close(); err != nil {
224+ t.Fatalf("first Close() error = %v", err)
225+ }
226+ if err := closer.Close(); err != nil {
227+ t.Fatalf("second Close() error = %v", err)
228+ }
229+}
230+
231+func TestRotationRenameFailureIsNonFatal(t *testing.T) {
232+ dir := t.TempDir()
233+ path := filepath.Join(dir, "vev-daemon.log")
234+ if err := os.WriteFile(path, nil, 0o600); err != nil {
235+ t.Fatalf("seed log: %v", err)
236+ }
237+ if err := os.Mkdir(path+".old", 0o700); err != nil {
238+ t.Fatalf("make rename obstacle: %v", err)
239+ }
240+ if err := os.WriteFile(filepath.Join(path+".old", "child"), []byte("x"), 0o600); err != nil {
241+ t.Fatalf("make non-empty obstacle: %v", err)
242+ }
243+
244+ w, err := newRotatingWriter(path, 4, true)
245+ if err != nil {
246+ t.Fatalf("newRotatingWriter() error = %v", err)
247+ }
248+ defer func() {
249+ if err := w.Close(); err != nil {
250+ t.Errorf("Close() error = %v", err)
251+ }
252+ }()
253+
254+ if _, err := w.Write([]byte("exceeds")); err != nil {
255+ t.Fatalf("Write() error = %v", err)
256+ }
257+ live, err := os.ReadFile(path)
258+ if err != nil {
259+ t.Fatalf("read live file: %v", err)
260+ }
261+ if !strings.Contains(string(live), "exceeds") {
262+ t.Fatalf("write was not preserved after failed rename: %q", live)
263+ }
264+}
A internal/logging/rotate.go +82 -0
@@ -0,0 +1,82 @@
1+package logging
2+
3+import (
4+ "os"
5+ "sync"
6+)
7+
8+type rotatingWriter struct {
9+ mu sync.Mutex
10+ path string
11+ max int64
12+ runtime bool
13+ file *os.File
14+ size int64
15+ closed bool
16+}
17+
18+func newRotatingWriter(path string, maxBytes int64, rotateAtRuntime bool) (*rotatingWriter, error) {
19+ if maxBytes <= 0 {
20+ maxBytes = DefaultMaxBytes
21+ }
22+ rw := &rotatingWriter{path: path, max: maxBytes, runtime: rotateAtRuntime}
23+ if err := rw.open(); err != nil {
24+ return nil, err
25+ }
26+ if !rotateAtRuntime && rw.size >= rw.max {
27+ rw.rotateLocked()
28+ }
29+ return rw, nil
30+}
31+
32+func (w *rotatingWriter) Write(p []byte) (int, error) {
33+ w.mu.Lock()
34+ defer w.mu.Unlock()
35+
36+ if w.closed {
37+ return 0, os.ErrClosed
38+ }
39+ n, err := w.file.Write(p)
40+ w.size += int64(n)
41+ if err == nil && w.runtime && w.size >= w.max {
42+ w.rotateLocked()
43+ }
44+ return n, err
45+}
46+
47+func (w *rotatingWriter) Close() error {
48+ w.mu.Lock()
49+ defer w.mu.Unlock()
50+
51+ if w.closed {
52+ return nil
53+ }
54+ w.closed = true
55+ return w.file.Close()
56+}
57+
58+func (w *rotatingWriter) open() error {
59+ f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
60+ if err != nil {
61+ return err
62+ }
63+ info, err := f.Stat()
64+ if err != nil {
65+ _ = f.Close()
66+ return err
67+ }
68+ w.file = f
69+ w.size = info.Size()
70+ return nil
71+}
72+
73+func (w *rotatingWriter) rotateLocked() {
74+ old := w.file
75+ _ = os.Rename(w.path, w.path+".old")
76+ if err := w.open(); err != nil {
77+ w.file = old
78+ w.size = w.max
79+ return
80+ }
81+ _ = old.Close()
82+}
M internal/persist/persist.go +26 -3
@@ -70,15 +70,38 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
7070 }
7171
7272 createdAt := at
73- var tabNames []string
73+ lastUsedSeq := uint64(0)
7474 if v, ok := p.store.Get([]byte(name)); ok {
7575 if r, err := decodeRecordValue(name, v); err == nil {
7676 createdAt = r.CreatedAt
77- tabNames = r.TabNames
77+ lastUsedSeq = r.LastUsedSeq
7878 }
7979 }
8080
81- value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, TabNames: tabNames})
81+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq})
82+ if err != nil {
83+ return err
84+ }
85+ return p.store.Set([]byte(name), value)
86+}
87+
88+// TouchMRU updates hot-path session recency without forcing an fsync.
89+func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error {
90+ if p == nil || p.store == nil {
91+ return nil
92+ }
93+ if name == "" {
94+ return errEmptyName
95+ }
96+
97+ r := Record{Name: name, LastUsedSeq: lastUsedSeq}
98+ if v, ok := p.store.Get([]byte(name)); ok {
99+ if decoded, err := decodeRecordValue(name, v); err == nil {
100+ r = decoded
101+ r.LastUsedSeq = lastUsedSeq
102+ }
103+ }
104+ value, err := encodeRecordValue(r)
82105 if err != nil {
83106 return err
84107 }
M internal/persist/persist_test.go +28 -24
@@ -12,8 +12,13 @@ import (
1212 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1313 )
1414
15+func privateDir(t *testing.T) string {
16+ t.Helper()
17+ return filepath.Join(t.TempDir(), "vev")
18+}
19+
1520 func TestRecordCodecRoundTrip(t *testing.T) {
16- r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, TabNames: []string{"shell", "logs"}}
21+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33}
1722 value, err := encodeRecordValue(r)
1823 require.NoError(t, err)
1924
@@ -22,18 +27,14 @@ func TestRecordCodecRoundTrip(t *testing.T) {
2227 require.Equal(t, r, got)
2328 }
2429
25-func TestRecordCodecReadsLegacyRecordWithoutTabNames(t *testing.T) {
26- cwd := "/tmp/project"
27- value := make([]byte, 4+len(cwd)+8+8)
28- binary.BigEndian.PutUint32(value[:4], uint32(len(cwd)))
29- copy(value[4:], cwd)
30- off := 4 + len(cwd)
31- binary.BigEndian.PutUint64(value[off:off+8], 11)
32- binary.BigEndian.PutUint64(value[off+8:off+16], 22)
30+func TestRecordCodecOldRecordDefaultsLastUsed(t *testing.T) {
31+ old := []byte{0, 0, 0, 4, '/', 't', 'm', 'p'}
32+ old = append(old, 0, 0, 0, 0, 0, 0, 0, 11)
33+ old = append(old, 0, 0, 0, 0, 0, 0, 0, 22)
3334
34- got, err := decodeRecordValue("work", value)
35+ got, err := decodeRecordValue("work", old)
3536 require.NoError(t, err)
36- require.Equal(t, Record{Name: "work", Cwd: cwd, CreatedAt: 11, UpdatedAt: 22}, got)
37+ require.Equal(t, Record{Name: "work", Cwd: "/tmp", CreatedAt: 11, UpdatedAt: 22}, got)
3738 }
3839
3940 func TestRecordCodecRejectsMalformedData(t *testing.T) {
@@ -46,8 +47,6 @@ func TestRecordCodecRejectsMalformedData(t *testing.T) {
4647 {0, 0, 0, 5, 'a'},
4748 {0, 0, 0, 1, 'a', 0},
4849 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
49- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
50- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255},
5150 }
5251 for _, value := range badValues {
5352 _, err := decodeRecordValue("name", value)
@@ -79,30 +78,31 @@ func TestNilPersisterNoOps(t *testing.T) {
7978 }
8079
8180 func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
82- dir := t.TempDir()
81+ dir := privateDir(t)
8382 p, err := Open(dir)
8483 require.NoError(t, err)
8584
86- require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, TabNames: []string{"shell", "logs"}}))
87- require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}))
85+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40}))
86+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))
8887 require.NoError(t, p.Touch("b", "/b/next", 30))
88+ require.NoError(t, p.TouchMRU("b", 60))
8989
9090 records, err := p.LoadAll()
9191 require.NoError(t, err)
9292 require.Equal(t, []Record{
93- {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20},
94- {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, TabNames: []string{"shell", "logs"}},
93+ {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},
94+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60},
9595 }, records)
9696
9797 require.NoError(t, p.Delete("a"))
9898 records, err = p.LoadAll()
9999 require.NoError(t, err)
100- require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, TabNames: []string{"shell", "logs"}}}, records)
100+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60}}, records)
101101 require.NoError(t, p.Close())
102102 }
103103
104104 func TestDecodeAllSkipsMalformedRecords(t *testing.T) {
105- good, err := encodeRecordValue(Record{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2})
105+ good, err := encodeRecordValue(Record{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3})
106106 require.NoError(t, err)
107107
108108 records, err := decodeAll(map[string][]byte{
@@ -110,19 +110,19 @@ func TestDecodeAllSkipsMalformedRecords(t *testing.T) {
110110 "good": good,
111111 })
112112 require.NoError(t, err)
113- require.Equal(t, []Record{{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2}}, records)
113+ require.Equal(t, []Record{{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}}, records)
114114 }
115115
116116 func TestLoadReadOnlyUsesReplay(t *testing.T) {
117- dir := t.TempDir()
117+ dir := privateDir(t)
118118 p, err := Open(dir)
119119 require.NoError(t, err)
120- require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}))
120+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}))
121121 require.NoError(t, p.Close())
122122
123123 records, err := LoadReadOnly(dir)
124124 require.NoError(t, err)
125- require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}}, records)
125+ require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}}, records)
126126
127127 // Missing stores replay as empty rather than creating the WAL.
128128 missing := filepath.Join(t.TempDir(), "missing")
@@ -143,6 +143,10 @@ func TestSyncBehavior(t *testing.T) {
143143 require.Equal(t, 2, state.sets)
144144 require.Equal(t, 1, state.syncs)
145145
146+ require.NoError(t, p.TouchMRU("work", 3))
147+ require.Equal(t, 3, state.sets)
148+ require.Equal(t, 1, state.syncs)
149+
146150 require.NoError(t, p.Delete("work"))
147151 require.Equal(t, 1, state.deletes)
148152 require.Equal(t, 2, state.syncs)
M internal/persist/record.go +10 -48
@@ -9,11 +9,11 @@ import (
99
1010 // Record is the persisted metadata for a named session.
1111 type Record struct {
12- Name string
13- Cwd string
14- CreatedAt int64
15- UpdatedAt int64
16- TabNames []string
12+ Name string
13+ Cwd string
14+ CreatedAt int64
15+ UpdatedAt int64
16+ LastUsedSeq uint64
1717 }
1818
1919 var (
@@ -39,21 +39,13 @@ func encodeRecordValue(r Record) ([]byte, error) {
3939 tabNamesSize += 4 + len(name)
4040 }
4141
42- buf := make([]byte, 4+len(r.Cwd)+8+8+tabNamesSize)
42+ buf := make([]byte, 4+len(r.Cwd)+8+8+8)
4343 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
4444 copy(buf[4:], r.Cwd)
4545 off := 4 + len(r.Cwd)
4646 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
4747 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
48- off += 16
49- binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(r.TabNames)))
50- off += 4
51- for _, name := range r.TabNames {
52- binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(name)))
53- off += 4
54- copy(buf[off:], name)
55- off += len(name)
56- }
48+ binary.BigEndian.PutUint64(buf[off+16:off+24], r.LastUsedSeq)
5749 return buf, nil
5850 }
5951
@@ -70,8 +62,7 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
7062 return Record{}, errMalformedRecord
7163 }
7264 cwdLen := int(cwdLen32)
73- baseLen := 4 + cwdLen + 8 + 8
74- if len(value) < baseLen {
65+ if len(value) != 4+cwdLen+8+8 && len(value) != 4+cwdLen+8+8+8 {
7566 return Record{}, errMalformedRecord
7667 }
7768 off := 4 + cwdLen
@@ -81,37 +72,8 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
8172 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
8273 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
8374 }
84- off += 16
85- if len(value) == baseLen {
86- return r, nil
87- }
88- if len(value)-off < 4 {
89- return Record{}, errMalformedRecord
90- }
91- tabCount32 := binary.BigEndian.Uint32(value[off : off+4])
92- off += 4
93- if uint64(tabCount32) > uint64(len(value)-off)/4 {
94- return Record{}, errMalformedRecord
95- }
96- tabCount := int(tabCount32)
97- if tabCount > 0 {
98- r.TabNames = make([]string, 0, tabCount)
99- }
100- for range tabCount {
101- if len(value)-off < 4 {
102- return Record{}, errMalformedRecord
103- }
104- nameLen32 := binary.BigEndian.Uint32(value[off : off+4])
105- off += 4
106- if uint64(nameLen32) > uint64(len(value)-off) {
107- return Record{}, errMalformedRecord
108- }
109- nameLen := int(nameLen32)
110- r.TabNames = append(r.TabNames, string(value[off:off+nameLen]))
111- off += nameLen
112- }
113- if off != len(value) {
114- return Record{}, errMalformedRecord
75+ if len(value) == 4+cwdLen+8+8+8 {
76+ r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
11577 }
11678 return r, nil
11779 }
M internal/platform/dir.go +14 -0
@@ -20,6 +20,20 @@ func StateDir() string {
2020 return filepath.Join(home, ".local", "state", "vev")
2121 }
2222
23+// ConfigPath returns the user config file path: $XDG_CONFIG_HOME/vev/config if
24+// set, else ~/.config/vev/config.
25+func ConfigPath() string {
26+ if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
27+ return filepath.Join(xdg, "vev", "config")
28+ }
29+ home, err := os.UserHomeDir()
30+ if err != nil || home == "" {
31+ // Last resort: a per-uid temp path so config lookup never aborts startup.
32+ return filepath.Join(os.TempDir(), fmt.Sprintf("vev-config-%d", os.Getuid()), "config")
33+ }
34+ return filepath.Join(home, ".config", "vev", "config")
35+}
36+
2337 // DirOrHome returns dir when it names an existing directory. Otherwise it
2438 // falls back to the current user's home directory, then to / as a last resort.
2539 func DirOrHome(dir string) string {
A internal/platform/dir_test.go +137 -0
@@ -0,0 +1,137 @@
1+package platform
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestStateDir(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ xdgState string
14+ home string
15+ want func(*testing.T, string, string) string
16+ }{
17+ {
18+ name: "xdg state home",
19+ xdgState: filepath.Join(t.TempDir(), "xdg-state"),
20+ home: filepath.Join(t.TempDir(), "home"),
21+ want: func(_ *testing.T, xdgState, _ string) string {
22+ return filepath.Join(xdgState, "vev")
23+ },
24+ },
25+ {
26+ name: "home fallback",
27+ home: filepath.Join(t.TempDir(), "home"),
28+ want: func(_ *testing.T, _, home string) string {
29+ return filepath.Join(home, ".local", "state", "vev")
30+ },
31+ },
32+ {
33+ name: "temp fallback without home",
34+ want: func(t *testing.T, _, _ string) string {
35+ t.Helper()
36+ return filepath.Join(os.TempDir(), "vev-state-")
37+ },
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ t.Setenv("XDG_STATE_HOME", tt.xdgState)
44+ t.Setenv("HOME", tt.home)
45+
46+ want := tt.want(t, tt.xdgState, tt.home)
47+ got := StateDir()
48+ if strings.HasSuffix(want, "vev-state-") {
49+ if !strings.HasPrefix(got, want) {
50+ t.Fatalf("StateDir() = %q, want prefix %q", got, want)
51+ }
52+ return
53+ }
54+ if got != want {
55+ t.Fatalf("StateDir() = %q, want %q", got, want)
56+ }
57+ })
58+ }
59+}
60+
61+func TestConfigPath(t *testing.T) {
62+ tests := []struct {
63+ name string
64+ xdgConfig string
65+ home string
66+ want func(*testing.T, string, string) string
67+ }{
68+ {
69+ name: "xdg config home",
70+ xdgConfig: filepath.Join(t.TempDir(), "xdg-config"),
71+ home: filepath.Join(t.TempDir(), "home"),
72+ want: func(_ *testing.T, xdgConfig, _ string) string {
73+ return filepath.Join(xdgConfig, "vev", "config")
74+ },
75+ },
76+ {
77+ name: "home fallback",
78+ home: filepath.Join(t.TempDir(), "home"),
79+ want: func(_ *testing.T, _, home string) string {
80+ return filepath.Join(home, ".config", "vev", "config")
81+ },
82+ },
83+ {
84+ name: "temp fallback without home",
85+ want: func(t *testing.T, _, _ string) string {
86+ t.Helper()
87+ return filepath.Join(os.TempDir(), "vev-config-")
88+ },
89+ },
90+ }
91+
92+ for _, tt := range tests {
93+ t.Run(tt.name, func(t *testing.T) {
94+ t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig)
95+ t.Setenv("HOME", tt.home)
96+
97+ want := tt.want(t, tt.xdgConfig, tt.home)
98+ got := ConfigPath()
99+ if strings.HasSuffix(want, "vev-config-") {
100+ if !strings.HasPrefix(got, want) || filepath.Base(got) != "config" {
101+ t.Fatalf("ConfigPath() = %q, want prefix %q and base config", got, want)
102+ }
103+ return
104+ }
105+ if got != want {
106+ t.Fatalf("ConfigPath() = %q, want %q", got, want)
107+ }
108+ })
109+ }
110+}
111+
112+func TestDirOrHome(t *testing.T) {
113+ existing := t.TempDir()
114+ home := filepath.Join(t.TempDir(), "home")
115+ nonexistent := filepath.Join(t.TempDir(), "missing")
116+
117+ tests := []struct {
118+ name string
119+ dir string
120+ home string
121+ want string
122+ }{
123+ {name: "existing dir", dir: existing, home: home, want: existing},
124+ {name: "non-existent dir falls back to home", dir: nonexistent, home: home, want: home},
125+ {name: "non-existent dir without home falls back to root", dir: nonexistent, want: "/"},
126+ }
127+
128+ for _, tt := range tests {
129+ t.Run(tt.name, func(t *testing.T) {
130+ t.Setenv("HOME", tt.home)
131+
132+ if got := DirOrHome(tt.dir); got != tt.want {
133+ t.Fatalf("DirOrHome(%q) = %q, want %q", tt.dir, got, tt.want)
134+ }
135+ })
136+ }
137+}
M internal/platform/proc_linux.go +29 -1
@@ -3,9 +3,37 @@ package platform
33 import (
44 "fmt"
55 "os"
6+ "strings"
67 )
78
89 // ProcessCwd returns the current working directory for a Linux process.
910 func ProcessCwd(pid int) (string, error) {
10- return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))
11+ return processCwd("/proc", pid)
12+}
13+
14+func processCwd(root string, pid int) (string, error) {
15+ if pid <= 0 {
16+ return "", fmt.Errorf("process cwd: invalid pid %d", pid)
17+ }
18+ return os.Readlink(fmt.Sprintf("%s/%d/cwd", root, pid))
19+}
20+
21+// ProcessComm returns the command name from /proc/<pid>/comm.
22+func ProcessComm(pid int) (string, error) {
23+ return processComm("/proc", pid)
24+}
25+
26+func processComm(root string, pid int) (string, error) {
27+ if pid <= 0 {
28+ return "", fmt.Errorf("process comm: invalid pid %d", pid)
29+ }
30+ data, err := os.ReadFile(fmt.Sprintf("%s/%d/comm", root, pid))
31+ if err != nil {
32+ return "", fmt.Errorf("process comm: read pid %d: %w", pid, err)
33+ }
34+ comm := strings.TrimSpace(string(data))
35+ if comm == "" {
36+ return "", fmt.Errorf("process comm: empty comm for pid %d", pid)
37+ }
38+ return comm, nil
1139 }
A internal/platform/proc_linux_test.go +23 -0
@@ -0,0 +1,23 @@
1+//go:build linux
2+
3+package platform
4+
5+import (
6+ "os"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestProcessComm_CurrentProcess(t *testing.T) {
13+ comm, err := ProcessComm(os.Getpid())
14+ require.NoError(t, err)
15+ require.NotEmpty(t, comm)
16+ require.NotContains(t, comm, "\n")
17+}
18+
19+func TestProcessComm_InvalidPID(t *testing.T) {
20+ comm, err := ProcessComm(0)
21+ require.Error(t, err)
22+ require.Empty(t, comm)
23+}
A internal/platform/process_linux.go +157 -0
@@ -0,0 +1,157 @@
1+package platform
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strconv"
10+ "strings"
11+ "sync"
12+ "time"
13+)
14+
15+const processRecordCacheTTL = 250 * time.Millisecond
16+
17+// ProcessInspector implements ports.ProcessInspector using Linux /proc.
18+type ProcessInspector struct {
19+ root string
20+
21+ mu sync.Mutex
22+ cachedRecords []processRecord
23+ cachedRecordsUntil time.Time
24+}
25+
26+// NewProcessInspector returns a Linux /proc-backed process inspector.
27+func NewProcessInspector() *ProcessInspector { return newProcessInspector("/proc") }
28+
29+func newProcessInspector(root string) *ProcessInspector { return &ProcessInspector{root: root} }
30+
31+func (p *ProcessInspector) Cwd(pid int) (string, error) {
32+ return processCwd(p.root, pid)
33+}
34+func (p *ProcessInspector) Comm(pid int) (string, error) {
35+ return processComm(p.root, pid)
36+}
37+func (p *ProcessInspector) Argv(pid int) ([]string, error) {
38+ return processArgv(filepath.Join(p.root, strconv.Itoa(pid), "cmdline"), pid)
39+}
40+func (p *ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
41+ recs, err := p.processRecords()
42+ if err != nil {
43+ return nil, err
44+ }
45+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
46+ if !ok {
47+ return nil, nil
48+ }
49+ return p.Argv(pid)
50+}
51+
52+func (p *ProcessInspector) processRecords() ([]processRecord, error) {
53+ p.mu.Lock()
54+ defer p.mu.Unlock()
55+ now := time.Now()
56+ if now.Before(p.cachedRecordsUntil) {
57+ return p.cachedRecords, nil
58+ }
59+ recs, err := readProcRecords(p.root)
60+ if err != nil {
61+ return nil, err
62+ }
63+ p.cachedRecords = recs
64+ p.cachedRecordsUntil = now.Add(processRecordCacheTTL)
65+ return recs, nil
66+}
67+
68+// ProcessArgv returns argv from /proc/<pid>/cmdline.
69+func ProcessArgv(pid int) ([]string, error) {
70+ return processArgv(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"), pid)
71+}
72+
73+func processArgv(path string, pid int) ([]string, error) {
74+ data, err := os.ReadFile(path)
75+ if err != nil {
76+ return nil, err
77+ }
78+ data = bytes.TrimRight(data, "\x00")
79+ if len(data) == 0 {
80+ return nil, fmt.Errorf("process argv: empty argv for pid %d", pid)
81+ }
82+ parts := bytes.Split(data, []byte{0})
83+ argv := make([]string, len(parts))
84+ for i, part := range parts {
85+ argv[i] = string(part)
86+ }
87+ return argv, nil
88+}
89+
90+type processRecord struct{ pid, pgrp int }
91+
92+// ProcessGroupArgv returns argv for a restorable foreground process in pgid.
93+// A bare pane shell (the only process in its foreground group) returns nil argv.
94+func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
95+ recs, err := readProcRecords("/proc")
96+ if err != nil {
97+ return nil, err
98+ }
99+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
100+ if !ok {
101+ return nil, nil
102+ }
103+ return ProcessArgv(pid)
104+}
105+
106+func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) {
107+ if pgid <= 0 {
108+ return 0, false
109+ }
110+ matches := make([]int, 0)
111+ for _, r := range recs {
112+ if r.pgrp == pgid && r.pid != shellPid {
113+ matches = append(matches, r.pid)
114+ }
115+ }
116+ if len(matches) == 0 {
117+ return 0, false
118+ }
119+ sort.Ints(matches)
120+ return matches[0], true
121+}
122+
123+func readProcRecords(root string) ([]processRecord, error) {
124+ ents, err := os.ReadDir(root)
125+ if err != nil {
126+ return nil, err
127+ }
128+ var recs []processRecord
129+ for _, ent := range ents {
130+ pid, err := strconv.Atoi(ent.Name())
131+ if err != nil {
132+ continue
133+ }
134+ data, err := os.ReadFile(filepath.Join(root, ent.Name(), "stat"))
135+ if err != nil {
136+ continue
137+ }
138+ pgrp, err := parseStatPgrp(string(data))
139+ if err != nil {
140+ continue
141+ }
142+ recs = append(recs, processRecord{pid: pid, pgrp: pgrp})
143+ }
144+ return recs, nil
145+}
146+
147+func parseStatPgrp(stat string) (int, error) {
148+ end := strings.LastIndex(stat, ")")
149+ if end < 0 || end+2 >= len(stat) {
150+ return 0, fmt.Errorf("invalid stat")
151+ }
152+ fields := strings.Fields(stat[end+2:])
153+ if len(fields) < 3 {
154+ return 0, fmt.Errorf("invalid stat")
155+ }
156+ return strconv.Atoi(fields[2])
157+}
A internal/platform/process_linux_test.go +176 -0
@@ -0,0 +1,176 @@
1+package platform
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "reflect"
8+ "strconv"
9+ "testing"
10+)
11+
12+func TestProcessCwdRejectsInvalidPID(t *testing.T) {
13+ if _, err := processCwd("/proc", 0); err == nil {
14+ t.Fatal("processCwd accepted invalid pid")
15+ }
16+}
17+
18+func TestProcessArgv(t *testing.T) {
19+ argv, err := ProcessArgv(os.Getpid())
20+ if err != nil {
21+ t.Fatalf("ProcessArgv current process: %v", err)
22+ }
23+ if len(argv) == 0 {
24+ t.Fatal("ProcessArgv returned empty argv")
25+ }
26+}
27+
28+func TestProcessArgvRejectsEmptyCmdline(t *testing.T) {
29+ path := filepath.Join(t.TempDir(), "cmdline")
30+ if err := os.WriteFile(path, []byte("\x00\x00"), 0o600); err != nil {
31+ t.Fatal(err)
32+ }
33+ if argv, err := processArgv(path, 123); err == nil || argv != nil {
34+ t.Fatalf("processArgv empty = %#v, %v; want nil error", argv, err)
35+ }
36+}
37+
38+func TestSelectProcessGroupPID(t *testing.T) {
39+ tests := []struct {
40+ name string
41+ recs []processRecord
42+ pgid int
43+ shell int
44+ wantPID int
45+ wantOK bool
46+ }{
47+ {
48+ name: "selects other member",
49+ recs: []processRecord{{pid: 10, pgrp: 7}, {pid: 12, pgrp: 7}, {pid: 20, pgrp: 9}},
50+ pgid: 7,
51+ shell: 10,
52+ wantPID: 12,
53+ wantOK: true,
54+ },
55+ {
56+ name: "bare shell has no candidate",
57+ recs: []processRecord{{pid: 10, pgrp: 7}, {pid: 20, pgrp: 9}},
58+ pgid: 7,
59+ shell: 10,
60+ wantPID: 0,
61+ wantOK: false,
62+ },
63+ }
64+
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ pid, ok := selectProcessGroupPID(tt.recs, tt.pgid, tt.shell)
68+ if ok != tt.wantOK || pid != tt.wantPID {
69+ t.Fatalf("selectProcessGroupPID = %d, %v; want %d, %v", pid, ok, tt.wantPID, tt.wantOK)
70+ }
71+ })
72+ }
73+}
74+
75+func TestProcessInspectorUsesConfiguredRoot(t *testing.T) {
76+ root := t.TempDir()
77+ writeProcStat(t, root, 10, 7)
78+ pidDir := filepath.Join(root, "10")
79+ cwdTarget := t.TempDir()
80+ if err := os.Symlink(cwdTarget, filepath.Join(pidDir, "cwd")); err != nil {
81+ t.Fatal(err)
82+ }
83+ if err := os.WriteFile(filepath.Join(pidDir, "comm"), []byte("custom\n"), 0o600); err != nil {
84+ t.Fatal(err)
85+ }
86+ if err := os.WriteFile(filepath.Join(pidDir, "cmdline"), []byte("custom\x00arg\x00"), 0o600); err != nil {
87+ t.Fatal(err)
88+ }
89+ ins := newProcessInspector(root)
90+
91+ cwd, err := ins.Cwd(10)
92+ if err != nil || cwd != cwdTarget {
93+ t.Fatalf("Cwd = %q, %v; want %q, nil", cwd, err, cwdTarget)
94+ }
95+ comm, err := ins.Comm(10)
96+ if err != nil || comm != "custom" {
97+ t.Fatalf("Comm = %q, %v; want custom, nil", comm, err)
98+ }
99+ argv, err := ins.Argv(10)
100+ if err != nil || !reflect.DeepEqual(argv, []string{"custom", "arg"}) {
101+ t.Fatalf("Argv = %#v, %v; want custom argv, nil", argv, err)
102+ }
103+}
104+
105+func TestProcessInspectorCachesProcessRecordsBriefly(t *testing.T) {
106+ root := t.TempDir()
107+ writeProcStat(t, root, 10, 7)
108+ ins := newProcessInspector(root)
109+
110+ first, err := ins.processRecords()
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ writeProcStat(t, root, 12, 7)
115+ second, err := ins.processRecords()
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ if len(first) != 1 || len(second) != 1 {
120+ t.Fatalf("cached processRecords lengths = %d, %d; want 1, 1", len(first), len(second))
121+ }
122+}
123+
124+func writeProcStat(t *testing.T, root string, pid int, pgrp int) {
125+ t.Helper()
126+ dir := filepath.Join(root, strconv.Itoa(pid))
127+ if err := os.MkdirAll(dir, 0o700); err != nil {
128+ t.Fatal(err)
129+ }
130+ stat := fmt.Sprintf("%d (cmd) S 1 %d 1 0 0", pid, pgrp)
131+ if err := os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o600); err != nil {
132+ t.Fatal(err)
133+ }
134+}
135+
136+func TestParseStatPgrp(t *testing.T) {
137+ got, err := parseStatPgrp("123 (cmd with spaces) S 1 456 456 0")
138+ if err != nil || got != 456 {
139+ t.Fatalf("parseStatPgrp = %d, %v; want 456, nil", got, err)
140+ }
141+}
142+
143+func TestProcessArgvFileParsing(t *testing.T) {
144+ tests := []struct {
145+ name string
146+ data []byte
147+ want []string
148+ }{
149+ {
150+ name: "splits nul-delimited argv",
151+ data: []byte("cmd\x00two words\x00"),
152+ want: []string{"cmd", "two words"},
153+ },
154+ {
155+ name: "preserves empty argument",
156+ data: []byte("cmd\x00\x00arg\x00"),
157+ want: []string{"cmd", "", "arg"},
158+ },
159+ }
160+
161+ for _, tt := range tests {
162+ t.Run(tt.name, func(t *testing.T) {
163+ path := filepath.Join(t.TempDir(), "cmdline")
164+ if err := os.WriteFile(path, tt.data, 0o600); err != nil {
165+ t.Fatal(err)
166+ }
167+ got, err := processArgv(path, 123)
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ if !reflect.DeepEqual(got, tt.want) {
172+ t.Fatalf("processArgv = %#v; want %#v", got, tt.want)
173+ }
174+ })
175+ }
176+}
M internal/ports/frame.go +11 -9
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 4
4+const ProtocolVersion uint16 = 10
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -10,14 +10,16 @@ type MsgType uint8
1010 // through 15, server-originated messages from 16, leaving room for growth in
1111 // each band.
1212 const (
13- MsgHello MsgType = 1
14- MsgInput MsgType = 2
15- MsgResize MsgType = 3
16- MsgDetach MsgType = 4
17- MsgPing MsgType = 5
18- MsgList MsgType = 6
19- MsgKill MsgType = 7
20- MsgTheme MsgType = 8
13+ MsgHello MsgType = 1
14+ MsgInput MsgType = 2
15+ MsgResize MsgType = 3
16+ MsgDetach MsgType = 4
17+ MsgPing MsgType = 5
18+ MsgList MsgType = 6
19+ MsgKill MsgType = 7
20+ MsgTheme MsgType = 8
21+ MsgAck MsgType = 9
22+ MsgImagePush MsgType = 10
2123
2224 MsgWelcome MsgType = 16
2325 MsgError MsgType = 17
M internal/ports/frame_test.go +4 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 4 {
7- t.Fatalf("ProtocolVersion = %d, want 4", ProtocolVersion)
6+ if ProtocolVersion != 10 {
7+ t.Fatalf("ProtocolVersion = %d, want 10", ProtocolVersion)
88 }
99 }
1010
@@ -21,6 +21,8 @@ func TestMsgTypeUnique(t *testing.T) {
2121 {"MsgList", MsgList},
2222 {"MsgKill", MsgKill},
2323 {"MsgTheme", MsgTheme},
24+ {"MsgAck", MsgAck},
25+ {"MsgImagePush", MsgImagePush},
2426 {"MsgWelcome", MsgWelcome},
2527 {"MsgError", MsgError},
2628 {"MsgOutput", MsgOutput},
A internal/ports/link.go +51 -0
@@ -0,0 +1,51 @@
1+package ports
2+
3+import "time"
4+
5+// LinkState reports best-effort transport health without changing Transport.
6+type LinkState int
7+
8+const (
9+ LinkStateConnected LinkState = iota
10+ LinkStateDegraded
11+ LinkStateProbing
12+ LinkStateOffline
13+ LinkStateDead
14+)
15+
16+func (s LinkState) String() string {
17+ switch s {
18+ case LinkStateConnected:
19+ return "connected"
20+ case LinkStateDegraded:
21+ return "degraded"
22+ case LinkStateProbing:
23+ return "probing"
24+ case LinkStateOffline:
25+ return "offline"
26+ case LinkStateDead:
27+ return "dead"
28+ default:
29+ return "unknown"
30+ }
31+}
32+
33+// LinkEvent is emitted when a transport's observed link state changes.
34+type LinkEvent struct {
35+ State LinkState
36+ At time.Time
37+ Err error
38+}
39+
40+// LinkStateReporter is an optional boundary-safe port implemented by transports
41+// that can report link degradation before Transport fails terminally.
42+//
43+// LinkEvents returns a best-effort event stream. Implementations must not block
44+// transport progress while publishing events; use a buffered channel and drop
45+// events when the consumer is slow. The channel is not required to close on
46+// transport shutdown, so consumers should also watch their own context or the
47+// underlying Transport result.
48+type LinkStateReporter interface {
49+ LinkState() LinkState
50+ LinkEvents() <-chan LinkEvent
51+}
M internal/ports/mocks/mocks.go +1107 -38
@@ -5,7 +5,9 @@
55 package portsmocks
66
77 import (
8+ "context"
89 "io"
10+ "log/slog"
911 "time"
1012
1113 "github.com/bnema/vev/internal/domain"
@@ -13,6 +15,123 @@ import (
1315 mock "github.com/stretchr/testify/mock"
1416 )
1517
18+// NewMockLinkStateReporter creates a new instance of MockLinkStateReporter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
19+// The first argument is typically a *testing.T value.
20+func NewMockLinkStateReporter(t interface {
21+ mock.TestingT
22+ Cleanup(func())
23+}) *MockLinkStateReporter {
24+ mock := &MockLinkStateReporter{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() { mock.AssertExpectations(t) })
28+
29+ return mock
30+}
31+
32+// MockLinkStateReporter is an autogenerated mock type for the LinkStateReporter type
33+type MockLinkStateReporter struct {
34+ mock.Mock
35+}
36+
37+type MockLinkStateReporter_Expecter struct {
38+ mock *mock.Mock
39+}
40+
41+func (_m *MockLinkStateReporter) EXPECT() *MockLinkStateReporter_Expecter {
42+ return &MockLinkStateReporter_Expecter{mock: &_m.Mock}
43+}
44+
45+// LinkEvents provides a mock function for the type MockLinkStateReporter
46+func (_mock *MockLinkStateReporter) LinkEvents() <-chan ports.LinkEvent {
47+ ret := _mock.Called()
48+
49+ if len(ret) == 0 {
50+ panic("no return value specified for LinkEvents")
51+ }
52+
53+ var r0 <-chan ports.LinkEvent
54+ if returnFunc, ok := ret.Get(0).(func() <-chan ports.LinkEvent); ok {
55+ r0 = returnFunc()
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).(<-chan ports.LinkEvent)
59+ }
60+ }
61+ return r0
62+}
63+
64+// MockLinkStateReporter_LinkEvents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinkEvents'
65+type MockLinkStateReporter_LinkEvents_Call struct {
66+ *mock.Call
67+}
68+
69+// LinkEvents is a helper method to define mock.On call
70+func (_e *MockLinkStateReporter_Expecter) LinkEvents() *MockLinkStateReporter_LinkEvents_Call {
71+ return &MockLinkStateReporter_LinkEvents_Call{Call: _e.mock.On("LinkEvents")}
72+}
73+
74+func (_c *MockLinkStateReporter_LinkEvents_Call) Run(run func()) *MockLinkStateReporter_LinkEvents_Call {
75+ _c.Call.Run(func(args mock.Arguments) {
76+ run()
77+ })
78+ return _c
79+}
80+
81+func (_c *MockLinkStateReporter_LinkEvents_Call) Return(linkEventCh <-chan ports.LinkEvent) *MockLinkStateReporter_LinkEvents_Call {
82+ _c.Call.Return(linkEventCh)
83+ return _c
84+}
85+
86+func (_c *MockLinkStateReporter_LinkEvents_Call) RunAndReturn(run func() <-chan ports.LinkEvent) *MockLinkStateReporter_LinkEvents_Call {
87+ _c.Call.Return(run)
88+ return _c
89+}
90+
91+// LinkState provides a mock function for the type MockLinkStateReporter
92+func (_mock *MockLinkStateReporter) LinkState() ports.LinkState {
93+ ret := _mock.Called()
94+
95+ if len(ret) == 0 {
96+ panic("no return value specified for LinkState")
97+ }
98+
99+ var r0 ports.LinkState
100+ if returnFunc, ok := ret.Get(0).(func() ports.LinkState); ok {
101+ r0 = returnFunc()
102+ } else {
103+ r0 = ret.Get(0).(ports.LinkState)
104+ }
105+ return r0
106+}
107+
108+// MockLinkStateReporter_LinkState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinkState'
109+type MockLinkStateReporter_LinkState_Call struct {
110+ *mock.Call
111+}
112+
113+// LinkState is a helper method to define mock.On call
114+func (_e *MockLinkStateReporter_Expecter) LinkState() *MockLinkStateReporter_LinkState_Call {
115+ return &MockLinkStateReporter_LinkState_Call{Call: _e.mock.On("LinkState")}
116+}
117+
118+func (_c *MockLinkStateReporter_LinkState_Call) Run(run func()) *MockLinkStateReporter_LinkState_Call {
119+ _c.Call.Run(func(args mock.Arguments) {
120+ run()
121+ })
122+ return _c
123+}
124+
125+func (_c *MockLinkStateReporter_LinkState_Call) Return(linkState ports.LinkState) *MockLinkStateReporter_LinkState_Call {
126+ _c.Call.Return(linkState)
127+ return _c
128+}
129+
130+func (_c *MockLinkStateReporter_LinkState_Call) RunAndReturn(run func() ports.LinkState) *MockLinkStateReporter_LinkState_Call {
131+ _c.Call.Return(run)
132+ return _c
133+}
134+
16135 // NewMockPTY creates a new instance of MockPTY. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17136 // The first argument is typically a *testing.T value.
18137 func NewMockPTY(t interface {
@@ -84,6 +203,59 @@ func (_c *MockPTY_Close_Call) RunAndReturn(run func() error) *MockPTY_Close_Call
84203 return _c
85204 }
86205
206+// ForegroundPgid provides a mock function for the type MockPTY
207+func (_mock *MockPTY) ForegroundPgid() (int, error) {
208+ ret := _mock.Called()
209+
210+ if len(ret) == 0 {
211+ panic("no return value specified for ForegroundPgid")
212+ }
213+
214+ var r0 int
215+ var r1 error
216+ if returnFunc, ok := ret.Get(0).(func() (int, error)); ok {
217+ return returnFunc()
218+ }
219+ if returnFunc, ok := ret.Get(0).(func() int); ok {
220+ r0 = returnFunc()
221+ } else {
222+ r0 = ret.Get(0).(int)
223+ }
224+ if returnFunc, ok := ret.Get(1).(func() error); ok {
225+ r1 = returnFunc()
226+ } else {
227+ r1 = ret.Error(1)
228+ }
229+ return r0, r1
230+}
231+
232+// MockPTY_ForegroundPgid_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForegroundPgid'
233+type MockPTY_ForegroundPgid_Call struct {
234+ *mock.Call
235+}
236+
237+// ForegroundPgid is a helper method to define mock.On call
238+func (_e *MockPTY_Expecter) ForegroundPgid() *MockPTY_ForegroundPgid_Call {
239+ return &MockPTY_ForegroundPgid_Call{Call: _e.mock.On("ForegroundPgid")}
240+}
241+
242+func (_c *MockPTY_ForegroundPgid_Call) Run(run func()) *MockPTY_ForegroundPgid_Call {
243+ _c.Call.Run(func(args mock.Arguments) {
244+ run()
245+ })
246+ return _c
247+}
248+
249+func (_c *MockPTY_ForegroundPgid_Call) Return(n int, err error) *MockPTY_ForegroundPgid_Call {
250+ _c.Call.Return(n, err)
251+ return _c
252+}
253+
254+func (_c *MockPTY_ForegroundPgid_Call) RunAndReturn(run func() (int, error)) *MockPTY_ForegroundPgid_Call {
255+ _c.Call.Return(run)
256+ return _c
257+}
258+
87259 // Pid provides a mock function for the type MockPTY
88260 func (_mock *MockPTY) Pid() int {
89261 ret := _mock.Called()
@@ -630,6 +802,50 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina
630802 return _c
631803 }
632804
805+// QueryColors provides a mock function for the type MockTerminal
806+func (_mock *MockTerminal) QueryColors() error {
807+ ret := _mock.Called()
808+
809+ if len(ret) == 0 {
810+ panic("no return value specified for QueryColors")
811+ }
812+
813+ var r0 error
814+ if returnFunc, ok := ret.Get(0).(func() error); ok {
815+ r0 = returnFunc()
816+ } else {
817+ r0 = ret.Error(0)
818+ }
819+ return r0
820+}
821+
822+// MockTerminal_QueryColors_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryColors'
823+type MockTerminal_QueryColors_Call struct {
824+ *mock.Call
825+}
826+
827+// QueryColors is a helper method to define mock.On call
828+func (_e *MockTerminal_Expecter) QueryColors() *MockTerminal_QueryColors_Call {
829+ return &MockTerminal_QueryColors_Call{Call: _e.mock.On("QueryColors")}
830+}
831+
832+func (_c *MockTerminal_QueryColors_Call) Run(run func()) *MockTerminal_QueryColors_Call {
833+ _c.Call.Run(func(args mock.Arguments) {
834+ run()
835+ })
836+ return _c
837+}
838+
839+func (_c *MockTerminal_QueryColors_Call) Return(err error) *MockTerminal_QueryColors_Call {
840+ _c.Call.Return(err)
841+ return _c
842+}
843+
844+func (_c *MockTerminal_QueryColors_Call) RunAndReturn(run func() error) *MockTerminal_QueryColors_Call {
845+ _c.Call.Return(run)
846+ return _c
847+}
848+
633849 // ResizeEvents provides a mock function for the type MockTerminal
634850 func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size {
635851 ret := _mock.Called()
@@ -904,6 +1120,95 @@ func (_c *MockTransport_Send_Call) RunAndReturn(run func(frame ports.Frame) erro
9041120 return _c
9051121 }
9061122
1123+// NewMockDialer creates a new instance of MockDialer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1124+// The first argument is typically a *testing.T value.
1125+func NewMockDialer(t interface {
1126+ mock.TestingT
1127+ Cleanup(func())
1128+}) *MockDialer {
1129+ mock := &MockDialer{}
1130+ mock.Mock.Test(t)
1131+
1132+ t.Cleanup(func() { mock.AssertExpectations(t) })
1133+
1134+ return mock
1135+}
1136+
1137+// MockDialer is an autogenerated mock type for the Dialer type
1138+type MockDialer struct {
1139+ mock.Mock
1140+}
1141+
1142+type MockDialer_Expecter struct {
1143+ mock *mock.Mock
1144+}
1145+
1146+func (_m *MockDialer) EXPECT() *MockDialer_Expecter {
1147+ return &MockDialer_Expecter{mock: &_m.Mock}
1148+}
1149+
1150+// Dial provides a mock function for the type MockDialer
1151+func (_mock *MockDialer) Dial(ctx context.Context) (ports.Transport, error) {
1152+ ret := _mock.Called(ctx)
1153+
1154+ if len(ret) == 0 {
1155+ panic("no return value specified for Dial")
1156+ }
1157+
1158+ var r0 ports.Transport
1159+ var r1 error
1160+ if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.Transport, error)); ok {
1161+ return returnFunc(ctx)
1162+ }
1163+ if returnFunc, ok := ret.Get(0).(func(context.Context) ports.Transport); ok {
1164+ r0 = returnFunc(ctx)
1165+ } else {
1166+ if ret.Get(0) != nil {
1167+ r0 = ret.Get(0).(ports.Transport)
1168+ }
1169+ }
1170+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1171+ r1 = returnFunc(ctx)
1172+ } else {
1173+ r1 = ret.Error(1)
1174+ }
1175+ return r0, r1
1176+}
1177+
1178+// MockDialer_Dial_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Dial'
1179+type MockDialer_Dial_Call struct {
1180+ *mock.Call
1181+}
1182+
1183+// Dial is a helper method to define mock.On call
1184+// - ctx context.Context
1185+func (_e *MockDialer_Expecter) Dial(ctx any) *MockDialer_Dial_Call {
1186+ return &MockDialer_Dial_Call{Call: _e.mock.On("Dial", ctx)}
1187+}
1188+
1189+func (_c *MockDialer_Dial_Call) Run(run func(ctx context.Context)) *MockDialer_Dial_Call {
1190+ _c.Call.Run(func(args mock.Arguments) {
1191+ var arg0 context.Context
1192+ if args[0] != nil {
1193+ arg0 = args[0].(context.Context)
1194+ }
1195+ run(
1196+ arg0,
1197+ )
1198+ })
1199+ return _c
1200+}
1201+
1202+func (_c *MockDialer_Dial_Call) Return(transport ports.Transport, err error) *MockDialer_Dial_Call {
1203+ _c.Call.Return(transport, err)
1204+ return _c
1205+}
1206+
1207+func (_c *MockDialer_Dial_Call) RunAndReturn(run func(ctx context.Context) (ports.Transport, error)) *MockDialer_Dial_Call {
1208+ _c.Call.Return(run)
1209+ return _c
1210+}
1211+
9071212 // NewMockListener creates a new instance of MockListener. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
9081213 // The first argument is typically a *testing.T value.
9091214 func NewMockListener(t interface {
@@ -1399,13 +1704,13 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca
13991704 return _c
14001705 }
14011706
1402-// NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1707+// NewMockSnapshotStore creates a new instance of MockSnapshotStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14031708 // The first argument is typically a *testing.T value.
1404-func NewMockClock(t interface {
1709+func NewMockSnapshotStore(t interface {
14051710 mock.TestingT
14061711 Cleanup(func())
1407-}) *MockClock {
1408- mock := &MockClock{}
1712+}) *MockSnapshotStore {
1713+ mock := &MockSnapshotStore{}
14091714 mock.Mock.Test(t)
14101715
14111716 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -1413,54 +1718,52 @@ func NewMockClock(t interface {
14131718 return mock
14141719 }
14151720
1416-// MockClock is an autogenerated mock type for the Clock type
1417-type MockClock struct {
1721+// MockSnapshotStore is an autogenerated mock type for the SnapshotStore type
1722+type MockSnapshotStore struct {
14181723 mock.Mock
14191724 }
14201725
1421-type MockClock_Expecter struct {
1726+type MockSnapshotStore_Expecter struct {
14221727 mock *mock.Mock
14231728 }
14241729
1425-func (_m *MockClock) EXPECT() *MockClock_Expecter {
1426- return &MockClock_Expecter{mock: &_m.Mock}
1730+func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter {
1731+ return &MockSnapshotStore_Expecter{mock: &_m.Mock}
14271732 }
14281733
1429-// NewTimer provides a mock function for the type MockClock
1430-func (_mock *MockClock) NewTimer(d time.Duration) ports.Timer {
1431- ret := _mock.Called(d)
1734+// Delete provides a mock function for the type MockSnapshotStore
1735+func (_mock *MockSnapshotStore) Delete(name string) error {
1736+ ret := _mock.Called(name)
14321737
14331738 if len(ret) == 0 {
1434- panic("no return value specified for NewTimer")
1739+ panic("no return value specified for Delete")
14351740 }
14361741
1437- var r0 ports.Timer
1438- if returnFunc, ok := ret.Get(0).(func(time.Duration) ports.Timer); ok {
1439- r0 = returnFunc(d)
1742+ var r0 error
1743+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
1744+ r0 = returnFunc(name)
14401745 } else {
1441- if ret.Get(0) != nil {
1442- r0 = ret.Get(0).(ports.Timer)
1443- }
1746+ r0 = ret.Error(0)
14441747 }
14451748 return r0
14461749 }
14471750
1448-// MockClock_NewTimer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewTimer'
1449-type MockClock_NewTimer_Call struct {
1751+// MockSnapshotStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
1752+type MockSnapshotStore_Delete_Call struct {
14501753 *mock.Call
14511754 }
14521755
1453-// NewTimer is a helper method to define mock.On call
1454-// - d time.Duration
1455-func (_e *MockClock_Expecter) NewTimer(d any) *MockClock_NewTimer_Call {
1456- return &MockClock_NewTimer_Call{Call: _e.mock.On("NewTimer", d)}
1756+// Delete is a helper method to define mock.On call
1757+// - name string
1758+func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call {
1759+ return &MockSnapshotStore_Delete_Call{Call: _e.mock.On("Delete", name)}
14571760 }
14581761
1459-func (_c *MockClock_NewTimer_Call) Run(run func(d time.Duration)) *MockClock_NewTimer_Call {
1762+func (_c *MockSnapshotStore_Delete_Call) Run(run func(name string)) *MockSnapshotStore_Delete_Call {
14601763 _c.Call.Run(func(args mock.Arguments) {
1461- var arg0 time.Duration
1764+ var arg0 string
14621765 if args[0] != nil {
1463- arg0 = args[0].(time.Duration)
1766+ arg0 = args[0].(string)
14641767 }
14651768 run(
14661769 arg0,
@@ -1469,28 +1772,220 @@ func (_c *MockClock_NewTimer_Call) Run(run func(d time.Duration)) *MockClock_New
14691772 return _c
14701773 }
14711774
1472-func (_c *MockClock_NewTimer_Call) Return(timer ports.Timer) *MockClock_NewTimer_Call {
1473- _c.Call.Return(timer)
1775+func (_c *MockSnapshotStore_Delete_Call) Return(err error) *MockSnapshotStore_Delete_Call {
1776+ _c.Call.Return(err)
14741777 return _c
14751778 }
14761779
1477-func (_c *MockClock_NewTimer_Call) RunAndReturn(run func(d time.Duration) ports.Timer) *MockClock_NewTimer_Call {
1780+func (_c *MockSnapshotStore_Delete_Call) RunAndReturn(run func(name string) error) *MockSnapshotStore_Delete_Call {
14781781 _c.Call.Return(run)
14791782 return _c
14801783 }
14811784
1482-// Now provides a mock function for the type MockClock
1483-func (_mock *MockClock) Now() time.Time {
1785+// Load provides a mock function for the type MockSnapshotStore
1786+func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
14841787 ret := _mock.Called()
14851788
14861789 if len(ret) == 0 {
1487- panic("no return value specified for Now")
1790+ panic("no return value specified for Load")
14881791 }
14891792
1490- var r0 time.Time
1491- if returnFunc, ok := ret.Get(0).(func() time.Time); ok {
1492- r0 = returnFunc()
1493- } else {
1793+ var r0 []ports.SnapshotBlob
1794+ var r1 error
1795+ if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok {
1796+ return returnFunc()
1797+ }
1798+ if returnFunc, ok := ret.Get(0).(func() []ports.SnapshotBlob); ok {
1799+ r0 = returnFunc()
1800+ } else {
1801+ if ret.Get(0) != nil {
1802+ r0 = ret.Get(0).([]ports.SnapshotBlob)
1803+ }
1804+ }
1805+ if returnFunc, ok := ret.Get(1).(func() error); ok {
1806+ r1 = returnFunc()
1807+ } else {
1808+ r1 = ret.Error(1)
1809+ }
1810+ return r0, r1
1811+}
1812+
1813+// MockSnapshotStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'
1814+type MockSnapshotStore_Load_Call struct {
1815+ *mock.Call
1816+}
1817+
1818+// Load is a helper method to define mock.On call
1819+func (_e *MockSnapshotStore_Expecter) Load() *MockSnapshotStore_Load_Call {
1820+ return &MockSnapshotStore_Load_Call{Call: _e.mock.On("Load")}
1821+}
1822+
1823+func (_c *MockSnapshotStore_Load_Call) Run(run func()) *MockSnapshotStore_Load_Call {
1824+ _c.Call.Run(func(args mock.Arguments) {
1825+ run()
1826+ })
1827+ return _c
1828+}
1829+
1830+func (_c *MockSnapshotStore_Load_Call) Return(snapshotBlobs []ports.SnapshotBlob, err error) *MockSnapshotStore_Load_Call {
1831+ _c.Call.Return(snapshotBlobs, err)
1832+ return _c
1833+}
1834+
1835+func (_c *MockSnapshotStore_Load_Call) RunAndReturn(run func() ([]ports.SnapshotBlob, error)) *MockSnapshotStore_Load_Call {
1836+ _c.Call.Return(run)
1837+ return _c
1838+}
1839+
1840+// Write provides a mock function for the type MockSnapshotStore
1841+func (_mock *MockSnapshotStore) Write(name string, data []byte) error {
1842+ ret := _mock.Called(name, data)
1843+
1844+ if len(ret) == 0 {
1845+ panic("no return value specified for Write")
1846+ }
1847+
1848+ var r0 error
1849+ if returnFunc, ok := ret.Get(0).(func(string, []byte) error); ok {
1850+ r0 = returnFunc(name, data)
1851+ } else {
1852+ r0 = ret.Error(0)
1853+ }
1854+ return r0
1855+}
1856+
1857+// MockSnapshotStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
1858+type MockSnapshotStore_Write_Call struct {
1859+ *mock.Call
1860+}
1861+
1862+// Write is a helper method to define mock.On call
1863+// - name string
1864+// - data []byte
1865+func (_e *MockSnapshotStore_Expecter) Write(name any, data any) *MockSnapshotStore_Write_Call {
1866+ return &MockSnapshotStore_Write_Call{Call: _e.mock.On("Write", name, data)}
1867+}
1868+
1869+func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte)) *MockSnapshotStore_Write_Call {
1870+ _c.Call.Run(func(args mock.Arguments) {
1871+ var arg0 string
1872+ if args[0] != nil {
1873+ arg0 = args[0].(string)
1874+ }
1875+ var arg1 []byte
1876+ if args[1] != nil {
1877+ arg1 = args[1].([]byte)
1878+ }
1879+ run(
1880+ arg0,
1881+ arg1,
1882+ )
1883+ })
1884+ return _c
1885+}
1886+
1887+func (_c *MockSnapshotStore_Write_Call) Return(err error) *MockSnapshotStore_Write_Call {
1888+ _c.Call.Return(err)
1889+ return _c
1890+}
1891+
1892+func (_c *MockSnapshotStore_Write_Call) RunAndReturn(run func(name string, data []byte) error) *MockSnapshotStore_Write_Call {
1893+ _c.Call.Return(run)
1894+ return _c
1895+}
1896+
1897+// NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1898+// The first argument is typically a *testing.T value.
1899+func NewMockClock(t interface {
1900+ mock.TestingT
1901+ Cleanup(func())
1902+}) *MockClock {
1903+ mock := &MockClock{}
1904+ mock.Mock.Test(t)
1905+
1906+ t.Cleanup(func() { mock.AssertExpectations(t) })
1907+
1908+ return mock
1909+}
1910+
1911+// MockClock is an autogenerated mock type for the Clock type
1912+type MockClock struct {
1913+ mock.Mock
1914+}
1915+
1916+type MockClock_Expecter struct {
1917+ mock *mock.Mock
1918+}
1919+
1920+func (_m *MockClock) EXPECT() *MockClock_Expecter {
1921+ return &MockClock_Expecter{mock: &_m.Mock}
1922+}
1923+
1924+// NewTimer provides a mock function for the type MockClock
1925+func (_mock *MockClock) NewTimer(d time.Duration) ports.Timer {
1926+ ret := _mock.Called(d)
1927+
1928+ if len(ret) == 0 {
1929+ panic("no return value specified for NewTimer")
1930+ }
1931+
1932+ var r0 ports.Timer
1933+ if returnFunc, ok := ret.Get(0).(func(time.Duration) ports.Timer); ok {
1934+ r0 = returnFunc(d)
1935+ } else {
1936+ if ret.Get(0) != nil {
1937+ r0 = ret.Get(0).(ports.Timer)
1938+ }
1939+ }
1940+ return r0
1941+}
1942+
1943+// MockClock_NewTimer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewTimer'
1944+type MockClock_NewTimer_Call struct {
1945+ *mock.Call
1946+}
1947+
1948+// NewTimer is a helper method to define mock.On call
1949+// - d time.Duration
1950+func (_e *MockClock_Expecter) NewTimer(d any) *MockClock_NewTimer_Call {
1951+ return &MockClock_NewTimer_Call{Call: _e.mock.On("NewTimer", d)}
1952+}
1953+
1954+func (_c *MockClock_NewTimer_Call) Run(run func(d time.Duration)) *MockClock_NewTimer_Call {
1955+ _c.Call.Run(func(args mock.Arguments) {
1956+ var arg0 time.Duration
1957+ if args[0] != nil {
1958+ arg0 = args[0].(time.Duration)
1959+ }
1960+ run(
1961+ arg0,
1962+ )
1963+ })
1964+ return _c
1965+}
1966+
1967+func (_c *MockClock_NewTimer_Call) Return(timer ports.Timer) *MockClock_NewTimer_Call {
1968+ _c.Call.Return(timer)
1969+ return _c
1970+}
1971+
1972+func (_c *MockClock_NewTimer_Call) RunAndReturn(run func(d time.Duration) ports.Timer) *MockClock_NewTimer_Call {
1973+ _c.Call.Return(run)
1974+ return _c
1975+}
1976+
1977+// Now provides a mock function for the type MockClock
1978+func (_mock *MockClock) Now() time.Time {
1979+ ret := _mock.Called()
1980+
1981+ if len(ret) == 0 {
1982+ panic("no return value specified for Now")
1983+ }
1984+
1985+ var r0 time.Time
1986+ if returnFunc, ok := ret.Get(0).(func() time.Time); ok {
1987+ r0 = returnFunc()
1988+ } else {
14941989 r0 = ret.Get(0).(time.Time)
14951990 }
14961991 return r0
@@ -1690,3 +2185,577 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal
16902185 _c.Call.Return(run)
16912186 return _c
16922187 }
2188+
2189+// NewMockShellCommandRunner creates a new instance of MockShellCommandRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2190+// The first argument is typically a *testing.T value.
2191+func NewMockShellCommandRunner(t interface {
2192+ mock.TestingT
2193+ Cleanup(func())
2194+}) *MockShellCommandRunner {
2195+ mock := &MockShellCommandRunner{}
2196+ mock.Mock.Test(t)
2197+
2198+ t.Cleanup(func() { mock.AssertExpectations(t) })
2199+
2200+ return mock
2201+}
2202+
2203+// MockShellCommandRunner is an autogenerated mock type for the ShellCommandRunner type
2204+type MockShellCommandRunner struct {
2205+ mock.Mock
2206+}
2207+
2208+type MockShellCommandRunner_Expecter struct {
2209+ mock *mock.Mock
2210+}
2211+
2212+func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter {
2213+ return &MockShellCommandRunner_Expecter{mock: &_m.Mock}
2214+}
2215+
2216+// Run provides a mock function for the type MockShellCommandRunner
2217+func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
2218+ ret := _mock.Called(ctx, spec)
2219+
2220+ if len(ret) == 0 {
2221+ panic("no return value specified for Run")
2222+ }
2223+
2224+ var r0 []byte
2225+ var r1 error
2226+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok {
2227+ return returnFunc(ctx, spec)
2228+ }
2229+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok {
2230+ r0 = returnFunc(ctx, spec)
2231+ } else {
2232+ if ret.Get(0) != nil {
2233+ r0 = ret.Get(0).([]byte)
2234+ }
2235+ }
2236+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {
2237+ r1 = returnFunc(ctx, spec)
2238+ } else {
2239+ r1 = ret.Error(1)
2240+ }
2241+ return r0, r1
2242+}
2243+
2244+// MockShellCommandRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run'
2245+type MockShellCommandRunner_Run_Call struct {
2246+ *mock.Call
2247+}
2248+
2249+// Run is a helper method to define mock.On call
2250+// - ctx context.Context
2251+// - spec ports.CommandSpec
2252+func (_e *MockShellCommandRunner_Expecter) Run(ctx any, spec any) *MockShellCommandRunner_Run_Call {
2253+ return &MockShellCommandRunner_Run_Call{Call: _e.mock.On("Run", ctx, spec)}
2254+}
2255+
2256+func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spec ports.CommandSpec)) *MockShellCommandRunner_Run_Call {
2257+ _c.Call.Run(func(args mock.Arguments) {
2258+ var arg0 context.Context
2259+ if args[0] != nil {
2260+ arg0 = args[0].(context.Context)
2261+ }
2262+ var arg1 ports.CommandSpec
2263+ if args[1] != nil {
2264+ arg1 = args[1].(ports.CommandSpec)
2265+ }
2266+ run(
2267+ arg0,
2268+ arg1,
2269+ )
2270+ })
2271+ return _c
2272+}
2273+
2274+func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call {
2275+ _c.Call.Return(bytes, err)
2276+ return _c
2277+}
2278+
2279+func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call {
2280+ _c.Call.Return(run)
2281+ return _c
2282+}
2283+
2284+// NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2285+// The first argument is typically a *testing.T value.
2286+func NewMockProcessInspector(t interface {
2287+ mock.TestingT
2288+ Cleanup(func())
2289+}) *MockProcessInspector {
2290+ mock := &MockProcessInspector{}
2291+ mock.Mock.Test(t)
2292+
2293+ t.Cleanup(func() { mock.AssertExpectations(t) })
2294+
2295+ return mock
2296+}
2297+
2298+// MockProcessInspector is an autogenerated mock type for the ProcessInspector type
2299+type MockProcessInspector struct {
2300+ mock.Mock
2301+}
2302+
2303+type MockProcessInspector_Expecter struct {
2304+ mock *mock.Mock
2305+}
2306+
2307+func (_m *MockProcessInspector) EXPECT() *MockProcessInspector_Expecter {
2308+ return &MockProcessInspector_Expecter{mock: &_m.Mock}
2309+}
2310+
2311+// Argv provides a mock function for the type MockProcessInspector
2312+func (_mock *MockProcessInspector) Argv(pid int) ([]string, error) {
2313+ ret := _mock.Called(pid)
2314+
2315+ if len(ret) == 0 {
2316+ panic("no return value specified for Argv")
2317+ }
2318+
2319+ var r0 []string
2320+ var r1 error
2321+ if returnFunc, ok := ret.Get(0).(func(int) ([]string, error)); ok {
2322+ return returnFunc(pid)
2323+ }
2324+ if returnFunc, ok := ret.Get(0).(func(int) []string); ok {
2325+ r0 = returnFunc(pid)
2326+ } else {
2327+ if ret.Get(0) != nil {
2328+ r0 = ret.Get(0).([]string)
2329+ }
2330+ }
2331+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
2332+ r1 = returnFunc(pid)
2333+ } else {
2334+ r1 = ret.Error(1)
2335+ }
2336+ return r0, r1
2337+}
2338+
2339+// MockProcessInspector_Argv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Argv'
2340+type MockProcessInspector_Argv_Call struct {
2341+ *mock.Call
2342+}
2343+
2344+// Argv is a helper method to define mock.On call
2345+// - pid int
2346+func (_e *MockProcessInspector_Expecter) Argv(pid any) *MockProcessInspector_Argv_Call {
2347+ return &MockProcessInspector_Argv_Call{Call: _e.mock.On("Argv", pid)}
2348+}
2349+
2350+func (_c *MockProcessInspector_Argv_Call) Run(run func(pid int)) *MockProcessInspector_Argv_Call {
2351+ _c.Call.Run(func(args mock.Arguments) {
2352+ var arg0 int
2353+ if args[0] != nil {
2354+ arg0 = args[0].(int)
2355+ }
2356+ run(
2357+ arg0,
2358+ )
2359+ })
2360+ return _c
2361+}
2362+
2363+func (_c *MockProcessInspector_Argv_Call) Return(strings []string, err error) *MockProcessInspector_Argv_Call {
2364+ _c.Call.Return(strings, err)
2365+ return _c
2366+}
2367+
2368+func (_c *MockProcessInspector_Argv_Call) RunAndReturn(run func(pid int) ([]string, error)) *MockProcessInspector_Argv_Call {
2369+ _c.Call.Return(run)
2370+ return _c
2371+}
2372+
2373+// Comm provides a mock function for the type MockProcessInspector
2374+func (_mock *MockProcessInspector) Comm(pid int) (string, error) {
2375+ ret := _mock.Called(pid)
2376+
2377+ if len(ret) == 0 {
2378+ panic("no return value specified for Comm")
2379+ }
2380+
2381+ var r0 string
2382+ var r1 error
2383+ if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok {
2384+ return returnFunc(pid)
2385+ }
2386+ if returnFunc, ok := ret.Get(0).(func(int) string); ok {
2387+ r0 = returnFunc(pid)
2388+ } else {
2389+ r0 = ret.Get(0).(string)
2390+ }
2391+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
2392+ r1 = returnFunc(pid)
2393+ } else {
2394+ r1 = ret.Error(1)
2395+ }
2396+ return r0, r1
2397+}
2398+
2399+// MockProcessInspector_Comm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Comm'
2400+type MockProcessInspector_Comm_Call struct {
2401+ *mock.Call
2402+}
2403+
2404+// Comm is a helper method to define mock.On call
2405+// - pid int
2406+func (_e *MockProcessInspector_Expecter) Comm(pid any) *MockProcessInspector_Comm_Call {
2407+ return &MockProcessInspector_Comm_Call{Call: _e.mock.On("Comm", pid)}
2408+}
2409+
2410+func (_c *MockProcessInspector_Comm_Call) Run(run func(pid int)) *MockProcessInspector_Comm_Call {
2411+ _c.Call.Run(func(args mock.Arguments) {
2412+ var arg0 int
2413+ if args[0] != nil {
2414+ arg0 = args[0].(int)
2415+ }
2416+ run(
2417+ arg0,
2418+ )
2419+ })
2420+ return _c
2421+}
2422+
2423+func (_c *MockProcessInspector_Comm_Call) Return(s string, err error) *MockProcessInspector_Comm_Call {
2424+ _c.Call.Return(s, err)
2425+ return _c
2426+}
2427+
2428+func (_c *MockProcessInspector_Comm_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Comm_Call {
2429+ _c.Call.Return(run)
2430+ return _c
2431+}
2432+
2433+// Cwd provides a mock function for the type MockProcessInspector
2434+func (_mock *MockProcessInspector) Cwd(pid int) (string, error) {
2435+ ret := _mock.Called(pid)
2436+
2437+ if len(ret) == 0 {
2438+ panic("no return value specified for Cwd")
2439+ }
2440+
2441+ var r0 string
2442+ var r1 error
2443+ if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok {
2444+ return returnFunc(pid)
2445+ }
2446+ if returnFunc, ok := ret.Get(0).(func(int) string); ok {
2447+ r0 = returnFunc(pid)
2448+ } else {
2449+ r0 = ret.Get(0).(string)
2450+ }
2451+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
2452+ r1 = returnFunc(pid)
2453+ } else {
2454+ r1 = ret.Error(1)
2455+ }
2456+ return r0, r1
2457+}
2458+
2459+// MockProcessInspector_Cwd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Cwd'
2460+type MockProcessInspector_Cwd_Call struct {
2461+ *mock.Call
2462+}
2463+
2464+// Cwd is a helper method to define mock.On call
2465+// - pid int
2466+func (_e *MockProcessInspector_Expecter) Cwd(pid any) *MockProcessInspector_Cwd_Call {
2467+ return &MockProcessInspector_Cwd_Call{Call: _e.mock.On("Cwd", pid)}
2468+}
2469+
2470+func (_c *MockProcessInspector_Cwd_Call) Run(run func(pid int)) *MockProcessInspector_Cwd_Call {
2471+ _c.Call.Run(func(args mock.Arguments) {
2472+ var arg0 int
2473+ if args[0] != nil {
2474+ arg0 = args[0].(int)
2475+ }
2476+ run(
2477+ arg0,
2478+ )
2479+ })
2480+ return _c
2481+}
2482+
2483+func (_c *MockProcessInspector_Cwd_Call) Return(s string, err error) *MockProcessInspector_Cwd_Call {
2484+ _c.Call.Return(s, err)
2485+ return _c
2486+}
2487+
2488+func (_c *MockProcessInspector_Cwd_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Cwd_Call {
2489+ _c.Call.Return(run)
2490+ return _c
2491+}
2492+
2493+// GroupArgv provides a mock function for the type MockProcessInspector
2494+func (_mock *MockProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
2495+ ret := _mock.Called(pgid, shellPid)
2496+
2497+ if len(ret) == 0 {
2498+ panic("no return value specified for GroupArgv")
2499+ }
2500+
2501+ var r0 []string
2502+ var r1 error
2503+ if returnFunc, ok := ret.Get(0).(func(int, int) ([]string, error)); ok {
2504+ return returnFunc(pgid, shellPid)
2505+ }
2506+ if returnFunc, ok := ret.Get(0).(func(int, int) []string); ok {
2507+ r0 = returnFunc(pgid, shellPid)
2508+ } else {
2509+ if ret.Get(0) != nil {
2510+ r0 = ret.Get(0).([]string)
2511+ }
2512+ }
2513+ if returnFunc, ok := ret.Get(1).(func(int, int) error); ok {
2514+ r1 = returnFunc(pgid, shellPid)
2515+ } else {
2516+ r1 = ret.Error(1)
2517+ }
2518+ return r0, r1
2519+}
2520+
2521+// MockProcessInspector_GroupArgv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GroupArgv'
2522+type MockProcessInspector_GroupArgv_Call struct {
2523+ *mock.Call
2524+}
2525+
2526+// GroupArgv is a helper method to define mock.On call
2527+// - pgid int
2528+// - shellPid int
2529+func (_e *MockProcessInspector_Expecter) GroupArgv(pgid any, shellPid any) *MockProcessInspector_GroupArgv_Call {
2530+ return &MockProcessInspector_GroupArgv_Call{Call: _e.mock.On("GroupArgv", pgid, shellPid)}
2531+}
2532+
2533+func (_c *MockProcessInspector_GroupArgv_Call) Run(run func(pgid int, shellPid int)) *MockProcessInspector_GroupArgv_Call {
2534+ _c.Call.Run(func(args mock.Arguments) {
2535+ var arg0 int
2536+ if args[0] != nil {
2537+ arg0 = args[0].(int)
2538+ }
2539+ var arg1 int
2540+ if args[1] != nil {
2541+ arg1 = args[1].(int)
2542+ }
2543+ run(
2544+ arg0,
2545+ arg1,
2546+ )
2547+ })
2548+ return _c
2549+}
2550+
2551+func (_c *MockProcessInspector_GroupArgv_Call) Return(strings []string, err error) *MockProcessInspector_GroupArgv_Call {
2552+ _c.Call.Return(strings, err)
2553+ return _c
2554+}
2555+
2556+func (_c *MockProcessInspector_GroupArgv_Call) RunAndReturn(run func(pgid int, shellPid int) ([]string, error)) *MockProcessInspector_GroupArgv_Call {
2557+ _c.Call.Return(run)
2558+ return _c
2559+}
2560+
2561+// NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2562+// The first argument is typically a *testing.T value.
2563+func NewMockClipboardReader(t interface {
2564+ mock.TestingT
2565+ Cleanup(func())
2566+}) *MockClipboardReader {
2567+ mock := &MockClipboardReader{}
2568+ mock.Mock.Test(t)
2569+
2570+ t.Cleanup(func() { mock.AssertExpectations(t) })
2571+
2572+ return mock
2573+}
2574+
2575+// MockClipboardReader is an autogenerated mock type for the ClipboardReader type
2576+type MockClipboardReader struct {
2577+ mock.Mock
2578+}
2579+
2580+type MockClipboardReader_Expecter struct {
2581+ mock *mock.Mock
2582+}
2583+
2584+func (_m *MockClipboardReader) EXPECT() *MockClipboardReader_Expecter {
2585+ return &MockClipboardReader_Expecter{mock: &_m.Mock}
2586+}
2587+
2588+// ReadImage provides a mock function for the type MockClipboardReader
2589+func (_mock *MockClipboardReader) ReadImage(ctx context.Context) (string, []byte, error) {
2590+ ret := _mock.Called(ctx)
2591+
2592+ if len(ret) == 0 {
2593+ panic("no return value specified for ReadImage")
2594+ }
2595+
2596+ var r0 string
2597+ var r1 []byte
2598+ var r2 error
2599+ if returnFunc, ok := ret.Get(0).(func(context.Context) (string, []byte, error)); ok {
2600+ return returnFunc(ctx)
2601+ }
2602+ if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok {
2603+ r0 = returnFunc(ctx)
2604+ } else {
2605+ r0 = ret.Get(0).(string)
2606+ }
2607+ if returnFunc, ok := ret.Get(1).(func(context.Context) []byte); ok {
2608+ r1 = returnFunc(ctx)
2609+ } else {
2610+ if ret.Get(1) != nil {
2611+ r1 = ret.Get(1).([]byte)
2612+ }
2613+ }
2614+ if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok {
2615+ r2 = returnFunc(ctx)
2616+ } else {
2617+ r2 = ret.Error(2)
2618+ }
2619+ return r0, r1, r2
2620+}
2621+
2622+// MockClipboardReader_ReadImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadImage'
2623+type MockClipboardReader_ReadImage_Call struct {
2624+ *mock.Call
2625+}
2626+
2627+// ReadImage is a helper method to define mock.On call
2628+// - ctx context.Context
2629+func (_e *MockClipboardReader_Expecter) ReadImage(ctx any) *MockClipboardReader_ReadImage_Call {
2630+ return &MockClipboardReader_ReadImage_Call{Call: _e.mock.On("ReadImage", ctx)}
2631+}
2632+
2633+func (_c *MockClipboardReader_ReadImage_Call) Run(run func(ctx context.Context)) *MockClipboardReader_ReadImage_Call {
2634+ _c.Call.Run(func(args mock.Arguments) {
2635+ var arg0 context.Context
2636+ if args[0] != nil {
2637+ arg0 = args[0].(context.Context)
2638+ }
2639+ run(
2640+ arg0,
2641+ )
2642+ })
2643+ return _c
2644+}
2645+
2646+func (_c *MockClipboardReader_ReadImage_Call) Return(mime string, data []byte, err error) *MockClipboardReader_ReadImage_Call {
2647+ _c.Call.Return(mime, data, err)
2648+ return _c
2649+}
2650+
2651+func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context.Context) (string, []byte, error)) *MockClipboardReader_ReadImage_Call {
2652+ _c.Call.Return(run)
2653+ return _c
2654+}
2655+
2656+// NewMockRemoteDialerFactory creates a new instance of MockRemoteDialerFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2657+// The first argument is typically a *testing.T value.
2658+func NewMockRemoteDialerFactory(t interface {
2659+ mock.TestingT
2660+ Cleanup(func())
2661+}) *MockRemoteDialerFactory {
2662+ mock := &MockRemoteDialerFactory{}
2663+ mock.Mock.Test(t)
2664+
2665+ t.Cleanup(func() { mock.AssertExpectations(t) })
2666+
2667+ return mock
2668+}
2669+
2670+// MockRemoteDialerFactory is an autogenerated mock type for the RemoteDialerFactory type
2671+type MockRemoteDialerFactory struct {
2672+ mock.Mock
2673+}
2674+
2675+type MockRemoteDialerFactory_Expecter struct {
2676+ mock *mock.Mock
2677+}
2678+
2679+func (_m *MockRemoteDialerFactory) EXPECT() *MockRemoteDialerFactory_Expecter {
2680+ return &MockRemoteDialerFactory_Expecter{mock: &_m.Mock}
2681+}
2682+
2683+// DialerForRemote provides a mock function for the type MockRemoteDialerFactory
2684+func (_mock *MockRemoteDialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
2685+ ret := _mock.Called(target, session, mode, log)
2686+
2687+ if len(ret) == 0 {
2688+ panic("no return value specified for DialerForRemote")
2689+ }
2690+
2691+ var r0 ports.Dialer
2692+ var r1 error
2693+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error)); ok {
2694+ return returnFunc(target, session, mode, log)
2695+ }
2696+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) ports.Dialer); ok {
2697+ r0 = returnFunc(target, session, mode, log)
2698+ } else {
2699+ if ret.Get(0) != nil {
2700+ r0 = ret.Get(0).(ports.Dialer)
2701+ }
2702+ }
2703+ if returnFunc, ok := ret.Get(1).(func(string, string, ports.RemoteTransportMode, *slog.Logger) error); ok {
2704+ r1 = returnFunc(target, session, mode, log)
2705+ } else {
2706+ r1 = ret.Error(1)
2707+ }
2708+ return r0, r1
2709+}
2710+
2711+// MockRemoteDialerFactory_DialerForRemote_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DialerForRemote'
2712+type MockRemoteDialerFactory_DialerForRemote_Call struct {
2713+ *mock.Call
2714+}
2715+
2716+// DialerForRemote is a helper method to define mock.On call
2717+// - target string
2718+// - session string
2719+// - mode ports.RemoteTransportMode
2720+// - log *slog.Logger
2721+func (_e *MockRemoteDialerFactory_Expecter) DialerForRemote(target any, session any, mode any, log any) *MockRemoteDialerFactory_DialerForRemote_Call {
2722+ return &MockRemoteDialerFactory_DialerForRemote_Call{Call: _e.mock.On("DialerForRemote", target, session, mode, log)}
2723+}
2724+
2725+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Run(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger)) *MockRemoteDialerFactory_DialerForRemote_Call {
2726+ _c.Call.Run(func(args mock.Arguments) {
2727+ var arg0 string
2728+ if args[0] != nil {
2729+ arg0 = args[0].(string)
2730+ }
2731+ var arg1 string
2732+ if args[1] != nil {
2733+ arg1 = args[1].(string)
2734+ }
2735+ var arg2 ports.RemoteTransportMode
2736+ if args[2] != nil {
2737+ arg2 = args[2].(ports.RemoteTransportMode)
2738+ }
2739+ var arg3 *slog.Logger
2740+ if args[3] != nil {
2741+ arg3 = args[3].(*slog.Logger)
2742+ }
2743+ run(
2744+ arg0,
2745+ arg1,
2746+ arg2,
2747+ arg3,
2748+ )
2749+ })
2750+ return _c
2751+}
2752+
2753+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Return(dialer ports.Dialer, err error) *MockRemoteDialerFactory_DialerForRemote_Call {
2754+ _c.Call.Return(dialer, err)
2755+ return _c
2756+}
2757+
2758+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error)) *MockRemoteDialerFactory_DialerForRemote_Call {
2759+ _c.Call.Return(run)
2760+ return _c
2761+}
A internal/ports/paste.go +9 -0
@@ -0,0 +1,9 @@
1+package ports
2+
3+// Bracketed-paste markers bracket terminal paste payloads when bracketed-paste
4+// mode is enabled. The client coalescer and daemon key router both depend on
5+// these bytes staying in sync.
6+var (
7+ BracketedPasteOpenMarker = []byte("\x1b[200~")
8+ BracketedPasteCloseMarker = []byte("\x1b[201~")
9+)
M internal/ports/ports.go +61 -0
@@ -1,17 +1,25 @@
11 package ports
22
33 import (
4+ "context"
5+ "errors"
46 "io"
57 "time"
68
79 "github.com/bnema/vev/internal/domain"
810 )
911
12+// ErrNoClipboardImage is returned by ClipboardReader.ReadImage when the
13+// clipboard holds no image (or an unsupported type). Callers fall back to
14+// forwarding whatever keystroke triggered the read.
15+var ErrNoClipboardImage = errors.New("ports: no image on clipboard")
16+
1017 // PTY is a running pseudo-terminal-backed child process.
1118 type PTY interface {
1219 io.ReadWriteCloser // Read = child output, Write = child input
1320 Resize(sz domain.Size) error
1421 Pid() int
22+ ForegroundPgid() (int, error)
1523 }
1624
1725 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.
@@ -24,6 +32,7 @@ type Terminal interface {
2432 EnterRaw() (restore func() error, err error)
2533 Size() (domain.Size, error)
2634 ResizeEvents() <-chan domain.Size
35+ QueryColors() error
2736 In() io.Reader
2837 Out() io.Writer
2938 Flush() error
@@ -36,6 +45,11 @@ type Transport interface {
3645 Close() error
3746 }
3847
48+// Dialer establishes outbound Transport connections.
49+type Dialer interface {
50+ Dial(ctx context.Context) (Transport, error)
51+}
52+
3953 // Listener accepts incoming Transport connections.
4054 type Listener interface {
4155 Accept() (Transport, error)
@@ -56,6 +70,19 @@ type Store interface {
5670 Close() error
5771 }
5872
73+// SnapshotBlob is a durable named session snapshot payload.
74+type SnapshotBlob struct {
75+ Name string
76+ Data []byte
77+}
78+
79+// SnapshotStore persists encoded session snapshots by name.
80+type SnapshotStore interface {
81+ Write(name string, data []byte) error
82+ Load() ([]SnapshotBlob, error)
83+ Delete(name string) error
84+}
85+
5986 // Clock abstracts time so usecases can be tested without real delays.
6087 type Clock interface {
6188 Now() time.Time
@@ -68,3 +95,37 @@ type Timer interface {
6895 Reset(d time.Duration) bool
6996 Stop() bool
7097 }
98+
99+// CommandSpec describes a bounded shell command execution request.
100+type CommandSpec struct {
101+ Command string
102+ Env []string
103+ Timeout time.Duration
104+ StdoutLimit int
105+}
106+
107+// ShellCommandRunner runs shell commands and returns bounded stdout bytes.
108+type ShellCommandRunner interface {
109+ Run(ctx context.Context, spec CommandSpec) ([]byte, error)
110+}
111+
112+// ProcessInspector reads process metadata for daemon snapshot/persistence use.
113+// Implementations may live in platform packages; usecases depend on this port.
114+type ProcessInspector interface {
115+ Cwd(pid int) (string, error)
116+ Comm(pid int) (string, error)
117+ Argv(pid int) ([]string, error)
118+ GroupArgv(pgid int, shellPid int) ([]string, error)
119+}
120+
121+// ClipboardReader reads an image off the CLIENT-side system clipboard, for
122+// forwarding to a remote session's focused pane. Implementations live in
123+// internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only
124+// see this interface. See
125+// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md.
126+type ClipboardReader interface {
127+ // ReadImage returns the clipboard's image bytes and MIME type. It returns
128+ // ErrNoClipboardImage when the clipboard holds no image (or an
129+ // unsupported type).
130+ ReadImage(ctx context.Context) (mime string, data []byte, err error)
131+}
A internal/ports/remote.go +17 -0
@@ -0,0 +1,17 @@
1+package ports
2+
3+import "log/slog"
4+
5+// RemoteTransportMode selects the transport used for remote attach after CLI parsing.
6+type RemoteTransportMode string
7+
8+const (
9+ RemoteTransportUDP RemoteTransportMode = "udp"
10+ RemoteTransportStdio RemoteTransportMode = "stdio"
11+)
12+
13+// RemoteDialerFactory builds the transport-specific dialer for a remote target.
14+// Implementations live in adapters; app wiring consumes this interface.
15+type RemoteDialerFactory interface {
16+ DialerForRemote(target string, session string, mode RemoteTransportMode, log *slog.Logger) (Dialer, error)
17+}
A internal/ports/snapshot_test.go +21 -0
@@ -0,0 +1,21 @@
1+package ports
2+
3+import "testing"
4+
5+type snapshotStoreContract struct{}
6+
7+func (snapshotStoreContract) Write(name string, data []byte) error { return nil }
8+func (snapshotStoreContract) Load() ([]SnapshotBlob, error) { return nil, nil }
9+func (snapshotStoreContract) Delete(name string) error { return nil }
10+
11+func TestSnapshotStoreContract(t *testing.T) {
12+ var _ SnapshotStore = snapshotStoreContract{}
13+
14+ blob := SnapshotBlob{Name: "named-session", Data: []byte("snapshot-bytes")}
15+ if blob.Name != "named-session" {
16+ t.Fatalf("SnapshotBlob.Name = %q, want %q", blob.Name, "named-session")
17+ }
18+ if string(blob.Data) != "snapshot-bytes" {
19+ t.Fatalf("SnapshotBlob.Data = %q, want %q", string(blob.Data), "snapshot-bytes")
20+ }
21+}
M internal/ports/wire.go +206 -24
@@ -28,15 +28,24 @@ const (
2828 IntentEphemeral uint8 = 0
2929 IntentNew uint8 = 1
3030 IntentAttach uint8 = 2
31+ IntentResume uint8 = 3
32+)
33+
34+// Capability bits advertised in Welcome.
35+const (
36+ CapabilityResume uint32 = 1 << 0
37+ CapabilityUDP uint32 = 1 << 1
38+ CapabilityPredict uint32 = 1 << 2
3139 )
3240
3341 // ErrorMsg codes.
3442 const (
35- ErrVersionMismatch uint16 = 1
36- ErrNoSuchSession uint16 = 2
37- ErrNameTaken uint16 = 3
38- ErrServerShutdown uint16 = 4
39- ErrInternal uint16 = 255
43+ ErrVersionMismatch uint16 = 1
44+ ErrNoSuchSession uint16 = 2
45+ ErrNameTaken uint16 = 3
46+ ErrServerShutdown uint16 = 4
47+ ErrInvalidSessionName uint16 = 5
48+ ErrInternal uint16 = 255
4049 )
4150
4251 // Detached reasons.
@@ -48,17 +57,21 @@ const (
4857
4958 // Hello is sent by the client immediately after connecting.
5059 type Hello struct {
51- Version uint16
52- Intent uint8
53- Name string
54- Size domain.Size
55- TermEnv string
56- Cwd string
60+ Version uint16
61+ Intent uint8
62+ ClientID [16]byte
63+ ResumeToken uint64
64+ Name string
65+ Size domain.Size
66+ TermEnv string
67+ Cwd string
68+ TrueColor bool
5769 }
5870
5971 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
6072 type Input struct {
61- Data []byte
73+ InputSeq uint64
74+ Data []byte
6275 }
6376
6477 // Resize notifies the daemon of a client-side terminal size change.
@@ -74,6 +87,17 @@ type Theme struct {
7487 HasBackground bool
7588 Background renderer.RGB
7689 TrueColor bool
90+ SchemeKnown bool
91+ Light bool
92+}
93+
94+// ImagePush carries a clipboard image from a remote client, to be written to
95+// a temp file and injected into the focused pane's PTY as a path (see
96+// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
97+type ImagePush struct {
98+ InputSeq uint64
99+ Mime string
100+ Data []byte
77101 }
78102
79103 // Detach asks the daemon to detach the current client without killing the
@@ -86,11 +110,18 @@ type Ping struct{}
86110 // Pong answers a Ping.
87111 type Pong struct{}
88112
113+// Ack acknowledges receipt/application of an output state number.
114+type Ack struct {
115+ AckedStateNum uint64
116+}
117+
89118 // Welcome is the daemon's reply to a successful Hello.
90119 type Welcome struct {
91- SessionID string
92- SessionName string
93- Ephemeral bool
120+ SessionID string
121+ SessionName string
122+ Ephemeral bool
123+ ResumeToken uint64
124+ Capabilities uint32
94125 }
95126
96127 // ErrorMsg reports a protocol- or session-level failure to the client.
@@ -101,7 +132,10 @@ type ErrorMsg struct {
101132
102133 // Output carries raw PTY output, destined for the client's terminal.
103134 type Output struct {
104- Data []byte
135+ BaseStateNum uint64
136+ NewStateNum uint64
137+ EchoAck uint64
138+ Data []byte
105139 }
106140
107141 // Detached tells a client it has been disconnected from its session and why.
@@ -149,6 +183,22 @@ func (w *payloadWriter) putUint16(v uint16) {
149183 w.b = append(w.b, tmp[:]...)
150184 }
151185
186+func (w *payloadWriter) putUint32(v uint32) {
187+ var tmp [4]byte
188+ binary.BigEndian.PutUint32(tmp[:], v)
189+ w.b = append(w.b, tmp[:]...)
190+}
191+
192+func (w *payloadWriter) putUint64(v uint64) {
193+ var tmp [8]byte
194+ binary.BigEndian.PutUint64(tmp[:], v)
195+ w.b = append(w.b, tmp[:]...)
196+}
197+
198+func (w *payloadWriter) putBytes(b []byte) {
199+ w.b = append(w.b, b...)
200+}
201+
152202 func (w *payloadWriter) putString(s string) {
153203 w.putUint16(uint16(len(s)))
154204 w.b = append(w.b, s...)
@@ -178,6 +228,33 @@ func (r *payloadReader) getUint16() (uint16, error) {
178228 return v, nil
179229 }
180230
231+func (r *payloadReader) getUint32() (uint32, error) {
232+ if len(r.b) < 4 {
233+ return 0, errShortPayload
234+ }
235+ v := binary.BigEndian.Uint32(r.b)
236+ r.b = r.b[4:]
237+ return v, nil
238+}
239+
240+func (r *payloadReader) getUint64() (uint64, error) {
241+ if len(r.b) < 8 {
242+ return 0, errShortPayload
243+ }
244+ v := binary.BigEndian.Uint64(r.b)
245+ r.b = r.b[8:]
246+ return v, nil
247+}
248+
249+func (r *payloadReader) getBytes(n int) ([]byte, error) {
250+ if len(r.b) < n {
251+ return nil, errShortPayload
252+ }
253+ b := append([]byte(nil), r.b[:n]...)
254+ r.b = r.b[n:]
255+ return b, nil
256+}
257+
181258 func (r *payloadReader) getString() (string, error) {
182259 n, err := r.getUint16()
183260 if err != nil {
@@ -220,11 +297,18 @@ func MarshalHello(h Hello) []byte {
220297 w := payloadWriter{}
221298 w.putUint16(h.Version)
222299 w.putUint8(h.Intent)
300+ w.putBytes(h.ClientID[:])
301+ w.putUint64(h.ResumeToken)
223302 w.putString(h.Name)
224303 w.putUint16(uint16(h.Size.Cols))
225304 w.putUint16(uint16(h.Size.Rows))
226305 w.putString(h.TermEnv)
227306 w.putString(h.Cwd)
307+ if h.TrueColor {
308+ w.putUint8(1)
309+ } else {
310+ w.putUint8(0)
311+ }
228312 return w.b
229313 }
230314
@@ -240,6 +324,14 @@ func UnmarshalHello(b []byte) (Hello, error) {
240324 if h.Intent, err = r.getUint8(); err != nil {
241325 return Hello{}, err
242326 }
327+ clientID, err := r.getBytes(len(h.ClientID))
328+ if err != nil {
329+ return Hello{}, err
330+ }
331+ copy(h.ClientID[:], clientID)
332+ if h.ResumeToken, err = r.getUint64(); err != nil {
333+ return Hello{}, err
334+ }
243335 if h.Name, err = r.getString(); err != nil {
244336 return Hello{}, err
245337 }
@@ -258,6 +350,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
258350 if h.Cwd, err = r.getString(); err != nil {
259351 return Hello{}, err
260352 }
353+ trueColor, err := r.getUint8()
354+ if err != nil {
355+ return Hello{}, err
356+ }
357+ h.TrueColor = trueColor != 0
261358 if err := r.done(); err != nil {
262359 return Hello{}, err
263360 }
@@ -266,14 +363,46 @@ func UnmarshalHello(b []byte) (Hello, error) {
266363
267364 // MarshalInput encodes m into an Input message payload.
268365 func MarshalInput(m Input) []byte {
269- return append([]byte(nil), m.Data...)
366+ w := payloadWriter{}
367+ w.putUint64(m.InputSeq)
368+ w.putBytes(m.Data)
369+ return w.b
270370 }
271371
272-// UnmarshalInput decodes an Input message payload. The whole payload is the
273-// data; there is no separate length prefix.
372+// UnmarshalInput decodes an Input message payload. After the fixed input
373+// sequence, the rest of the payload is data; there is no length prefix.
274374 func UnmarshalInput(b []byte) (Input, error) {
275375 r := payloadReader{b: b}
276- return Input{Data: r.rest()}, nil
376+ seq, err := r.getUint64()
377+ if err != nil {
378+ return Input{}, err
379+ }
380+ return Input{InputSeq: seq, Data: r.rest()}, nil
381+}
382+
383+// MarshalImagePush encodes m into an ImagePush message payload.
384+func MarshalImagePush(m ImagePush) []byte {
385+ w := payloadWriter{}
386+ w.putUint64(m.InputSeq)
387+ w.putString(m.Mime)
388+ w.putBytes(m.Data)
389+ return w.b
390+}
391+
392+// UnmarshalImagePush decodes an ImagePush message payload. After the fixed
393+// input sequence and length-prefixed mime string, the rest of the payload is
394+// data; there is no length prefix for it.
395+func UnmarshalImagePush(b []byte) (ImagePush, error) {
396+ r := payloadReader{b: b}
397+ seq, err := r.getUint64()
398+ if err != nil {
399+ return ImagePush{}, err
400+ }
401+ mime, err := r.getString()
402+ if err != nil {
403+ return ImagePush{}, err
404+ }
405+ return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil
277406 }
278407
279408 // MarshalResize encodes m into a Resize message payload.
@@ -296,6 +425,12 @@ func MarshalTheme(m Theme) []byte {
296425 if m.TrueColor {
297426 flags |= 0x04
298427 }
428+ if m.SchemeKnown {
429+ flags |= 0x08
430+ }
431+ if m.Light {
432+ flags |= 0x10
433+ }
299434 return []byte{
300435 flags,
301436 m.Foreground.R, m.Foreground.G, m.Foreground.B,
@@ -343,6 +478,8 @@ func UnmarshalTheme(b []byte) (Theme, error) {
343478 HasBackground: flags&0x02 != 0,
344479 Background: renderer.RGB{R: bgR, G: bgG, B: bgB},
345480 TrueColor: flags&0x04 != 0,
481+ SchemeKnown: flags&0x08 != 0,
482+ Light: flags&0x10 != 0,
346483 }, nil
347484 }
348485
@@ -405,6 +542,26 @@ func UnmarshalPong(b []byte) (Pong, error) {
405542 return Pong{}, nil
406543 }
407544
545+// MarshalAck encodes m into an Ack message payload.
546+func MarshalAck(m Ack) []byte {
547+ w := payloadWriter{}
548+ w.putUint64(m.AckedStateNum)
549+ return w.b
550+}
551+
552+// UnmarshalAck decodes an Ack message payload.
553+func UnmarshalAck(b []byte) (Ack, error) {
554+ r := payloadReader{b: b}
555+ acked, err := r.getUint64()
556+ if err != nil {
557+ return Ack{}, err
558+ }
559+ if err := r.done(); err != nil {
560+ return Ack{}, err
561+ }
562+ return Ack{AckedStateNum: acked}, nil
563+}
564+
408565 // MarshalWelcome encodes m into a Welcome message payload.
409566 func MarshalWelcome(m Welcome) []byte {
410567 w := payloadWriter{}
@@ -415,6 +572,8 @@ func MarshalWelcome(m Welcome) []byte {
415572 } else {
416573 w.putUint8(0)
417574 }
575+ w.putUint64(m.ResumeToken)
576+ w.putUint32(m.Capabilities)
418577 return w.b
419578 }
420579
@@ -435,6 +594,12 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
435594 return Welcome{}, err
436595 }
437596 m.Ephemeral = eph != 0
597+ if m.ResumeToken, err = r.getUint64(); err != nil {
598+ return Welcome{}, err
599+ }
600+ if m.Capabilities, err = r.getUint32(); err != nil {
601+ return Welcome{}, err
602+ }
438603 if err := r.done(); err != nil {
439604 return Welcome{}, err
440605 }
@@ -469,14 +634,31 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) {
469634
470635 // MarshalOutput encodes m into an Output message payload.
471636 func MarshalOutput(m Output) []byte {
472- return append([]byte(nil), m.Data...)
637+ w := payloadWriter{}
638+ w.putUint64(m.BaseStateNum)
639+ w.putUint64(m.NewStateNum)
640+ w.putUint64(m.EchoAck)
641+ w.putBytes(m.Data)
642+ return w.b
473643 }
474644
475-// UnmarshalOutput decodes an Output message payload. The whole payload is
476-// the data; there is no separate length prefix.
645+// UnmarshalOutput decodes an Output message payload. After fixed state
646+// numbers, the rest of the payload is data; there is no length prefix.
477647 func UnmarshalOutput(b []byte) (Output, error) {
478648 r := payloadReader{b: b}
479- return Output{Data: r.rest()}, nil
649+ base, err := r.getUint64()
650+ if err != nil {
651+ return Output{}, err
652+ }
653+ newState, err := r.getUint64()
654+ if err != nil {
655+ return Output{}, err
656+ }
657+ echoAck, err := r.getUint64()
658+ if err != nil {
659+ return Output{}, err
660+ }
661+ return Output{BaseStateNum: base, NewStateNum: newState, EchoAck: echoAck, Data: r.rest()}, nil
480662 }
481663
482664 // MarshalDetached encodes m into a Detached message payload.
M internal/ports/wire_test.go +108 -39
@@ -60,26 +60,30 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
6060 {
6161 name: "typical",
6262 msg: Hello{
63- Version: 1,
64- Intent: IntentNew,
65- Name: "w0",
66- Size: domain.Size{Cols: 80, Rows: 24},
67- TermEnv: "xterm-256color",
68- Cwd: "/tmp/project",
63+ Version: 1,
64+ Intent: IntentNew,
65+ ClientID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10},
66+ ResumeToken: 0x0102030405060708,
67+ Name: "w0",
68+ Size: domain.Size{Cols: 80, Rows: 24},
69+ TermEnv: "xterm-256color",
70+ Cwd: "/tmp/project",
71+ TrueColor: true,
6972 },
70- want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74},
73+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01},
7174 },
7275 {
7376 name: "empty strings",
7477 msg: Hello{
75- Version: 1,
76- Intent: IntentEphemeral,
77- Name: "",
78- Size: domain.Size{Cols: 0, Rows: 0},
79- TermEnv: "",
80- Cwd: "",
78+ Version: 1,
79+ Intent: IntentEphemeral,
80+ Name: "",
81+ Size: domain.Size{Cols: 0, Rows: 0},
82+ TermEnv: "",
83+ Cwd: "",
84+ TrueColor: false,
8185 },
82- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
86+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
8387 },
8488 }
8589
@@ -96,6 +100,10 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
96100 if !reflect.DeepEqual(back, tt.msg) {
97101 t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
98102 }
103+ peek, ok := PeekHelloVersion(got)
104+ if !ok || peek != tt.msg.Version {
105+ t.Fatalf("PeekHelloVersion() = %d, %v; want %d, true", peek, ok, tt.msg.Version)
106+ }
99107 })
100108 }
101109
@@ -110,8 +118,8 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
110118 msg Input
111119 want []byte
112120 }{
113- {name: "data", msg: Input{Data: []byte("hi")}, want: []byte{0x68, 0x69}},
114- {name: "empty", msg: Input{Data: nil}, want: []byte{}},
121+ {name: "data", msg: Input{InputSeq: 0x0102030405060708, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x68, 0x69}},
122+ {name: "empty", msg: Input{InputSeq: 0, Data: nil}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
115123 }
116124
117125 for _, tt := range tests {
@@ -130,14 +138,42 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
130138 })
131139 }
132140
133- // Input is rest-of-payload: any byte sequence, including a truncated
134- // one, is a valid (if empty or partial) Data value. No error path
135- // exists to exercise beyond "never panics".
136- mustNotPanic(t, func() {
137- if _, err := UnmarshalInput([]byte{0x01}); err != nil {
138- t.Fatalf("UnmarshalInput() unexpected error = %v", err)
139- }
140- })
141+ full := MarshalInput(tests[0].msg)
142+ assertAllPrefixesFail(t, full[:8], UnmarshalInput)
143+}
144+
145+func TestImagePushGoldenAndRoundTrip(t *testing.T) {
146+ tests := []struct {
147+ name string
148+ msg ImagePush
149+ want []byte
150+ }{
151+ {
152+ name: "png data",
153+ msg: ImagePush{InputSeq: 7, Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}},
154+ want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03},
155+ },
156+ {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
157+ }
158+
159+ for _, tt := range tests {
160+ t.Run(tt.name, func(t *testing.T) {
161+ got := MarshalImagePush(tt.msg)
162+ if !bytes.Equal(got, tt.want) {
163+ t.Fatalf("MarshalImagePush() = %#v, want %#v", got, tt.want)
164+ }
165+ back, err := UnmarshalImagePush(got)
166+ if err != nil {
167+ t.Fatalf("UnmarshalImagePush() error = %v", err)
168+ }
169+ if !reflect.DeepEqual(back, tt.msg) {
170+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
171+ }
172+ })
173+ }
174+
175+ full := MarshalImagePush(tests[0].msg)
176+ assertAllPrefixesFail(t, full[:19], UnmarshalImagePush)
141177 }
142178
143179 func TestThemeGoldenAndRoundTrip(t *testing.T) {
@@ -147,15 +183,23 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) {
147183 want []byte
148184 }{
149185 {
150- name: "foreground background truecolor",
186+ name: "foreground background truecolor dark scheme",
151187 msg: Theme{
152188 HasForeground: true,
153189 Foreground: renderer.RGB{R: 1, G: 2, B: 3},
154190 HasBackground: true,
155191 Background: renderer.RGB{R: 4, G: 5, B: 6},
156192 TrueColor: true,
193+ SchemeKnown: true,
157194 },
158- want: []byte{0x07, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},
195+ want: []byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},
196+ },
197+ {
198+ name: "light scheme bit without known is preserved",
199+ msg: Theme{
200+ Light: true,
201+ },
202+ want: []byte{0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
159203 },
160204 {
161205 name: "foreground only no truecolor",
@@ -267,13 +311,13 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
267311 }{
268312 {
269313 name: "ephemeral",
270- msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true},
271- want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01},
314+ msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},
315+ want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05},
272316 },
273317 {
274318 name: "non-ephemeral empty name",
275319 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},
276- want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00},
320+ want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
277321 },
278322 }
279323
@@ -319,8 +363,8 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
319363 }
320364
321365 func TestOutputGoldenAndRoundTrip(t *testing.T) {
322- msg := Output{Data: []byte("hello\n")}
323- want := []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
366+ msg := Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 3, Data: []byte("hello\n")}
367+ want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
324368
325369 got := MarshalOutput(msg)
326370 if !bytes.Equal(got, want) {
@@ -333,6 +377,28 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) {
333377 if !reflect.DeepEqual(back, msg) {
334378 t.Fatalf("round trip = %#v, want %#v", back, msg)
335379 }
380+
381+ assertAllPrefixesFail(t, got[:24], UnmarshalOutput)
382+}
383+
384+func TestAckGoldenAndRoundTrip(t *testing.T) {
385+ msg := Ack{AckedStateNum: 0x0102030405060708}
386+ want := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
387+
388+ got := MarshalAck(msg)
389+ if !bytes.Equal(got, want) {
390+ t.Fatalf("MarshalAck() = %#v, want %#v", got, want)
391+ }
392+ back, err := UnmarshalAck(got)
393+ if err != nil {
394+ t.Fatalf("UnmarshalAck() error = %v", err)
395+ }
396+ if back != msg {
397+ t.Fatalf("round trip = %#v, want %#v", back, msg)
398+ }
399+
400+ assertAllPrefixesFail(t, got, UnmarshalAck)
401+ assertTrailingGarbageFails(t, got, UnmarshalAck)
336402 }
337403
338404 func TestDetachedGoldenAndRoundTrip(t *testing.T) {
@@ -469,21 +535,24 @@ func TestMsgTypeConstantsDistinct(t *testing.T) {
469535 IntentEphemeral: "IntentEphemeral",
470536 IntentNew: "IntentNew",
471537 IntentAttach: "IntentAttach",
538+ IntentResume: "IntentResume",
472539 }
473- if len(vals) != 3 {
474- t.Fatalf("expected 3 distinct intent values, got %d", len(vals))
540+ if len(vals) != 4 {
541+ t.Fatalf("expected 4 distinct intent values, got %d", len(vals))
475542 }
476543 })
477544
478545 t.Run("error codes", func(t *testing.T) {
479546 vals := map[uint16]string{
480- ErrVersionMismatch: "ErrVersionMismatch",
481- ErrNoSuchSession: "ErrNoSuchSession",
482- ErrNameTaken: "ErrNameTaken",
483- ErrInternal: "ErrInternal",
547+ ErrVersionMismatch: "ErrVersionMismatch",
548+ ErrNoSuchSession: "ErrNoSuchSession",
549+ ErrNameTaken: "ErrNameTaken",
550+ ErrServerShutdown: "ErrServerShutdown",
551+ ErrInvalidSessionName: "ErrInvalidSessionName",
552+ ErrInternal: "ErrInternal",
484553 }
485- if len(vals) != 4 {
486- t.Fatalf("expected 4 distinct error codes, got %d", len(vals))
554+ if len(vals) != 6 {
555+ t.Fatalf("expected 6 distinct error codes, got %d", len(vals))
487556 }
488557 })
489558
M internal/usecase/client/client.go +404 -68
@@ -9,12 +9,15 @@ package client
99
1010 import (
1111 "context"
12+ "crypto/rand"
1213 "errors"
1314 "fmt"
1415 "io"
1516 "log/slog"
1617 "os"
1718 "strings"
19+ "sync/atomic"
20+ "time"
1821
1922 "github.com/bnema/vev/internal/domain"
2023 "github.com/bnema/vev/internal/ports"
@@ -80,6 +83,18 @@ func (m milestones) missing() []string {
8083 return out
8184 }
8285
86+// processClientID identifies this client process across links. It is kept in
87+// memory only; a fresh process gets a fresh ID.
88+var processClientID = newClientID()
89+
90+func newClientID() [16]byte {
91+ var id [16]byte
92+ if _, err := rand.Read(id[:]); err != nil {
93+ panic("crypto/rand failed generating client ID: " + err.Error())
94+ }
95+ return id
96+}
97+
8398 // stdinBufSize is the read buffer for the terminal input pump.
8499 const stdinBufSize = 4096
85100
@@ -87,81 +102,306 @@ const stdinBufSize = 4096
87102 // goroutine, decoupling the input/resize pumps from transport back-pressure.
88103 const sendQueueDepth = 64
89104
90-// Attach connects an already-dialed transport to the controlling terminal
91-// and runs the attach loop until the session detaches, the daemon
92-// disappears, or the context is cancelled.
105+var reconnectSleep = sleepReconnect
106+var reconnectSleepWithResize = sleepReconnectWithResizeEvents
107+
108+const (
109+ statusReconnect = "\r\x1b[2Kreconnecting…"
110+ statusClear = "\r\x1b[2K"
111+)
112+
113+// Run connects via dialer and runs the attach client. It owns the terminal
114+// lifecycle above attach attempts so raw mode remains active while a live
115+// client process redials a lost link.
93116 //
94-// It owns transport for its lifetime and closes it before returning. The
95-// terminal is put into raw mode after a successful Welcome and restored on
96-// every exit path — normal return, error, or panic.
97-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) (retErr error) {
98- log := slog.Default().With("component", "client")
99- ms := milestones{dialed: true} // transport arrives already connected
100-
101- // Runs last: a single structured diagnostic on failure, to the log file
102- // (never the terminal). Console output while raw is forbidden.
117+// remote and clipboard together gate the clipboard-image-transfer feature
118+// (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md):
119+// Ctrl+V is only intercepted on a remote attach (remote true) with a
120+// ClipboardReader configured (clipboard non-nil) — local attaches forward
121+// 0x16 untouched so the locally running agent's own clipboard handling
122+// applies.
123+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) (retErr error) {
124+ if log == nil {
125+ log = slog.Default()
126+ }
127+ ms := milestones{}
128+
103129 defer func() {
104130 if retErr != nil {
105131 log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing())
106132 }
107133 }()
108134
109- // Attach owns the transport: close it on every exit path.
110- defer func() { _ = transport.Close() }()
135+ var restore func() error
136+ rawEntered := false
137+ defer func() {
138+ if rawEntered {
139+ if rerr := restore(); rerr != nil && retErr == nil {
140+ retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
141+ }
142+ }
143+ }()
144+ enterRaw := func() error {
145+ if rawEntered {
146+ return nil
147+ }
148+ var err error
149+ restore, err = term.EnterRaw()
150+ if err != nil {
151+ return fmt.Errorf("vev: entering raw mode: %w", err)
152+ }
153+ rawEntered = true
154+ ms.rawEntered = true
155+ return nil
156+ }
157+
158+ resumeToken := uint64(0)
159+ attemptIntent := intent
160+ attemptName := name
161+ backoff := defaultReconnectBackoff.initial
162+ showingStatus := false
163+ var reconnectToastRect domain.Rect
164+ statusStage := reconnectStageOfflineRetrying
165+ redrawRemoteStatus := func(size domain.Size) {
166+ if !rawEntered || !remote {
167+ return
168+ }
169+ if showingStatus {
170+ _ = clearReconnectToast(term.Out(), reconnectToastRect)
171+ }
172+ drawnRect, _ := drawReconnectToastStage(term.Out(), size, statusStage)
173+ reconnectToastRect = drawnRect
174+ _ = term.Flush()
175+ showingStatus = true
176+ }
177+ var drawStatus func()
178+ drawStatusStage := func(stage reconnectStage) {
179+ statusStage = stage
180+ if showingStatus && remote {
181+ size, err := term.Size()
182+ if err == nil {
183+ redrawRemoteStatus(size)
184+ }
185+ return
186+ }
187+ drawStatus()
188+ }
189+ drawStatus = func() {
190+ if !rawEntered || showingStatus {
191+ return
192+ }
193+ if remote {
194+ size, err := term.Size()
195+ if err != nil {
196+ return
197+ }
198+ redrawRemoteStatus(size)
199+ } else {
200+ _, _ = term.Out().Write([]byte(statusReconnect))
201+ _ = term.Flush()
202+ showingStatus = true
203+ }
204+ }
205+ clearStatus := func() {
206+ if !showingStatus {
207+ return
208+ }
209+ if remote {
210+ _ = clearReconnectToast(term.Out(), reconnectToastRect)
211+ } else {
212+ _, _ = term.Out().Write([]byte(statusClear))
213+ }
214+ _ = term.Flush()
215+ showingStatus = false
216+ }
217+ sleepWhileReconnecting := func(d time.Duration) bool {
218+ if remote && showingStatus {
219+ return reconnectSleepWithResize(ctx, clk, d, term.ResizeEvents(), redrawRemoteStatus)
220+ }
221+ return reconnectSleep(ctx, clk, d)
222+ }
223+
224+ for {
225+ transport, err := dialer.Dial(ctx)
226+ if err != nil {
227+ if resumeToken == 0 || ctx.Err() != nil {
228+ clearStatus()
229+ return err
230+ }
231+ log.Warn("reconnect dial failed", "err", err, "backoff", backoff)
232+ drawStatus()
233+ if !sleepWhileReconnecting(backoff) {
234+ clearStatus()
235+ return ctx.Err()
236+ }
237+ backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
238+ attemptIntent = ports.IntentResume
239+ continue
240+ }
241+ ms.dialed = true
242+
243+ var linkEvents <-chan ports.LinkEvent
244+ if reporter, ok := transport.(ports.LinkStateReporter); ok {
245+ linkEvents = reporter.LinkEvents()
246+ }
247+ result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log)
248+ if result.welcomed {
249+ backoff = defaultReconnectBackoff.initial
250+ }
251+ _ = transport.Close()
252+ if result.resumeToken != 0 {
253+ resumeToken = result.resumeToken
254+ }
255+ if result.sessionName != "" {
256+ attemptName = result.sessionName
257+ }
258+ if result.err == nil {
259+ clearStatus()
260+ return nil
261+ }
262+ if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {
263+ clearStatus()
264+ return result.err
265+ }
266+ log.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff)
267+ drawStatusStage(reconnectStageSSH)
268+ if !sleepWhileReconnecting(backoff) {
269+ clearStatus()
270+ return ctx.Err()
271+ }
272+ backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
273+ attemptIntent = ports.IntentResume
274+ }
275+}
276+
277+func sleepReconnect(ctx context.Context, clk ports.Clock, d time.Duration) bool {
278+ t := clk.NewTimer(d)
279+ defer t.Stop()
280+ select {
281+ case <-t.C():
282+ return true
283+ case <-ctx.Done():
284+ return false
285+ }
286+}
287+
288+func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time.Duration, resizeEvents <-chan domain.Size, onResize func(domain.Size)) bool {
289+ t := clk.NewTimer(d)
290+ defer t.Stop()
291+ for {
292+ select {
293+ case <-t.C():
294+ return true
295+ case <-ctx.Done():
296+ return false
297+ case size, ok := <-resizeEvents:
298+ if !ok {
299+ resizeEvents = nil
300+ continue
301+ }
302+ if onResize != nil {
303+ onResize(size)
304+ }
305+ }
306+ }
307+}
308+
309+// Attach connects an already-dialed transport to the controlling terminal
310+// and runs the attach loop until the session detaches, the daemon
311+// disappears, or the context is cancelled.
312+//
313+// It is kept as a compatibility wrapper for callers that still own dialing.
314+func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {
315+ return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default())
316+}
317+
318+type singleTransportDialer struct{ transport ports.Transport }
319+
320+func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
321+ return d.transport, nil
322+}
111323
324+type attachResult struct {
325+ resumeToken uint64
326+ sessionName string
327+ welcomed bool
328+ err error
329+}
330+
331+// DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
332+func DetectTrueColor(termEnv, colorTerm string) bool {
333+ switch strings.ToLower(strings.TrimSpace(colorTerm)) {
334+ case "truecolor", "24bit":
335+ return true
336+ }
337+
338+ termEnv = strings.ToLower(strings.TrimSpace(termEnv))
339+ return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct")
340+}
341+
342+func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), drawStatusStage func(reconnectStage), linkEvents <-chan ports.LinkEvent, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) attachResult {
112343 // 1. Handshake: send Hello with our size and TERM.
113344 size, err := term.Size()
114345 if err != nil {
115- return fmt.Errorf("vev: reading terminal size: %w", err)
346+ return attachResult{err: fmt.Errorf("vev: reading terminal size: %w", err)}
116347 }
117348 cwd, err := os.Getwd()
118349 if err != nil {
119350 cwd = ""
120351 }
121- colorTerm := strings.ToLower(os.Getenv("COLORTERM"))
122- trueColor := colorTerm == "truecolor" || colorTerm == "24bit"
352+ termEnv := os.Getenv("TERM")
353+ colorTerm := os.Getenv("COLORTERM")
354+ trueColor := DetectTrueColor(termEnv, colorTerm)
123355 hello := ports.Hello{
124- Version: ports.ProtocolVersion,
125- Intent: intent,
126- Name: name,
127- Size: size,
128- TermEnv: os.Getenv("TERM"),
129- Cwd: cwd,
356+ Version: ports.ProtocolVersion,
357+ Intent: intent,
358+ ClientID: clientID,
359+ ResumeToken: resumeToken,
360+ Name: name,
361+ Size: size,
362+ TermEnv: termEnv,
363+ Cwd: cwd,
364+ TrueColor: trueColor,
130365 }
131366 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
132- return fmt.Errorf("vev: sending hello: %w", err)
367+ return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}
133368 }
134369 ms.helloSent = true
135370
136371 // 2. Await Welcome or a typed rejection.
137372 reply, err := transport.Recv()
138373 if err != nil {
139- return fmt.Errorf("vev: awaiting welcome: %w", err)
374+ return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)}
375+ }
376+ result := func(welcomed bool, err error) attachResult {
377+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err}
140378 }
141379 switch reply.Type {
142380 case ports.MsgWelcome:
381+ welcome, derr := ports.UnmarshalWelcome(reply.Payload)
382+ if derr != nil {
383+ return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))
384+ }
385+ resumeToken = welcome.ResumeToken
386+ name = welcome.SessionName
143387 ms.welcomed = true
388+ log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
144389 case ports.MsgError:
145390 em, derr := ports.UnmarshalErrorMsg(reply.Payload)
146391 if derr != nil {
147- return fmt.Errorf("vev: decoding error reply: %w", derr)
392+ return result(false, fmt.Errorf("vev: decoding error reply: %w", derr))
148393 }
149- return &ProtocolError{Code: em.Code, Text: em.Text}
394+ return result(false, &ProtocolError{Code: em.Code, Text: em.Text})
150395 default:
151- return fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)
396+ return result(false, fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type))
152397 }
398+ welcomedResult := func(err error) attachResult { return result(true, err) }
153399
154- // 3. Enter raw mode; restore on every subsequent exit path.
155- restore, err := term.EnterRaw()
156- if err != nil {
157- return fmt.Errorf("vev: entering raw mode: %w", err)
400+ // 3. Enter raw mode after Welcome; Run owns restoration.
401+ if err := enterRaw(); err != nil {
402+ return welcomedResult(err)
158403 }
159- ms.rawEntered = true
160- defer func() {
161- if rerr := restore(); rerr != nil && retErr == nil {
162- retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
163- }
164- }()
404+ clearStatus()
165405
166406 // 4. Derive a cancellable context so the pumps always unwind when the
167407 // loop returns. cancel runs first (LIFO): it signals the pumps; the
@@ -172,13 +412,30 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
172412 sendCh := make(chan ports.Frame, sendQueueDepth)
173413 sendErrCh := make(chan error, 1)
174414
175- go runSender(loopCtx, cancel, transport, sendCh, sendErrCh)
176- go runStdin(loopCtx, cancel, term.In(), sendCh, trueColor)
177- go runResize(loopCtx, term.ResizeEvents(), sendCh)
415+ // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to
416+ // the main loop, which is the only goroutine allowed to touch term.Out()/
417+ // term.Flush(); QueryColors writes through the same batched writer, so
418+ // calling it directly from the stdin pump would race the main loop's
419+ // output writes.
420+ colorQueryCh := make(chan struct{}, 1)
421+ requestColors := func() {
422+ select {
423+ case colorQueryCh <- struct{}{}:
424+ default:
425+ }
426+ }
427+
428+ var clip ports.ClipboardReader
429+ if remote {
430+ clip = clipboard
431+ }
432+ go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)
433+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log)
434+ go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
178435
179436 // 5. Output/main loop: the only goroutine that touches the terminal.
180437 recvCh := make(chan recvResult, 1)
181- go runRecv(loopCtx, transport, recvCh)
438+ go runRecv(loopCtx, transport, recvCh, log)
182439
183440 for {
184441 select {
@@ -187,37 +444,75 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
187444 // context was cancelled. A queued sender error takes priority.
188445 select {
189446 case serr := <-sendErrCh:
190- return fmt.Errorf("vev: sending to daemon: %w", serr)
447+ return welcomedResult(fmt.Errorf("vev: sending to daemon: %w", serr))
191448 default:
192- return nil
449+ return welcomedResult(nil)
450+ }
451+ case ev, ok := <-linkEvents:
452+ if !ok {
453+ linkEvents = nil
454+ continue
455+ }
456+ if ev.State == ports.LinkStateConnected {
457+ clearStatus()
458+ continue
459+ }
460+ drawStatusStage(stageForLinkState(ev.State))
461+ if ev.State == ports.LinkStateOffline {
462+ // Stop waiting for the transport to reach Dead at 60s: exit with
463+ // a retryable error so Run re-dials over ssh with the resume
464+ // token. Run closes this transport on the way out.
465+ return welcomedResult(errLinkOffline)
466+ }
467+ case <-colorQueryCh:
468+ if err := term.QueryColors(); err != nil {
469+ log.Warn("querying terminal colors", "err", err)
193470 }
194471 case r := <-recvCh:
195472 if r.err != nil {
196473 if errors.Is(r.err, io.EOF) {
197- return fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)
474+ return welcomedResult(fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err))
198475 }
199- return fmt.Errorf("vev: receiving from daemon: %w", r.err)
476+ return welcomedResult(fmt.Errorf("vev: receiving from daemon: %w", r.err))
200477 }
201478 switch r.frame.Type {
202479 case ports.MsgOutput:
203480 o, derr := ports.UnmarshalOutput(r.frame.Payload)
204481 if derr != nil {
205- return fmt.Errorf("vev: decoding output: %w", derr)
482+ return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))
206483 }
207484 if _, werr := term.Out().Write(o.Data); werr != nil {
208- return fmt.Errorf("vev: writing terminal output: %w", werr)
485+ return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
209486 }
210487 if ferr := term.Flush(); ferr != nil {
211- return fmt.Errorf("vev: flushing terminal: %w", ferr)
488+ return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
489+ }
490+ if o.NewStateNum != 0 {
491+ ack := ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: o.NewStateNum})}
492+ select {
493+ case sendCh <- ack:
494+ case <-loopCtx.Done():
495+ return welcomedResult(nil)
496+ default:
497+ log.Debug("dropping output ack because send queue is full", "state_num", o.NewStateNum)
498+ }
499+ }
500+ if !ms.firstOutput {
501+ ms.firstOutput = true
502+ log.Debug("received first output")
212503 }
213- ms.firstOutput = true
214504 case ports.MsgDetached:
215505 d, derr := ports.UnmarshalDetached(r.frame.Payload)
216506 if derr != nil {
217- return fmt.Errorf("vev: decoding detached: %w", derr)
507+ return welcomedResult(fmt.Errorf("vev: decoding detached: %w", derr))
218508 }
219509 ms.detached = true
220- return detachedResult(d.Reason)
510+ if d.Reason == ports.ReasonDetach {
511+ log.Info("detached cleanly", "reason", d.Reason)
512+ } else {
513+ log.Warn("detached by daemon", "reason", d.Reason)
514+ }
515+ return welcomedResult(detachedResult(d.Reason))
221516 case ports.MsgPong:
222517 // Liveness reply; nothing to do.
223518 default:
@@ -237,7 +532,8 @@ type recvResult struct {
237532
238533 // runRecv reads frames until an error, forwarding each to the main loop.
239534 // It exits on the first error or when the loop context is cancelled.
240-func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult) {
535+func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult, log *slog.Logger) {
536+ defer log.Debug("receive pump exited")
241537 for {
242538 f, err := transport.Recv()
243539 select {
@@ -254,7 +550,8 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu
254550 // runSender is the sole caller of transport.Send: serialising the input and
255551 // resize pumps through one goroutine keeps the transport's single-writer
256552 // contract intact. A send failure cancels the loop and is surfaced once.
257-func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, errCh chan<- error) {
553+func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, errCh chan<- error, log *slog.Logger) {
554+ defer log.Debug("sender pump exited")
258555 for {
259556 select {
260557 case f := <-in:
@@ -287,22 +584,59 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
287584 // exits. That is harmless here — Attach has already returned and restored
288585 // the terminal — and matches the standard pattern for stdin pumps; a
289586 // closable stdin duplicate could lift it later if ever needed.
290-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) {
587+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) {
588+ defer log.Debug("stdin pump exited")
291589 buf := make([]byte, stdinBufSize)
292590 var scanner theme.Scanner
591+ var inputSeq uint64
293592 current := ports.Theme{TrueColor: trueColor}
294- send := func(frame ports.Frame) bool {
593+ var sendOK atomic.Bool
594+ sendOK.Store(true)
595+ send := func(frame ports.Frame) {
295596 select {
296597 case out <- frame:
297- return true
298598 case <-ctx.Done():
299- return false
599+ sendOK.Store(false)
300600 }
301601 }
602+ // The coalescer reframes a bracketed paste split across reads into one
603+ // MsgInput frame, so a marker boundary can never leave a lone ESC on the
604+ // wire. Its emit runs one MsgInput per call, keeping the inputSeq contract.
605+ coalescer := newPasteCoalescer(clk, func(data []byte) {
606+ if len(data) == 0 {
607+ return
608+ }
609+ copyData := append([]byte(nil), data...)
610+ inputSeq++
611+ send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
612+ })
613+ defer coalescer.Close()
614+
615+ // On a remote attach with a ClipboardReader configured, splice in the
616+ // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16)
617+ // becomes a clipboard image push instead of reaching the coalescer (and
618+ // from there the remote pane) as an ordinary keystroke.
619+ sink := coalescer.Scan
620+ if clipboard != nil {
621+ ci := &clipboardIntercept{
622+ coalescer: coalescer,
623+ reader: clipboard,
624+ log: log,
625+ sendImage: func(mime string, data []byte) {
626+ inputSeq++
627+ send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})
628+ },
629+ next: coalescer.Scan,
630+ }
631+ sink = ci.Scan
632+ }
633+
302634 for {
303635 n, rerr := in.Read(buf)
304636 if n > 0 {
305- ok := true
637+ sendTheme := func() {
638+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
639+ }
306640 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
307641 switch kind {
308642 case 10:
@@ -314,15 +648,16 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
314648 default:
315649 return
316650 }
317- ok = send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
318- }, func(data []byte) {
319- if len(data) == 0 || !ok {
320- return
651+ sendTheme()
652+ }, func(light bool) {
653+ current.SchemeKnown = true
654+ current.Light = light
655+ if requestColors != nil {
656+ requestColors()
321657 }
322- copyData := append([]byte(nil), data...)
323- ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: copyData})})
324- })
325- if !ok {
658+ sendTheme()
659+ }, sink)
660+ if !sendOK.Load() {
326661 return
327662 }
328663 }
@@ -341,7 +676,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
341676 // runResize forwards coalesced terminal resize events to the daemon. It
342677 // tolerates an already-closed resize channel (which the terminal adapter
343678 // hands back when restore ran before ResizeEvents was first called).
344-func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame) {
679+func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, log *slog.Logger) {
680+ defer log.Debug("resize pump exited")
345681 for {
346682 select {
347683 case sz, ok := <-events:
M internal/usecase/client/client_test.go +328 -11
@@ -25,6 +25,21 @@ func init() {
2525 slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
2626 }
2727
28+// realClock is a stdlib-backed ports.Clock for tests that exercise the stdin
29+// pump end to end; the paste coalescer's flush timer runs on wall time here.
30+type realClock struct{}
31+
32+func (realClock) Now() time.Time { return time.Now() }
33+func (realClock) NewTimer(d time.Duration) ports.Timer {
34+ return &realTimer{t: time.NewTimer(d)}
35+}
36+
37+type realTimer struct{ t *time.Timer }
38+
39+func (r *realTimer) C() <-chan time.Time { return r.t.C }
40+func (r *realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
41+func (r *realTimer) Stop() bool { return r.t.Stop() }
42+
2843 // recvItem is one scripted Recv result.
2944 type recvItem struct {
3045 f ports.Frame
@@ -92,6 +107,31 @@ func (r *oneShotBlockingReader) Read(p []byte) (int, error) {
92107
93108 func (r *oneShotBlockingReader) unblock() { close(r.done) }
94109
110+type chunkedBlockingReader struct {
111+ chunks [][]byte
112+ done chan struct{}
113+ mu sync.Mutex
114+ next int
115+}
116+
117+func newChunkedBlockingReader(chunks ...[]byte) *chunkedBlockingReader {
118+ return &chunkedBlockingReader{chunks: chunks, done: make(chan struct{})}
119+}
120+
121+func (r *chunkedBlockingReader) Read(p []byte) (int, error) {
122+ r.mu.Lock()
123+ defer r.mu.Unlock()
124+ if r.next < len(r.chunks) {
125+ chunk := r.chunks[r.next]
126+ r.next++
127+ return copy(p, chunk), nil
128+ }
129+ <-r.done
130+ return 0, io.EOF
131+}
132+
133+func (r *chunkedBlockingReader) unblock() { close(r.done) }
134+
95135 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {
96136 tm := portsmocks.NewMockTerminal(t)
97137 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
@@ -111,6 +151,43 @@ func isType(typ ports.MsgType) any {
111151 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })
112152 }
113153
154+func TestAttachHelloIncludesTrueColor(t *testing.T) {
155+ t.Setenv("TERM", "xterm-256color")
156+ t.Setenv("COLORTERM", "truecolor")
157+
158+ var out bytes.Buffer
159+ var restoreCount atomic.Int32
160+ resizeCh := make(chan domain.Size)
161+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
162+ defer in.unblock()
163+
164+ gotHello := make(chan ports.Hello, 1)
165+ tr := portsmocks.NewMockTransport(t)
166+ tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
167+ hello, err := ports.UnmarshalHello(f.Payload)
168+ require.NoError(t, err)
169+ gotHello <- hello
170+ return nil
171+ }).Once()
172+ unblock := scriptRecv(tr,
173+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
174+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
175+ )
176+ defer unblock()
177+ tr.EXPECT().Close().Return(nil).Once()
178+
179+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
180+ require.NoError(t, err)
181+
182+ select {
183+ case hello := <-gotHello:
184+ require.Equal(t, "xterm-256color", hello.TermEnv)
185+ require.True(t, hello.TrueColor)
186+ case <-time.After(2 * time.Second):
187+ t.Fatal("hello frame was not sent")
188+ }
189+}
190+
114191 func TestAttachHappyPath(t *testing.T) {
115192 var out bytes.Buffer
116193 var restoreCount atomic.Int32
@@ -128,7 +205,7 @@ func TestAttachHappyPath(t *testing.T) {
128205 defer unblock()
129206 tr.EXPECT().Close().Return(nil).Once()
130207
131- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
208+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
132209 require.NoError(t, err)
133210 require.Equal(t, "hello world", out.String())
134211 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")
@@ -147,7 +224,7 @@ func TestAttachVersionMismatch(t *testing.T) {
147224 ).Once()
148225 tr.EXPECT().Close().Return(nil).Once()
149226
150- err := client.Attach(context.Background(), tr, tm, ports.IntentAttach, "main")
227+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentAttach, "main")
151228 require.Error(t, err)
152229 var pe *client.ProtocolError
153230 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -171,7 +248,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
171248 defer unblock()
172249 tr.EXPECT().Close().Return(nil).Once()
173250
174- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
251+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
175252 require.Error(t, err)
176253 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")
177254 }
@@ -192,7 +269,7 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
192269 defer unblock()
193270 tr.EXPECT().Close().Return(nil).Once()
194271
195- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
272+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
196273 require.Error(t, err)
197274 require.ErrorIs(t, err, io.EOF)
198275 require.Equal(t, int32(1), restoreCount.Load())
@@ -264,7 +341,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
264341 }).Maybe()
265342 tr.EXPECT().Close().Return(nil).Once()
266343
267- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
344+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
268345 require.NoError(t, err)
269346 require.Equal(t, int32(1), restoreCount.Load())
270347
@@ -292,6 +369,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
292369 }
293370
294371 func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
372+ t.Setenv("TERM", "xterm-256color")
295373 t.Setenv("COLORTERM", "")
296374
297375 var out bytes.Buffer
@@ -342,7 +420,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
342420 }).Maybe()
343421 tr.EXPECT().Close().Return(nil).Once()
344422
345- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
423+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
346424 require.NoError(t, err)
347425 select {
348426 case th := <-gotTheme:
@@ -402,7 +480,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
402480 }).Maybe()
403481 tr.EXPECT().Close().Return(nil).Once()
404482
405- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
483+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
406484 require.NoError(t, err)
407485 select {
408486 case got := <-gotInput:
@@ -461,7 +539,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
461539 }).Maybe()
462540 tr.EXPECT().Close().Return(nil).Once()
463541
464- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
542+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
465543 require.NoError(t, err)
466544 select {
467545 case got := <-gotInput:
@@ -471,6 +549,66 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
471549 }
472550 }
473551
552+func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
553+ var out bytes.Buffer
554+ var restoreCount atomic.Int32
555+ resizeCh := make(chan domain.Size)
556+ paste := []byte("\x1b[200~hello\nworld\x1b[201~")
557+ input := newChunkedBlockingReader([]byte("\x1b[200~hello\n"), []byte("world\x1b[201~"))
558+ defer input.unblock()
559+
560+ tm := portsmocks.NewMockTerminal(t)
561+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
562+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
563+ tm.EXPECT().In().Return(input).Maybe()
564+ tm.EXPECT().Out().Return(&out).Maybe()
565+ tm.EXPECT().Flush().Return(nil).Maybe()
566+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
567+
568+ gotInput := make(chan []byte, 1)
569+ allowDetach := make(chan struct{})
570+ tr := portsmocks.NewMockTransport(t)
571+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
572+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
573+ in, err := ports.UnmarshalInput(f.Payload)
574+ require.NoError(t, err)
575+ gotInput <- in.Data
576+ close(allowDetach)
577+ return nil
578+ }).Once()
579+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
580+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
581+ recvCh := make(chan recvItem, 1)
582+ recvCh <- recvItem{f: welcome}
583+ closed := make(chan struct{})
584+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
585+ select {
586+ case it := <-recvCh:
587+ return it.f, it.err
588+ case <-allowDetach:
589+ select {
590+ case <-closed:
591+ return ports.Frame{}, io.EOF
592+ default:
593+ close(closed)
594+ return detached, nil
595+ }
596+ case <-closed:
597+ return ports.Frame{}, io.EOF
598+ }
599+ }).Maybe()
600+ tr.EXPECT().Close().Return(nil).Once()
601+
602+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
603+ require.NoError(t, err)
604+ select {
605+ case got := <-gotInput:
606+ require.Equal(t, paste, got, "split bracketed paste must arrive as one intact MsgInput frame")
607+ case <-time.After(2 * time.Second):
608+ t.Fatal("split bracketed paste input was not sent")
609+ }
610+}
611+
474612 func TestAttachForwardsResize(t *testing.T) {
475613 var out bytes.Buffer
476614 var restoreCount atomic.Int32
@@ -479,6 +617,8 @@ func TestAttachForwardsResize(t *testing.T) {
479617 defer in.unblock()
480618
481619 detachAfterResize := make(chan struct{})
620+ firstRecv := make(chan struct{})
621+ var firstRecvOnce sync.Once
482622
483623 tr := portsmocks.NewMockTransport(t)
484624 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
@@ -497,6 +637,7 @@ func TestAttachForwardsResize(t *testing.T) {
497637 recvCh <- recvItem{f: welcome}
498638 done := make(chan struct{})
499639 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
640+ firstRecvOnce.Do(func() { close(firstRecv) })
500641 select {
501642 case it := <-recvCh:
502643 return it.f, it.err
@@ -515,13 +656,13 @@ func TestAttachForwardsResize(t *testing.T) {
515656 }).Maybe()
516657 tr.EXPECT().Close().Return(nil).Once()
517658
518- // Push a resize event shortly after attach begins.
659+ // Push a resize event once attach begins receiving daemon frames.
519660 go func() {
520- time.Sleep(20 * time.Millisecond)
661+ <-firstRecv
521662 resizeCh <- domain.Size{Cols: 120, Rows: 40}
522663 }()
523664
524- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
665+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
525666 require.NoError(t, err)
526667 select {
527668 case r := <-gotResize:
@@ -530,3 +671,179 @@ func TestAttachForwardsResize(t *testing.T) {
530671 t.Fatal("resize was not forwarded")
531672 }
532673 }
674+
675+func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
676+ var out bytes.Buffer
677+ var restoreCount atomic.Int32
678+ resizeCh := make(chan domain.Size)
679+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
680+ defer in.unblock()
681+
682+ tr := portsmocks.NewMockTransport(t)
683+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
684+ boom := errors.New("connection reset")
685+ unblock := scriptRecv(tr,
686+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
687+ recvItem{err: boom},
688+ )
689+ defer unblock()
690+ tr.EXPECT().Close().Return(nil).Once()
691+ d := portsmocks.NewMockDialer(t)
692+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
693+
694+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
695+ require.Error(t, err)
696+ require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
697+}
698+
699+func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
700+ tm := portsmocks.NewMockTerminal(t)
701+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
702+ // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
703+
704+ tr := portsmocks.NewMockTransport(t)
705+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
706+ tr.EXPECT().Recv().Return(
707+ frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),
708+ nil,
709+ ).Once()
710+ tr.EXPECT().Close().Return(nil).Once()
711+ d := portsmocks.NewMockDialer(t)
712+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
713+
714+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
715+ require.Error(t, err)
716+ var pe *client.ProtocolError
717+ require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
718+}
719+
720+func TestRunPhaseASingleAttempt(t *testing.T) {
721+ dialErr := errors.New("dial failed")
722+ d := portsmocks.NewMockDialer(t)
723+ d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
724+ tm := portsmocks.NewMockTerminal(t)
725+
726+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
727+ require.ErrorIs(t, err, dialErr)
728+}
729+
730+type sequenceDialer struct {
731+ trs []ports.Transport
732+ errs []error
733+ calls atomic.Int32
734+}
735+
736+func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
737+ i := int(d.calls.Add(1)) - 1
738+ if i < len(d.errs) && d.errs[i] != nil {
739+ return nil, d.errs[i]
740+ }
741+ if i >= len(d.trs) {
742+ return nil, io.EOF
743+ }
744+ return d.trs[i], nil
745+}
746+
747+type recordingTransport struct {
748+ recvs []recvItem
749+ sends []ports.Frame
750+ closed atomic.Int32
751+}
752+
753+func (t *recordingTransport) Send(f ports.Frame) error {
754+ t.sends = append(t.sends, f)
755+ return nil
756+}
757+
758+func (t *recordingTransport) Recv() (ports.Frame, error) {
759+ if len(t.recvs) == 0 {
760+ return ports.Frame{}, io.EOF
761+ }
762+ it := t.recvs[0]
763+ t.recvs = t.recvs[1:]
764+ return it.f, it.err
765+}
766+
767+func (t *recordingTransport) Close() error {
768+ t.closed.Add(1)
769+ return nil
770+}
771+
772+type runTerminal struct {
773+ in *blockingReader
774+ out bytes.Buffer
775+ rawCount atomic.Int32
776+ restoreCount atomic.Int32
777+ resizeCh chan domain.Size
778+}
779+
780+func newRunTerminal() *runTerminal {
781+ return &runTerminal{in: newBlockingReader(), resizeCh: make(chan domain.Size)}
782+}
783+
784+func (t *runTerminal) EnterRaw() (func() error, error) {
785+ t.rawCount.Add(1)
786+ return func() error { t.restoreCount.Add(1); return nil }, nil
787+}
788+func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }
789+func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
790+func (t *runTerminal) QueryColors() error { return nil }
791+func (t *runTerminal) In() io.Reader { return t.in }
792+func (t *runTerminal) Out() io.Writer { return &t.out }
793+func (t *runTerminal) Flush() error { return nil }
794+
795+func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello {
796+ t.Helper()
797+ require.NotEmpty(t, tr.sends)
798+ require.Equal(t, ports.MsgHello, tr.sends[0].Type)
799+ h, err := ports.UnmarshalHello(tr.sends[0].Payload)
800+ require.NoError(t, err)
801+ return h
802+}
803+
804+func welcomeFrame(token uint64) ports.Frame {
805+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))
806+}
807+
808+func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
809+ term := newRunTerminal()
810+ defer term.in.unblock()
811+ tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
812+ tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
813+ tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
814+ d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
815+
816+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
817+ require.NoError(t, err)
818+ require.Equal(t, int32(3), d.calls.Load())
819+ require.Equal(t, int32(1), term.rawCount.Load())
820+ require.Equal(t, int32(1), term.restoreCount.Load())
821+
822+ h1 := helloFromSend(t, tr1)
823+ h2 := helloFromSend(t, tr2)
824+ h3 := helloFromSend(t, tr3)
825+ require.Equal(t, ports.IntentAttach, h1.Intent)
826+ require.Zero(t, h1.ResumeToken)
827+ require.Equal(t, ports.IntentResume, h2.Intent)
828+ require.Equal(t, uint64(11), h2.ResumeToken)
829+ require.Equal(t, ports.IntentResume, h3.Intent)
830+ require.Equal(t, uint64(22), h3.ResumeToken)
831+ require.Equal(t, h1.ClientID, h2.ClientID)
832+ require.Equal(t, h1.ClientID, h3.ClientID)
833+ require.Contains(t, term.out.String(), "reconnecting…")
834+ require.Contains(t, term.out.String(), "\x1b[2K")
835+}
836+
837+func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
838+ term := newRunTerminal()
839+ defer term.in.unblock()
840+ tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
841+ d := &sequenceDialer{trs: []ports.Transport{tr}}
842+
843+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
844+ require.Error(t, err)
845+ var de *client.DetachedError
846+ require.True(t, errors.As(err, &de))
847+ require.Equal(t, int32(1), d.calls.Load())
848+ require.Equal(t, int32(1), term.restoreCount.Load())
849+}
A internal/usecase/client/clipboard_intercept.go +112 -0
@@ -0,0 +1,112 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "log/slog"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// ctrlV is the byte a Ctrl+V keypress sends on the wire.
13+const ctrlV = 0x16
14+
15+// maxClipboardImagePush caps the image the client will send in one
16+// MsgImagePush, mirroring the daemon's independent cap. The 1 MiB cap fits
17+// both sshstdio and the datagram transport's fragmented payload ceiling.
18+const maxClipboardImagePush = 1 << 20 // 1 MiB
19+
20+// clipboardIntercept sits between the terminal's theme scanner and the paste
21+// coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V
22+// (0x16): each occurrence triggers a clipboard image read, sent as one
23+// MsgImagePush frame instead of the keystroke. Everything else — ordinary
24+// bytes, and any 0x16 that arrives while the coalescer is mid-paste (pasted
25+// text may legitimately contain it) — is forwarded to next unchanged, so it
26+// still gets the coalescer's marker-splitting treatment.
27+type clipboardIntercept struct {
28+ coalescer *pasteCoalescer
29+ reader ports.ClipboardReader
30+ log *slog.Logger
31+ sendImage func(mime string, data []byte)
32+ next func([]byte)
33+}
34+
35+// Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it
36+// can be used interchangeably as the scanner sink.
37+//
38+// It must never scan for Ctrl+V past the point where a bracketed paste
39+// starts (or might start): pasted text can legitimately contain 0x16, and
40+// the paste's opening marker can itself be split across two reads. So:
41+//
42+// 1. If the coalescer is already Buffering (mid-paste, or holding a
43+// trailing strict prefix of the opening marker from a previous call),
44+// the whole chunk is handed to next untouched.
45+// 2. Otherwise, this chunk is scanned only up to the first byte that either
46+// starts the opening marker in full or could be the start of a marker
47+// split across this chunk's end (pasteMarkerBoundary); everything from
48+// that point onward is handed to next as one piece, unscanned, letting
49+// the coalescer's own state decide what happens to it (and to any
50+// bytes trailing an in-chunk paste — see the package-level note below).
51+func (c *clipboardIntercept) Scan(data []byte) {
52+ if c.coalescer.Buffering() {
53+ c.next(data)
54+ return
55+ }
56+
57+ boundary := pasteMarkerBoundary(data)
58+ segment := data[:boundary]
59+ for len(segment) > 0 {
60+ idx := bytes.IndexByte(segment, ctrlV)
61+ if idx < 0 {
62+ c.next(segment)
63+ break
64+ }
65+ if idx > 0 {
66+ c.next(segment[:idx])
67+ }
68+ segment = segment[idx+1:]
69+ c.handleCtrlV()
70+ }
71+
72+ // Bytes at/after boundary are (the possible start of) a bracketed
73+ // paste: handed off whole, so any Ctrl+V trailing a paste that closes
74+ // within this same chunk is not intercepted either. In practice a real
75+ // keypress never arrives concatenated with paste bytes in one read, so
76+ // this is a deliberate, narrow trade-off in favor of never breaking
77+ // paste content.
78+ if tail := data[boundary:]; len(tail) > 0 {
79+ c.next(tail)
80+ }
81+}
82+
83+// pasteMarkerBoundary returns the index in data at or after which bytes must
84+// not be scanned for Ctrl+V because they start (or, at the very end of data,
85+// might be a split-across-reads prefix of) the bracketed-paste opening
86+// marker. It returns len(data) when neither is present.
87+func pasteMarkerBoundary(data []byte) int {
88+ if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
89+ return idx
90+ }
91+ if p := trailingOpenPrefixLen(data); p > 0 {
92+ return len(data) - p
93+ }
94+ return len(data)
95+}
96+
97+func (c *clipboardIntercept) handleCtrlV() {
98+ mime, data, err := c.reader.ReadImage(context.Background())
99+ if err != nil {
100+ if !errors.Is(err, ports.ErrNoClipboardImage) {
101+ c.log.Warn("clipboard image read failed", "err", err)
102+ }
103+ c.next([]byte{ctrlV})
104+ return
105+ }
106+ if len(data) > maxClipboardImagePush {
107+ c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush)
108+ c.next([]byte{ctrlV})
109+ return
110+ }
111+ c.sendImage(mime, data)
112+}
A internal/usecase/client/clipboard_intercept_internal_test.go +160 -0
@@ -0,0 +1,160 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// ciFakeReader is a directly-controllable ports.ClipboardReader for
15+// white-box interceptor tests (this file lives in package client, alongside
16+// paste_coalescer_test.go, so it can drive pasteCoalescer.Scan directly).
17+type ciFakeReader struct {
18+ mime string
19+ data []byte
20+ err error
21+ n int
22+}
23+
24+func (r *ciFakeReader) ReadImage(context.Context) (string, []byte, error) {
25+ r.n++
26+ return r.mime, r.data, r.err
27+}
28+
29+func newTestClipboardIntercept(reader ports.ClipboardReader) (*clipboardIntercept, *pasteCoalescer, *pcFakeClock, *pcCollector, *[]ports.ImagePush) {
30+ clk := &pcFakeClock{}
31+ col := &pcCollector{}
32+ pc := newPasteCoalescer(clk, col.emit)
33+ var images []ports.ImagePush
34+ ci := &clipboardIntercept{
35+ coalescer: pc,
36+ reader: reader,
37+ log: slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{Level: slog.LevelError + 1})),
38+ sendImage: func(mime string, data []byte) {
39+ images = append(images, ports.ImagePush{Mime: mime, Data: data})
40+ },
41+ next: pc.Scan,
42+ }
43+ return ci, pc, clk, col, &images
44+}
45+
46+func TestClipboardInterceptCtrlVWithImageSendsNoPassthrough(t *testing.T) {
47+ reader := &ciFakeReader{mime: "image/png", data: []byte("PNGDATA")}
48+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
49+ defer pc.Close()
50+
51+ ci.Scan([]byte("a"))
52+ ci.Scan([]byte{ctrlV})
53+ ci.Scan([]byte("b"))
54+
55+ require.Equal(t, 1, reader.n, "clipboard must be read exactly once per Ctrl+V")
56+ require.Equal(t, []ports.ImagePush{{Mime: "image/png", Data: []byte("PNGDATA")}}, *images)
57+ require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, col.snapshot(), "0x16 itself must never reach the coalescer/pane")
58+}
59+
60+func TestClipboardInterceptCtrlVNoImageForwardsByte(t *testing.T) {
61+ reader := &ciFakeReader{err: ports.ErrNoClipboardImage}
62+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
63+ defer pc.Close()
64+
65+ ci.Scan([]byte("a\x16b"))
66+
67+ require.Empty(t, *images)
68+ require.Equal(t, [][]byte{[]byte("a"), {ctrlV}, []byte("b")}, col.snapshot())
69+}
70+
71+func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) {
72+ reader := &ciFakeReader{err: errors.New("boom")}
73+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
74+ defer pc.Close()
75+
76+ ci.Scan([]byte{ctrlV})
77+
78+ require.Empty(t, *images)
79+ require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
80+}
81+
82+func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) {
83+ huge := make([]byte, maxClipboardImagePush+1)
84+ reader := &ciFakeReader{mime: "image/png", data: huge}
85+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
86+ defer pc.Close()
87+
88+ ci.Scan([]byte{ctrlV})
89+
90+ require.Empty(t, *images, "an oversized image must not be sent")
91+ require.Equal(t, [][]byte{{ctrlV}}, col.snapshot(), "an oversized image must fall back to forwarding Ctrl+V, not drop it")
92+}
93+
94+func TestClipboardInterceptMultipleCtrlVInOneChunk(t *testing.T) {
95+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
96+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
97+ defer pc.Close()
98+
99+ ci.Scan([]byte("a\x16b\x16c"))
100+
101+ require.Equal(t, 2, reader.n)
102+ require.Len(t, *images, 2)
103+ require.Equal(t, [][]byte{[]byte("a"), []byte("b"), []byte("c")}, col.snapshot())
104+}
105+
106+func TestClipboardInterceptCtrlVInsideBracketedPasteSameChunkNotIntercepted(t *testing.T) {
107+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
108+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
109+ defer pc.Close()
110+
111+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~")
112+ ci.Scan(paste)
113+
114+ require.Zero(t, reader.n, "clipboard must never be consulted for a Ctrl+V inside a paste")
115+ require.Empty(t, *images)
116+ require.Equal(t, [][]byte{paste}, col.snapshot(), "the paste must reach the coalescer whole, byte-identical")
117+}
118+
119+func TestClipboardInterceptCtrlVInsideBracketedPasteAcrossChunksNotIntercepted(t *testing.T) {
120+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
121+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
122+ defer pc.Close()
123+
124+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~")
125+ for s := 1; s < len(paste); s++ {
126+ t.Run("", func(t *testing.T) {
127+ reader.n = 0
128+ *images = nil
129+ col.emits = nil
130+ ci.Scan(paste[:s])
131+ ci.Scan(paste[s:])
132+ require.Zero(t, reader.n, "split at %d: clipboard must never be consulted for a Ctrl+V inside a paste", s)
133+ require.Empty(t, *images)
134+ require.Equal(t, [][]byte{paste}, col.snapshot(), "split at %d: the paste must reach the coalescer whole", s)
135+ })
136+ }
137+}
138+
139+func TestClipboardInterceptCtrlVAfterLoneEscapePendingNotIntercepted(t *testing.T) {
140+ // A trailing lone ESC (a strict prefix of the open marker) is held by
141+ // the coalescer as "pending" without yet flipping Buffering() via the
142+ // old (buffering-only) definition; Buffering() must also cover pending
143+ // so a Ctrl+V arriving before the flush timer fires is never
144+ // misinterpreted mid-marker.
145+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
146+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
147+ defer pc.Close()
148+
149+ ci.Scan([]byte{0x1b}) // held as pending, awaiting more marker bytes
150+ require.True(t, pc.Buffering(), "a pending trailing marker-prefix must count as Buffering")
151+
152+ ci.Scan([]byte{ctrlV})
153+
154+ require.Zero(t, reader.n, "clipboard must not be consulted while a marker prefix is pending")
155+ require.Empty(t, *images)
156+ // The ESC-then-Ctrl+V recombine as ordinary bytes handed whole to the
157+ // coalescer, which does not find a marker in "\x1bfamiliar" and emits
158+ // it as plain text once no further marker bytes follow.
159+ require.Equal(t, [][]byte{{0x1b, ctrlV}}, col.snapshot())
160+}
A internal/usecase/client/clipboard_intercept_test.go +177 -0
@@ -0,0 +1,177 @@
1+package client_test
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
17+ "github.com/bnema/vev/internal/usecase/client"
18+)
19+
20+// clipboardRunTestDeps wires a Run() attempt with an input reader that
21+// delivers data then blocks (so the pump stays parked until detach), a
22+// transport that captures every MsgInput/MsgImagePush send, and a Recv that
23+// answers Welcome then, once triggered, Detached — mirroring the pattern used
24+// by the OSC-color-response test elsewhere in this package.
25+func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReader, stdin []byte, triggerByte byte) (gotInput chan []byte, gotImage chan ports.ImagePush) {
26+ t.Helper()
27+ var out bytes.Buffer
28+ var restoreCount int32
29+ resizeCh := make(chan domain.Size)
30+ input := newOneShotBlockingReader(stdin)
31+ defer input.unblock()
32+
33+ tm := portsmocks.NewMockTerminal(t)
34+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
35+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()
36+ tm.EXPECT().In().Return(input).Maybe()
37+ tm.EXPECT().Out().Return(&out).Maybe()
38+ tm.EXPECT().Flush().Return(nil).Maybe()
39+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
40+
41+ gotInput = make(chan []byte, 8)
42+ gotImage = make(chan ports.ImagePush, 1)
43+ allowDetach := make(chan struct{})
44+
45+ tr := portsmocks.NewMockTransport(t)
46+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
47+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
48+ in, err := ports.UnmarshalInput(f.Payload)
49+ require.NoError(t, err)
50+ gotInput <- append([]byte(nil), in.Data...)
51+ if bytes.IndexByte(in.Data, triggerByte) >= 0 {
52+ closeOnce(allowDetach)
53+ }
54+ return nil
55+ }).Maybe()
56+ tr.EXPECT().Send(isType(ports.MsgImagePush)).RunAndReturn(func(f ports.Frame) error {
57+ ip, err := ports.UnmarshalImagePush(f.Payload)
58+ require.NoError(t, err)
59+ gotImage <- ip
60+ return nil
61+ }).Maybe()
62+
63+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
64+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
65+ recvCh := make(chan recvItem, 1)
66+ recvCh <- recvItem{f: welcome}
67+ closed := make(chan struct{})
68+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
69+ select {
70+ case it := <-recvCh:
71+ return it.f, it.err
72+ case <-allowDetach:
73+ select {
74+ case <-closed:
75+ return ports.Frame{}, io.EOF
76+ default:
77+ close(closed)
78+ return detached, nil
79+ }
80+ case <-closed:
81+ return ports.Frame{}, io.EOF
82+ }
83+ }).Maybe()
84+ tr.EXPECT().Close().Return(nil).Once()
85+
86+ d := portsmocks.NewMockDialer(t)
87+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
88+
89+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip, slog.New(slog.DiscardHandler))
90+ require.NoError(t, err)
91+ return gotInput, gotImage
92+}
93+
94+func closeOnce(ch chan struct{}) {
95+ select {
96+ case <-ch:
97+ default:
98+ close(ch)
99+ }
100+}
101+
102+func drainInput(gotInput chan []byte) []byte {
103+ var b []byte
104+ for {
105+ select {
106+ case chunk := <-gotInput:
107+ b = append(b, chunk...)
108+ default:
109+ return b
110+ }
111+ }
112+}
113+
114+func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testing.T) {
115+ clip := portsmocks.NewMockClipboardReader(t)
116+ clip.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte("PNGDATA"), nil).Once()
117+
118+ gotInput, gotImage := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
119+
120+ select {
121+ case ip := <-gotImage:
122+ require.Equal(t, uint64(2), ip.InputSeq)
123+ require.Equal(t, "image/png", ip.Mime)
124+ require.Equal(t, []byte("PNGDATA"), ip.Data)
125+ case <-time.After(2 * time.Second):
126+ t.Fatal("MsgImagePush was not sent")
127+ }
128+ require.Equal(t, []byte("ab"), drainInput(gotInput), "Ctrl+V must not be forwarded as input when an image is sent")
129+}
130+
131+func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) {
132+ clip := portsmocks.NewMockClipboardReader(t)
133+ clip.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once()
134+
135+ gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
136+
137+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image")
138+}
139+
140+func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
141+ huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap
142+ clip := portsmocks.NewMockClipboardReader(t)
143+ clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once()
144+
145+ gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
146+
147+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "an oversized clipboard image must not be swallowed: Ctrl+V is forwarded instead")
148+}
149+
150+func TestRunRemoteClipboardCtrlVInsideBracketedPasteIsNotIntercepted(t *testing.T) {
151+ // A MockClipboardReader with no .EXPECT() set fails the test if
152+ // ReadImage is ever called — which must never happen for a 0x16 that
153+ // arrives as part of pasted content, even when the whole paste (open
154+ // marker, content, close marker) lands in a single read alongside it.
155+ clip := portsmocks.NewMockClipboardReader(t)
156+
157+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~Z")
158+ gotInput, gotImage := runRemoteClipboardTest(t, true, clip, paste, 'Z')
159+
160+ require.Equal(t, paste, drainInput(gotInput), "a Ctrl+V byte inside a bracketed paste must be forwarded as ordinary paste content, unsplit")
161+ select {
162+ case ip := <-gotImage:
163+ t.Fatalf("clipboard image must not be pushed for a Ctrl+V inside a paste, got %+v", ip)
164+ default:
165+ }
166+}
167+
168+func TestRunLocalAttachDoesNotInterceptCtrlVEvenIfClipboardConfigured(t *testing.T) {
169+ // A MockClipboardReader with no .EXPECT() set fails the test if ReadImage
170+ // is ever called, which is exactly what a local (non-remote) attach must
171+ // never do.
172+ clip := portsmocks.NewMockClipboardReader(t)
173+
174+ gotInput, _ := runRemoteClipboardTest(t, false, clip, []byte("a\x16b"), 'b')
175+
176+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "local attach must forward Ctrl+V untouched")
177+}
A internal/usecase/client/paste_coalescer.go +267 -0
@@ -0,0 +1,267 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+const (
12+ // pasteFlushDelay bounds how long a trailing strict prefix of the opening
13+ // marker is held before it is emitted as ordinary bytes. It only has to
14+ // bridge a local kernel read split: the rest of the marker is already in
15+ // the tty buffer and arrives on the next Read microseconds later. It is NOT
16+ // meant to bridge a network-scale gap — once the opening marker completes,
17+ // buffering mode holds the whole paste (up to the 2s safety timer), so a
18+ // close-marker split at any realistic latency is covered by whole-paste
19+ // framing rather than this timer. Kept well under the daemon router's
20+ // ESCDelay so a real lone Esc keypress still reaches the pane promptly.
21+ pasteFlushDelay = 20 * time.Millisecond
22+ // pasteSafetyDelay caps how long the coalescer waits for a closing marker
23+ // before flushing a partial paste, so a paste with a lost closing marker
24+ // degrades to today's per-frame behavior instead of stalling input.
25+ pasteSafetyDelay = 2 * time.Second
26+ // maxPasteBuffer caps buffered paste bytes. Beyond it the buffer is flushed
27+ // verbatim and normal scanning resumes; input is never dropped.
28+ maxPasteBuffer = 2 << 20 // 2 MiB
29+)
30+
31+// pasteCoalescer reframes a bracketed paste that arrives split across input
32+// reads into a single emit call. Over a remote attach, stdin is read in fixed
33+// chunks and forwarded frame by frame; an input-frame boundary can land inside
34+// a paste marker (e.g. between the marker's ESC and its '['). When the daemon's
35+// keys router then sees a lone trailing ESC it retains it for ESCDelay and, if
36+// the next frame is late, forwards it as a standalone Esc keypress — breaking
37+// the opening marker so the pane's TUI never enters paste mode. Coalescing the
38+// whole paste into one emit keeps both markers contiguous on the wire.
39+//
40+// It is safe for concurrent use: Scan runs on the stdin pump goroutine while a
41+// flush/safety timer may fire on its own goroutine; both take the mutex, so
42+// emit calls are serialised.
43+type pasteCoalescer struct {
44+ clock ports.Clock
45+ emit func([]byte)
46+
47+ mu sync.Mutex
48+ buffering bool // inside a paste: accumulating until the closing marker
49+ buf []byte // paste bytes accumulated in buffering mode
50+ pending []byte // held trailing strict prefix of the opening marker
51+
52+ flushTimer ports.Timer
53+ flushDone chan struct{}
54+ safetyTimer ports.Timer
55+ safetyDone chan struct{}
56+ closed bool
57+}
58+
59+func newPasteCoalescer(clock ports.Clock, emit func([]byte)) *pasteCoalescer {
60+ return &pasteCoalescer{clock: clock, emit: emit}
61+}
62+
63+// Scan consumes one run of ordinary input bytes, emitting either pass-through
64+// bytes immediately or a whole bracketed paste as a single emit call once its
65+// closing marker is seen.
66+func (c *pasteCoalescer) Scan(data []byte) {
67+ if len(data) == 0 {
68+ return
69+ }
70+ c.mu.Lock()
71+ defer c.mu.Unlock()
72+ if len(c.pending) > 0 {
73+ // New bytes arrived before the flush timer fired: the held prefix was
74+ // the start of this data, not a lone Esc. Recombine and rescan.
75+ c.stopFlushTimer()
76+ data = append(c.pending, data...)
77+ c.pending = nil
78+ }
79+ c.process(data)
80+}
81+
82+// process runs the scan/buffer state machine with the mutex held.
83+func (c *pasteCoalescer) process(data []byte) {
84+ for {
85+ if c.buffering {
86+ searchFrom := len(c.buf)
87+ if overlap := len(ports.BracketedPasteCloseMarker) - 1; searchFrom > overlap {
88+ searchFrom -= overlap
89+ } else {
90+ searchFrom = 0
91+ }
92+ c.buf = append(c.buf, data...)
93+ idx := bytes.Index(c.buf[searchFrom:], ports.BracketedPasteCloseMarker)
94+ if idx >= 0 {
95+ idx += searchFrom
96+ }
97+ if idx < 0 {
98+ if len(c.buf) > maxPasteBuffer {
99+ c.emit(c.buf)
100+ c.resetBuffering()
101+ }
102+ return
103+ }
104+ end := idx + len(ports.BracketedPasteCloseMarker)
105+ c.emit(c.buf[:end])
106+ rest := append([]byte(nil), c.buf[end:]...)
107+ c.resetBuffering()
108+ data = rest
109+ // Fall through to scan any bytes trailing the paste.
110+ }
111+
112+ if len(data) == 0 {
113+ return
114+ }
115+
116+ if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
117+ if idx > 0 {
118+ c.emit(data[:idx])
119+ }
120+ c.buffering = true
121+ c.startSafetyTimer()
122+ data = data[idx:] // marker onward; re-enter the buffering branch
123+ continue
124+ }
125+
126+ if p := trailingOpenPrefixLen(data); p > 0 {
127+ if len(data) > p {
128+ c.emit(data[:len(data)-p])
129+ }
130+ c.pending = append([]byte(nil), data[len(data)-p:]...)
131+ c.startFlushTimer()
132+ return
133+ }
134+
135+ c.emit(data)
136+ return
137+ }
138+}
139+
140+// resetBuffering leaves paste mode and stops the safety timer.
141+func (c *pasteCoalescer) resetBuffering() {
142+ c.buffering = false
143+ c.buf = nil
144+ c.stopSafetyTimer()
145+}
146+
147+// Buffering reports whether it is currently unsafe to reinterpret incoming
148+// bytes as ordinary keystrokes: either a bracketed paste is being
149+// accumulated, or a trailing strict prefix of the opening marker is being
150+// held across a read boundary (pending, awaiting either completion into a
151+// real paste or the flush timer proving it was not one). The clipboard
152+// Ctrl+V interceptor uses this to avoid treating bytes that are, or might
153+// turn out to be, part of an in-flight paste as a standalone keystroke —
154+// pasted text may legitimately contain 0x16.
155+func (c *pasteCoalescer) Buffering() bool {
156+ c.mu.Lock()
157+ defer c.mu.Unlock()
158+ return c.buffering || len(c.pending) > 0
159+}
160+
161+// Close stops any pending timers and their goroutines. Held bytes are dropped:
162+// Close runs only as the stdin pump unwinds on detach, when there is nowhere
163+// left to deliver them.
164+func (c *pasteCoalescer) Close() {
165+ c.mu.Lock()
166+ defer c.mu.Unlock()
167+ c.closed = true
168+ c.stopFlushTimer()
169+ c.stopSafetyTimer()
170+}
171+
172+func (c *pasteCoalescer) startFlushTimer() {
173+ c.stopFlushTimer()
174+ if c.closed {
175+ return
176+ }
177+ timer := c.clock.NewTimer(pasteFlushDelay)
178+ done := make(chan struct{})
179+ c.flushTimer = timer
180+ c.flushDone = done
181+ go func() {
182+ select {
183+ case <-timer.C():
184+ case <-done:
185+ return
186+ }
187+ c.mu.Lock()
188+ defer c.mu.Unlock()
189+ if c.flushTimer != timer || len(c.pending) == 0 {
190+ return
191+ }
192+ c.flushTimer = nil
193+ c.flushDone = nil
194+ held := c.pending
195+ c.pending = nil
196+ c.emit(held)
197+ }()
198+}
199+
200+func (c *pasteCoalescer) stopFlushTimer() {
201+ if c.flushTimer != nil {
202+ c.flushTimer.Stop()
203+ c.flushTimer = nil
204+ }
205+ if c.flushDone != nil {
206+ close(c.flushDone)
207+ c.flushDone = nil
208+ }
209+}
210+
211+func (c *pasteCoalescer) startSafetyTimer() {
212+ c.stopSafetyTimer()
213+ if c.closed {
214+ return
215+ }
216+ timer := c.clock.NewTimer(pasteSafetyDelay)
217+ done := make(chan struct{})
218+ c.safetyTimer = timer
219+ c.safetyDone = done
220+ go func() {
221+ select {
222+ case <-timer.C():
223+ case <-done:
224+ return
225+ }
226+ c.mu.Lock()
227+ defer c.mu.Unlock()
228+ if c.safetyTimer != timer || !c.buffering {
229+ return
230+ }
231+ buffered := c.buf
232+ c.buffering = false
233+ c.buf = nil
234+ c.safetyTimer = nil
235+ c.safetyDone = nil
236+ if len(buffered) > 0 {
237+ c.emit(buffered)
238+ }
239+ }()
240+}
241+
242+func (c *pasteCoalescer) stopSafetyTimer() {
243+ if c.safetyTimer != nil {
244+ c.safetyTimer.Stop()
245+ c.safetyTimer = nil
246+ }
247+ if c.safetyDone != nil {
248+ close(c.safetyDone)
249+ c.safetyDone = nil
250+ }
251+}
252+
253+// trailingOpenPrefixLen returns the length of the longest suffix of data that
254+// is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or
255+// "\x1b[20"). A full marker match is handled by the caller before this runs.
256+func trailingOpenPrefixLen(data []byte) int {
257+ matchLimit := len(ports.BracketedPasteOpenMarker) - 1
258+ if matchLimit > len(data) {
259+ matchLimit = len(data)
260+ }
261+ for n := matchLimit; n >= 1; n-- {
262+ if bytes.Equal(data[len(data)-n:], ports.BracketedPasteOpenMarker[:n]) {
263+ return n
264+ }
265+ }
266+ return 0
267+}
A internal/usecase/client/paste_coalescer_test.go +227 -0
@@ -0,0 +1,227 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "strconv"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+// pcFakeClock hands out fake timers the test fires by hand. The coalescer only
16+// ever has one timer live at a time (a flush timer in normal mode or a safety
17+// timer while buffering), so fireLast drives whichever is pending.
18+type pcFakeClock struct {
19+ mu sync.Mutex
20+ timers []*pcFakeTimer
21+}
22+
23+func (c *pcFakeClock) Now() time.Time { return time.Time{} }
24+
25+func (c *pcFakeClock) NewTimer(d time.Duration) ports.Timer {
26+ c.mu.Lock()
27+ defer c.mu.Unlock()
28+ t := &pcFakeTimer{ch: make(chan time.Time, 1), d: d}
29+ c.timers = append(c.timers, t)
30+ return t
31+}
32+
33+func (c *pcFakeClock) fireLast() {
34+ c.mu.Lock()
35+ defer c.mu.Unlock()
36+ for i := len(c.timers) - 1; i >= 0; i-- {
37+ if !c.timers[i].stopped && !c.timers[i].fired {
38+ c.timers[i].fired = true
39+ c.timers[i].ch <- time.Time{}
40+ return
41+ }
42+ }
43+}
44+
45+type pcFakeTimer struct {
46+ ch chan time.Time
47+ d time.Duration
48+ stopped bool
49+ fired bool
50+}
51+
52+func (t *pcFakeTimer) C() <-chan time.Time { return t.ch }
53+func (t *pcFakeTimer) Reset(d time.Duration) bool { t.d = d; return !t.stopped }
54+func (t *pcFakeTimer) Stop() bool { s := t.stopped; t.stopped = true; return !s }
55+
56+// pcCollector records emit calls under a mutex so a timer goroutine's emit is
57+// safe to observe from the test goroutine.
58+type pcCollector struct {
59+ mu sync.Mutex
60+ emits [][]byte
61+}
62+
63+func (c *pcCollector) emit(data []byte) {
64+ c.mu.Lock()
65+ defer c.mu.Unlock()
66+ c.emits = append(c.emits, append([]byte(nil), data...))
67+}
68+
69+func (c *pcCollector) snapshot() [][]byte {
70+ c.mu.Lock()
71+ defer c.mu.Unlock()
72+ out := make([][]byte, len(c.emits))
73+ copy(out, c.emits)
74+ return out
75+}
76+
77+func (c *pcCollector) waitEmits(t *testing.T, n int) {
78+ t.Helper()
79+ require.Eventually(t, func() bool {
80+ c.mu.Lock()
81+ defer c.mu.Unlock()
82+ return len(c.emits) >= n
83+ }, time.Second, time.Millisecond)
84+}
85+
86+func newTestCoalescer() (*pasteCoalescer, *pcFakeClock, *pcCollector) {
87+ clk := &pcFakeClock{}
88+ col := &pcCollector{}
89+ return newPasteCoalescer(clk, col.emit), clk, col
90+}
91+
92+// samplePaste is a multiline bracketed paste whose content deliberately embeds
93+// sequences that would otherwise be interpreted (a lone ESC+letter, an Alt
94+// arrow CSI, a marker-looking prefix) to prove content is carried verbatim.
95+var samplePaste = []byte("\x1b[200~line one\nline\x1btwo\n\x1b[1;3Athree\x1b[20not-a-marker\x1b[201~")
96+
97+func TestPasteCoalescerSplitAtEveryOffsetTwoReads(t *testing.T) {
98+ for s := 0; s <= len(samplePaste); s++ {
99+ t.Run("split_"+strconv.Itoa(s), func(t *testing.T) {
100+ pc, _, col := newTestCoalescer()
101+ defer pc.Close()
102+ pc.Scan(samplePaste[:s])
103+ pc.Scan(samplePaste[s:])
104+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
105+ "paste split at offset %d must emit exactly once, byte-identical", s)
106+ })
107+ }
108+}
109+
110+func TestPasteCoalescerByteByByte(t *testing.T) {
111+ pc, _, col := newTestCoalescer()
112+ defer pc.Close()
113+ for i := 0; i < len(samplePaste); i++ {
114+ pc.Scan(samplePaste[i : i+1])
115+ }
116+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
117+ "paste fed one byte at a time must still emit as one contiguous paste")
118+}
119+
120+func TestPasteCoalescerBackToBackPastesInOneRead(t *testing.T) {
121+ pc, _, col := newTestCoalescer()
122+ defer pc.Close()
123+
124+ first := []byte("\x1b[200~first\x1b[201~")
125+ second := []byte("\x1b[200~second\x1b[201~")
126+ pc.Scan(append(append([]byte(nil), first...), second...))
127+
128+ require.Equal(t, [][]byte{first, second}, col.snapshot(),
129+ "back-to-back pastes in one read must emit as two intact paste frames")
130+}
131+
132+func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) {
133+ pc, clk, col := newTestCoalescer()
134+ defer pc.Close()
135+
136+ pc.Scan([]byte{0x1b})
137+ require.Empty(t, col.snapshot(), "a trailing ESC must be held, not emitted immediately")
138+
139+ clk.fireLast()
140+ col.waitEmits(t, 1)
141+ require.Equal(t, [][]byte{{0x1b}}, col.snapshot(), "held ESC must flush unchanged after the timer")
142+}
143+
144+func TestPasteCoalescerEscapeThenOpenMarkerNextRead(t *testing.T) {
145+ pc, _, col := newTestCoalescer()
146+ defer pc.Close()
147+
148+ pc.Scan([]byte{0x1b})
149+ require.Empty(t, col.snapshot(), "ESC held pending the rest of the marker")
150+
151+ pc.Scan(samplePaste[1:]) // "[200~...\x1b[201~"
152+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
153+ "ESC recombined with the following marker must emit a single paste")
154+}
155+
156+func TestPasteCoalescerPlainTextPassthrough(t *testing.T) {
157+ pc, _, col := newTestCoalescer()
158+ defer pc.Close()
159+
160+ pc.Scan([]byte("hello world"))
161+ pc.Scan([]byte("second read"))
162+ require.Equal(t, [][]byte{[]byte("hello world"), []byte("second read")}, col.snapshot(),
163+ "plain text must pass through unchanged at the same call granularity")
164+}
165+
166+func TestPasteCoalescerTextPasteTextInOneRead(t *testing.T) {
167+ pc, _, col := newTestCoalescer()
168+ defer pc.Close()
169+
170+ paste := []byte("\x1b[200~pasted\x1b[201~")
171+ read := append(append([]byte("AB"), paste...), []byte("CD")...)
172+ pc.Scan(read)
173+ require.Equal(t, [][]byte{[]byte("AB"), paste, []byte("CD")}, col.snapshot(),
174+ "text + paste + text in one read must emit three chunks in order")
175+}
176+
177+func TestPasteCoalescerBufferCapOverflowFlushesVerbatim(t *testing.T) {
178+ pc, _, col := newTestCoalescer()
179+ defer pc.Close()
180+
181+ big := append([]byte("\x1b[200~"), bytes.Repeat([]byte("x"), maxPasteBuffer+16)...)
182+ pc.Scan(big)
183+ require.Equal(t, [][]byte{big}, col.snapshot(),
184+ "an oversized unterminated paste must flush verbatim without dropping bytes")
185+
186+ // The coalescer must be back in normal mode and keep working.
187+ pc.Scan([]byte("after"))
188+ require.Equal(t, [][]byte{big, []byte("after")}, col.snapshot())
189+}
190+
191+func TestPasteCoalescerSafetyTimeoutFlushesVerbatim(t *testing.T) {
192+ pc, clk, col := newTestCoalescer()
193+ defer pc.Close()
194+
195+ partial := []byte("\x1b[200~unterminated paste")
196+ pc.Scan(partial)
197+ require.Empty(t, col.snapshot(), "an open paste with no closing marker is buffered, not emitted")
198+
199+ clk.fireLast() // safety timer
200+ col.waitEmits(t, 1)
201+ require.Equal(t, [][]byte{partial}, col.snapshot(),
202+ "the safety timeout must flush buffered paste bytes verbatim")
203+
204+ pc.Scan([]byte("normal"))
205+ require.Equal(t, [][]byte{partial, []byte("normal")}, col.snapshot(),
206+ "after the safety flush the coalescer must resume normal scanning")
207+}
208+
209+func TestTrailingOpenPrefixLen(t *testing.T) {
210+ cases := []struct {
211+ in string
212+ want int
213+ }{
214+ {"", 0},
215+ {"abc", 0},
216+ {"abc\x1b", 1},
217+ {"abc\x1b[", 2},
218+ {"\x1b[2", 3},
219+ {"\x1b[20", 4},
220+ {"\x1b[200", 5},
221+ {"\x1b[200~", 0}, // full marker is not a strict prefix
222+ {"[200", 0}, // no ESC: not a marker start
223+ }
224+ for _, tc := range cases {
225+ require.Equal(t, tc.want, trailingOpenPrefixLen([]byte(tc.in)), "input %q", tc.in)
226+ }
227+}
A internal/usecase/client/reconnect.go +61 -0
@@ -0,0 +1,61 @@
1+package client
2+
3+import (
4+ "errors"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+type reconnectBackoff struct {
11+ initial time.Duration
12+ max time.Duration
13+}
14+
15+var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}
16+
17+// errLinkOffline is returned by attachOnce when the transport link reaches the
18+// Offline state. It is retryable so Run re-dials over ssh with the resume token
19+// at ~30s of silence instead of waiting the full Dead timeout at 60s.
20+var errLinkOffline = errors.New("vev: link offline; reconnecting")
21+
22+type reconnectStage int
23+
24+const (
25+ reconnectStageDegraded reconnectStage = iota
26+ reconnectStageProbingUDP
27+ reconnectStageSSH
28+ reconnectStageOfflineRetrying
29+)
30+
31+func stageForLinkState(state ports.LinkState) reconnectStage {
32+ switch state {
33+ case ports.LinkStateDegraded:
34+ return reconnectStageDegraded
35+ case ports.LinkStateProbing:
36+ return reconnectStageProbingUDP
37+ case ports.LinkStateOffline, ports.LinkStateDead:
38+ return reconnectStageOfflineRetrying
39+ default:
40+ return reconnectStageSSH
41+ }
42+}
43+
44+func nextReconnectBackoff(cur, limit time.Duration) time.Duration {
45+ cur *= 2
46+ if cur > limit {
47+ return limit
48+ }
49+ return cur
50+}
51+
52+func shouldReconnect(err error) bool {
53+ if err == nil {
54+ return false
55+ }
56+ if _, ok := errors.AsType[*ProtocolError](err); ok {
57+ return false
58+ }
59+ _, ok := errors.AsType[*DetachedError](err)
60+ return !ok
61+}
A internal/usecase/client/reconnect_toast.go +105 -0
@@ -0,0 +1,105 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "io"
6+ "strings"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/ui"
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+const reconnectToastMessage = "offline; retrying…"
14+
15+func reconnectStageMessage(stage reconnectStage) string {
16+ switch stage {
17+ case reconnectStageDegraded:
18+ return "connection degraded"
19+ case reconnectStageProbingUDP:
20+ return "probing UDP path"
21+ case reconnectStageSSH:
22+ return "offline; retrying… reconnecting through SSH"
23+ case reconnectStageOfflineRetrying:
24+ return reconnectToastMessage
25+ default:
26+ return reconnectToastMessage
27+ }
28+}
29+
30+func drawReconnectToast(out io.Writer, size domain.Size) error {
31+ _, err := drawReconnectToastStage(out, size, reconnectStageOfflineRetrying)
32+ return err
33+}
34+
35+func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) {
36+ message := reconnectStageMessage(stage)
37+ bounds := reconnectToastBoundsFor(size, message)
38+ if bounds.Width <= 0 || bounds.Height <= 0 {
39+ return domain.Rect{}, nil
40+ }
41+ return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
42+}
43+
44+func clearReconnectToast(out io.Writer, bounds domain.Rect) error {
45+ if bounds.Width <= 0 || bounds.Height <= 0 {
46+ return nil
47+ }
48+ blank := strings.Repeat(" ", bounds.Width)
49+ lines := make([]string, bounds.Height)
50+ for i := range lines {
51+ lines[i] = blank
52+ }
53+ return writeReconnectToast(out, bounds, lines)
54+}
55+
56+func reconnectToastBounds(size domain.Size) domain.Rect {
57+ return reconnectToastBoundsFor(size, reconnectToastMessage)
58+}
59+
60+func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect {
61+ return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: ui.ToastCenter})
62+}
63+
64+func reconnectToastLines(bounds domain.Rect) []string {
65+ return reconnectToastLinesFor(bounds, reconnectToastMessage)
66+}
67+
68+func reconnectToastLinesFor(bounds domain.Rect, message string) []string {
69+ if bounds.Width <= 0 || bounds.Height <= 0 {
70+ return nil
71+ }
72+ frame := renderer.NewFrame(bounds.Width, bounds.Height)
73+ ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: message, Anchor: ui.ToastCenter}}}, ui.ToastStyles{
74+ Text: renderer.DefaultStyle(),
75+ Box: renderer.DefaultStyle(),
76+ }, nil)
77+ lines := make([]string, bounds.Height)
78+ for y := range bounds.Height {
79+ var b strings.Builder
80+ b.Grow(bounds.Width)
81+ for x := range bounds.Width {
82+ cell := frame.At(x, y)
83+ if cell.Continuation || cell.Rune == 0 {
84+ b.WriteRune(' ')
85+ continue
86+ }
87+ b.WriteRune(cell.Rune)
88+ }
89+ lines[y] = b.String()
90+ }
91+ return lines
92+}
93+
94+func writeReconnectToast(out io.Writer, bounds domain.Rect, lines []string) error {
95+ if _, err := io.WriteString(out, "\x1b[s"); err != nil {
96+ return err
97+ }
98+ for i, line := range lines {
99+ if _, err := fmt.Fprintf(out, "\x1b[%d;%dH\x1b[0m%s", bounds.Y+i+1, bounds.X+1, line); err != nil {
100+ return err
101+ }
102+ }
103+ _, err := io.WriteString(out, "\x1b[0m\x1b[u")
104+ return err
105+}
A internal/usecase/client/reconnect_toast_test.go +559 -0
@@ -0,0 +1,559 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "log/slog"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/ports"
19+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
20+ "github.com/bnema/vev/pkg/renderer"
21+)
22+
23+type reconnectToastTerminalHarness struct {
24+ term *portsmocks.MockTerminal
25+ in *io.PipeReader
26+ inWriter *io.PipeWriter
27+ out bytes.Buffer
28+ size domain.Size
29+ resizeCh chan domain.Size
30+}
31+
32+func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarness {
33+ t.Helper()
34+ in, inWriter := io.Pipe()
35+ h := &reconnectToastTerminalHarness{
36+ term: portsmocks.NewMockTerminal(t),
37+ in: in,
38+ inWriter: inWriter,
39+ size: domain.Size{Cols: 80, Rows: 24},
40+ resizeCh: make(chan domain.Size),
41+ }
42+ h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
43+ h.term.EXPECT().Size().Return(h.size, nil).Maybe()
44+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
45+ h.term.EXPECT().QueryColors().Return(nil).Maybe()
46+ h.term.EXPECT().In().Return(h.in).Maybe()
47+ h.term.EXPECT().Out().Return(&h.out).Maybe()
48+ h.term.EXPECT().Flush().Return(nil).Maybe()
49+ return h
50+}
51+
52+func newReconnectToastTerminalHarnessWithOutput(t *testing.T, out io.Writer) *reconnectToastTerminalHarness {
53+ t.Helper()
54+ in, inWriter := io.Pipe()
55+ h := &reconnectToastTerminalHarness{
56+ term: portsmocks.NewMockTerminal(t),
57+ in: in,
58+ inWriter: inWriter,
59+ size: domain.Size{Cols: 80, Rows: 24},
60+ resizeCh: make(chan domain.Size),
61+ }
62+ h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
63+ h.term.EXPECT().Size().Return(h.size, nil).Maybe()
64+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
65+ h.term.EXPECT().QueryColors().Return(nil).Maybe()
66+ h.term.EXPECT().In().Return(h.in).Maybe()
67+ h.term.EXPECT().Out().Return(out).Maybe()
68+ h.term.EXPECT().Flush().Return(nil).Maybe()
69+ return h
70+}
71+
72+func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }
73+
74+func reconnectToastWelcome(token uint64) ports.Frame {
75+ return reconnectToastWelcomeNamed("", token)
76+}
77+
78+func reconnectToastWelcomeNamed(name string, token uint64) ports.Frame {
79+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: name, ResumeToken: token, Capabilities: ports.CapabilityResume})}
80+}
81+
82+func reconnectToastDetach(reason uint8) ports.Frame {
83+ return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
84+}
85+
86+func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport {
87+ t.Helper()
88+ tr := portsmocks.NewMockTransport(t)
89+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
90+ for _, recv := range recvs {
91+ tr.EXPECT().Recv().Return(recv.frame, recv.err).Once()
92+ }
93+ tr.EXPECT().Recv().Return(ports.Frame{}, io.EOF).Maybe()
94+ tr.EXPECT().Close().Return(nil).Maybe()
95+ return tr
96+}
97+
98+type reconnectToastRecv struct {
99+ frame ports.Frame
100+ err error
101+}
102+
103+type reconnectToastSequenceDialer struct {
104+ transports []ports.Transport
105+ calls int
106+}
107+
108+func (d *reconnectToastSequenceDialer) Dial(context.Context) (ports.Transport, error) {
109+ if d.calls >= len(d.transports) {
110+ return nil, io.EOF
111+ }
112+ tr := d.transports[d.calls]
113+ d.calls++
114+ return tr, nil
115+}
116+
117+type reconnectToastRecordingTransport struct {
118+ recvs []reconnectToastRecv
119+ sends []ports.Frame
120+ closed bool
121+}
122+
123+type reconnectToastLinkTransport struct {
124+ recvCh chan reconnectToastRecv
125+ sends []ports.Frame
126+ events chan ports.LinkEvent
127+ state ports.LinkState
128+}
129+
130+func newReconnectToastLinkTransport() *reconnectToastLinkTransport {
131+ return &reconnectToastLinkTransport{
132+ recvCh: make(chan reconnectToastRecv, 4),
133+ events: make(chan ports.LinkEvent, 4),
134+ state: ports.LinkStateConnected,
135+ }
136+}
137+
138+func (t *reconnectToastLinkTransport) Send(f ports.Frame) error {
139+ t.sends = append(t.sends, f)
140+ return nil
141+}
142+
143+func (t *reconnectToastLinkTransport) Recv() (ports.Frame, error) {
144+ recv := <-t.recvCh
145+ return recv.frame, recv.err
146+}
147+
148+func (t *reconnectToastLinkTransport) Close() error { return nil }
149+
150+func (t *reconnectToastLinkTransport) LinkEvents() <-chan ports.LinkEvent { return t.events }
151+
152+func (t *reconnectToastLinkTransport) LinkState() ports.LinkState { return t.state }
153+
154+func (t *reconnectToastRecordingTransport) Send(f ports.Frame) error {
155+ t.sends = append(t.sends, f)
156+ return nil
157+}
158+
159+func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) {
160+ if len(t.recvs) == 0 {
161+ return ports.Frame{}, io.EOF
162+ }
163+ recv := t.recvs[0]
164+ t.recvs = t.recvs[1:]
165+ return recv.frame, recv.err
166+}
167+
168+func (t *reconnectToastRecordingTransport) Close() error {
169+ t.closed = true
170+ return nil
171+}
172+
173+func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransport) ports.Hello {
174+ t.Helper()
175+ require.NotEmpty(t, tr.sends)
176+ require.Equal(t, ports.MsgHello, tr.sends[0].Type)
177+ hello, err := ports.UnmarshalHello(tr.sends[0].Payload)
178+ require.NoError(t, err)
179+ return hello
180+}
181+
182+func TestAttachOnceLinkStateEventsDriveReconnectUIStage(t *testing.T) {
183+ term := newReconnectToastTerminalHarness(t)
184+ defer term.closeInput()
185+ tr := newReconnectToastLinkTransport()
186+ tr.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)}
187+
188+ stages := make(chan reconnectStage, 4)
189+ resultCh := make(chan attachResult, 1)
190+ ms := milestones{}
191+ go func() {
192+ resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error {
193+ _, err := term.term.EnterRaw()
194+ return err
195+ }, func() {}, func(stage reconnectStage) {
196+ stages <- stage
197+ }, tr.LinkEvents(), true, nil, slog.New(slog.DiscardHandler))
198+ }()
199+
200+ tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded}
201+ require.Equal(t, reconnectStageDegraded, <-stages)
202+ tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
203+ require.Equal(t, reconnectStageProbingUDP, <-stages)
204+ tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
205+ result := <-resultCh
206+ require.NoError(t, result.err)
207+ require.True(t, result.welcomed)
208+}
209+
210+func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) {
211+ term := newReconnectToastTerminalHarness(t)
212+ defer term.closeInput()
213+ tr := newReconnectToastLinkTransport()
214+ tr.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)}
215+
216+ stages := make(chan reconnectStage, 4)
217+ resultCh := make(chan attachResult, 1)
218+ ms := milestones{}
219+ go func() {
220+ resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error {
221+ _, err := term.term.EnterRaw()
222+ return err
223+ }, func() {}, func(stage reconnectStage) {
224+ stages <- stage
225+ }, tr.LinkEvents(), true, nil, slog.New(slog.DiscardHandler))
226+ }()
227+
228+ tr.events <- ports.LinkEvent{State: ports.LinkStateOffline}
229+ require.Equal(t, reconnectStageOfflineRetrying, <-stages)
230+ result := <-resultCh
231+ require.ErrorIs(t, result.err, errLinkOffline)
232+ require.True(t, result.welcomed)
233+ require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable")
234+}
235+
236+func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
237+ var out bytes.Buffer
238+ size := domain.Size{Cols: 80, Rows: 24}
239+
240+ require.NoError(t, drawReconnectToast(&out, size))
241+ require.Contains(t, out.String(), "┌")
242+ require.Contains(t, out.String(), reconnectToastMessage)
243+ require.Contains(t, out.String(), "\x1b[0m")
244+
245+ bounds := reconnectToastBounds(size)
246+ require.NoError(t, clearReconnectToast(&out, bounds))
247+ require.Contains(t, out.String(), strings.Repeat(" ", bounds.Width))
248+}
249+
250+func TestReconnectToastLinesClampToBounds(t *testing.T) {
251+ bounds := reconnectToastBounds(domain.Size{Cols: 8, Rows: 3})
252+ lines := reconnectToastLines(bounds)
253+ require.Len(t, lines, bounds.Height)
254+ for _, line := range lines {
255+ require.LessOrEqual(t, displayWidth(line), bounds.Width)
256+ }
257+ require.Contains(t, strings.Join(lines, "\n"), "…")
258+}
259+
260+func TestReconnectToastStageTransitionsClearDrawnBounds(t *testing.T) {
261+ size := domain.Size{Cols: 80, Rows: 24}
262+ tests := []struct {
263+ name string
264+ stages []reconnectStage
265+ }{
266+ {
267+ name: "degraded to probing to ssh to offline",
268+ stages: []reconnectStage{reconnectStageDegraded, reconnectStageProbingUDP, reconnectStageSSH, reconnectStageOfflineRetrying},
269+ },
270+ {
271+ name: "widest ssh to offline",
272+ stages: []reconnectStage{reconnectStageSSH, reconnectStageOfflineRetrying},
273+ },
274+ }
275+
276+ for _, tt := range tests {
277+ t.Run(tt.name, func(t *testing.T) {
278+ var out bytes.Buffer
279+ previousBounds, err := drawReconnectToastStage(&out, size, tt.stages[0])
280+ require.NoError(t, err)
281+
282+ for _, stage := range tt.stages[1:] {
283+ out.Reset()
284+ require.NoError(t, clearReconnectToast(&out, previousBounds))
285+ assertReconnectToastClearCoversBounds(t, out.String(), previousBounds)
286+
287+ out.Reset()
288+ previousBounds, err = drawReconnectToastStage(&out, size, stage)
289+ require.NoError(t, err)
290+ }
291+ })
292+ }
293+}
294+
295+func TestReconnectToastClearAfterWidestStageErasesFullBorder(t *testing.T) {
296+ size := domain.Size{Cols: 80, Rows: 24}
297+ var out bytes.Buffer
298+ bounds, err := drawReconnectToastStage(&out, size, reconnectStageSSH)
299+ require.NoError(t, err)
300+ require.Greater(t, bounds.Width, reconnectToastBounds(size).Width)
301+
302+ out.Reset()
303+ require.NoError(t, clearReconnectToast(&out, bounds))
304+ assertReconnectToastClearCoversBounds(t, out.String(), bounds)
305+}
306+
307+func assertReconnectToastClearCoversBounds(t *testing.T, output string, bounds domain.Rect) {
308+ t.Helper()
309+ blank := strings.Repeat(" ", bounds.Width)
310+ for row := range bounds.Height {
311+ require.Contains(t, output, fmt.Sprintf("\x1b[%d;%dH\x1b[0m%s", bounds.Y+row+1, bounds.X+1, blank))
312+ }
313+}
314+
315+func displayWidth(s string) int {
316+ width := 0
317+ for _, r := range s {
318+ width += renderer.RuneWidth(r)
319+ }
320+ return width
321+}
322+
323+func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
324+ clk := portsmocks.NewMockClock(t)
325+ timer := portsmocks.NewMockTimer(t)
326+ timerCh := make(chan time.Time, 1)
327+ clk.EXPECT().NewTimer(time.Hour).Return(timer).Once()
328+ timer.EXPECT().C().Return((<-chan time.Time)(timerCh)).Maybe()
329+ timer.EXPECT().Stop().Return(true).Once()
330+ resizeCh := make(chan domain.Size, 2)
331+ resizeCh <- domain.Size{Cols: 100, Rows: 30}
332+ resizeCh <- domain.Size{Cols: 120, Rows: 40}
333+ got := make(chan domain.Size, 2)
334+ done := make(chan bool, 1)
335+
336+ go func() {
337+ done <- sleepReconnectWithResizeEvents(context.Background(), clk, time.Hour, resizeCh, func(size domain.Size) {
338+ got <- size
339+ })
340+ }()
341+
342+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, <-got)
343+ require.Equal(t, domain.Size{Cols: 120, Rows: 40}, <-got)
344+ timerCh <- time.Time{}
345+ require.True(t, <-done)
346+}
347+
348+type reconnectToastFailOnceWriter struct {
349+ bytes.Buffer
350+ needle string
351+ failed bool
352+}
353+
354+func (w *reconnectToastFailOnceWriter) Write(p []byte) (int, error) {
355+ n, _ := w.Buffer.Write(p)
356+ if !w.failed && strings.Contains(string(p), w.needle) {
357+ w.failed = true
358+ return n, errors.New("reconnect toast write failed")
359+ }
360+ return n, nil
361+}
362+
363+func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) {
364+ oldSleep := reconnectSleep
365+ oldSleepWithResize := reconnectSleepWithResize
366+ ctx, cancel := context.WithCancel(context.Background())
367+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return false }
368+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
369+ cancel()
370+ return false
371+ }
372+ defer func() {
373+ reconnectSleep = oldSleep
374+ reconnectSleepWithResize = oldSleepWithResize
375+ }()
376+
377+ out := &reconnectToastFailOnceWriter{needle: "reconnecting through SSH"}
378+ term := newReconnectToastTerminalHarnessWithOutput(t, out)
379+ defer term.closeInput()
380+ tr := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
381+ {frame: reconnectToastWelcome(44)},
382+ {err: io.EOF},
383+ }}
384+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
385+
386+ err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
387+ require.ErrorIs(t, err, context.Canceled)
388+ require.True(t, out.failed)
389+ assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)))
390+}
391+
392+func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
393+ linkDead := errors.New("remote link dead")
394+ wrappedLinkDead := errors.Join(
395+ fmt.Errorf("remote transport receive failed: %w", io.EOF),
396+ linkDead,
397+ )
398+ oldSleep := reconnectSleep
399+ oldSleepWithResize := reconnectSleepWithResize
400+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
401+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
402+ return true
403+ }
404+ defer func() {
405+ reconnectSleep = oldSleep
406+ reconnectSleepWithResize = oldSleepWithResize
407+ }()
408+
409+ term := newReconnectToastTerminalHarness(t)
410+ defer term.closeInput()
411+ tr1 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
412+ {frame: reconnectToastWelcome(44)},
413+ {err: wrappedLinkDead},
414+ }}
415+ tr2 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
416+ {frame: reconnectToastWelcome(55)},
417+ {frame: reconnectToastDetach(ports.ReasonDetach)},
418+ }}
419+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
420+
421+ err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
422+ require.NoError(t, err)
423+ require.True(t, tr1.closed)
424+ require.True(t, tr2.closed)
425+ require.Equal(t, 2, dialer.calls)
426+
427+ firstHello := reconnectToastHelloFromSend(t, tr1)
428+ resumeHello := reconnectToastHelloFromSend(t, tr2)
429+ require.Equal(t, ports.IntentAttach, firstHello.Intent)
430+ require.Zero(t, firstHello.ResumeToken)
431+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
432+ require.Equal(t, uint64(44), resumeHello.ResumeToken)
433+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
434+
435+ out := term.out.String()
436+ require.Contains(t, out, reconnectToastMessage)
437+ require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
438+}
439+
440+func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
441+ linkDead := errors.New("remote link dead")
442+ oldSleep := reconnectSleep
443+ oldSleepWithResize := reconnectSleepWithResize
444+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
445+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
446+ return true
447+ }
448+ defer func() {
449+ reconnectSleep = oldSleep
450+ reconnectSleepWithResize = oldSleepWithResize
451+ }()
452+
453+ term := newReconnectToastTerminalHarness(t)
454+ defer term.closeInput()
455+ tr1 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
456+ {frame: reconnectToastWelcomeNamed("0", 44)},
457+ {err: linkDead},
458+ }}
459+ tr2 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
460+ {frame: reconnectToastWelcomeNamed("0", 55)},
461+ {frame: reconnectToastDetach(ports.ReasonDetach)},
462+ }}
463+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
464+
465+ err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler))
466+ require.NoError(t, err)
467+ require.Equal(t, 2, dialer.calls)
468+
469+ firstHello := reconnectToastHelloFromSend(t, tr1)
470+ resumeHello := reconnectToastHelloFromSend(t, tr2)
471+ require.Equal(t, ports.IntentEphemeral, firstHello.Intent)
472+ require.Empty(t, firstHello.Name)
473+ require.Zero(t, firstHello.ResumeToken)
474+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
475+ require.Equal(t, "0", resumeHello.Name)
476+ require.Equal(t, uint64(44), resumeHello.ResumeToken)
477+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
478+ require.Contains(t, term.out.String(), reconnectToastMessage)
479+}
480+
481+func TestRemoteReconnectToastLifecycle(t *testing.T) {
482+ tests := []struct {
483+ name string
484+ configure func(t *testing.T, dialer *portsmocks.MockDialer)
485+ sleep func(cancel context.CancelFunc) bool
486+ wantErr func(t *testing.T, err error)
487+ wantBlank bool
488+ }{
489+ {
490+ name: "clears on successful reconnect",
491+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
492+ tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
493+ tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)})
494+ dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
495+ dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
496+ },
497+ sleep: func(context.CancelFunc) bool { return true },
498+ wantErr: func(t *testing.T, err error) { require.NoError(t, err) },
499+ wantBlank: true,
500+ },
501+ {
502+ name: "clears on cancellation",
503+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
504+ tr := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
505+ dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
506+ },
507+ sleep: func(cancel context.CancelFunc) bool {
508+ cancel()
509+ return false
510+ },
511+ wantErr: func(t *testing.T, err error) { require.ErrorIs(t, err, context.Canceled) },
512+ wantBlank: true,
513+ },
514+ {
515+ name: "clears on final exit",
516+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
517+ tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
518+ tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)})
519+ dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
520+ dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
521+ },
522+ sleep: func(context.CancelFunc) bool { return true },
523+ wantErr: func(t *testing.T, err error) {
524+ var detached *DetachedError
525+ require.True(t, errors.As(err, &detached))
526+ },
527+ wantBlank: true,
528+ },
529+ }
530+
531+ for _, tt := range tests {
532+ t.Run(tt.name, func(t *testing.T) {
533+ oldSleep := reconnectSleep
534+ oldSleepWithResize := reconnectSleepWithResize
535+ ctx, cancel := context.WithCancel(context.Background())
536+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return tt.sleep(cancel) }
537+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
538+ return tt.sleep(cancel)
539+ }
540+ defer func() {
541+ reconnectSleep = oldSleep
542+ reconnectSleepWithResize = oldSleepWithResize
543+ }()
544+
545+ term := newReconnectToastTerminalHarness(t)
546+ defer term.closeInput()
547+ dialer := portsmocks.NewMockDialer(t)
548+ tt.configure(t, dialer)
549+
550+ err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
551+ tt.wantErr(t, err)
552+ out := term.out.String()
553+ require.Contains(t, out, reconnectToastMessage)
554+ if tt.wantBlank {
555+ require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
556+ }
557+ })
558+ }
559+}
A internal/usecase/client/truecolor_test.go +31 -0
@@ -0,0 +1,31 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestDetectTrueColor(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ termEnv string
13+ colorTerm string
14+ want bool
15+ }{
16+ {name: "COLORTERM truecolor", termEnv: "screen", colorTerm: "truecolor", want: true},
17+ {name: "COLORTERM 24bit", termEnv: "screen", colorTerm: "24bit", want: true},
18+ {name: "COLORTERM trims and ignores case", termEnv: "screen", colorTerm: " TRUECOLOR ", want: true},
19+ {name: "xterm-direct", termEnv: "xterm-direct", colorTerm: "", want: true},
20+ {name: "direct suffix", termEnv: "foot-direct", colorTerm: "", want: true},
21+ {name: "direct suffix ignores case and space", termEnv: " FOOT-DIRECT ", colorTerm: "", want: true},
22+ {name: "xterm 256 only", termEnv: "xterm-256color", colorTerm: "", want: false},
23+ {name: "empty", termEnv: "", colorTerm: "", want: false},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ require.Equal(t, tt.want, DetectTrueColor(tt.termEnv, tt.colorTerm))
29+ })
30+ }
31+}
M internal/usecase/command/command.go +15 -2
@@ -5,8 +5,21 @@ type Context interface {
55 CreateTab() error
66 CreateSession() error
77 CloseTab() error
8+ SplitRight() error
9+ SplitLeft() error
10+ SplitUp() error
11+ SplitDown() error
12+ StackPane() error
13+ ToggleStack() error
14+ ClosePane() error
15+ FocusPaneLeft() error
16+ FocusPaneRight() error
17+ FocusPaneUp() error
18+ FocusPaneDown() error
819 NextTab() error
920 PrevTab() error
21+ BackSession() error
22+ ForwardSession() error
1023 Detach() error
1124 EnterVisualMode() error
1225 RenameSession() error
@@ -16,6 +29,6 @@ type Context interface {
1629
1730 // Command describes an executable command.
1831 type Command struct {
19- Code, Name, Desc string
20- Run func(Context) error
32+ Slug, Code, Name, Desc string
33+ Run func(Context) error
2134 }
A internal/usecase/command/context_mock_test.go +1004 -0
@@ -0,0 +1,1004 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package command
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockContext creates a new instance of MockContext. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockContext(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockContext {
17+ mock := &MockContext{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// MockContext is an autogenerated mock type for the Context type
26+type MockContext struct {
27+ mock.Mock
28+}
29+
30+type MockContext_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *MockContext) EXPECT() *MockContext_Expecter {
35+ return &MockContext_Expecter{mock: &_m.Mock}
36+}
37+
38+// BackSession provides a mock function for the type MockContext
39+func (_mock *MockContext) BackSession() error {
40+ ret := _mock.Called()
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for BackSession")
44+ }
45+
46+ var r0 error
47+ if returnFunc, ok := ret.Get(0).(func() error); ok {
48+ r0 = returnFunc()
49+ } else {
50+ r0 = ret.Error(0)
51+ }
52+ return r0
53+}
54+
55+// MockContext_BackSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BackSession'
56+type MockContext_BackSession_Call struct {
57+ *mock.Call
58+}
59+
60+// BackSession is a helper method to define mock.On call
61+func (_e *MockContext_Expecter) BackSession() *MockContext_BackSession_Call {
62+ return &MockContext_BackSession_Call{Call: _e.mock.On("BackSession")}
63+}
64+
65+func (_c *MockContext_BackSession_Call) Run(run func()) *MockContext_BackSession_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ run()
68+ })
69+ return _c
70+}
71+
72+func (_c *MockContext_BackSession_Call) Return(err error) *MockContext_BackSession_Call {
73+ _c.Call.Return(err)
74+ return _c
75+}
76+
77+func (_c *MockContext_BackSession_Call) RunAndReturn(run func() error) *MockContext_BackSession_Call {
78+ _c.Call.Return(run)
79+ return _c
80+}
81+
82+// ClosePane provides a mock function for the type MockContext
83+func (_mock *MockContext) ClosePane() error {
84+ ret := _mock.Called()
85+
86+ if len(ret) == 0 {
87+ panic("no return value specified for ClosePane")
88+ }
89+
90+ var r0 error
91+ if returnFunc, ok := ret.Get(0).(func() error); ok {
92+ r0 = returnFunc()
93+ } else {
94+ r0 = ret.Error(0)
95+ }
96+ return r0
97+}
98+
99+// MockContext_ClosePane_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClosePane'
100+type MockContext_ClosePane_Call struct {
101+ *mock.Call
102+}
103+
104+// ClosePane is a helper method to define mock.On call
105+func (_e *MockContext_Expecter) ClosePane() *MockContext_ClosePane_Call {
106+ return &MockContext_ClosePane_Call{Call: _e.mock.On("ClosePane")}
107+}
108+
109+func (_c *MockContext_ClosePane_Call) Run(run func()) *MockContext_ClosePane_Call {
110+ _c.Call.Run(func(args mock.Arguments) {
111+ run()
112+ })
113+ return _c
114+}
115+
116+func (_c *MockContext_ClosePane_Call) Return(err error) *MockContext_ClosePane_Call {
117+ _c.Call.Return(err)
118+ return _c
119+}
120+
121+func (_c *MockContext_ClosePane_Call) RunAndReturn(run func() error) *MockContext_ClosePane_Call {
122+ _c.Call.Return(run)
123+ return _c
124+}
125+
126+// CloseTab provides a mock function for the type MockContext
127+func (_mock *MockContext) CloseTab() error {
128+ ret := _mock.Called()
129+
130+ if len(ret) == 0 {
131+ panic("no return value specified for CloseTab")
132+ }
133+
134+ var r0 error
135+ if returnFunc, ok := ret.Get(0).(func() error); ok {
136+ r0 = returnFunc()
137+ } else {
138+ r0 = ret.Error(0)
139+ }
140+ return r0
141+}
142+
143+// MockContext_CloseTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CloseTab'
144+type MockContext_CloseTab_Call struct {
145+ *mock.Call
146+}
147+
148+// CloseTab is a helper method to define mock.On call
149+func (_e *MockContext_Expecter) CloseTab() *MockContext_CloseTab_Call {
150+ return &MockContext_CloseTab_Call{Call: _e.mock.On("CloseTab")}
151+}
152+
153+func (_c *MockContext_CloseTab_Call) Run(run func()) *MockContext_CloseTab_Call {
154+ _c.Call.Run(func(args mock.Arguments) {
155+ run()
156+ })
157+ return _c
158+}
159+
160+func (_c *MockContext_CloseTab_Call) Return(err error) *MockContext_CloseTab_Call {
161+ _c.Call.Return(err)
162+ return _c
163+}
164+
165+func (_c *MockContext_CloseTab_Call) RunAndReturn(run func() error) *MockContext_CloseTab_Call {
166+ _c.Call.Return(run)
167+ return _c
168+}
169+
170+// CreateSession provides a mock function for the type MockContext
171+func (_mock *MockContext) CreateSession() error {
172+ ret := _mock.Called()
173+
174+ if len(ret) == 0 {
175+ panic("no return value specified for CreateSession")
176+ }
177+
178+ var r0 error
179+ if returnFunc, ok := ret.Get(0).(func() error); ok {
180+ r0 = returnFunc()
181+ } else {
182+ r0 = ret.Error(0)
183+ }
184+ return r0
185+}
186+
187+// MockContext_CreateSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateSession'
188+type MockContext_CreateSession_Call struct {
189+ *mock.Call
190+}
191+
192+// CreateSession is a helper method to define mock.On call
193+func (_e *MockContext_Expecter) CreateSession() *MockContext_CreateSession_Call {
194+ return &MockContext_CreateSession_Call{Call: _e.mock.On("CreateSession")}
195+}
196+
197+func (_c *MockContext_CreateSession_Call) Run(run func()) *MockContext_CreateSession_Call {
198+ _c.Call.Run(func(args mock.Arguments) {
199+ run()
200+ })
201+ return _c
202+}
203+
204+func (_c *MockContext_CreateSession_Call) Return(err error) *MockContext_CreateSession_Call {
205+ _c.Call.Return(err)
206+ return _c
207+}
208+
209+func (_c *MockContext_CreateSession_Call) RunAndReturn(run func() error) *MockContext_CreateSession_Call {
210+ _c.Call.Return(run)
211+ return _c
212+}
213+
214+// CreateTab provides a mock function for the type MockContext
215+func (_mock *MockContext) CreateTab() error {
216+ ret := _mock.Called()
217+
218+ if len(ret) == 0 {
219+ panic("no return value specified for CreateTab")
220+ }
221+
222+ var r0 error
223+ if returnFunc, ok := ret.Get(0).(func() error); ok {
224+ r0 = returnFunc()
225+ } else {
226+ r0 = ret.Error(0)
227+ }
228+ return r0
229+}
230+
231+// MockContext_CreateTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateTab'
232+type MockContext_CreateTab_Call struct {
233+ *mock.Call
234+}
235+
236+// CreateTab is a helper method to define mock.On call
237+func (_e *MockContext_Expecter) CreateTab() *MockContext_CreateTab_Call {
238+ return &MockContext_CreateTab_Call{Call: _e.mock.On("CreateTab")}
239+}
240+
241+func (_c *MockContext_CreateTab_Call) Run(run func()) *MockContext_CreateTab_Call {
242+ _c.Call.Run(func(args mock.Arguments) {
243+ run()
244+ })
245+ return _c
246+}
247+
248+func (_c *MockContext_CreateTab_Call) Return(err error) *MockContext_CreateTab_Call {
249+ _c.Call.Return(err)
250+ return _c
251+}
252+
253+func (_c *MockContext_CreateTab_Call) RunAndReturn(run func() error) *MockContext_CreateTab_Call {
254+ _c.Call.Return(run)
255+ return _c
256+}
257+
258+// Detach provides a mock function for the type MockContext
259+func (_mock *MockContext) Detach() error {
260+ ret := _mock.Called()
261+
262+ if len(ret) == 0 {
263+ panic("no return value specified for Detach")
264+ }
265+
266+ var r0 error
267+ if returnFunc, ok := ret.Get(0).(func() error); ok {
268+ r0 = returnFunc()
269+ } else {
270+ r0 = ret.Error(0)
271+ }
272+ return r0
273+}
274+
275+// MockContext_Detach_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Detach'
276+type MockContext_Detach_Call struct {
277+ *mock.Call
278+}
279+
280+// Detach is a helper method to define mock.On call
281+func (_e *MockContext_Expecter) Detach() *MockContext_Detach_Call {
282+ return &MockContext_Detach_Call{Call: _e.mock.On("Detach")}
283+}
284+
285+func (_c *MockContext_Detach_Call) Run(run func()) *MockContext_Detach_Call {
286+ _c.Call.Run(func(args mock.Arguments) {
287+ run()
288+ })
289+ return _c
290+}
291+
292+func (_c *MockContext_Detach_Call) Return(err error) *MockContext_Detach_Call {
293+ _c.Call.Return(err)
294+ return _c
295+}
296+
297+func (_c *MockContext_Detach_Call) RunAndReturn(run func() error) *MockContext_Detach_Call {
298+ _c.Call.Return(run)
299+ return _c
300+}
301+
302+// EnterVisualMode provides a mock function for the type MockContext
303+func (_mock *MockContext) EnterVisualMode() error {
304+ ret := _mock.Called()
305+
306+ if len(ret) == 0 {
307+ panic("no return value specified for EnterVisualMode")
308+ }
309+
310+ var r0 error
311+ if returnFunc, ok := ret.Get(0).(func() error); ok {
312+ r0 = returnFunc()
313+ } else {
314+ r0 = ret.Error(0)
315+ }
316+ return r0
317+}
318+
319+// MockContext_EnterVisualMode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnterVisualMode'
320+type MockContext_EnterVisualMode_Call struct {
321+ *mock.Call
322+}
323+
324+// EnterVisualMode is a helper method to define mock.On call
325+func (_e *MockContext_Expecter) EnterVisualMode() *MockContext_EnterVisualMode_Call {
326+ return &MockContext_EnterVisualMode_Call{Call: _e.mock.On("EnterVisualMode")}
327+}
328+
329+func (_c *MockContext_EnterVisualMode_Call) Run(run func()) *MockContext_EnterVisualMode_Call {
330+ _c.Call.Run(func(args mock.Arguments) {
331+ run()
332+ })
333+ return _c
334+}
335+
336+func (_c *MockContext_EnterVisualMode_Call) Return(err error) *MockContext_EnterVisualMode_Call {
337+ _c.Call.Return(err)
338+ return _c
339+}
340+
341+func (_c *MockContext_EnterVisualMode_Call) RunAndReturn(run func() error) *MockContext_EnterVisualMode_Call {
342+ _c.Call.Return(run)
343+ return _c
344+}
345+
346+// FocusPaneDown provides a mock function for the type MockContext
347+func (_mock *MockContext) FocusPaneDown() error {
348+ ret := _mock.Called()
349+
350+ if len(ret) == 0 {
351+ panic("no return value specified for FocusPaneDown")
352+ }
353+
354+ var r0 error
355+ if returnFunc, ok := ret.Get(0).(func() error); ok {
356+ r0 = returnFunc()
357+ } else {
358+ r0 = ret.Error(0)
359+ }
360+ return r0
361+}
362+
363+// MockContext_FocusPaneDown_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FocusPaneDown'
364+type MockContext_FocusPaneDown_Call struct {
365+ *mock.Call
366+}
367+
368+// FocusPaneDown is a helper method to define mock.On call
369+func (_e *MockContext_Expecter) FocusPaneDown() *MockContext_FocusPaneDown_Call {
370+ return &MockContext_FocusPaneDown_Call{Call: _e.mock.On("FocusPaneDown")}
371+}
372+
373+func (_c *MockContext_FocusPaneDown_Call) Run(run func()) *MockContext_FocusPaneDown_Call {
374+ _c.Call.Run(func(args mock.Arguments) {
375+ run()
376+ })
377+ return _c
378+}
379+
380+func (_c *MockContext_FocusPaneDown_Call) Return(err error) *MockContext_FocusPaneDown_Call {
381+ _c.Call.Return(err)
382+ return _c
383+}
384+
385+func (_c *MockContext_FocusPaneDown_Call) RunAndReturn(run func() error) *MockContext_FocusPaneDown_Call {
386+ _c.Call.Return(run)
387+ return _c
388+}
389+
390+// FocusPaneLeft provides a mock function for the type MockContext
391+func (_mock *MockContext) FocusPaneLeft() error {
392+ ret := _mock.Called()
393+
394+ if len(ret) == 0 {
395+ panic("no return value specified for FocusPaneLeft")
396+ }
397+
398+ var r0 error
399+ if returnFunc, ok := ret.Get(0).(func() error); ok {
400+ r0 = returnFunc()
401+ } else {
402+ r0 = ret.Error(0)
403+ }
404+ return r0
405+}
406+
407+// MockContext_FocusPaneLeft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FocusPaneLeft'
408+type MockContext_FocusPaneLeft_Call struct {
409+ *mock.Call
410+}
411+
412+// FocusPaneLeft is a helper method to define mock.On call
413+func (_e *MockContext_Expecter) FocusPaneLeft() *MockContext_FocusPaneLeft_Call {
414+ return &MockContext_FocusPaneLeft_Call{Call: _e.mock.On("FocusPaneLeft")}
415+}
416+
417+func (_c *MockContext_FocusPaneLeft_Call) Run(run func()) *MockContext_FocusPaneLeft_Call {
418+ _c.Call.Run(func(args mock.Arguments) {
419+ run()
420+ })
421+ return _c
422+}
423+
424+func (_c *MockContext_FocusPaneLeft_Call) Return(err error) *MockContext_FocusPaneLeft_Call {
425+ _c.Call.Return(err)
426+ return _c
427+}
428+
429+func (_c *MockContext_FocusPaneLeft_Call) RunAndReturn(run func() error) *MockContext_FocusPaneLeft_Call {
430+ _c.Call.Return(run)
431+ return _c
432+}
433+
434+// FocusPaneRight provides a mock function for the type MockContext
435+func (_mock *MockContext) FocusPaneRight() error {
436+ ret := _mock.Called()
437+
438+ if len(ret) == 0 {
439+ panic("no return value specified for FocusPaneRight")
440+ }
441+
442+ var r0 error
443+ if returnFunc, ok := ret.Get(0).(func() error); ok {
444+ r0 = returnFunc()
445+ } else {
446+ r0 = ret.Error(0)
447+ }
448+ return r0
449+}
450+
451+// MockContext_FocusPaneRight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FocusPaneRight'
452+type MockContext_FocusPaneRight_Call struct {
453+ *mock.Call
454+}
455+
456+// FocusPaneRight is a helper method to define mock.On call
457+func (_e *MockContext_Expecter) FocusPaneRight() *MockContext_FocusPaneRight_Call {
458+ return &MockContext_FocusPaneRight_Call{Call: _e.mock.On("FocusPaneRight")}
459+}
460+
461+func (_c *MockContext_FocusPaneRight_Call) Run(run func()) *MockContext_FocusPaneRight_Call {
462+ _c.Call.Run(func(args mock.Arguments) {
463+ run()
464+ })
465+ return _c
466+}
467+
468+func (_c *MockContext_FocusPaneRight_Call) Return(err error) *MockContext_FocusPaneRight_Call {
469+ _c.Call.Return(err)
470+ return _c
471+}
472+
473+func (_c *MockContext_FocusPaneRight_Call) RunAndReturn(run func() error) *MockContext_FocusPaneRight_Call {
474+ _c.Call.Return(run)
475+ return _c
476+}
477+
478+// FocusPaneUp provides a mock function for the type MockContext
479+func (_mock *MockContext) FocusPaneUp() error {
480+ ret := _mock.Called()
481+
482+ if len(ret) == 0 {
483+ panic("no return value specified for FocusPaneUp")
484+ }
485+
486+ var r0 error
487+ if returnFunc, ok := ret.Get(0).(func() error); ok {
488+ r0 = returnFunc()
489+ } else {
490+ r0 = ret.Error(0)
491+ }
492+ return r0
493+}
494+
495+// MockContext_FocusPaneUp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FocusPaneUp'
496+type MockContext_FocusPaneUp_Call struct {
497+ *mock.Call
498+}
499+
500+// FocusPaneUp is a helper method to define mock.On call
501+func (_e *MockContext_Expecter) FocusPaneUp() *MockContext_FocusPaneUp_Call {
502+ return &MockContext_FocusPaneUp_Call{Call: _e.mock.On("FocusPaneUp")}
503+}
504+
505+func (_c *MockContext_FocusPaneUp_Call) Run(run func()) *MockContext_FocusPaneUp_Call {
506+ _c.Call.Run(func(args mock.Arguments) {
507+ run()
508+ })
509+ return _c
510+}
511+
512+func (_c *MockContext_FocusPaneUp_Call) Return(err error) *MockContext_FocusPaneUp_Call {
513+ _c.Call.Return(err)
514+ return _c
515+}
516+
517+func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockContext_FocusPaneUp_Call {
518+ _c.Call.Return(run)
519+ return _c
520+}
521+
522+// ForwardSession provides a mock function for the type MockContext
523+func (_mock *MockContext) ForwardSession() error {
524+ ret := _mock.Called()
525+
526+ if len(ret) == 0 {
527+ panic("no return value specified for ForwardSession")
528+ }
529+
530+ var r0 error
531+ if returnFunc, ok := ret.Get(0).(func() error); ok {
532+ r0 = returnFunc()
533+ } else {
534+ r0 = ret.Error(0)
535+ }
536+ return r0
537+}
538+
539+// MockContext_ForwardSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForwardSession'
540+type MockContext_ForwardSession_Call struct {
541+ *mock.Call
542+}
543+
544+// ForwardSession is a helper method to define mock.On call
545+func (_e *MockContext_Expecter) ForwardSession() *MockContext_ForwardSession_Call {
546+ return &MockContext_ForwardSession_Call{Call: _e.mock.On("ForwardSession")}
547+}
548+
549+func (_c *MockContext_ForwardSession_Call) Run(run func()) *MockContext_ForwardSession_Call {
550+ _c.Call.Run(func(args mock.Arguments) {
551+ run()
552+ })
553+ return _c
554+}
555+
556+func (_c *MockContext_ForwardSession_Call) Return(err error) *MockContext_ForwardSession_Call {
557+ _c.Call.Return(err)
558+ return _c
559+}
560+
561+func (_c *MockContext_ForwardSession_Call) RunAndReturn(run func() error) *MockContext_ForwardSession_Call {
562+ _c.Call.Return(run)
563+ return _c
564+}
565+
566+// NextTab provides a mock function for the type MockContext
567+func (_mock *MockContext) NextTab() error {
568+ ret := _mock.Called()
569+
570+ if len(ret) == 0 {
571+ panic("no return value specified for NextTab")
572+ }
573+
574+ var r0 error
575+ if returnFunc, ok := ret.Get(0).(func() error); ok {
576+ r0 = returnFunc()
577+ } else {
578+ r0 = ret.Error(0)
579+ }
580+ return r0
581+}
582+
583+// MockContext_NextTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NextTab'
584+type MockContext_NextTab_Call struct {
585+ *mock.Call
586+}
587+
588+// NextTab is a helper method to define mock.On call
589+func (_e *MockContext_Expecter) NextTab() *MockContext_NextTab_Call {
590+ return &MockContext_NextTab_Call{Call: _e.mock.On("NextTab")}
591+}
592+
593+func (_c *MockContext_NextTab_Call) Run(run func()) *MockContext_NextTab_Call {
594+ _c.Call.Run(func(args mock.Arguments) {
595+ run()
596+ })
597+ return _c
598+}
599+
600+func (_c *MockContext_NextTab_Call) Return(err error) *MockContext_NextTab_Call {
601+ _c.Call.Return(err)
602+ return _c
603+}
604+
605+func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_NextTab_Call {
606+ _c.Call.Return(run)
607+ return _c
608+}
609+
610+// OpenSessionPicker provides a mock function for the type MockContext
611+func (_mock *MockContext) OpenSessionPicker() error {
612+ ret := _mock.Called()
613+
614+ if len(ret) == 0 {
615+ panic("no return value specified for OpenSessionPicker")
616+ }
617+
618+ var r0 error
619+ if returnFunc, ok := ret.Get(0).(func() error); ok {
620+ r0 = returnFunc()
621+ } else {
622+ r0 = ret.Error(0)
623+ }
624+ return r0
625+}
626+
627+// MockContext_OpenSessionPicker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenSessionPicker'
628+type MockContext_OpenSessionPicker_Call struct {
629+ *mock.Call
630+}
631+
632+// OpenSessionPicker is a helper method to define mock.On call
633+func (_e *MockContext_Expecter) OpenSessionPicker() *MockContext_OpenSessionPicker_Call {
634+ return &MockContext_OpenSessionPicker_Call{Call: _e.mock.On("OpenSessionPicker")}
635+}
636+
637+func (_c *MockContext_OpenSessionPicker_Call) Run(run func()) *MockContext_OpenSessionPicker_Call {
638+ _c.Call.Run(func(args mock.Arguments) {
639+ run()
640+ })
641+ return _c
642+}
643+
644+func (_c *MockContext_OpenSessionPicker_Call) Return(err error) *MockContext_OpenSessionPicker_Call {
645+ _c.Call.Return(err)
646+ return _c
647+}
648+
649+func (_c *MockContext_OpenSessionPicker_Call) RunAndReturn(run func() error) *MockContext_OpenSessionPicker_Call {
650+ _c.Call.Return(run)
651+ return _c
652+}
653+
654+// PrevTab provides a mock function for the type MockContext
655+func (_mock *MockContext) PrevTab() error {
656+ ret := _mock.Called()
657+
658+ if len(ret) == 0 {
659+ panic("no return value specified for PrevTab")
660+ }
661+
662+ var r0 error
663+ if returnFunc, ok := ret.Get(0).(func() error); ok {
664+ r0 = returnFunc()
665+ } else {
666+ r0 = ret.Error(0)
667+ }
668+ return r0
669+}
670+
671+// MockContext_PrevTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrevTab'
672+type MockContext_PrevTab_Call struct {
673+ *mock.Call
674+}
675+
676+// PrevTab is a helper method to define mock.On call
677+func (_e *MockContext_Expecter) PrevTab() *MockContext_PrevTab_Call {
678+ return &MockContext_PrevTab_Call{Call: _e.mock.On("PrevTab")}
679+}
680+
681+func (_c *MockContext_PrevTab_Call) Run(run func()) *MockContext_PrevTab_Call {
682+ _c.Call.Run(func(args mock.Arguments) {
683+ run()
684+ })
685+ return _c
686+}
687+
688+func (_c *MockContext_PrevTab_Call) Return(err error) *MockContext_PrevTab_Call {
689+ _c.Call.Return(err)
690+ return _c
691+}
692+
693+func (_c *MockContext_PrevTab_Call) RunAndReturn(run func() error) *MockContext_PrevTab_Call {
694+ _c.Call.Return(run)
695+ return _c
696+}
697+
698+// RenameSession provides a mock function for the type MockContext
699+func (_mock *MockContext) RenameSession() error {
700+ ret := _mock.Called()
701+
702+ if len(ret) == 0 {
703+ panic("no return value specified for RenameSession")
704+ }
705+
706+ var r0 error
707+ if returnFunc, ok := ret.Get(0).(func() error); ok {
708+ r0 = returnFunc()
709+ } else {
710+ r0 = ret.Error(0)
711+ }
712+ return r0
713+}
714+
715+// MockContext_RenameSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenameSession'
716+type MockContext_RenameSession_Call struct {
717+ *mock.Call
718+}
719+
720+// RenameSession is a helper method to define mock.On call
721+func (_e *MockContext_Expecter) RenameSession() *MockContext_RenameSession_Call {
722+ return &MockContext_RenameSession_Call{Call: _e.mock.On("RenameSession")}
723+}
724+
725+func (_c *MockContext_RenameSession_Call) Run(run func()) *MockContext_RenameSession_Call {
726+ _c.Call.Run(func(args mock.Arguments) {
727+ run()
728+ })
729+ return _c
730+}
731+
732+func (_c *MockContext_RenameSession_Call) Return(err error) *MockContext_RenameSession_Call {
733+ _c.Call.Return(err)
734+ return _c
735+}
736+
737+func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockContext_RenameSession_Call {
738+ _c.Call.Return(run)
739+ return _c
740+}
741+
742+// SplitDown provides a mock function for the type MockContext
743+func (_mock *MockContext) SplitDown() error {
744+ ret := _mock.Called()
745+
746+ if len(ret) == 0 {
747+ panic("no return value specified for SplitDown")
748+ }
749+
750+ var r0 error
751+ if returnFunc, ok := ret.Get(0).(func() error); ok {
752+ r0 = returnFunc()
753+ } else {
754+ r0 = ret.Error(0)
755+ }
756+ return r0
757+}
758+
759+// MockContext_SplitDown_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SplitDown'
760+type MockContext_SplitDown_Call struct {
761+ *mock.Call
762+}
763+
764+// SplitDown is a helper method to define mock.On call
765+func (_e *MockContext_Expecter) SplitDown() *MockContext_SplitDown_Call {
766+ return &MockContext_SplitDown_Call{Call: _e.mock.On("SplitDown")}
767+}
768+
769+func (_c *MockContext_SplitDown_Call) Run(run func()) *MockContext_SplitDown_Call {
770+ _c.Call.Run(func(args mock.Arguments) {
771+ run()
772+ })
773+ return _c
774+}
775+
776+func (_c *MockContext_SplitDown_Call) Return(err error) *MockContext_SplitDown_Call {
777+ _c.Call.Return(err)
778+ return _c
779+}
780+
781+func (_c *MockContext_SplitDown_Call) RunAndReturn(run func() error) *MockContext_SplitDown_Call {
782+ _c.Call.Return(run)
783+ return _c
784+}
785+
786+// SplitLeft provides a mock function for the type MockContext
787+func (_mock *MockContext) SplitLeft() error {
788+ ret := _mock.Called()
789+
790+ if len(ret) == 0 {
791+ panic("no return value specified for SplitLeft")
792+ }
793+
794+ var r0 error
795+ if returnFunc, ok := ret.Get(0).(func() error); ok {
796+ r0 = returnFunc()
797+ } else {
798+ r0 = ret.Error(0)
799+ }
800+ return r0
801+}
802+
803+// MockContext_SplitLeft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SplitLeft'
804+type MockContext_SplitLeft_Call struct {
805+ *mock.Call
806+}
807+
808+// SplitLeft is a helper method to define mock.On call
809+func (_e *MockContext_Expecter) SplitLeft() *MockContext_SplitLeft_Call {
810+ return &MockContext_SplitLeft_Call{Call: _e.mock.On("SplitLeft")}
811+}
812+
813+func (_c *MockContext_SplitLeft_Call) Run(run func()) *MockContext_SplitLeft_Call {
814+ _c.Call.Run(func(args mock.Arguments) {
815+ run()
816+ })
817+ return _c
818+}
819+
820+func (_c *MockContext_SplitLeft_Call) Return(err error) *MockContext_SplitLeft_Call {
821+ _c.Call.Return(err)
822+ return _c
823+}
824+
825+func (_c *MockContext_SplitLeft_Call) RunAndReturn(run func() error) *MockContext_SplitLeft_Call {
826+ _c.Call.Return(run)
827+ return _c
828+}
829+
830+// SplitRight provides a mock function for the type MockContext
831+func (_mock *MockContext) SplitRight() error {
832+ ret := _mock.Called()
833+
834+ if len(ret) == 0 {
835+ panic("no return value specified for SplitRight")
836+ }
837+
838+ var r0 error
839+ if returnFunc, ok := ret.Get(0).(func() error); ok {
840+ r0 = returnFunc()
841+ } else {
842+ r0 = ret.Error(0)
843+ }
844+ return r0
845+}
846+
847+// MockContext_SplitRight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SplitRight'
848+type MockContext_SplitRight_Call struct {
849+ *mock.Call
850+}
851+
852+// SplitRight is a helper method to define mock.On call
853+func (_e *MockContext_Expecter) SplitRight() *MockContext_SplitRight_Call {
854+ return &MockContext_SplitRight_Call{Call: _e.mock.On("SplitRight")}
855+}
856+
857+func (_c *MockContext_SplitRight_Call) Run(run func()) *MockContext_SplitRight_Call {
858+ _c.Call.Run(func(args mock.Arguments) {
859+ run()
860+ })
861+ return _c
862+}
863+
864+func (_c *MockContext_SplitRight_Call) Return(err error) *MockContext_SplitRight_Call {
865+ _c.Call.Return(err)
866+ return _c
867+}
868+
869+func (_c *MockContext_SplitRight_Call) RunAndReturn(run func() error) *MockContext_SplitRight_Call {
870+ _c.Call.Return(run)
871+ return _c
872+}
873+
874+// SplitUp provides a mock function for the type MockContext
875+func (_mock *MockContext) SplitUp() error {
876+ ret := _mock.Called()
877+
878+ if len(ret) == 0 {
879+ panic("no return value specified for SplitUp")
880+ }
881+
882+ var r0 error
883+ if returnFunc, ok := ret.Get(0).(func() error); ok {
884+ r0 = returnFunc()
885+ } else {
886+ r0 = ret.Error(0)
887+ }
888+ return r0
889+}
890+
891+// MockContext_SplitUp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SplitUp'
892+type MockContext_SplitUp_Call struct {
893+ *mock.Call
894+}
895+
896+// SplitUp is a helper method to define mock.On call
897+func (_e *MockContext_Expecter) SplitUp() *MockContext_SplitUp_Call {
898+ return &MockContext_SplitUp_Call{Call: _e.mock.On("SplitUp")}
899+}
900+
901+func (_c *MockContext_SplitUp_Call) Run(run func()) *MockContext_SplitUp_Call {
902+ _c.Call.Run(func(args mock.Arguments) {
903+ run()
904+ })
905+ return _c
906+}
907+
908+func (_c *MockContext_SplitUp_Call) Return(err error) *MockContext_SplitUp_Call {
909+ _c.Call.Return(err)
910+ return _c
911+}
912+
913+func (_c *MockContext_SplitUp_Call) RunAndReturn(run func() error) *MockContext_SplitUp_Call {
914+ _c.Call.Return(run)
915+ return _c
916+}
917+
918+// StackPane provides a mock function for the type MockContext
919+func (_mock *MockContext) StackPane() error {
920+ ret := _mock.Called()
921+
922+ if len(ret) == 0 {
923+ panic("no return value specified for StackPane")
924+ }
925+
926+ var r0 error
927+ if returnFunc, ok := ret.Get(0).(func() error); ok {
928+ r0 = returnFunc()
929+ } else {
930+ r0 = ret.Error(0)
931+ }
932+ return r0
933+}
934+
935+// MockContext_StackPane_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StackPane'
936+type MockContext_StackPane_Call struct {
937+ *mock.Call
938+}
939+
940+// StackPane is a helper method to define mock.On call
941+func (_e *MockContext_Expecter) StackPane() *MockContext_StackPane_Call {
942+ return &MockContext_StackPane_Call{Call: _e.mock.On("StackPane")}
943+}
944+
945+func (_c *MockContext_StackPane_Call) Run(run func()) *MockContext_StackPane_Call {
946+ _c.Call.Run(func(args mock.Arguments) {
947+ run()
948+ })
949+ return _c
950+}
951+
952+func (_c *MockContext_StackPane_Call) Return(err error) *MockContext_StackPane_Call {
953+ _c.Call.Return(err)
954+ return _c
955+}
956+
957+func (_c *MockContext_StackPane_Call) RunAndReturn(run func() error) *MockContext_StackPane_Call {
958+ _c.Call.Return(run)
959+ return _c
960+}
961+
962+// ToggleStack provides a mock function for the type MockContext
963+func (_mock *MockContext) ToggleStack() error {
964+ ret := _mock.Called()
965+
966+ if len(ret) == 0 {
967+ panic("no return value specified for ToggleStack")
968+ }
969+
970+ var r0 error
971+ if returnFunc, ok := ret.Get(0).(func() error); ok {
972+ r0 = returnFunc()
973+ } else {
974+ r0 = ret.Error(0)
975+ }
976+ return r0
977+}
978+
979+// MockContext_ToggleStack_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ToggleStack'
980+type MockContext_ToggleStack_Call struct {
981+ *mock.Call
982+}
983+
984+// ToggleStack is a helper method to define mock.On call
985+func (_e *MockContext_Expecter) ToggleStack() *MockContext_ToggleStack_Call {
986+ return &MockContext_ToggleStack_Call{Call: _e.mock.On("ToggleStack")}
987+}
988+
989+func (_c *MockContext_ToggleStack_Call) Run(run func()) *MockContext_ToggleStack_Call {
990+ _c.Call.Run(func(args mock.Arguments) {
991+ run()
992+ })
993+ return _c
994+}
995+
996+func (_c *MockContext_ToggleStack_Call) Return(err error) *MockContext_ToggleStack_Call {
997+ _c.Call.Return(err)
998+ return _c
999+}
1000+
1001+func (_c *MockContext_ToggleStack_Call) RunAndReturn(run func() error) *MockContext_ToggleStack_Call {
1002+ _c.Call.Return(run)
1003+ return _c
1004+}
M internal/usecase/command/registry.go +22 -10
@@ -5,16 +5,28 @@ import "strings"
55 // Registry returns all commands in display order.
66 func Registry() []Command {
77 commands := []Command{
8- {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},
9- {Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},
10- {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},
11- {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
12- {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
13- {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
14- {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
15- {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
16- {Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},
17- {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
8+ {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},
9+ {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},
10+ {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},
11+ {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }},
12+ {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }},
13+ {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }},
14+ {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},
15+ {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},
16+ {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }},
17+ {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},
18+ {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }},
19+ {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }},
20+ {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }},
21+ {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
22+ {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
23+ {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
24+ {Slug: "back-session", Code: "BSK", Name: "Back session", Desc: "Step backward through recent-session history", Run: func(ctx Context) error { return ctx.BackSession() }},
25+ {Slug: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Step forward through recent-session history", Run: func(ctx Context) error { return ctx.ForwardSession() }},
26+ {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
27+ {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
28+ {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
29+ {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
1830 }
1931
2032 return commands
M internal/usecase/command/registry_test.go +39 -49
@@ -1,21 +1,21 @@
11 package command
22
33 import (
4- "fmt"
54 "regexp"
65 "testing"
76 )
87
9-func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
8+func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
109 commands := Registry()
11- wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "RNT", "DET"}
10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}
1211
1312 if len(commands) != len(wantCodes) {
1413 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
1514 }
1615
1716 codePattern := regexp.MustCompile(`^[A-Z]{3}$`)
18- seen := make(map[string]bool, len(commands))
17+ seenCodes := make(map[string]bool, len(commands))
18+ seenSlugs := make(map[string]bool, len(commands))
1919 for i, cmd := range commands {
2020 if cmd.Code != wantCodes[i] {
2121 t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i])
@@ -23,10 +23,17 @@ func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
2323 if !codePattern.MatchString(cmd.Code) {
2424 t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code)
2525 }
26- if seen[cmd.Code] {
26+ if seenCodes[cmd.Code] {
2727 t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code)
2828 }
29- seen[cmd.Code] = true
29+ seenCodes[cmd.Code] = true
30+ if cmd.Slug == "" {
31+ t.Errorf("Registry()[%d].Slug is empty", i)
32+ }
33+ if seenSlugs[cmd.Slug] {
34+ t.Errorf("Registry()[%d].Slug = %q, duplicate slug", i, cmd.Slug)
35+ }
36+ seenSlugs[cmd.Slug] = true
3037 }
3138 }
3239
@@ -48,19 +55,31 @@ func TestByCodeIsCaseInsensitive(t *testing.T) {
4855
4956 func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
5057 tests := []struct {
51- code string
52- want string
58+ code string
59+ expect func(*MockContext)
5360 }{
54- {code: "CNT", want: "CreateTab"},
55- {code: "CNS", want: "CreateSession"},
56- {code: "CLT", want: "CloseTab"},
57- {code: "NXT", want: "NextTab"},
58- {code: "PVT", want: "PrevTab"},
59- {code: "SSP", want: "OpenSessionPicker"},
60- {code: "VIS", want: "EnterVisualMode"},
61- {code: "RNS", want: "RenameSession"},
62- {code: "RNT", want: "RenameTab"},
63- {code: "DET", want: "Detach"},
61+ {code: "CNT", expect: func(ctx *MockContext) { ctx.EXPECT().CreateTab().Return(nil).Once() }},
62+ {code: "CNS", expect: func(ctx *MockContext) { ctx.EXPECT().CreateSession().Return(nil).Once() }},
63+ {code: "CLT", expect: func(ctx *MockContext) { ctx.EXPECT().CloseTab().Return(nil).Once() }},
64+ {code: "SPR", expect: func(ctx *MockContext) { ctx.EXPECT().SplitRight().Return(nil).Once() }},
65+ {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }},
66+ {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }},
67+ {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }},
68+ {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }},
69+ {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},
70+ {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }},
71+ {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }},
72+ {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},
73+ {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }},
74+ {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},
75+ {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
76+ {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
77+ {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
78+ {code: "FSK", expect: func(ctx *MockContext) { ctx.EXPECT().ForwardSession().Return(nil).Once() }},
79+ {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
80+ {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
81+ {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
82+ {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }},
6483 }
6584
6685 for _, tt := range tests {
@@ -70,40 +89,11 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
7089 t.Fatalf("ByCode(%q) ok = false, want true", tt.code)
7190 }
7291
73- ctx := &spyContext{}
92+ ctx := NewMockContext(t)
93+ tt.expect(ctx)
7494 if err := cmd.Run(ctx); err != nil {
7595 t.Fatalf("Run() error = %v, want nil", err)
7696 }
77- if got := ctx.onlyCall(); got != tt.want {
78- t.Fatalf("Run() called %q, want %q", got, tt.want)
79- }
8097 })
8198 }
8299 }
83-
84-type spyContext struct {
85- calls []string
86-}
87-
88-func (s *spyContext) record(name string) error {
89- s.calls = append(s.calls, name)
90- return nil
91-}
92-
93-func (s *spyContext) onlyCall() string {
94- if len(s.calls) != 1 {
95- return fmt.Sprintf("%v", s.calls)
96- }
97- return s.calls[0]
98-}
99-
100-func (s *spyContext) CreateTab() error { return s.record("CreateTab") }
101-func (s *spyContext) CreateSession() error { return s.record("CreateSession") }
102-func (s *spyContext) CloseTab() error { return s.record("CloseTab") }
103-func (s *spyContext) NextTab() error { return s.record("NextTab") }
104-func (s *spyContext) PrevTab() error { return s.record("PrevTab") }
105-func (s *spyContext) Detach() error { return s.record("Detach") }
106-func (s *spyContext) EnterVisualMode() error { return s.record("EnterVisualMode") }
107-func (s *spyContext) RenameSession() error { return s.record("RenameSession") }
108-func (s *spyContext) RenameTab() error { return s.record("RenameTab") }
109-func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") }
M internal/usecase/copy/mode.go +146 -1
@@ -3,6 +3,7 @@ package copy
33 import (
44 "encoding/base64"
55 "strings"
6+ "unicode"
67
78 "github.com/bnema/vev/pkg/renderer"
89 )
@@ -40,11 +41,21 @@ func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
4041 }
4142
4243 // Mode stores per-client scrollback viewport and line-selection state.
44+type SearchMatch struct {
45+ Row int
46+ Start int
47+ End int
48+ Text string
49+}
50+
4351 type Mode struct {
4452 ViewportTop int
4553 Cursor int
4654 Anchor int
4755 Selecting bool
56+ SearchQuery string
57+ Searches []SearchMatch
58+ SearchIndex int
4859 }
4960
5061 func NewMode(s Snapshot) *Mode {
@@ -60,6 +71,109 @@ func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s
6071 func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) }
6172 func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) }
6273
74+func FindMatches(s Snapshot, query string) []SearchMatch {
75+ query = strings.TrimSpace(query)
76+ if query == "" {
77+ return nil
78+ }
79+ needle := lowerRunes(query)
80+ matches := make([]SearchMatch, 0)
81+ for row, cells := range s.Rows {
82+ haystack, cellIndexes := searchableCells(cells)
83+ var text string
84+ for start := 0; start+len(needle) <= len(haystack); {
85+ if !sameRunes(haystack[start:start+len(needle)], needle) {
86+ start++
87+ continue
88+ }
89+ if text == "" {
90+ text = rowString(cells)
91+ }
92+ end := start + len(needle)
93+ cellEnd := len(cells)
94+ if end < len(cellIndexes) {
95+ cellEnd = cellIndexes[end]
96+ }
97+ matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text})
98+ start += len(needle)
99+ }
100+ }
101+ return matches
102+}
103+
104+func searchableCells(cells []renderer.Cell) ([]rune, []int) {
105+ runes := make([]rune, 0, len(cells))
106+ cellIndexes := make([]int, 0, len(cells))
107+ for x, cell := range cells {
108+ if cell.Continuation {
109+ continue
110+ }
111+ r := cell.Rune
112+ if r == 0 {
113+ r = ' '
114+ }
115+ runes = append(runes, unicode.ToLower(r))
116+ cellIndexes = append(cellIndexes, x)
117+ }
118+ return runes, cellIndexes
119+}
120+
121+func lowerRunes(s string) []rune {
122+ runes := []rune(s)
123+ for i, r := range runes {
124+ runes[i] = unicode.ToLower(r)
125+ }
126+ return runes
127+}
128+
129+func sameRunes(a, b []rune) bool {
130+ if len(a) != len(b) {
131+ return false
132+ }
133+ for i := range a {
134+ if a[i] != b[i] {
135+ return false
136+ }
137+ }
138+ return true
139+}
140+
141+func (m *Mode) Search(s Snapshot, query string) bool {
142+ return m.SetSearchMatches(s, query, FindMatches(s, query), 0)
143+}
144+
145+func (m *Mode) SetSearchMatches(s Snapshot, query string, matches []SearchMatch, index int) bool {
146+ if len(matches) == 0 {
147+ m.SearchQuery = strings.TrimSpace(query)
148+ m.Searches = nil
149+ m.SearchIndex = -1
150+ return false
151+ }
152+ m.SearchQuery = strings.TrimSpace(query)
153+ m.Searches = append(m.Searches[:0], matches...)
154+ if index < 0 {
155+ index = 0
156+ }
157+ if index >= len(matches) {
158+ index = len(matches) - 1
159+ }
160+ m.SearchIndex = index
161+ m.SetCursor(s, matches[index].Row)
162+ return true
163+}
164+
165+func (m *Mode) NextSearchMatch(s Snapshot, delta int) bool {
166+ if len(m.Searches) == 0 || delta == 0 {
167+ return false
168+ }
169+ m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches)
170+ if m.SearchIndex < 0 {
171+ m.SearchIndex += len(m.Searches)
172+ }
173+ m.SetCursor(s, m.Searches[m.SearchIndex].Row)
174+ return true
175+}
176+
63177 // SetCursor moves the visual cursor to row, clamping it to the snapshot and
64178 // scrolling the viewport just enough to keep it visible.
65179 func (m *Mode) SetCursor(s Snapshot, row int) { m.setCursor(s, row) }
@@ -159,6 +273,11 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
159273 applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)
160274 }
161275 }
276+ if match, ok := m.currentSearchMatchForRow(src); ok {
277+ for x := match.Start; x < match.End && x < len(row); x++ {
278+ applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)
279+ }
280+ }
162281 if src == m.Cursor && !lineSelected && len(row) > 0 {
163282 applySelectionStyle(&row[0].Style, selectionStyle, hasSelectionStyle)
164283 }
@@ -171,6 +290,14 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
171290 return frame
172291 }
173292
293+func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {
294+ if m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
295+ return SearchMatch{}, false
296+ }
297+ match := m.Searches[m.SearchIndex]
298+ return match, match.Row == row
299+}
300+
174301 func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
175302 if idx >= len(styles) {
176303 return renderer.Style{}, false
@@ -211,7 +338,7 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl
211338 for i := range row {
212339 row[i] = renderer.BlankCell()
213340 }
214- text := " [VISUAL] "
341+ text := " [SCROLL] "
215342 if m.Selecting {
216343 text = " [SELECT] "
217344 }
@@ -220,6 +347,12 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl
220347 } else {
221348 text += "0/0 "
222349 }
350+ if m.SearchQuery != "" {
351+ if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) {
352+ text += strconvItoa(m.SearchIndex+1) + "/" + strconvItoa(len(m.Searches)) + " "
353+ }
354+ text += "/" + m.SearchQuery + " "
355+ }
223356 for i, r := range text {
224357 if i >= len(row) {
225358 break
@@ -239,6 +372,18 @@ func OSC52(text string) [][]byte {
239372 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
240373 }
241374
375+// OSC52FromBase64 builds the normalized OSC 52 sequence to forward a
376+// clipboard set request that a pane app already emitted as base64. It
377+// returns nil if b64 is not valid base64 or its decoded length exceeds
378+// OSC52MaxPayloadBytes — callers must drop the request silently in that case.
379+func OSC52FromBase64(b64 string) []byte {
380+ decoded, err := base64.StdEncoding.DecodeString(b64)
381+ if err != nil || len(decoded) > OSC52MaxPayloadBytes {
382+ return nil
383+ }
384+ return []byte("\x1b]52;c;" + b64 + "\x07")
385+}
386+
242387 func strconvItoa(n int) string {
243388 if n == 0 {
244389 return "0"
M internal/usecase/copy/mode_test.go +115 -4
@@ -1,11 +1,13 @@
11 package copy
22
33 import (
4+ "bytes"
45 "encoding/base64"
56 "strings"
67 "testing"
78
89 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/stretchr/testify/require"
911 )
1012
1113 func snapshot(lines []string, height int) Snapshot {
@@ -211,14 +213,14 @@ func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) {
211213 m := NewMode(s)
212214
213215 frame := m.Render(s)
214- if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[VISUAL]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[SELECT]") {
215- t.Fatalf("passive status = %q, want [VISUAL] with N/M and no scroll/select label", got)
216+ if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SCROLL]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SELECT]") {
217+ t.Fatalf("passive status = %q, want [SCROLL] with N/M and no visual/select label", got)
216218 }
217219
218220 m.ToggleSelection()
219221 frame = m.Render(s)
220- if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SELECT]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[VISUAL]") {
221- t.Fatalf("selection status = %q, want [SELECT] with N/M and no [VISUAL]", got)
222+ if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SELECT]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[VISUAL]") {
223+ t.Fatalf("selection status = %q, want [SELECT] with N/M and no passive label", got)
222224 }
223225 }
224226
@@ -309,6 +311,91 @@ func frameText(row []renderer.Cell) string {
309311 return b.String()
310312 }
311313
314+func TestCopyModeSearchMovesAndCyclesMatches(t *testing.T) {
315+ s := snapshot([]string{"alpha", "beta alpha", "gamma", "alpha omega"}, 2)
316+ m := NewMode(s)
317+
318+ require.True(t, m.Search(s, "alpha"))
319+ require.Equal(t, 0, m.Cursor)
320+ require.Equal(t, 0, m.ViewportTop)
321+
322+ require.True(t, m.NextSearchMatch(s, 1))
323+ require.Equal(t, 1, m.Cursor)
324+ require.Equal(t, 0, m.ViewportTop)
325+
326+ require.True(t, m.NextSearchMatch(s, 1))
327+ require.Equal(t, 3, m.Cursor)
328+ require.Equal(t, 2, m.ViewportTop)
329+
330+ require.True(t, m.NextSearchMatch(s, 1))
331+ require.Equal(t, 0, m.Cursor)
332+
333+ require.True(t, m.NextSearchMatch(s, -1))
334+ require.Equal(t, 3, m.Cursor)
335+}
336+
337+func TestFindMatchesReturnsNonOverlappingRepeatedMatches(t *testing.T) {
338+ s := snapshot([]string{"aaa"}, 1)
339+
340+ matches := FindMatches(s, "aa")
341+
342+ require.Len(t, matches, 1)
343+ require.Equal(t, 0, matches[0].Start)
344+ require.Equal(t, 2, matches[0].End)
345+}
346+
347+func TestFindMatchesKeepsRuneAndCellIndexesAlignedWhenLowercasing(t *testing.T) {
348+ s := snapshot([]string{"İa"}, 1)
349+
350+ matches := FindMatches(s, "İa")
351+
352+ require.Len(t, matches, 1)
353+ require.Equal(t, 0, matches[0].Start)
354+ require.Equal(t, 2, matches[0].End)
355+}
356+
357+func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {
358+ row := []renderer.Cell{
359+ {Rune: '界'},
360+ {Continuation: true},
361+ {Rune: ' '},
362+ {Rune: 'a'},
363+ {Rune: 'l'},
364+ {Rune: 'p'},
365+ {Rune: 'h'},
366+ {Rune: 'a'},
367+ }
368+ s := Snapshot{Rows: [][]renderer.Cell{row}, Width: 8, Height: 1}
369+
370+ wideMatches := FindMatches(s, "界")
371+ require.Len(t, wideMatches, 1)
372+ require.Equal(t, 0, wideMatches[0].Start)
373+ require.Equal(t, 2, wideMatches[0].End)
374+
375+ matches := FindMatches(s, "alpha")
376+ require.Len(t, matches, 1)
377+ require.Equal(t, 3, matches[0].Start)
378+ require.Equal(t, 8, matches[0].End)
379+}
380+
381+func TestCopyModeRenderHighlightsCurrentSearchMatch(t *testing.T) {
382+ s := snapshot([]string{"alpha", "beta alpha"}, 2)
383+ s.Width = 32
384+ m := NewMode(s)
385+ require.True(t, m.Search(s, "alpha"))
386+
387+ frame := m.Render(s)
388+
389+ for x := range len("alpha") {
390+ if !frame.At(x, 0).Style.Inverse {
391+ t.Fatalf("search match cell %d inverse = false, want highlighted", x)
392+ }
393+ }
394+ if !strings.Contains(frameText(frame.Row(s.Height)), "/alpha") {
395+ t.Fatalf("status row = %q, want search query", frameText(frame.Row(s.Height)))
396+ }
397+}
398+
312399 func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) {
313400 s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3)
314401 m := NewMode(s)
@@ -366,6 +453,30 @@ func TestOSC52OversizedPayloadIsDeferred(t *testing.T) {
366453 }
367454 }
368455
456+func TestOSC52FromBase64(t *testing.T) {
457+ valid := base64.StdEncoding.EncodeToString([]byte("hello"))
458+ oversized := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", OSC52MaxPayloadBytes+1)))
459+
460+ tests := []struct {
461+ name string
462+ in string
463+ want []byte
464+ }{
465+ {name: "valid", in: valid, want: []byte("\x1b]52;c;" + valid + "\x07")},
466+ {name: "empty payload", in: "", want: []byte("\x1b]52;c;\x07")},
467+ {name: "invalid base64", in: "not-valid-base64!!", want: nil},
468+ {name: "oversized decoded payload", in: oversized, want: nil},
469+ }
470+ for _, tt := range tests {
471+ t.Run(tt.name, func(t *testing.T) {
472+ got := OSC52FromBase64(tt.in)
473+ if !bytes.Equal(got, tt.want) {
474+ t.Fatalf("OSC52FromBase64(%q) = %q, want %q", tt.in, got, tt.want)
475+ }
476+ })
477+ }
478+}
479+
369480 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {
370481 t.Helper()
371482 s := string(chunk)
M internal/usecase/copy/scrollback.go +15 -0
@@ -41,6 +41,21 @@ func (s *Scrollback) Append(row []renderer.Cell) {
4141 s.head = (s.head + 1) % len(s.rows)
4242 }
4343
44+// Snapshot returns retained rows oldest-first.
45+//
46+// The returned outer slice is a copy, but row slices are shared with the
47+// scrollback storage. Callers must treat the returned rows as read-only.
48+func (s *Scrollback) Snapshot() [][]renderer.Cell {
49+ if s.len == 0 || len(s.rows) == 0 {
50+ return nil
51+ }
52+ rows := make([][]renderer.Cell, s.len)
53+ for i := range s.len {
54+ rows[i] = s.rows[(s.head+i)%len(s.rows)]
55+ }
56+ return rows
57+}
58+
4459 // Row returns a copy of the retained row at logical index i, oldest first.
4560 func (s *Scrollback) Row(i int) []renderer.Cell {
4661 if i < 0 || i >= s.len || len(s.rows) == 0 {
M internal/usecase/copy/scrollback_test.go +67 -2
@@ -7,8 +7,9 @@ import (
77 )
88
99 func row(text string) []renderer.Cell {
10- cells := make([]renderer.Cell, len(text))
11- for i, r := range text {
10+ runes := []rune(text)
11+ cells := make([]renderer.Cell, len(runes))
12+ for i, r := range runes {
1213 cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
1314 }
1415 return cells
@@ -110,3 +111,67 @@ func TestScrollbackCopiesRows(t *testing.T) {
110111 })
111112 }
112113 }
114+
115+func TestScrollbackSnapshot(t *testing.T) {
116+ tests := []struct {
117+ name string
118+ cap int
119+ append []string
120+ wantRows []string
121+ }{
122+ {
123+ name: "empty capacity snapshots no rows",
124+ cap: 0,
125+ append: []string{"aa", "bb"},
126+ wantRows: nil,
127+ },
128+ {
129+ name: "oldest first after ring wrap",
130+ cap: 3,
131+ append: []string{"aa", "bb", "cc", "dd"},
132+ wantRows: []string{"bb", "cc", "dd"},
133+ },
134+ }
135+
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ sb := NewScrollback(tt.cap)
139+ for _, r := range tt.append {
140+ sb.Append(row(r))
141+ }
142+
143+ got := sb.Snapshot()
144+ if len(got) != len(tt.wantRows) {
145+ t.Fatalf("Snapshot() len = %d, want %d", len(got), len(tt.wantRows))
146+ }
147+ for i, want := range tt.wantRows {
148+ if text := rowText(got[i]); text != want {
149+ t.Fatalf("Snapshot()[%d] = %q, want %q", i, text, want)
150+ }
151+ }
152+ if len(got) > 0 {
153+ got[0] = row("zz")
154+ if text := rowText(sb.Row(0)); text != tt.wantRows[0] {
155+ t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])
156+ }
157+ }
158+ })
159+ }
160+}
161+
162+func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) {
163+ sb := NewScrollback(2)
164+ sb.Append(row("aa"))
165+ sb.Append(row("bb"))
166+ snapshot := sb.Snapshot()
167+
168+ sb.Append(row("cc"))
169+ sb.Append(row("dd"))
170+
171+ if text := rowText(snapshot[0]); text != "aa" {
172+ t.Fatalf("snapshot[0] after append = %q, want original row", text)
173+ }
174+ if text := rowText(snapshot[1]); text != "bb" {
175+ t.Fatalf("snapshot[1] after append = %q, want original row", text)
176+ }
177+}
M internal/usecase/daemon/attention.go +11 -7
@@ -9,8 +9,9 @@ import (
99 "github.com/bnema/vev/internal/usecase/picker"
1010 )
1111
12-// noteAttention records attention for tabs that are not currently visible to an
13-// attached client. The caller must not hold tab.mu.
12+// noteAttention records attention for a tab. If the tab is currently visible,
13+// it is kept long enough to render one pulse before paint acknowledges it. The
14+// caller must not hold tab.mu.
1415 func (d *Daemon) noteAttention(sess *session, tb *tab) {
1516 if sess == nil || tb == nil {
1617 return
@@ -23,14 +24,13 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
2324
2425 sess.mu.Lock()
2526 visible := sess.client != nil && sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
26- if visible {
27- sess.mu.Unlock()
28- return
29- }
3027 if !tb.attention {
3128 tb.attentionAt = now
3229 }
3330 tb.attention = true
31+ if visible {
32+ tb.attentionVisiblePaint = true
33+ }
3434 sess.mu.Unlock()
3535
3636 // Do not repaint here: this runs on the PTY reader goroutine, and paint
@@ -135,7 +135,7 @@ func (d *Daemon) repaintAllAttachedClients() {
135135 }
136136 }
137137
138-func (s *session) ackAttention(tb *tab) bool {
138+func (s *session) ackAttention(tb *tab, visible bool) bool {
139139 if tb == nil {
140140 return false
141141 }
@@ -144,8 +144,12 @@ func (s *session) ackAttention(tb *tab) bool {
144144 if s.active < 0 || s.active >= len(s.tabs) || s.tabs[s.active] != tb || !tb.attention {
145145 return false
146146 }
147+ if tb.attentionVisiblePaint && !visible {
148+ return false
149+ }
147150 tb.attention = false
148151 tb.attentionAt = time.Time{}
152+ tb.attentionVisiblePaint = false
149153 return true
150154 }
151155
M internal/usecase/daemon/attention_test.go +91 -35
@@ -15,7 +15,6 @@ import (
1515 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1616 "github.com/bnema/vev/internal/usecase/keys"
1717 "github.com/bnema/vev/pkg/renderer"
18- "github.com/bnema/vev/pkg/vt"
1918 )
2019
2120 func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
@@ -33,7 +32,7 @@ func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
3332 require.False(t, sess.tabs[0].attention)
3433 }
3534
36-func TestNoteAttentionIgnoresVisibleTab(t *testing.T) {
35+func TestNoteAttentionFlagsVisibleTabUntilPainted(t *testing.T) {
3736 d, sess, _, _, releases := newManualTabSession(t, 1)
3837 defer releases[0]()
3938 sess.active = 0
@@ -42,8 +41,77 @@ func TestNoteAttentionIgnoresVisibleTab(t *testing.T) {
4241
4342 sess.mu.Lock()
4443 defer sess.mu.Unlock()
44+ require.True(t, sess.tabs[0].attention)
45+ require.False(t, sess.tabs[0].attentionAt.IsZero())
46+}
47+
48+func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
49+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
50+ defer releases[0]()
51+ sess.active = 0
52+ d.setAttentionFrame(0)
53+ d.noteAttention(sess, sess.tabs[0])
54+
55+ d.paint(sess, ac, true)
56+ data := mustOutputData(t, sends)
57+ require.NotContains(t, string(data), string(attentionGlyph))
58+
59+ sess.mu.Lock()
60+ require.True(t, sess.tabs[0].attention)
61+ sess.mu.Unlock()
62+
63+ d.setAttentionFrame(1)
64+ d.paint(sess, ac, true)
65+ data = mustOutputData(t, sends)
66+ require.Contains(t, string(data), string(attentionGlyph))
67+
68+ sess.mu.Lock()
4569 require.False(t, sess.tabs[0].attention)
46- require.True(t, sess.tabs[0].attentionAt.IsZero())
70+ sess.mu.Unlock()
71+}
72+
73+func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.T) {
74+ tests := []struct {
75+ name string
76+ data []byte
77+ }{
78+ {name: "bell", data: []byte("\a")},
79+ {name: "osc notify", data: []byte("\x1b]777;notify;Claude;needs input\x1b\\")},
80+ {name: "osc progress complete", data: []byte("\x1b]9;4;1;50\a\x1b]9;4;0;100\a")},
81+ }
82+
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ pty := newScriptPTY([][]byte{tt.data})
86+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
87+ sess.active = 0
88+ d.setAttentionFrame(1)
89+
90+ d.sessWg.Add(1)
91+ go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
92+
93+ require.Eventually(t, func() bool {
94+ sess.mu.Lock()
95+ defer sess.mu.Unlock()
96+ return sess.tabs[0].attention && !sess.tabs[0].attentionAt.IsZero()
97+ }, 2*time.Second, 5*time.Millisecond)
98+
99+ d.paint(sess, ac, true)
100+ data := mustOutputData(t, sends)
101+ require.Contains(t, string(data), string(attentionGlyph))
102+
103+ sess.mu.Lock()
104+ require.False(t, sess.tabs[0].attention)
105+ require.True(t, sess.tabs[0].attentionAt.IsZero())
106+ sess.mu.Unlock()
107+
108+ cleared := mustOutputData(t, sends)
109+ require.NotContains(t, string(cleared), string(attentionGlyph))
110+
111+ _ = pty.Close()
112+ d.sessWg.Wait()
113+ })
114+ }
47115 }
48116
49117 func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) {
@@ -68,7 +136,7 @@ func TestPTYReaderBellMarksBackgroundTab(t *testing.T) {
68136 sess.active = 0
69137
70138 d.sessWg.Add(1)
71- go d.ptyReader(sess, sess.tabs[1])
139+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
72140
73141 require.Eventually(t, func() bool {
74142 sess.mu.Lock()
@@ -102,12 +170,13 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
102170 }).Maybe()
103171 trW.EXPECT().Close().Return(nil).Maybe()
104172 acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
173+ acW.initOverlays()
105174 sctxW, cancelW := context.WithCancel(d.serveCtx)
106175 t.Cleanup(cancelW)
107- tabW := &tab{screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxW, cancel: cancelW}
176+ tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
108177 sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}
109178 acW.setSession(sessW)
110- acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW})
179+ acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil)
111180 d.sessions[sessW.id] = sessW
112181
113182 done := make(chan struct{})
@@ -156,8 +225,9 @@ func (p *scriptPTY) Close() error {
156225 p.once.Do(func() { close(p.closed) })
157226 return nil
158227 }
159-func (p *scriptPTY) Resize(domain.Size) error { return nil }
160-func (p *scriptPTY) Pid() int { return 4242 }
228+func (p *scriptPTY) Resize(domain.Size) error { return nil }
229+func (p *scriptPTY) Pid() int { return 4242 }
230+func (p *scriptPTY) ForegroundPgid() (int, error) { return 4242, nil }
161231
162232 func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
163233 d, sess, ac, sends, releases := newManualTabSession(t, 2)
@@ -269,7 +339,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
269339 defer releases[0]()
270340 defer releases[1]()
271341 sess.active = 0
272- sess.tabs[0].screen.Write([]byte("MIDSCREENMARKER"))
342+ sess.tabs[0].focusedPane().screen.Write([]byte("MIDSCREENMARKER"))
273343 sess.mu.Lock()
274344 sess.tabs[1].attention = true
275345 sess.tabs[1].attentionAt = time.Unix(1, 0)
@@ -314,12 +384,12 @@ func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testi
314384 bars := d.barStateFor(sess, "")
315385 _, damage := composeClientFrameWithState(bars, tb, true, &cache)
316386 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
317- tb.screen.ClearDamage()
387+ tb.focusedPane().screen.ClearDamage()
318388
319389 bars.attentionFrame++
320390 _, damage = composeClientFrameWithState(bars, tb, false, &cache)
321391 require.NotEmpty(t, damage)
322- lastRow := tb.screen.Frame.Height + 1
392+ lastRow := tb.focusedPane().screen.Frame.Height + 1
323393 for _, dmg := range damage {
324394 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)
325395 }
@@ -348,12 +418,13 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
348418
349419 trB, sendsB := newCapturingTransport(t)
350420 acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
421+ acB.initOverlays()
351422 sctxB, cancelB := context.WithCancel(d.serveCtx)
352423 t.Cleanup(cancelB)
353- tbB := &tab{pty: pB, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxB, cancel: cancelB}
424+ tbB := newTestTabWithContext(pB, sctxB, cancelB)
354425 sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}
355426 acB.setSession(sessB)
356- acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB})
427+ acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
357428 d.sessions[sessB.id] = sessB
358429
359430 d.paint(sessB, acB, true)
@@ -396,33 +467,18 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
396467 d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)
397468 sctx2, cancel2 := context.WithCancel(d.serveCtx)
398469 defer cancel2()
470+ tab2a := newTestTabWithContext(p2, sctx2, cancel2)
471+ tab2b := newTestTabWithContext(p3, sctx2, cancel2)
472+ tab2a.attention = true
473+ tab2a.attentionAt = time.Unix(9, 0)
474+ tab2b.attention = true
475+ tab2b.attentionAt = time.Unix(5, 0)
399476 sess2 := &session{
400477 id: "other",
401478 name: "other",
402479 ctx: sctx2,
403480 cancel: cancel2,
404- tabs: []*tab{
405- {
406- pty: p2,
407- screen: vt.NewScreen(80, 23),
408- dirty: make(chan struct{}, 1),
409- size: domain.Size{Cols: 80, Rows: 23},
410- ctx: sctx2,
411- cancel: cancel2,
412- attention: true,
413- attentionAt: time.Unix(9, 0),
414- },
415- {
416- pty: p3,
417- screen: vt.NewScreen(80, 23),
418- dirty: make(chan struct{}, 1),
419- size: domain.Size{Cols: 80, Rows: 23},
420- ctx: sctx2,
421- cancel: cancel2,
422- attention: true,
423- attentionAt: time.Unix(5, 0),
424- },
425- },
481+ tabs: []*tab{tab2a, tab2b},
426482 }
427483 d.sessions[sess2.id] = sess2
428484
A internal/usecase/daemon/bar_refresh.go +317 -0
@@ -0,0 +1,317 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+type barScriptExecutor interface {
12+ run(context.Context, string, barScriptContext) (string, error)
13+}
14+
15+type barScriptConfig struct {
16+ topRight string
17+ bottomRight string
18+ interval time.Duration
19+}
20+
21+type barScriptOutputs struct {
22+ topRight string
23+ bottomRight string
24+}
25+
26+type barScriptState struct {
27+ mu sync.Mutex
28+ cfg barScriptConfig
29+ runner barScriptExecutor
30+ outputs map[domain.SessionID]barScriptOutputs
31+ lastRefresh map[domain.SessionID]time.Time
32+ lastContext map[domain.SessionID]barScriptContext
33+ running map[domain.SessionID]bool
34+ pending map[domain.SessionID]bool
35+ version uint64
36+}
37+
38+func effectiveBarInterval(d time.Duration) time.Duration {
39+ if d <= 0 {
40+ return 5 * time.Second
41+ }
42+ if d < domain.MinBarInterval {
43+ return domain.MinBarInterval
44+ }
45+ return d
46+}
47+
48+func barConfigFromDomain(cfg domain.BarConfig) barScriptConfig {
49+ return barScriptConfig{topRight: cfg.TopRight, bottomRight: cfg.BottomRight, interval: effectiveBarInterval(cfg.Interval)}
50+}
51+
52+func (s *barScriptState) initLocked() {
53+ if s.outputs == nil {
54+ s.outputs = make(map[domain.SessionID]barScriptOutputs)
55+ }
56+ if s.lastRefresh == nil {
57+ s.lastRefresh = make(map[domain.SessionID]time.Time)
58+ }
59+ if s.lastContext == nil {
60+ s.lastContext = make(map[domain.SessionID]barScriptContext)
61+ }
62+ if s.running == nil {
63+ s.running = make(map[domain.SessionID]bool)
64+ }
65+ if s.pending == nil {
66+ s.pending = make(map[domain.SessionID]bool)
67+ }
68+}
69+
70+func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
71+ if d == nil || d.barScripts == nil || sess == nil {
72+ return "", ""
73+ }
74+ d.barScripts.mu.Lock()
75+ defer d.barScripts.mu.Unlock()
76+ out := d.barScripts.outputs[sess.id]
77+ return out.topRight, out.bottomRight
78+}
79+
80+func (d *Daemon) barScriptInterval() time.Duration {
81+ if d == nil || d.barScripts == nil {
82+ return 5 * time.Second
83+ }
84+ d.barScripts.mu.Lock()
85+ defer d.barScripts.mu.Unlock()
86+ return effectiveBarInterval(d.barScripts.cfg.interval)
87+}
88+
89+func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) {
90+ ctx := barScriptContext{Anchor: anchor}
91+ if sess == nil {
92+ return ctx, false
93+ }
94+ sess.mu.Lock()
95+ ctx.Session = sess.name
96+ active := sess.active
97+ var tb *tab
98+ if active >= 0 && active < len(sess.tabs) {
99+ tb = sess.tabs[active]
100+ }
101+ ac := sess.client
102+ ctx.PaneCWD = sess.cwd
103+ sess.mu.Unlock()
104+ if ac == nil || tb == nil {
105+ return ctx, false
106+ }
107+ tb.mu.Lock()
108+ ctx.Tab = tb.stableID
109+ ctx.Cols = tb.size.Cols
110+ pid := 0
111+ if p := tb.focusedPane(); p != nil {
112+ ctx.Pane = p.stableID
113+ if d != nil && d.procCwd != nil && p.pty != nil {
114+ pid = p.pty.Pid()
115+ }
116+ }
117+ tb.mu.Unlock()
118+ if pid != 0 && d != nil && d.procCwd != nil {
119+ if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
120+ ctx.PaneCWD = cwd
121+ }
122+ }
123+ return ctx, true
124+}
125+
126+func (d *Daemon) barScriptPoller(ctx context.Context) {
127+ timer := d.clock.NewTimer(d.barScriptInterval())
128+ defer timer.Stop()
129+ for {
130+ select {
131+ case <-ctx.Done():
132+ return
133+ case now := <-timer.C():
134+ for _, sess := range d.sessionsSnapshot() {
135+ d.refreshBarScriptsIfDue(sess, now, false)
136+ }
137+ timer.Reset(d.barScriptInterval())
138+ }
139+ }
140+}
141+
142+func (d *Daemon) sessionsSnapshot() []*session {
143+ d.mu.Lock()
144+ defer d.mu.Unlock()
145+ sessions := make([]*session, 0, len(d.sessions))
146+ for _, sess := range d.sessions {
147+ sessions = append(sessions, sess)
148+ }
149+ return sessions
150+}
151+
152+func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
153+ if d == nil || d.barScripts == nil {
154+ return
155+ }
156+ d.barScripts.mu.Lock()
157+ defer d.barScripts.mu.Unlock()
158+ delete(d.barScripts.outputs, id)
159+ delete(d.barScripts.lastRefresh, id)
160+ delete(d.barScripts.lastContext, id)
161+ delete(d.barScripts.running, id)
162+ delete(d.barScripts.pending, id)
163+}
164+
165+func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
166+ if d == nil || d.barScripts == nil || sess == nil {
167+ return false
168+ }
169+ baseCtx, ok := d.collectBarScriptContext(sess, "")
170+ if !ok {
171+ return false
172+ }
173+ d.barScripts.mu.Lock()
174+ d.barScripts.initLocked()
175+ if d.barScripts.lastContext[sess.id] != baseCtx {
176+ force = true
177+ }
178+ lastRefresh := d.barScripts.lastRefresh[sess.id]
179+ if d.barScripts.running[sess.id] {
180+ if force {
181+ d.barScripts.pending[sess.id] = true
182+ }
183+ d.barScripts.mu.Unlock()
184+ return false
185+ }
186+ if !lastRefresh.IsZero() && now.Sub(lastRefresh) < domain.MinBarInterval {
187+ if force {
188+ d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(domain.MinBarInterval).Sub(now))
189+ }
190+ d.barScripts.mu.Unlock()
191+ return false
192+ }
193+ if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval {
194+ d.barScripts.mu.Unlock()
195+ return false
196+ }
197+ cfg := d.barScripts.cfg
198+ if cfg.topRight == "" && cfg.bottomRight == "" {
199+ d.barScripts.lastRefresh[sess.id] = now
200+ d.barScripts.lastContext[sess.id] = baseCtx
201+ current := d.barScripts.outputs[sess.id]
202+ changed := current.topRight != "" || current.bottomRight != ""
203+ delete(d.barScripts.outputs, sess.id)
204+ d.barScripts.mu.Unlock()
205+ if changed {
206+ d.pokeSessionRender(sess)
207+ }
208+ return false
209+ }
210+ d.barScripts.running[sess.id] = true
211+ d.barScripts.lastRefresh[sess.id] = now
212+ d.barScripts.lastContext[sess.id] = baseCtx
213+ runner := d.barScripts.runner
214+ version := d.barScripts.version
215+ d.barScripts.mu.Unlock()
216+ go d.runBarScripts(sess, runner, cfg, baseCtx, version)
217+ return true
218+}
219+
220+func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {
221+ if d.barScripts.pending[sess.id] {
222+ return
223+ }
224+ if d.clock == nil {
225+ return
226+ }
227+ if delay < 0 {
228+ delay = 0
229+ }
230+ d.barScripts.pending[sess.id] = true
231+ go func() {
232+ ctx := sess.ctx
233+ if ctx == nil {
234+ ctx = context.Background()
235+ }
236+ timer := d.clock.NewTimer(delay)
237+ select {
238+ case <-ctx.Done():
239+ timer.Stop()
240+ return
241+ case <-timer.C():
242+ }
243+ d.barScripts.mu.Lock()
244+ delete(d.barScripts.pending, sess.id)
245+ d.barScripts.mu.Unlock()
246+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
247+ }()
248+}
249+
250+func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) {
251+ if runner == nil {
252+ runner = barScriptRunner{baseEnv: d.baseEnv}
253+ }
254+ ctx := sess.ctx
255+ if ctx == nil {
256+ ctx = context.Background()
257+ }
258+ top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, base, "top-right", sess.name)
259+ bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right", sess.name)
260+ d.barScripts.mu.Lock()
261+ d.barScripts.initLocked()
262+ if !d.barScripts.running[sess.id] || d.barScripts.version != version {
263+ delete(d.barScripts.running, sess.id)
264+ d.barScripts.mu.Unlock()
265+ return
266+ }
267+ current := d.barScripts.outputs[sess.id]
268+ changed := false
269+ if topOK && current.topRight != top {
270+ current.topRight = top
271+ changed = true
272+ }
273+ if bottomOK && current.bottomRight != bottom {
274+ current.bottomRight = bottom
275+ changed = true
276+ }
277+ d.barScripts.outputs[sess.id] = current
278+ pending := d.barScripts.pending[sess.id]
279+ delete(d.barScripts.running, sess.id)
280+ delete(d.barScripts.pending, sess.id)
281+ d.barScripts.mu.Unlock()
282+ if changed {
283+ d.pokeSessionRender(sess)
284+ }
285+ if pending {
286+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
287+ }
288+}
289+
290+func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) {
291+ if command == "" {
292+ return "", true
293+ }
294+ base.Anchor = anchor
295+ out, err := runner.run(ctx, command, base)
296+ if err != nil {
297+ if d.log != nil {
298+ d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err)
299+ }
300+ return "", false
301+ }
302+ return out, true
303+}
304+
305+func (d *Daemon) pokeSessionRender(sess *session) {
306+ if sess == nil {
307+ return
308+ }
309+ if tb := sess.activeTab(); tb != nil {
310+ tb.mu.Lock()
311+ p := tb.focusedPane()
312+ tb.mu.Unlock()
313+ if p != nil {
314+ signal(p.dirty)
315+ }
316+ }
317+}
A internal/usecase/daemon/bar_refresh_test.go +339 -0
@@ -0,0 +1,339 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "log/slog"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/layout"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+type fakeBarRunner struct {
20+ mu sync.Mutex
21+ calls []barScriptContext
22+ outs []string
23+ errs []error
24+}
25+
26+func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) {
27+ r.mu.Lock()
28+ defer r.mu.Unlock()
29+ r.calls = append(r.calls, ctx)
30+ i := len(r.calls) - 1
31+ if i < len(r.errs) && r.errs[i] != nil {
32+ return "", r.errs[i]
33+ }
34+ if i < len(r.outs) {
35+ return r.outs[i], nil
36+ }
37+ return "", nil
38+}
39+
40+type blockingBarRunner struct {
41+ *fakeBarRunner
42+ entered chan struct{}
43+ release chan struct{}
44+ once sync.Once
45+}
46+
47+func newBlockingBarRunner(outs []string) *blockingBarRunner {
48+ return &blockingBarRunner{fakeBarRunner: &fakeBarRunner{outs: outs}, entered: make(chan struct{}), release: make(chan struct{})}
49+}
50+
51+func (r *blockingBarRunner) run(ctx context.Context, command string, barCtx barScriptContext) (string, error) {
52+ r.once.Do(func() { close(r.entered) })
53+ select {
54+ case <-r.release:
55+ case <-ctx.Done():
56+ return "", ctx.Err()
57+ }
58+ return r.fakeBarRunner.run(ctx, command, barCtx)
59+}
60+
61+func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {
62+ r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
63+ d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
64+ sess := newBarRefreshTestSession()
65+ sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}}
66+
67+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
68+ waitBarRefreshIdle(t, d)
69+ require.Equal(t, 2, len(r.calls))
70+ require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
71+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, int64(500*time.Millisecond)), false), "clamped interval should skip sub-second tick")
72+
73+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
74+ waitBarRefreshIdle(t, d)
75+ require.Equal(t, 4, len(r.calls))
76+ require.Equal(t, "top2", d.barStateFor(sess, "").topRight)
77+
78+ r.errs = []error{nil, nil, nil, nil, errors.New("boom"), errors.New("boom")}
79+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(2, 0), false))
80+ waitBarRefreshIdle(t, d)
81+ state := d.barStateFor(sess, "")
82+ require.Equal(t, "top2", state.topRight)
83+ require.Equal(t, "bot2", state.bottomRight)
84+}
85+
86+func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
87+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
88+ d := newBarRefreshTestDaemon(r, time.Second)
89+ sess := newBarRefreshTestSession()
90+ sess.name = "work"
91+ sess.cwd = "/repo"
92+ sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}}
93+ sess.active = 1
94+ sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38}
95+ active := sess.tabs[1].focusedPane()
96+ active.stableID = "pane-active"
97+ active.pty = newScriptPTY(nil)
98+ d.procCwd = func(pid int) (string, error) {
99+ require.Equal(t, 4242, pid)
100+ return "/pane-repo", nil
101+ }
102+
103+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
104+ waitBarRefreshIdle(t, d)
105+ require.Len(t, r.calls, 2)
106+ for _, call := range r.calls {
107+ require.Equal(t, "work", call.Session)
108+ require.Equal(t, "tab-active", call.Tab)
109+ require.Equal(t, "pane-active", call.Pane)
110+ require.Equal(t, "/pane-repo", call.PaneCWD)
111+ require.Equal(t, 132, call.Cols)
112+ }
113+ require.Equal(t, "top-right", r.calls[0].Anchor)
114+ require.Equal(t, "bottom-right", r.calls[1].Anchor)
115+}
116+
117+func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
118+ r := &fakeBarRunner{}
119+ d := newBarRefreshTestDaemon(r, time.Second)
120+ sess := newBarRefreshTestSession()
121+
122+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
123+ require.Empty(t, r.calls)
124+}
125+
126+func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) {
127+ r := &fakeBarRunner{}
128+ d := newBarRefreshTestDaemon(r, time.Second)
129+ d.barScripts.cfg.topRight = ""
130+ d.barScripts.cfg.bottomRight = ""
131+ sess := newBarRefreshTestSession()
132+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
133+ d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}
134+
135+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
136+ require.Empty(t, r.calls)
137+ state := d.barStateFor(sess, "")
138+ require.Empty(t, state.topRight)
139+ require.Empty(t, state.bottomRight)
140+}
141+
142+func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
143+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
144+ d := newBarRefreshTestDaemon(r, 5*time.Second)
145+ sess := newBarRefreshTestSession()
146+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
147+
148+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
149+ waitBarRefreshIdle(t, d)
150+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true))
151+ require.Len(t, r.calls, 2)
152+ require.Eventually(t, func() bool {
153+ r.mu.Lock()
154+ defer r.mu.Unlock()
155+ return len(r.calls) == 4
156+ }, 1500*time.Millisecond, 10*time.Millisecond)
157+}
158+
159+func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
160+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
161+ d := newBarRefreshTestDaemon(r, 5*time.Second)
162+ sess := newBarRefreshTestSession()
163+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
164+
165+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))
166+ waitBarRefreshIdle(t, d)
167+ sess.cwd = "/other"
168+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Now().Add(100*time.Millisecond), false))
169+ require.Eventually(t, func() bool {
170+ r.mu.Lock()
171+ defer r.mu.Unlock()
172+ return len(r.calls) == 4
173+ }, 1500*time.Millisecond, 10*time.Millisecond)
174+ require.Equal(t, "/other", r.calls[2].PaneCWD)
175+}
176+
177+func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
178+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
179+ d := newBarRefreshTestDaemon(r, 5*time.Second)
180+ sess := newBarRefreshTestSession()
181+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
182+
183+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
184+ waitBarRefreshIdle(t, d)
185+ sess.cwd = "/other"
186+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
187+ waitBarRefreshIdle(t, d)
188+ require.Len(t, r.calls, 4)
189+ require.Equal(t, "/other", r.calls[2].PaneCWD)
190+}
191+
192+func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) {
193+ r := &fakeBarRunner{}
194+ d := newBarRefreshTestDaemon(r, time.Second)
195+ sess := newBarRefreshTestSession()
196+ d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "top-good", bottomRight: "bottom-good"}
197+
198+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
199+
200+ state := d.barStateFor(sess, "")
201+ require.Equal(t, "top-good", state.topRight)
202+ require.Equal(t, "bottom-good", state.bottomRight)
203+}
204+
205+func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
206+ var logs bytes.Buffer
207+ r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}
208+ d := newBarRefreshTestDaemon(r, time.Second)
209+ d.log = slog.New(slog.NewTextHandler(&logs, nil))
210+ sess := newBarRefreshTestSession()
211+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
212+
213+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
214+ waitBarRefreshIdle(t, d)
215+ r.errs = []error{nil, nil, errors.New("boom"), errors.New("bang")}
216+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
217+ waitBarRefreshIdle(t, d)
218+
219+ state := d.barStateFor(sess, "")
220+ require.Equal(t, "top-good", state.topRight)
221+ require.Equal(t, "bottom-good", state.bottomRight)
222+ require.True(t, strings.Contains(logs.String(), "bar script failed; keeping last good output"), logs.String())
223+ require.True(t, strings.Contains(logs.String(), "anchor=top-right"), logs.String())
224+}
225+
226+func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
227+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
228+ d := newBarRefreshTestDaemon(r, time.Second)
229+ sess := newBarRefreshTestSession()
230+ d.barScripts.mu.Lock()
231+ d.barScripts.running[sess.id] = true
232+ d.barScripts.mu.Unlock()
233+
234+ d.clearBarScriptsForSession(sess.id)
235+ d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 0)
236+
237+ state := d.barStateFor(sess, "")
238+ require.Empty(t, state.topRight)
239+ require.Empty(t, state.bottomRight)
240+}
241+
242+func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
243+ r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"})
244+ d := newBarRefreshTestDaemon(r, time.Second)
245+ sess := newBarRefreshTestSession()
246+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
247+
248+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
249+ select {
250+ case <-r.entered:
251+ case <-time.After(time.Second):
252+ t.Fatal("timeout waiting for first bar script run")
253+ }
254+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
255+ close(r.release)
256+ waitBarRefreshIdle(t, d)
257+ require.Eventually(t, func() bool {
258+ r.mu.Lock()
259+ defer r.mu.Unlock()
260+ return len(r.calls) == 4
261+ }, time.Second, time.Millisecond)
262+}
263+
264+func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
265+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
266+ d := newBarRefreshTestDaemon(r, time.Second)
267+ sess := newBarRefreshTestSession()
268+ d.barScripts.mu.Lock()
269+ d.barScripts.running[sess.id] = true
270+ d.barScripts.version = 2
271+ d.barScripts.mu.Unlock()
272+
273+ d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 1)
274+
275+ state := d.barStateFor(sess, "")
276+ require.Empty(t, state.topRight)
277+ require.Empty(t, state.bottomRight)
278+}
279+
280+func TestBarScriptRefreshIsPerSession(t *testing.T) {
281+ r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
282+ d := newBarRefreshTestDaemon(r, time.Second)
283+ a := newBarRefreshTestSession()
284+ a.id = "a"
285+ a.name = "a"
286+ a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
287+ b := newBarRefreshTestSession()
288+ b.id = "b"
289+ b.name = "b"
290+ b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
291+
292+ require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))
293+ waitBarRefreshIdle(t, d)
294+ require.True(t, d.refreshBarScriptsIfDue(b, time.Unix(0, 0), false))
295+ waitBarRefreshIdle(t, d)
296+
297+ stateA := d.barStateFor(a, "")
298+ stateB := d.barStateFor(b, "")
299+ require.Equal(t, "top-a", stateA.topRight)
300+ require.Equal(t, "bottom-a", stateA.bottomRight)
301+ require.Equal(t, "top-b", stateB.topRight)
302+ require.Equal(t, "bottom-b", stateB.bottomRight)
303+}
304+
305+func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon {
306+ d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{
307+ cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
308+ runner: r,
309+ outputs: make(map[domain.SessionID]barScriptOutputs),
310+ lastRefresh: make(map[domain.SessionID]time.Time),
311+ lastContext: make(map[domain.SessionID]barScriptContext),
312+ running: make(map[domain.SessionID]bool),
313+ pending: make(map[domain.SessionID]bool),
314+ }}
315+ return d
316+}
317+
318+type barRefreshTestClock struct{}
319+
320+func (barRefreshTestClock) Now() time.Time { return time.Now() }
321+func (barRefreshTestClock) NewTimer(d time.Duration) ports.Timer {
322+ return realTimer{t: time.NewTimer(d)}
323+}
324+
325+func newBarRefreshTestSession() *session {
326+ inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
327+ active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
328+ active.tree.Focus = layout.PaneID("pane-1")
329+ return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()}
330+}
331+
332+func waitBarRefreshIdle(t *testing.T, d *Daemon) {
333+ t.Helper()
334+ require.Eventually(t, func() bool {
335+ d.barScripts.mu.Lock()
336+ defer d.barScripts.mu.Unlock()
337+ return len(d.barScripts.running) == 0
338+ }, time.Second, time.Millisecond)
339+}
A internal/usecase/daemon/bar_script.go +183 -0
@@ -0,0 +1,183 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "strconv"
8+ "strings"
9+ "time"
10+ "unicode/utf8"
11+
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+const (
16+ barScriptOutputLimit = 1024
17+ barScriptDisplayLimit = 256
18+ barScriptTimeout = time.Second
19+)
20+
21+type barScriptContext struct {
22+ Anchor string
23+ Session string
24+ Tab string
25+ Pane string
26+ PaneCWD string
27+ Cols int
28+}
29+
30+func (c barScriptContext) env(base []string) []string {
31+ vars := map[string]string{
32+ "VEV_ANCHOR": c.Anchor,
33+ "VEV_SESSION": c.Session,
34+ "VEV_TAB": c.Tab,
35+ "VEV_PANE": c.Pane,
36+ "VEV_PANE_CWD": c.PaneCWD,
37+ "VEV_COLS": strconv.Itoa(c.Cols),
38+ }
39+ out := make([]string, 0, len(base)+len(vars))
40+ for _, entry := range base {
41+ key, _, ok := strings.Cut(entry, "=")
42+ if ok {
43+ if _, replace := vars[key]; replace {
44+ continue
45+ }
46+ }
47+ out = append(out, entry)
48+ }
49+ for _, key := range []string{"VEV_ANCHOR", "VEV_SESSION", "VEV_TAB", "VEV_PANE", "VEV_PANE_CWD", "VEV_COLS"} {
50+ out = append(out, key+"="+vars[key])
51+ }
52+ return out
53+}
54+
55+type barScriptRunner struct {
56+ runner ports.ShellCommandRunner
57+ timeout time.Duration
58+ baseEnv []string
59+}
60+
61+func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) {
62+ if strings.TrimSpace(command) == "" {
63+ return "", nil
64+ }
65+ if r.runner == nil {
66+ return "", errors.New("bar script command runner is nil")
67+ }
68+ timeout := r.timeout
69+ if timeout <= 0 {
70+ timeout = barScriptTimeout
71+ }
72+ ctx, cancel := context.WithTimeout(ctx, timeout)
73+ defer cancel()
74+
75+ stdout, err := r.runner.Run(ctx, ports.CommandSpec{
76+ Command: command,
77+ Env: scriptCtx.env(r.baseEnv),
78+ Timeout: timeout,
79+ StdoutLimit: barScriptOutputLimit,
80+ })
81+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
82+ return "", context.DeadlineExceeded
83+ }
84+ text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit)
85+ if err != nil {
86+ return text, err
87+ }
88+ return text, nil
89+}
90+
91+func sanitizeBarScriptOutput(raw []byte, limit int) string {
92+ if limit <= 0 || limit > len(raw) {
93+ limit = len(raw)
94+ }
95+ raw = raw[:limit]
96+ if i := bytes.IndexAny(raw, "\r\n"); i >= 0 {
97+ raw = raw[:i]
98+ }
99+ text := strings.ToValidUTF8(string(raw), "")
100+ text = stripANSIAndControls(text)
101+ if len(text) <= barScriptDisplayLimit {
102+ return text
103+ }
104+ return trimUTF8Bytes(text, barScriptDisplayLimit)
105+}
106+
107+func stripANSIAndControls(s string) string {
108+ var out strings.Builder
109+ for i := 0; i < len(s); {
110+ r, size := utf8.DecodeRuneInString(s[i:])
111+ switch {
112+ case r == '\x1b':
113+ i = skipEscapeSequence(s, i+size)
114+ continue
115+ case r == 0x9b:
116+ i = skipCSI(s, i+size)
117+ continue
118+ case r == 0x90 || r == 0x98 || r == 0x9d || r == 0x9e || r == 0x9f:
119+ i = skipStringControl(s, i+size)
120+ continue
121+ case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
122+ i += size
123+ continue
124+ }
125+ out.WriteRune(r)
126+ i += size
127+ }
128+ return out.String()
129+}
130+
131+func skipEscapeSequence(s string, i int) int {
132+ if i >= len(s) {
133+ return i
134+ }
135+ switch s[i] {
136+ case '[':
137+ return skipCSI(s, i+1)
138+ case ']', 'P', '^', '_', 'X':
139+ return skipStringControl(s, i+1)
140+ }
141+ for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f {
142+ i++
143+ }
144+ if i < len(s) && s[i] >= 0x30 && s[i] <= 0x7e {
145+ return i + 1
146+ }
147+ return i
148+}
149+
150+func skipCSI(s string, i int) int {
151+ for i < len(s) {
152+ c := s[i]
153+ i++
154+ if c >= 0x40 && c <= 0x7e {
155+ break
156+ }
157+ }
158+ return i
159+}
160+
161+func skipStringControl(s string, i int) int {
162+ for i < len(s) {
163+ if s[i] == '\a' {
164+ return i + 1
165+ }
166+ if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
167+ return i + 2
168+ }
169+ _, size := utf8.DecodeRuneInString(s[i:])
170+ i += size
171+ }
172+ return i
173+}
174+
175+func trimUTF8Bytes(s string, limit int) string {
176+ if len(s) <= limit {
177+ return s
178+ }
179+ for limit > 0 && !utf8.ValidString(s[:limit]) {
180+ limit--
181+ }
182+ return s[:limit]
183+}
A internal/usecase/daemon/bar_script_test.go +182 -0
@@ -0,0 +1,182 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "slices"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+ "github.com/stretchr/testify/mock"
14+)
15+
16+func TestBarScriptSanitizeOutput(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ raw string
20+ limit int
21+ want string
22+ }{
23+ {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"},
24+ {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"},
25+ {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"},
26+ {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"},
27+ {name: "charset escape stripped", raw: "a\x1b(Bb\x1b)0c", limit: barScriptOutputLimit, want: "abc"},
28+ {name: "non csi escape stripped", raw: "a\x1b7b\x1b8c", limit: barScriptOutputLimit, want: "abc"},
29+ {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"},
30+ {name: "utf8 and nerd font preserved", raw: "󰍛 14:32 ↑3", limit: barScriptOutputLimit, want: "󰍛 14:32 ↑3"},
31+ {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)},
32+ {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"},
33+ }
34+
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ if got := sanitizeBarScriptOutput([]byte(tt.raw), tt.limit); got != tt.want {
38+ t.Fatalf("sanitizeBarScriptOutput() = %q, want %q", got, tt.want)
39+ }
40+ })
41+ }
42+}
43+
44+func TestBarScriptContextEnv(t *testing.T) {
45+ base := []string{
46+ "PATH=/bin",
47+ "VEV_ANCHOR=old",
48+ "VEV_SESSION=old",
49+ "VEV_TAB=old",
50+ "VEV_PANE=old",
51+ "VEV_PANE_CWD=/old",
52+ "VEV_COLS=1",
53+ }
54+ ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120}
55+ got := ctx.env(base)
56+
57+ want := map[string]string{
58+ "VEV_ANCHOR": "top-right",
59+ "VEV_SESSION": "work",
60+ "VEV_TAB": "2",
61+ "VEV_PANE": "pane-3",
62+ "VEV_PANE_CWD": "/repo",
63+ "VEV_COLS": "120",
64+ }
65+ seen := map[string]int{}
66+ for _, entry := range got {
67+ key, val, ok := strings.Cut(entry, "=")
68+ if !ok {
69+ continue
70+ }
71+ if _, isVEV := want[key]; isVEV {
72+ seen[key]++
73+ if val != want[key] {
74+ t.Fatalf("%s = %q, want %q in env %v", key, val, want[key], got)
75+ }
76+ }
77+ }
78+ for key := range want {
79+ if seen[key] != 1 {
80+ t.Fatalf("%s appears %d times, want 1 in env %v", key, seen[key], got)
81+ }
82+ }
83+}
84+
85+func TestBarScriptRunner(t *testing.T) {
86+ t.Run("nil runner returns explicit error for command", func(t *testing.T) {
87+ runner := barScriptRunner{}
88+ got, err := runner.run(context.Background(), "date", barScriptContext{})
89+ if err == nil || err.Error() != "bar script command runner is nil" {
90+ t.Fatalf("run err = %v, want explicit nil runner error", err)
91+ }
92+ if got != "" {
93+ t.Fatalf("run output = %q, want empty", got)
94+ }
95+ })
96+
97+ t.Run("empty command skips port runner", func(t *testing.T) {
98+ runner := barScriptRunner{}
99+ got, err := runner.run(context.Background(), " \t\n ", barScriptContext{})
100+ if err != nil {
101+ t.Fatalf("run err = %v, want nil", err)
102+ }
103+ if got != "" {
104+ t.Fatalf("run output = %q, want empty", got)
105+ }
106+ })
107+
108+ t.Run("success sanitizes first line and passes command spec", func(t *testing.T) {
109+ portRunner := portsmocks.NewMockShellCommandRunner(t)
110+ portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
111+ return spec.Command == "printf 'ok\\nignored'" &&
112+ spec.Timeout == 50*time.Millisecond &&
113+ spec.StdoutLimit == barScriptOutputLimit &&
114+ containsEnv(spec.Env, "PATH=/bin") &&
115+ containsEnv(spec.Env, "VEV_ANCHOR=top-right") &&
116+ containsEnv(spec.Env, "VEV_SESSION=work") &&
117+ containsEnv(spec.Env, "VEV_TAB=tab-1") &&
118+ containsEnv(spec.Env, "VEV_PANE=pane-1") &&
119+ containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&
120+ containsEnv(spec.Env, "VEV_COLS=120") &&
121+ !containsEnv(spec.Env, "VEV_ANCHOR=old")
122+ })).Return([]byte("\x1b[31mok\x1b[0m\nignored"), nil)
123+ runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}}
124+
125+ got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120})
126+ if err != nil {
127+ t.Fatalf("run err = %v, want nil", err)
128+ }
129+ if got != "ok" {
130+ t.Fatalf("run output = %q, want %q", got, "ok")
131+ }
132+ })
133+
134+ t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) {
135+ wantErr := errors.New("exit 1")
136+ portRunner := portsmocks.NewMockShellCommandRunner(t)
137+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).Return([]byte("partial\x00 output\nignored"), wantErr)
138+ runner := barScriptRunner{runner: portRunner, timeout: time.Second}
139+
140+ got, err := runner.run(context.Background(), "false", barScriptContext{})
141+ if !errors.Is(err, wantErr) {
142+ t.Fatalf("run err = %v, want %v", err, wantErr)
143+ }
144+ if got != "partial output" {
145+ t.Fatalf("run output = %q, want %q", got, "partial output")
146+ }
147+ })
148+
149+ t.Run("timeout propagates deadline", func(t *testing.T) {
150+ portRunner := portsmocks.NewMockShellCommandRunner(t)
151+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) {
152+ <-ctx.Done()
153+ return nil, ctx.Err()
154+ })
155+ runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}
156+
157+ _, err := runner.run(context.Background(), "sleep 1", barScriptContext{})
158+ if !errors.Is(err, context.DeadlineExceeded) {
159+ t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded)
160+ }
161+ })
162+}
163+
164+func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
165+ portRunner := portsmocks.NewMockShellCommandRunner(t)
166+ portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
167+ return spec.StdoutLimit == barScriptOutputLimit
168+ })).Return([]byte(strings.Repeat("a", 4096)), nil)
169+ runner := barScriptRunner{runner: portRunner, timeout: time.Second}
170+
171+ got, err := runner.run(context.Background(), "long-output", barScriptContext{})
172+ if err != nil {
173+ t.Fatalf("run bounded output: %v", err)
174+ }
175+ if len(got) > barScriptDisplayLimit {
176+ t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit)
177+ }
178+}
179+
180+func containsEnv(env []string, entry string) bool {
181+ return slices.Contains(env, entry)
182+}
M internal/usecase/daemon/client.go +323 -140
@@ -17,66 +17,57 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded
21-// by tab.mu; the attached-client pointer by session.mu; the registry by
22-// Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25-// a slow client.
20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2625 package daemon
2726
2827 import (
2928 "errors"
3029 "sync"
30+ "sync/atomic"
3131 "time"
3232
3333 "github.com/bnema/vev/internal/domain"
3434 "github.com/bnema/vev/internal/ports"
35- scopy "github.com/bnema/vev/internal/usecase/copy"
3635 "github.com/bnema/vev/internal/usecase/keys"
3736 "github.com/bnema/vev/internal/usecase/mouse"
38- "github.com/bnema/vev/internal/usecase/palette"
39- "github.com/bnema/vev/internal/usecase/picker"
40- promptui "github.com/bnema/vev/internal/usecase/prompt"
4137 themeui "github.com/bnema/vev/internal/usecase/theme"
4238 "github.com/bnema/vev/pkg/renderer"
4339 )
4440
4541 type attachedClient struct {
46- tr ports.Transport
47- rend *renderer.Renderer
48- bars barCache // only touched while sendMu is held
49- size domain.Size
50- keys *keys.Router
51- sess Guarded[*session]
52- copyMu sync.Mutex
53- copyMode *scopy.Mode
54- copyPending []byte
55- copyESC pendingByteTimer
56- copyFeedback string
57- copyPressRow int
58- copyPressRowValid bool
59- copyDragging bool
60- normalMousePressRow int
61- normalMousePressTop int
62- normalMousePressValid bool
63- pickerMu sync.Mutex
64- picker *picker.Model
65- pickerPreview *tab
66- pickerPending []byte
67- pickerESC pendingByteTimer
68- paletteMu sync.Mutex
69- palette *palette.Model
70- palettePending []byte
71- promptMu sync.Mutex
72- prompt *promptui.Model
73- promptSubmit func(string) error
74- promptPending []byte
75- mouseScan mouse.Scanner
76- themeMu sync.Mutex
77- theme themeui.Theme
78- lastCursor cursorOut
79- sendMu sync.Mutex
42+ tr ports.Transport
43+ rend *renderer.Renderer
44+ overlays *overlayRuntime
45+ clientID [16]byte
46+ resumeCapable bool
47+ resumeToken uint64
48+ parked bool
49+ nextStateNum uint64
50+ echoAck atomic.Uint64
51+ outputAck atomic.Uint64
52+ paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag
53+ bars barCache // only touched while sendMu is held
54+ composed composedFrameCache // only touched while sendMu is held
55+ size domain.Size
56+ keys *keys.Router
57+ sess Guarded[*session]
58+ mouseScan mouse.Scanner
59+ themeMu sync.Mutex
60+ theme themeui.Theme
61+ clientTheme themeui.Theme
62+ lastCursor cursorOut
63+ recentNav recentSessionNavigator
64+ // historyNavMu protects historyNav and historyNavTimer. When paint needs
65+ // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.
66+ historyNavMu sync.Mutex
67+ historyNav historyNavDisplay
68+ historyNavTimer pendingByteTimer
69+ linkMu sync.Mutex
70+ sendMu sync.Mutex
8071 }
8172
8273 type cursorOut struct {
@@ -117,9 +108,31 @@ func (p *pendingByteTimer) stop() {
117108 }
118109 }
119110
111+func (ac *attachedClient) initOverlays() {
112+ if ac.overlays == nil {
113+ ac.overlays = newOverlayRuntime(ac)
114+ }
115+}
116+
120117 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
121118
122-func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
119+func (ac *attachedClient) setSession(sess *session) {
120+ if sess == nil {
121+ ac.clearHistoryNav()
122+ }
123+ ac.sess.Set(sess)
124+}
125+
126+func (ac *attachedClient) clearHistoryNav() {
127+ if ac == nil {
128+ return
129+ }
130+ ac.historyNavMu.Lock()
131+ ac.historyNav.gen++
132+ ac.historyNavTimer.stop()
133+ ac.historyNav.clear()
134+ ac.historyNavMu.Unlock()
135+}
123136
124137 func (ac *attachedClient) getTheme() themeui.Theme {
125138 ac.themeMu.Lock()
@@ -127,41 +140,135 @@ func (ac *attachedClient) getTheme() themeui.Theme {
127140 return ac.theme
128141 }
129142
143+func (ac *attachedClient) getClientTheme() themeui.Theme {
144+ ac.themeMu.Lock()
145+ defer ac.themeMu.Unlock()
146+ return ac.clientTheme
147+}
148+
130149 func (ac *attachedClient) setTheme(t themeui.Theme) {
131150 ac.themeMu.Lock()
132151 ac.theme = t
133152 ac.themeMu.Unlock()
134153 }
135154
136-func (ac *attachedClient) copyModeActive() bool {
137- ac.copyMu.Lock()
138- defer ac.copyMu.Unlock()
139- return ac.copyMode != nil
155+func (ac *attachedClient) setClientTheme(t themeui.Theme) {
156+ ac.themeMu.Lock()
157+ ac.clientTheme = t
158+ ac.themeMu.Unlock()
140159 }
141160
142-func (ac *attachedClient) pickerActive() bool {
143- ac.pickerMu.Lock()
144- defer ac.pickerMu.Unlock()
145- return ac.picker != nil
161+func (ac *attachedClient) transport() ports.Transport {
162+ ac.linkMu.Lock()
163+ defer ac.linkMu.Unlock()
164+ return ac.tr
146165 }
147166
148-func (ac *attachedClient) paletteActive() bool {
149- ac.paletteMu.Lock()
150- defer ac.paletteMu.Unlock()
151- return ac.palette != nil
167+func (ac *attachedClient) replaceTransport(tr ports.Transport) {
168+ ac.linkMu.Lock()
169+ ac.tr = tr
170+ ac.linkMu.Unlock()
152171 }
153172
154-func (ac *attachedClient) promptActive() bool {
155- ac.promptMu.Lock()
156- defer ac.promptMu.Unlock()
157- return ac.prompt != nil
173+func (ac *attachedClient) closeTransport() error {
174+ tr := ac.transport()
175+ if tr == nil {
176+ return nil
177+ }
178+ return tr.Close()
179+}
180+
181+func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error {
182+ if tr == nil {
183+ return nil
184+ }
185+ return tr.Close()
186+}
187+
188+func (ac *attachedClient) transportIs(tr ports.Transport) bool {
189+ ac.linkMu.Lock()
190+ defer ac.linkMu.Unlock()
191+ return ac.tr == tr
192+}
193+
194+func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool {
195+ return tr != nil && ac.transportIs(tr)
196+}
197+
198+func (ac *attachedClient) nextOutputFrameLocked(b []byte, reset bool) ports.Frame {
199+ ac.nextStateNum++
200+ baseStateNum := ac.nextStateNum - 1
201+ if reset {
202+ baseStateNum = 0
203+ }
204+ return frameOutputState(b, baseStateNum, ac.nextStateNum, ac.echoAck.Load())
205+}
206+
207+// shouldResetOutputState honors explicit invalidations; reliable transport ack
208+// lag alone must not force a full output-state repaint.
209+func (ac *attachedClient) shouldResetOutputState(reset bool) bool {
210+ return reset
211+}
212+
213+func (ac *attachedClient) advanceOutputAck(state uint64) {
214+ ac.sendMu.Lock()
215+ defer ac.sendMu.Unlock()
216+ if state > ac.nextStateNum {
217+ return
218+ }
219+ for {
220+ cur := ac.outputAck.Load()
221+ if state <= cur {
222+ return
223+ }
224+ if ac.outputAck.CompareAndSwap(cur, state) {
225+ return
226+ }
227+ }
228+}
229+
230+// outputLagAtCapLocked reports whether the client has at least
231+// maxUnackedOutputStates output states in flight (sent but unacked). Caller
232+// holds sendMu.
233+func (ac *attachedClient) outputLagAtCapLocked() bool {
234+ return ac.nextStateNum >= ac.outputAck.Load()+maxUnackedOutputStates
235+}
236+
237+// takeDeferredPaint reports whether a deferred diff paint is now flushable
238+// because acks have caught up, clearing the flag when it is. The caller must
239+// then paint outside sendMu (paint re-acquires it).
240+func (ac *attachedClient) takeDeferredPaint() bool {
241+ ac.sendMu.Lock()
242+ defer ac.sendMu.Unlock()
243+ if ac.paintDeferred && !ac.outputLagAtCapLocked() {
244+ ac.paintDeferred = false
245+ return true
246+ }
247+ return false
248+}
249+
250+// clearPaintDeferred drops any pending deferred-paint flag. Used on park so a
251+// resumed client does not inherit a stale deferral.
252+func (ac *attachedClient) clearPaintDeferred() {
253+ ac.sendMu.Lock()
254+ ac.paintDeferred = false
255+ ac.sendMu.Unlock()
158256 }
159257
160258 // send serialises a frame onto the client's transport.
161259 func (ac *attachedClient) send(f ports.Frame) error {
260+ _, err := ac.sendTransport(f)
261+ return err
262+}
263+
264+func (ac *attachedClient) sendTransport(f ports.Frame) (ports.Transport, error) {
162265 ac.sendMu.Lock()
163266 defer ac.sendMu.Unlock()
164- return ac.tr.Send(f)
267+ tr := ac.transport()
268+ if tr == nil {
269+ return nil, errors.New("client transport is nil")
270+ }
271+ return tr, tr.Send(f)
165272 }
166273
167274 // boundedSend sends f to ac with a deadline watchdog: if the send (including
@@ -174,23 +281,69 @@ func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
174281 }
175282
176283 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
284+ tr, err := d.boundedSendWith(func(capture func(ports.Transport)) error {
285+ tr, err := ac.sendTransport(f)
286+ capture(tr)
287+ return err
288+ })
289+ if err != nil && errors.Is(err, errSendTimedOut) {
290+ _ = ac.closeCapturedTransport(tr)
291+ }
292+ return err
293+}
294+
295+func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
296+ _, err := d.boundedSendOutputErrTransport(ac, b)
297+ return err
298+}
299+
300+func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {
301+ return d.boundedSendWith(func(capture func(ports.Transport)) error {
302+ ac.sendMu.Lock()
303+ defer ac.sendMu.Unlock()
304+ tr := ac.transport()
305+ capture(tr)
306+ if tr == nil {
307+ return errors.New("client transport is nil")
308+ }
309+ return tr.Send(ac.nextOutputFrameLocked(b, false))
310+ })
311+}
312+
313+var errSendTimedOut = errors.New("send timed out")
314+
315+func (d *Daemon) boundedSendWith(send func(capture func(ports.Transport)) error) (ports.Transport, error) {
177316 timer := d.clock.NewTimer(detachNotifyTimeout)
178317 result := make(chan error, 1)
318+ var (
319+ capturedMu sync.Mutex
320+ captured ports.Transport
321+ )
322+ capture := func(tr ports.Transport) {
323+ capturedMu.Lock()
324+ captured = tr
325+ capturedMu.Unlock()
326+ }
327+ capturedTransport := func() ports.Transport {
328+ capturedMu.Lock()
329+ defer capturedMu.Unlock()
330+ return captured
331+ }
179332 go func() {
180- result <- ac.send(f)
333+ result <- send(capture)
181334 }()
182335 select {
183336 case err := <-result:
184337 timer.Stop()
185- return err
338+ return capturedTransport(), err
186339 case <-timer.C():
187340 select {
188341 case err := <-result:
189- return err
342+ return capturedTransport(), err
190343 default:
191344 }
192- _ = ac.tr.Close()
193- return errors.New("send timed out")
345+ d.log.Warn("bounded send timed out; force closing client transport")
346+ return capturedTransport(), errSendTimedOut
194347 }
195348 }
196349
@@ -218,7 +371,8 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
218371 go func() {
219372 defer close(done)
220373 d.boundedSend(ac, frameDetached(reason))
221- _ = ac.tr.Close()
374+ _ = ac.closeTransport()
375+ d.log.Info("client detached", "reason", reason)
222376 }()
223377 }
224378
@@ -239,24 +393,35 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
239393 return snapshot
240394 }
241395
242-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) (*attachedClient, *attachedClient) {
396+type attachClientOptions struct {
397+ clientID [16]byte
398+ resumeCapable bool
399+}
400+
401+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
402+ resumeToken := uint64(0)
403+ if opts.resumeCapable {
404+ resumeToken = d.nextResumeTokenLocked()
405+ }
243406 ac := &attachedClient{
244- tr: tr,
245- rend: renderer.New(renderer.Capabilities{}),
246- size: sz,
407+ tr: tr,
408+ rend: renderer.New(renderer.Capabilities{}),
409+ size: sz,
410+ clientID: opts.clientID,
411+ resumeCapable: opts.resumeCapable,
412+ resumeToken: resumeToken,
247413 }
414+ ac.initOverlays()
248415 ac.setSession(sess)
249- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
416+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
250417 sess.mu.Lock()
251418 old := sess.client
252419 sess.client = ac
253- tabs := append([]*tab(nil), sess.tabs...)
420+ name := sess.name
254421 sess.mu.Unlock()
255- for _, tb := range tabs {
256- tb.mu.Lock()
257- tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
258- tb.mu.Unlock()
259- }
422+ d.touchMRU(sess)
423+ d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
424+ d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
260425 return ac, old
261426 }
262427
@@ -272,18 +437,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
272437 // (and run its own attach-time reset), so this call must leave the tabs
273438 // alone rather than clobbering that client's freshly applied colors.
274439 func (d *Daemon) resetScreenDefaultColors(sess *session) {
275- sess.mu.Lock()
276- if sess.client != nil {
277- sess.mu.Unlock()
278- return
279- }
280- tabs := append([]*tab(nil), sess.tabs...)
281- sess.mu.Unlock()
282- for _, tb := range tabs {
283- tb.mu.Lock()
284- tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
285- tb.mu.Unlock()
286- }
440+ d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true)
287441 }
288442
289443 func (d *Daemon) detachReplacedClient(old *attachedClient) {
@@ -292,6 +446,7 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
292446 }
293447 d.unregisterPreview(old)
294448 old.setSession(nil)
449+ d.log.Info("client displaced")
295450 // Async + bounded: a dead or wedged old client must not stall the new
296451 // client's handshake.
297452 d.notifyDetachedAsync(old, ports.ReasonDetach)
@@ -313,20 +468,31 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
313468 d.resize(sess, ac, clientSize)
314469 return
315470 }
471+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
316472 d.paint(sess, ac, true)
317473 }
318474
319475 // runConnLoop is the per-connection input router: it pumps client messages
320476 // until detach, EOF, or a transport error.
321477 func (d *Daemon) runConnLoop(ac *attachedClient) {
478+ tr := ac.transport()
479+ if tr == nil {
480+ return
481+ }
322482 for {
483+ if !ac.currentTransportIs(tr) {
484+ return
485+ }
323486 sess := ac.currentSession()
324487 if sess == nil {
325488 return
326489 }
327- f, err := ac.tr.Recv()
490+ f, err := tr.Recv()
328491 if err != nil {
329- d.clientGone(sess, ac, false)
492+ d.clientGone(sess, ac, tr, false)
493+ return
494+ }
495+ if !ac.currentTransportIs(tr) {
330496 return
331497 }
332498 sess = ac.currentSession()
@@ -336,7 +502,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
336502 switch f.Type {
337503 case ports.MsgInput:
338504 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
339- d.handleInput(sess, ac, in.Data)
505+ d.handleSequencedInput(sess, ac, in.InputSeq, in.Data)
340506 }
341507 case ports.MsgResize:
342508 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
@@ -346,9 +512,20 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
346512 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
347513 d.applyTheme(sess, ac, th)
348514 }
515+ case ports.MsgImagePush:
516+ if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
517+ d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip)
518+ }
349519 case ports.MsgDetach:
350- d.clientGone(sess, ac, true)
520+ d.clientGone(sess, ac, tr, true)
351521 return
522+ case ports.MsgAck:
523+ if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
524+ ac.advanceOutputAck(ack.AckedStateNum)
525+ if ac.takeDeferredPaint() {
526+ d.paint(sess, ac, false)
527+ }
528+ }
352529 case ports.MsgPing:
353530 _ = ac.send(framePong())
354531 default:
@@ -358,10 +535,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
358535 }
359536 }
360537
361-// clientGone detaches ac if it is still the session's current client. An
362-// ephemeral session dies with its client; a named one survives headless. When
363-
364-func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
538+// clientGone detaches ac if it is still the session's current client. The
539+// session remains registered and headless after the client is gone.
540+func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
541+ if failed != nil && !ac.currentTransportIs(failed) {
542+ return // stale connection loop; a newer transport owns this client
543+ }
365544 if !sess.detachIfCurrent(ac) {
366545 return // already displaced by a newer client; nothing to do
367546 }
@@ -372,6 +551,16 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
372551 if !ephemeral {
373552 d.refreshSessionCwd(sess)
374553 }
554+ d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral)
555+ oldTr := failed
556+ if oldTr == nil {
557+ oldTr = ac.transport()
558+ }
559+ if !explicit && d.parkAttachment(sess, ac) {
560+ _ = ac.closeCapturedTransport(oldTr)
561+ d.log.Info("client parked", "session", sess.name)
562+ return
563+ }
375564 d.resetScreenDefaultColors(sess)
376565 if explicit {
377566 // Synchronous so the ack is delivered before the transport closes
@@ -380,39 +569,29 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
380569 // connWg.Wait.
381570 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
382571 }
383- _ = ac.tr.Close()
384- if ephemeral {
385- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
386- }
572+ _ = ac.closeCapturedTransport(oldTr)
573+ d.log.Info("client detached", "session", sess.name, "explicit", explicit)
387574 }
388575
389576 // detachIfCurrent clears the client iff ac is the current one, reporting
390577
391578 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
392- t := themeui.Theme{
393- Foreground: msg.Foreground,
394- Background: msg.Background,
395- HasFG: msg.HasForeground,
396- HasBG: msg.HasBackground,
397- TrueColor: msg.TrueColor,
398- Known: msg.HasForeground && msg.HasBackground,
579+ clientTheme := themeui.Theme{
580+ Foreground: msg.Foreground,
581+ Background: msg.Background,
582+ HasFG: msg.HasForeground,
583+ HasBG: msg.HasBackground,
584+ TrueColor: msg.TrueColor,
585+ Known: msg.HasForeground && msg.HasBackground,
586+ SchemeKnown: msg.SchemeKnown,
587+ Light: msg.Light,
399588 }
589+ ac.setClientTheme(clientTheme)
590+ t := d.effectiveTheme(clientTheme)
400591
401- knownDefaultColors := t.HasFG && t.HasBG
402- sess.mu.Lock()
403- if sess.client != ac {
404- sess.mu.Unlock()
592+ if !d.applyHostTheme(sess, ac, t, false) {
405593 return
406594 }
407- tabs := append([]*tab(nil), sess.tabs...)
408- sess.mu.Unlock()
409-
410- ac.setTheme(t)
411- for _, tb := range tabs {
412- tb.mu.Lock()
413- tb.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors)
414- tb.mu.Unlock()
415- }
416595 d.paint(sess, ac, true)
417596 }
418597
@@ -421,7 +600,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
421600 return
422601 }
423602 tbSize := tabSize(sz)
424- ac.size = sz
603+ if ac != nil {
604+ ac.size = sz
605+ }
425606
426607 sess.mu.Lock()
427608 tabs := append([]*tab(nil), sess.tabs...)
@@ -431,32 +612,34 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
431612 }
432613
433614 for _, tb := range tabs {
434- if err := tb.pty.Resize(tbSize); err != nil {
435- d.log.Warn("pty resize failed", "err", err, "session", sess.name)
436- }
437615 tb.mu.Lock()
438- tb.screen.Resize(tbSize.Cols, tbSize.Rows)
439616 tb.size = tbSize
617+ d.applyLayoutLocked(tb)
440618 tb.mu.Unlock()
441619 }
442- d.paint(sess, ac, true)
620+ markSnapshotDirty(sess)
621+ if ac != nil {
622+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
623+ d.paint(sess, ac, true)
624+ }
443625 }
444626
445-// detachOnSendError drops a client whose transport failed. Like every other
446-// detach path, losing the client kills an ephemeral session (its lifetime is
447-// its client's); a named session keeps running headless.
448-func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
627+// detachOnSendError drops a client whose transport failed, leaving the session
628+// registered and headless.
629+func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
630+ if failed != nil && !ac.currentTransportIs(failed) {
631+ return
632+ }
449633 if sess.detachIfCurrent(ac) {
450634 d.unregisterPreview(ac)
635+ if d.parkAttachment(sess, ac) {
636+ _ = ac.closeCapturedTransport(failed)
637+ d.log.Warn("parked client after send error", "session", sess.name)
638+ return
639+ }
451640 d.resetScreenDefaultColors(sess)
452- _ = ac.tr.Close()
641+ _ = ac.closeCapturedTransport(failed)
453642 d.log.Warn("detached client after send error", "session", sess.name)
454- sess.mu.Lock()
455- ephemeral := sess.ephemeral
456- sess.mu.Unlock()
457- if ephemeral {
458- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
459- }
460643 }
461644 }
462645
A internal/usecase/daemon/clipboard.go +211 -0
@@ -0,0 +1,211 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+package daemon
5+
6+import (
7+ "os"
8+
9+ "github.com/bnema/vev/internal/ports"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+)
12+
13+// maxImagePushSize independently caps an accepted MsgImagePush payload,
14+// defending against an old or foreign client even though the client is
15+// expected to enforce the same cap before sending. Kept at 1 MiB so one
16+// ImagePush fits the datagram transport's fragmented payload ceiling.
17+const maxImagePushSize = 1 << 20 // 1 MiB
18+
19+// Bracketed-paste markers used to wrap the injected clip path when the
20+// focused pane has DEC private mode 2004 enabled. Mirrors the client's
21+// pasteOpenMarker/pasteCloseMarker (internal/usecase/client), kept as a
22+// separate copy since the daemon has no dependency on the client package.
23+var (
24+ clipPasteOpenMarker = []byte("\x1b[200~")
25+ clipPasteCloseMarker = []byte("\x1b[201~")
26+)
27+
28+// handleSequencedImagePush routes an ImagePush through the same input-sequence
29+// surface as ordinary keystrokes. Echo prediction is still conservative; the
30+// sequence is carried so resume/dedup plumbing can order image pushes with
31+// surrounding input when that state reader grows beyond ordinary input.
32+func (d *Daemon) handleSequencedImagePush(sess *session, _ *attachedClient, _ uint64, ip ports.ImagePush) {
33+ d.handleImagePush(sess, ip)
34+}
35+
36+// handleImagePush writes an ImagePush's bytes to a temp file and injects the
37+// file's path into the session's focused pane, as if typed/pasted there.
38+// Oversized or empty payloads are rejected without panicking. A write
39+// failure is logged and the push is dropped: the client has already
40+// consumed the Ctrl+V keystroke that triggered it, so there is nothing left
41+// to re-trigger from here.
42+func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) {
43+ if len(ip.Data) == 0 {
44+ return
45+ }
46+ if len(ip.Data) > maxImagePushSize {
47+ d.log.Warn("rejected oversized clipboard image push", "size", len(ip.Data), "cap", maxImagePushSize)
48+ return
49+ }
50+ path, err := d.writeClipboardImage(sess, ip)
51+ if err != nil {
52+ d.log.Error("writing clipboard image failed", "err", err)
53+ return
54+ }
55+ d.injectClipboardPath(sess, path)
56+}
57+
58+// writeClipboardImage writes ip's bytes to a new 0600 temp file and records
59+// the path on sess for best-effort cleanup at session end (killSession).
60+func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) {
61+ dir := d.tempDir
62+ if dir == "" {
63+ dir = os.TempDir()
64+ }
65+ f, err := os.CreateTemp(dir, "vev-clip-*."+clipboardExt(ip.Mime))
66+ if err != nil {
67+ return "", err
68+ }
69+ path := f.Name()
70+ if _, err := f.Write(ip.Data); err != nil {
71+ _ = f.Close()
72+ _ = os.Remove(path)
73+ return "", err
74+ }
75+ if err := f.Close(); err != nil {
76+ _ = os.Remove(path)
77+ return "", err
78+ }
79+ sess.mu.Lock()
80+ sess.clipFiles = append(sess.clipFiles, path)
81+ sess.mu.Unlock()
82+ return path, nil
83+}
84+
85+// injectClipboardPath writes path's text into sess's focused pane, through
86+// the same PTY write path as ordinary input, wrapped in bracketed-paste
87+// markers iff the pane's Screen reports bracketed-paste mode enabled.
88+func (d *Daemon) injectClipboardPath(sess *session, path string) {
89+ tb := sess.activeTab()
90+ if tb == nil {
91+ return
92+ }
93+ tb.mu.Lock()
94+ p := tb.focusedPane()
95+ tb.mu.Unlock()
96+ if p == nil {
97+ return
98+ }
99+
100+ p.mu.Lock()
101+ bracketed := p.screen.BracketedPasteMode()
102+ p.mu.Unlock()
103+
104+ data := []byte(path)
105+ if bracketed {
106+ wrapped := make([]byte, 0, len(clipPasteOpenMarker)+len(data)+len(clipPasteCloseMarker))
107+ wrapped = append(wrapped, clipPasteOpenMarker...)
108+ wrapped = append(wrapped, data...)
109+ wrapped = append(wrapped, clipPasteCloseMarker...)
110+ data = wrapped
111+ }
112+ d.writeToPane(sess, p, data)
113+}
114+
115+// clipboardExt maps an ImagePush MIME type to a filename extension. Unknown
116+// types fall back to "bin" rather than rejecting the push: the daemon copies
117+// bytes as-is regardless (see the design doc's "no image conversion" scope
118+// decision).
119+func clipboardExt(mime string) string {
120+ switch mime {
121+ case "image/png":
122+ return "png"
123+ case "image/jpeg":
124+ return "jpg"
125+ case "image/webp":
126+ return "webp"
127+ case "image/gif":
128+ return "gif"
129+ default:
130+ return "bin"
131+ }
132+}
133+
134+type clipboardForward struct {
135+ ac *attachedClient
136+ seq []byte
137+}
138+
139+// forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
140+// request (already captured off the pane's screen while ptyReader held pane.mu,
141+// then handed here after that lock is released) to the session's attached
142+// client, if any. Invalid base64 or an oversized decoded payload is dropped
143+// silently, matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The
144+// caller must not hold pane.mu, tab.mu, or session.mu.
145+//
146+// Requests are queued on the session and drained by one worker so clipboard
147+// writes keep arrival order without making the PTY reader wait on client I/O.
148+func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
149+ seq := scopy.OSC52FromBase64(b64)
150+ if seq == nil {
151+ return
152+ }
153+
154+ sess.mu.Lock()
155+ if sess.ctx != nil {
156+ select {
157+ case <-sess.ctx.Done():
158+ sess.mu.Unlock()
159+ return
160+ default:
161+ }
162+ }
163+ ac := sess.client
164+ if ac == nil {
165+ sess.mu.Unlock()
166+ return
167+ }
168+ sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq})
169+ if !sess.clipboardWorkerRunning {
170+ sess.clipboardWorkerRunning = true
171+ go d.clipboardWorker(sess)
172+ }
173+ sess.mu.Unlock()
174+}
175+
176+func (d *Daemon) clipboardWorker(sess *session) {
177+ for {
178+ item, ok := nextClipboardForward(sess)
179+ if !ok {
180+ return
181+ }
182+ failed, err := d.boundedSendOutputErrTransport(item.ac, item.seq)
183+ if err != nil {
184+ d.detachOnSendError(sess, item.ac, failed)
185+ }
186+ }
187+}
188+
189+func nextClipboardForward(sess *session) (clipboardForward, bool) {
190+ sess.mu.Lock()
191+ defer sess.mu.Unlock()
192+ if sess.ctx != nil {
193+ select {
194+ case <-sess.ctx.Done():
195+ sess.clipboardQueue = nil
196+ sess.clipboardWorkerRunning = false
197+ return clipboardForward{}, false
198+ default:
199+ }
200+ }
201+ if len(sess.clipboardQueue) == 0 {
202+ sess.clipboardWorkerRunning = false
203+ return clipboardForward{}, false
204+ }
205+ item := sess.clipboardQueue[0]
206+ copy(sess.clipboardQueue, sess.clipboardQueue[1:])
207+ var zero clipboardForward
208+ sess.clipboardQueue[len(sess.clipboardQueue)-1] = zero
209+ sess.clipboardQueue = sess.clipboardQueue[:len(sess.clipboardQueue)-1]
210+ return item, true
211+}
A internal/usecase/daemon/clipboard_test.go +340 -0
@@ -0,0 +1,340 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ scopy "github.com/bnema/vev/internal/usecase/copy"
19+ "github.com/bnema/vev/pkg/renderer"
20+)
21+
22+func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
23+ writes := make(chan []byte, 1)
24+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
25+ defer releasePTY()
26+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
27+ d.tempDir = t.TempDir()
28+
29+ data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03}
30+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: data})
31+
32+ injected := <-writes
33+ path := string(injected)
34+ require.FileExists(t, path)
35+ require.Equal(t, ".png", filepath.Ext(path))
36+ require.True(t, filepath.IsAbs(path))
37+
38+ got, err := os.ReadFile(path)
39+ require.NoError(t, err)
40+ require.Equal(t, data, got)
41+
42+ info, err := os.Stat(path)
43+ require.NoError(t, err)
44+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
45+}
46+
47+func TestHandleImagePushInjectsPathWithoutBracketedPasteWrapping(t *testing.T) {
48+ writes := make(chan []byte, 1)
49+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
50+ defer releasePTY()
51+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
52+ d.tempDir = t.TempDir()
53+ pane := sess.tabs[0].focusedPane()
54+ require.False(t, pane.screen.BracketedPasteMode())
55+
56+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
57+
58+ injected := <-writes
59+ require.False(t, hasBracketedPasteMarkers(injected), "path must not be wrapped when the pane is not in bracketed-paste mode")
60+ require.FileExists(t, string(injected))
61+}
62+
63+func TestHandleImagePushInjectsPathWithBracketedPasteWrapping(t *testing.T) {
64+ writes := make(chan []byte, 1)
65+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
66+ defer releasePTY()
67+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
68+ d.tempDir = t.TempDir()
69+ pane := sess.tabs[0].focusedPane()
70+ pane.screen.Write([]byte("\x1b[?2004h"))
71+ require.True(t, pane.screen.BracketedPasteMode())
72+
73+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
74+
75+ injected := <-writes
76+ require.True(t, len(injected) > len(clipPasteOpenMarker)+len(clipPasteCloseMarker))
77+ require.Equal(t, clipPasteOpenMarker, injected[:len(clipPasteOpenMarker)])
78+ require.Equal(t, clipPasteCloseMarker, injected[len(injected)-len(clipPasteCloseMarker):])
79+ path := string(injected[len(clipPasteOpenMarker) : len(injected)-len(clipPasteCloseMarker)])
80+ require.FileExists(t, path)
81+}
82+
83+func hasBracketedPasteMarkers(b []byte) bool {
84+ return len(b) >= len(clipPasteOpenMarker) &&
85+ string(b[:len(clipPasteOpenMarker)]) == string(clipPasteOpenMarker)
86+}
87+
88+func TestHandleImagePushRejectsOversizedPayloadWithoutWritingOrInjecting(t *testing.T) {
89+ writes := make(chan []byte, 1)
90+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
91+ defer releasePTY()
92+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
93+ dir := t.TempDir()
94+ d.tempDir = dir
95+
96+ huge := make([]byte, maxImagePushSize+1)
97+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: huge})
98+
99+ select {
100+ case w := <-writes:
101+ t.Fatalf("oversized push must not be injected into the pane, got %q", w)
102+ default:
103+ }
104+ entries, err := os.ReadDir(dir)
105+ require.NoError(t, err)
106+ require.Empty(t, entries, "oversized push must not write a temp file")
107+}
108+
109+func TestHandleImagePushIgnoresEmptyPayload(t *testing.T) {
110+ writes := make(chan []byte, 1)
111+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
112+ defer releasePTY()
113+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
114+ dir := t.TempDir()
115+ d.tempDir = dir
116+
117+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: nil})
118+
119+ select {
120+ case w := <-writes:
121+ t.Fatalf("empty push must not be injected, got %q", w)
122+ default:
123+ }
124+ entries, err := os.ReadDir(dir)
125+ require.NoError(t, err)
126+ require.Empty(t, entries)
127+}
128+
129+func TestClipboardExtMapsKnownMimeTypesAndFallsBackToBin(t *testing.T) {
130+ cases := map[string]string{
131+ "image/png": "png",
132+ "image/jpeg": "jpg",
133+ "image/webp": "webp",
134+ "image/gif": "gif",
135+ "image/bmp": "bin",
136+ "": "bin",
137+ "application/octet-stream": "bin",
138+ }
139+ for mime, want := range cases {
140+ require.Equal(t, want, clipboardExt(mime), "mime %q", mime)
141+ }
142+}
143+
144+func TestKillSessionRemovesClipboardTempFiles(t *testing.T) {
145+ writes := make(chan []byte, 1)
146+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
147+ defer releasePTY()
148+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
149+ d.tempDir = t.TempDir()
150+ // killSession requires the session to be registered in the daemon's
151+ // registry (newManualSessionWithPTYs already does this via d.sessions).
152+
153+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
154+ injected := <-writes
155+ path := string(injected)
156+ require.FileExists(t, path)
157+
158+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
159+ require.NoFileExists(t, path)
160+}
161+
162+// chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then
163+// reports io.EOF — driving ptyReader through exactly len(chunks) reads before
164+// it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY.
165+func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
166+ t.Helper()
167+ p := portsmocks.NewMockPTY(t)
168+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
169+ if len(chunks) == 0 {
170+ return 0, io.EOF
171+ }
172+ n := copy(buf, chunks[0])
173+ if n == len(chunks[0]) {
174+ chunks = chunks[1:]
175+ } else {
176+ chunks[0] = chunks[0][n:]
177+ }
178+ return n, nil
179+ })
180+ p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
181+ p.EXPECT().Close().Return(nil).Maybe()
182+ return p
183+}
184+
185+func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
186+ d := newTestDaemon(t, nil, stubClock{})
187+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
188+
189+ tr, sends := newCapturingTransport(t)
190+ sctx, cancel := context.WithCancel(context.Background())
191+ defer cancel()
192+ win := newTestTabWithContext(p, sctx, cancel)
193+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
194+ ac.initOverlays()
195+ sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
196+ ac.setSession(sess)
197+ d.sessions[sess.id] = sess
198+
199+ d.sessWg.Add(1)
200+ d.ptyReader(sess, win, win.focusedPane())
201+
202+ f := awaitFrame(t, sends, ports.MsgOutput)
203+ out, err := ports.UnmarshalOutput(f.Payload)
204+ require.NoError(t, err)
205+ require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07")
206+}
207+
208+func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
209+ d := newTestDaemon(t, nil, stubClock{})
210+ big := strings.Repeat("a", scopy.OSC52MaxPayloadBytes+1)
211+ b64 := base64.StdEncoding.EncodeToString([]byte(big))
212+ p := chunkReadPTY(t, []byte("\x1b]52;c;"+b64+"\x07"))
213+
214+ tr, sends := newCapturingTransport(t)
215+ sctx, cancel := context.WithCancel(context.Background())
216+ defer cancel()
217+ win := newTestTabWithContext(p, sctx, cancel)
218+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
219+ ac.initOverlays()
220+ sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
221+ ac.setSession(sess)
222+ d.sessions[sess.id] = sess
223+
224+ d.sessWg.Add(1)
225+ d.ptyReader(sess, win, win.focusedPane())
226+
227+ select {
228+ case f := <-sends:
229+ require.NotEqual(t, ports.MsgOutput, f.Type)
230+ default:
231+ }
232+}
233+
234+func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
235+ d := newTestDaemon(t, nil, stubClock{})
236+ p := chunkReadPTY(t, []byte("\x1b]52;c;not-valid-base64!!\x07"))
237+
238+ tr, sends := newCapturingTransport(t)
239+ sctx, cancel := context.WithCancel(context.Background())
240+ defer cancel()
241+ win := newTestTabWithContext(p, sctx, cancel)
242+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
243+ ac.initOverlays()
244+ sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
245+ ac.setSession(sess)
246+ d.sessions[sess.id] = sess
247+
248+ d.sessWg.Add(1)
249+ d.ptyReader(sess, win, win.focusedPane())
250+
251+ select {
252+ case f := <-sends:
253+ require.NotEqual(t, ports.MsgOutput, f.Type)
254+ default:
255+ }
256+}
257+
258+func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
259+ d := newTestDaemon(t, nil, stubClock{})
260+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
261+
262+ sctx, cancel := context.WithCancel(context.Background())
263+ defer cancel()
264+ win := newTestTabWithContext(p, sctx, cancel)
265+ sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
266+ d.sessions[sess.id] = sess
267+
268+ d.sessWg.Add(1)
269+ require.NotPanics(t, func() {
270+ d.ptyReader(sess, win, win.focusedPane())
271+ })
272+}
273+
274+func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
275+ d := newTestDaemon(t, nil, stubClock{})
276+ tr := newBlockingClipboardTransport()
277+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
278+ ac.initOverlays()
279+ sctx, cancel := context.WithCancel(context.Background())
280+ defer cancel()
281+ sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}
282+ ac.setSession(sess)
283+
284+ first := base64.StdEncoding.EncodeToString([]byte("first"))
285+ second := base64.StdEncoding.EncodeToString([]byte("second"))
286+ d.forwardClipboardAsync(sess, first)
287+ require.Equal(t, "first", <-tr.started)
288+
289+ d.forwardClipboardAsync(sess, second)
290+ select {
291+ case got := <-tr.started:
292+ t.Fatalf("second clipboard send started before first completed: %q", got)
293+ case <-time.After(50 * time.Millisecond):
294+ }
295+
296+ close(tr.releaseFirst)
297+ require.Equal(t, "first", <-tr.sent)
298+ require.Equal(t, "second", <-tr.started)
299+ require.Equal(t, "second", <-tr.sent)
300+}
301+
302+type blockingClipboardTransport struct {
303+ started chan string
304+ sent chan string
305+ releaseFirst chan struct{}
306+}
307+
308+func newBlockingClipboardTransport() *blockingClipboardTransport {
309+ return &blockingClipboardTransport{
310+ started: make(chan string, 2),
311+ sent: make(chan string, 2),
312+ releaseFirst: make(chan struct{}),
313+ }
314+}
315+
316+func (tr *blockingClipboardTransport) Send(f ports.Frame) error {
317+ out, err := ports.UnmarshalOutput(f.Payload)
318+ if err != nil {
319+ return err
320+ }
321+ data := string(out.Data)
322+ var label string
323+ switch {
324+ case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("first"))):
325+ label = "first"
326+ case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("second"))):
327+ label = "second"
328+ default:
329+ label = data
330+ }
331+ tr.started <- label
332+ if label == "first" {
333+ <-tr.releaseFirst
334+ }
335+ tr.sent <- label
336+ return nil
337+}
338+
339+func (tr *blockingClipboardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
340+func (tr *blockingClipboardTransport) Close() error { return nil }
A internal/usecase/daemon/config.go +235 -0
@@ -0,0 +1,235 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "maps"
6+ "regexp"
7+ "sort"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/usecase/command"
13+ "github.com/bnema/vev/internal/usecase/keys"
14+ "github.com/bnema/vev/internal/usecase/theme"
15+)
16+
17+var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
18+
19+// ApplyConfig validates and atomically swaps daemon runtime configuration.
20+func (d *Daemon) ApplyConfig(cfg domain.Config) {
21+ bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)
22+ for _, warning := range warnings {
23+ d.logConfigWarning(warning)
24+ }
25+ overrides := d.buildCodeOverrides(cfg.Codes)
26+ allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot)
27+ d.bindings.Store(bindings)
28+ d.codeOverrides.Store(&overrides)
29+ d.restoreProcessAllowlist.Store(&allowlist)
30+ d.themeMode.Store(uint32(cfg.Theme))
31+ if d.barScripts != nil {
32+ d.barScripts.mu.Lock()
33+ d.barScripts.initLocked()
34+ barCfg := barConfigFromDomain(cfg.Bar)
35+ if d.barScripts.cfg != barCfg {
36+ d.barScripts.cfg = barCfg
37+ d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
38+ d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
39+ d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
40+ d.barScripts.pending = make(map[domain.SessionID]bool)
41+ d.barScripts.version++
42+ }
43+ d.barScripts.mu.Unlock()
44+ }
45+ d.reapplyThemeAllSessions()
46+ d.repaintAllAttachedClients()
47+}
48+
49+func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]string {
50+ commands := command.Registry()
51+ bySlug := make(map[string]command.Command, len(commands))
52+ for _, cmd := range commands {
53+ bySlug[cmd.Slug] = cmd
54+ }
55+
56+ desired := make(map[string]string)
57+ for _, slug := range sortedKeys(configured) {
58+ if _, ok := bySlug[slug]; !ok {
59+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)})
60+ continue
61+ }
62+ code := strings.ToUpper(strings.TrimSpace(configured[slug]))
63+ if !commandCodePattern.MatchString(code) {
64+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)})
65+ continue
66+ }
67+ desired[slug] = code
68+ }
69+
70+ // The effective code for a slug is its accepted override if it has one,
71+ // or its registry default otherwise. Resolve to a fixpoint rather than a
72+ // single sorted pass: dropping one slug's override to resolve a conflict
73+ // can free up its default code, which may in turn conflict with a
74+ // *different* slug's override — that must be re-checked regardless of
75+ // where the two slugs fall in sort order.
76+ accepted := make(map[string]string, len(desired))
77+ maps.Copy(accepted, desired)
78+ effectiveCode := func(slug string) string {
79+ if code, ok := accepted[slug]; ok {
80+ return code
81+ }
82+ return bySlug[slug].Code
83+ }
84+
85+ for {
86+ claimants := make(map[string][]string, len(commands))
87+ for _, cmd := range commands {
88+ code := effectiveCode(cmd.Slug)
89+ claimants[code] = append(claimants[code], cmd.Slug)
90+ }
91+
92+ dropped := false
93+ for code, slugs := range claimants {
94+ if len(slugs) < 2 {
95+ continue
96+ }
97+ sort.Strings(slugs)
98+
99+ // If one claimant holds this code by default (no accepted
100+ // override), every override claimant loses to it and is
101+ // dropped. Otherwise every claimant is an override; keep the
102+ // lexicographically smallest slug and drop the rest.
103+ keep := ""
104+ for _, slug := range slugs {
105+ if _, isOverride := accepted[slug]; !isOverride {
106+ keep = slug
107+ break
108+ }
109+ }
110+ if keep == "" {
111+ keep = slugs[0]
112+ }
113+ for _, slug := range slugs {
114+ if slug == keep {
115+ continue
116+ }
117+ if _, isOverride := accepted[slug]; !isOverride {
118+ continue
119+ }
120+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, keep)})
121+ delete(accepted, slug)
122+ dropped = true
123+ }
124+ }
125+ if !dropped {
126+ break
127+ }
128+ }
129+ return accepted
130+}
131+
132+func sortedKeys(values map[string]string) []string {
133+ keys := make([]string, 0, len(values))
134+ for key := range values {
135+ keys = append(keys, key)
136+ }
137+ sort.Strings(keys)
138+ return keys
139+}
140+
141+func restoreProcessAllowlistFromConfig(cfg domain.SnapshotConfig) map[string]struct{} {
142+ if cfg.RestoreProcessesSet {
143+ return buildRestoreProcessAllowlist(cfg.RestoreProcesses)
144+ }
145+ if len(cfg.RestoreProcesses) > 0 {
146+ return buildRestoreProcessAllowlist(cfg.RestoreProcesses)
147+ }
148+ return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
149+}
150+
151+func buildRestoreProcessAllowlist(values []string) map[string]struct{} {
152+ allow := make(map[string]struct{}, len(values))
153+ for _, value := range values {
154+ value = strings.TrimSpace(value)
155+ if value == "" {
156+ continue
157+ }
158+ allow[value] = struct{}{}
159+ }
160+ return allow
161+}
162+
163+func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} {
164+ allow := d.restoreProcessAllowlist.Load()
165+ if allow == nil {
166+ return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
167+ }
168+ return *allow
169+}
170+
171+func (d *Daemon) logConfigWarning(w domain.Warning) {
172+ if w.Line > 0 {
173+ d.log.Warn("config warning", "line", w.Line, "msg", w.Msg)
174+ return
175+ }
176+ d.log.Warn("config warning", "msg", w.Msg)
177+}
178+
179+func (d *Daemon) codeOverrideSnapshot() map[string]string {
180+ overrides := d.codeOverrides.Load()
181+ if overrides == nil {
182+ return nil
183+ }
184+ return *overrides
185+}
186+
187+func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {
188+ if code, ok := overrides[cmd.Slug]; ok {
189+ cmd.Code = code
190+ }
191+ return cmd
192+}
193+
194+func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
195+ code = strings.ToUpper(strings.TrimSpace(code))
196+ overrides := d.codeOverrideSnapshot()
197+ for _, cmd := range command.Registry() {
198+ cmd = commandWithOverrides(cmd, overrides)
199+ if cmd.Code == code {
200+ return cmd, true
201+ }
202+ }
203+ return command.Command{}, false
204+}
205+
206+func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
207+ switch domain.ThemeMode(d.themeMode.Load()) {
208+ case domain.ThemeDark:
209+ return theme.BuiltinDark
210+ case domain.ThemeLight:
211+ return theme.BuiltinLight
212+ default:
213+ return clientTheme
214+ }
215+}
216+
217+func (d *Daemon) reapplyThemeAllSessions() {
218+ d.mu.Lock()
219+ sessions := make([]*session, 0, len(d.sessions))
220+ for _, sess := range d.sessions {
221+ sessions = append(sessions, sess)
222+ }
223+ d.mu.Unlock()
224+
225+ for _, sess := range sessions {
226+ sess.mu.Lock()
227+ ac := sess.client
228+ sess.mu.Unlock()
229+ var clientTheme theme.Theme
230+ if ac != nil {
231+ clientTheme = ac.getClientTheme()
232+ }
233+ d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme), ac == nil)
234+ }
235+}
A internal/usecase/daemon/config_test.go +211 -0
@@ -0,0 +1,211 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/keys"
10+)
11+
12+type captureKeyHandler struct {
13+ actions []keys.Action
14+ forward [][]byte
15+}
16+
17+func (h *captureKeyHandler) Forward(data []byte) {
18+ h.forward = append(h.forward, append([]byte(nil), data...))
19+}
20+func (h *captureKeyHandler) Action(action keys.Action) { h.actions = append(h.actions, action) }
21+
22+func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
23+ d := newTestDaemon(t, nil, stubClock{})
24+ h := &captureKeyHandler{}
25+ r := keys.NewRouter(stubClock{}, h, &d.bindings)
26+
27+ r.Route([]byte("\x1bh"))
28+ require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions)
29+
30+ d.ApplyConfig(domain.Config{
31+ Theme: domain.ThemeAuto,
32+ BindingEntries: []domain.ConfigEntry{
33+ {Key: "focus-pane-left", Value: "alt+q"},
34+ },
35+ Codes: map[string]string{
36+ "new-tab": "nt",
37+ },
38+ })
39+
40+ r.Route([]byte("\x1bh"))
41+ require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions, "old key should no longer be intercepted")
42+ require.Equal(t, [][]byte{{'\x1b', 'h'}}, h.forward)
43+ r.Route([]byte("\x1bq"))
44+ require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions)
45+
46+ cmd, ok := d.commandByEffectiveCode("NT")
47+ require.True(t, ok)
48+ require.Equal(t, "new-tab", cmd.Slug)
49+ require.Equal(t, "NT", cmd.Code)
50+}
51+
52+func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
53+ tests := []struct {
54+ name string
55+ cfg domain.Config
56+ check func(t *testing.T, allow map[string]struct{})
57+ }{
58+ {
59+ name: "default allowlist",
60+ check: func(t *testing.T, allow map[string]struct{}) {
61+ t.Helper()
62+ require.Contains(t, allow, "claude")
63+ },
64+ },
65+ {
66+ name: "explicit empty disables restore",
67+ cfg: domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}},
68+ check: func(t *testing.T, allow map[string]struct{}) {
69+ t.Helper()
70+ require.Empty(t, allow)
71+ },
72+ },
73+ {
74+ name: "explicit values are trimmed",
75+ cfg: domain.Config{Snapshot: domain.SnapshotConfig{
76+ RestoreProcessesSet: true,
77+ RestoreProcesses: []string{"less", "", " pi "},
78+ }},
79+ check: func(t *testing.T, allow map[string]struct{}) {
80+ t.Helper()
81+ require.Contains(t, allow, "less")
82+ require.Contains(t, allow, "pi")
83+ require.NotContains(t, allow, "")
84+ },
85+ },
86+ }
87+
88+ for _, tt := range tests {
89+ t.Run(tt.name, func(t *testing.T) {
90+ d := newTestDaemon(t, nil, stubClock{})
91+ if tt.cfg.Snapshot.RestoreProcessesSet || len(tt.cfg.Snapshot.RestoreProcesses) > 0 {
92+ d.ApplyConfig(tt.cfg)
93+ }
94+ tt.check(t, d.restoreProcessAllowlistSnapshot())
95+ })
96+ }
97+}
98+
99+func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
100+ d := newTestDaemon(t, nil, stubClock{})
101+ d.ApplyConfig(domain.Config{
102+ Theme: domain.ThemeAuto,
103+ Codes: map[string]string{
104+ "new-tab": "NN",
105+ },
106+ })
107+
108+ commands := d.paletteCommands()
109+ require.NotEmpty(t, commands)
110+ require.Equal(t, "new-tab", commands[0].Slug)
111+ require.Equal(t, "NN", commands[0].Code)
112+
113+ cmd, ok := d.commandByEffectiveCode("nn")
114+ require.True(t, ok)
115+ require.Equal(t, "new-tab", cmd.Slug)
116+ require.Equal(t, "NN", cmd.Code)
117+}
118+
119+func TestPaletteCommandsUseOverrideSnapshotForRecentAndListing(t *testing.T) {
120+ d := newTestDaemon(t, nil, stubClock{})
121+ d.ApplyConfig(domain.Config{
122+ Theme: domain.ThemeAuto,
123+ Codes: map[string]string{
124+ "split-right": "SRX",
125+ },
126+ })
127+ d.recordPaletteUse("SRX")
128+
129+ commands := d.paletteCommands()
130+ require.NotEmpty(t, commands)
131+ require.Equal(t, "split-right", commands[0].Slug)
132+ require.Equal(t, "SRX", commands[0].Code)
133+
134+ seen := 0
135+ for _, cmd := range commands {
136+ if cmd.Slug == "split-right" {
137+ seen++
138+ require.Equal(t, "SRX", cmd.Code)
139+ }
140+ }
141+ require.Equal(t, 1, seen)
142+}
143+
144+func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) {
145+ d := newTestDaemon(t, nil, stubClock{})
146+ d.ApplyConfig(domain.Config{
147+ Theme: domain.ThemeAuto,
148+ Codes: map[string]string{
149+ "new-session": "CNT",
150+ "new-tab": "DET",
151+ },
152+ })
153+
154+ commands := d.paletteCommands()
155+ byCode := make(map[string]string, len(commands))
156+ for _, cmd := range commands {
157+ if existing, ok := byCode[cmd.Code]; ok {
158+ t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
159+ }
160+ byCode[cmd.Code] = cmd.Slug
161+ }
162+ require.Equal(t, "new-tab", byCode["CNT"])
163+ require.Equal(t, "new-session", byCode["CNS"])
164+ require.Equal(t, "detach", byCode["DET"])
165+}
166+
167+func TestCodeOverrideVacatedDefaultIsReusableRegardlessOfSlugOrder(t *testing.T) {
168+ d := newTestDaemon(t, nil, stubClock{})
169+ d.ApplyConfig(domain.Config{
170+ Theme: domain.ThemeAuto,
171+ Codes: map[string]string{
172+ "new-tab": "NT",
173+ "new-session": "CNT",
174+ },
175+ })
176+
177+ commands := d.paletteCommands()
178+ byCode := make(map[string]string, len(commands))
179+ for _, cmd := range commands {
180+ if existing, ok := byCode[cmd.Code]; ok {
181+ t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
182+ }
183+ byCode[cmd.Code] = cmd.Slug
184+ }
185+ require.Equal(t, "new-tab", byCode["NT"])
186+ require.Equal(t, "new-session", byCode["CNT"])
187+}
188+
189+func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
190+ d := newTestDaemon(t, nil, stubClock{})
191+ require.NotPanics(t, func() {
192+ d.ApplyConfig(domain.Config{
193+ Theme: domain.ThemeAuto,
194+ BindingEntries: []domain.ConfigEntry{
195+ {Key: "unknown-action", Value: "alt+x"},
196+ {Key: "focus-pane-left", Value: "ctrl+x"},
197+ },
198+ Codes: map[string]string{
199+ "new-tab": "toolong",
200+ "split-right": "det",
201+ "missing-command": "OK",
202+ },
203+ })
204+ })
205+
206+ cmd, ok := d.commandByEffectiveCode("CNT")
207+ require.True(t, ok)
208+ require.Equal(t, "new-tab", cmd.Slug)
209+ _, ok = d.commandByEffectiveCode("TOOLONG")
210+ require.False(t, ok)
211+}
M internal/usecase/daemon/copymode.go +229 -96
@@ -17,74 +17,96 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded
21-// by tab.mu; the attached-client pointer by session.mu; the registry by
22-// Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25-// a slow client.
20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2625 package daemon
2726
2827 import (
2928 "strconv"
3029
30+ "github.com/bnema/vev/internal/domain"
3131 "github.com/bnema/vev/internal/ports"
3232 scopy "github.com/bnema/vev/internal/usecase/copy"
3333 "github.com/bnema/vev/internal/usecase/keys"
34+ "github.com/bnema/vev/internal/usecase/layout"
3435 "github.com/bnema/vev/internal/usecase/mouse"
36+ "github.com/bnema/vev/internal/usecase/ui"
37+ "github.com/bnema/vev/internal/usecase/visualsearch"
3538 "github.com/bnema/vev/pkg/renderer"
3639 )
3740
41+var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1}
42+
3843 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
44+ rt := ac.overlays
3945 tb := sess.activeTab()
4046 if tb == nil {
4147 return
4248 }
4349
4450 tb.mu.Lock()
45- ac.copyMu.Lock()
46- if ac.copyMode == nil {
47- ac.copyMu.Unlock()
48- tb.mu.Unlock()
51+ p := tb.focusedPane()
52+ tb.mu.Unlock()
53+ if p == nil {
54+ return
55+ }
56+ p.mu.Lock()
57+ rt.copyMu.Lock()
58+ if rt.copyMode == nil {
59+ rt.copyMu.Unlock()
60+ p.mu.Unlock()
4961 return
5062 }
51- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
52- if delta > 0 && ac.copyMode.AtBottom(snap) {
53- ac.copyMode = nil
54- ac.copyMu.Unlock()
55- tb.mu.Unlock()
63+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
64+ if delta > 0 && rt.copyMode.AtBottom(snap) {
65+ rt.clearCopyModeLocked()
66+ rt.copyMu.Unlock()
67+ p.mu.Unlock()
5668 d.paint(sess, ac, true)
5769 return
5870 }
59- ac.copyMode.Move(snap, delta)
60- exit := delta > 0 && ac.copyMode.AtBottom(snap)
71+ rt.copyMode.Move(snap, delta)
72+ exit := delta > 0 && rt.copyMode.AtBottom(snap)
6173 if exit {
62- ac.copyMode = nil
74+ rt.clearCopyModeLocked()
6375 }
64- ac.copyMu.Unlock()
65- tb.mu.Unlock()
76+ rt.copyMu.Unlock()
77+ p.mu.Unlock()
6678
6779 d.paint(sess, ac, true)
6880 }
6981
7082 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
83+ rt := ac.overlays
7184 tb := sess.activeTab()
7285 if tb == nil {
7386 return
7487 }
7588 tb.mu.Lock()
76- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
89+ p := tb.focusedPane()
7790 tb.mu.Unlock()
78- ac.copyMu.Lock()
79- ac.copyMode = scopy.NewMode(snap)
80- ac.copyPressRowValid = false
81- ac.copyDragging = false
82- ac.normalMousePressValid = false
83- ac.copyMu.Unlock()
91+ if p == nil {
92+ return
93+ }
94+ p.mu.Lock()
95+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
96+ p.mu.Unlock()
97+ rt.copyMu.Lock()
98+ rt.copyMode = scopy.NewMode(snap)
99+ rt.copySearch = nil
100+ rt.copySearchPending = nil
101+ rt.copyPressRowValid = false
102+ rt.copyDragging = false
103+ rt.normalMousePressValid = false
104+ rt.copyMu.Unlock()
84105 d.paint(sess, ac, true)
85106 }
86107
87108 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
109+ rt := ac.overlays
88110 if ev.Button != mouse.Left {
89111 return
90112 }
@@ -94,47 +116,53 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
94116 }
95117
96118 tb.mu.Lock()
97- if ev.Row >= tb.screen.Frame.Height {
119+ p := tb.focusedPane()
120+ tb.mu.Unlock()
121+ if p == nil {
122+ return
123+ }
124+ p.mu.Lock()
125+ if ev.Row >= p.screen.Frame.Height {
98126 if ev.Type == mouse.Press {
99- ac.copyMu.Lock()
100- ac.copyPressRowValid = false
101- ac.copyDragging = false
102- ac.copyMu.Unlock()
127+ rt.copyMu.Lock()
128+ rt.copyPressRowValid = false
129+ rt.copyDragging = false
130+ rt.copyMu.Unlock()
103131 }
104- tb.mu.Unlock()
132+ p.mu.Unlock()
105133 return
106134 }
107- ac.copyMu.Lock()
108- if ac.copyMode == nil {
109- ac.copyMu.Unlock()
110- tb.mu.Unlock()
135+ rt.copyMu.Lock()
136+ if rt.copyMode == nil {
137+ rt.copyMu.Unlock()
138+ p.mu.Unlock()
111139 return
112140 }
113- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
114- absRow := ac.copyMode.ViewportTop + ev.Row
141+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
142+ absRow := rt.copyMode.ViewportTop + ev.Row
115143 changed := false
116144 switch ev.Type {
117145 case mouse.Press:
118- ac.copyMode.SetCursor(snap, absRow)
119- ac.copyPressRow = ac.copyMode.Cursor
120- ac.copyPressRowValid = true
121- ac.copyDragging = false
146+ rt.copyMode.SetCursor(snap, absRow)
147+ rt.copyPressRow = rt.copyMode.Cursor
148+ rt.copyPressRowValid = true
149+ rt.copyDragging = false
122150 changed = true
123151 case mouse.Motion:
124- if !ac.copyPressRowValid {
152+ if !rt.copyPressRowValid {
125153 break
126154 }
127- if !ac.copyDragging {
128- ac.copyMode.StartSelectionAt(snap, ac.copyPressRow)
129- ac.copyDragging = true
155+ if !rt.copyDragging {
156+ rt.copyMode.StartSelectionAt(snap, rt.copyPressRow)
157+ rt.copyDragging = true
130158 }
131- ac.copyMode.ExtendTo(snap, absRow)
159+ rt.copyMode.ExtendTo(snap, absRow)
132160 changed = true
133161 case mouse.Release:
134162 // Button release intentionally has no visual effect.
135163 }
136- ac.copyMu.Unlock()
137- tb.mu.Unlock()
164+ rt.copyMu.Unlock()
165+ p.mu.Unlock()
138166
139167 if changed {
140168 d.paint(sess, ac, true)
@@ -142,6 +170,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
142170 }
143171
144172 func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
173+ rt := ac.overlays
145174 sess := ac.currentSession()
146175 if sess == nil {
147176 return
@@ -152,50 +181,78 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
152181 }
153182
154183 tb.mu.Lock()
155- ac.copyMu.Lock()
156- if ac.copyMode == nil {
157- ac.copyPending = nil
184+ p := tb.focusedPane()
185+ tb.mu.Unlock()
186+ if p == nil {
187+ return
188+ }
189+ p.mu.Lock()
190+ rt.copyMu.Lock()
191+ if rt.copyMode == nil {
192+ rt.copyPending = nil
158193 d.stopCopyPendingTimerLocked(ac)
159- ac.copyMu.Unlock()
160- tb.mu.Unlock()
194+ rt.copyMu.Unlock()
195+ p.mu.Unlock()
161196 return
162197 }
163- if len(ac.copyPending) > 0 {
198+ if len(rt.copyPending) > 0 {
164199 d.stopCopyPendingTimerLocked(ac)
165- combined := make([]byte, 0, len(ac.copyPending)+len(data))
166- combined = append(combined, ac.copyPending...)
200+ combined := make([]byte, 0, len(rt.copyPending)+len(data))
201+ combined = append(combined, rt.copyPending...)
167202 combined = append(combined, data...)
168203 data = combined
169- ac.copyPending = nil
204+ rt.copyPending = nil
205+ }
206+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
207+ if rt.copySearch != nil {
208+ changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, snap, data)
209+ rt.copyMu.Unlock()
210+ p.mu.Unlock()
211+ if changed || closeSearch || accepted {
212+ d.paint(sess, ac, true)
213+ }
214+ return
170215 }
171- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
172216 changed := false
173217 copyOut := false
174218 exit := false
175219 for i := 0; i < len(data); i++ {
176220 switch data[i] {
177221 case 'j':
178- ac.copyMode.Move(snap, 1)
222+ rt.copyMode.Move(snap, 1)
179223 changed = true
180224 case 'k':
181- ac.copyMode.Move(snap, -1)
225+ rt.copyMode.Move(snap, -1)
182226 changed = true
183227 case 'g':
184- ac.copyMode.Top(snap)
228+ rt.copyMode.Top(snap)
185229 changed = true
186230 case 'G':
187- ac.copyMode.Bottom(snap)
231+ rt.copyMode.Bottom(snap)
188232 changed = true
189233 case ' ', 'v':
190- ac.copyMode.ToggleSelection()
234+ rt.copyMode.ToggleSelection()
235+ changed = true
236+ case '/':
237+ rt.copySearch = visualsearch.New(snap)
238+ rt.copySearchPending = nil
191239 changed = true
240+ if i+1 < len(data) {
241+ searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, snap, data[i+1:])
242+ changed = changed || searchChanged || accepted
243+ }
244+ i = len(data)
245+ case 'n':
246+ changed = rt.copyMode.NextSearchMatch(snap, 1) || changed
247+ case 'N':
248+ changed = rt.copyMode.NextSearchMatch(snap, -1) || changed
192249 case '\r', '\n', 'y':
193250 copyOut = true
194251 exit = true
195252 case 'q', 0x03, 0x1b:
196253 if data[i] == 0x1b {
197254 tail := data[i:]
198- consumed, ok := routeCopyEscape(ac.copyMode, snap, tail)
255+ consumed, ok := routeCopyEscape(rt.copyMode, snap, tail)
199256 if ok {
200257 i += consumed - 1
201258 changed = true
@@ -206,7 +263,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
206263 break
207264 }
208265 if isCopyEscapePrefix(tail) {
209- ac.copyPending = append(ac.copyPending[:0], tail...)
266+ rt.copyPending = append(rt.copyPending[:0], tail...)
210267 break
211268 }
212269 }
@@ -215,30 +272,31 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
215272 }
216273 text := ""
217274 if copyOut {
218- text = ac.copyMode.SelectedText(snap)
275+ text = rt.copyMode.SelectedText(snap)
219276 }
220277 if exit {
221278 d.stopCopyPendingTimerLocked(ac)
222- ac.copyMode = nil
279+ rt.clearCopyModeLocked()
223280 }
224- ac.copyMu.Unlock()
225- tb.mu.Unlock()
281+ rt.copyMu.Unlock()
282+ p.mu.Unlock()
226283
227284 if copyOut && text != "" {
228285 chunks := scopy.OSC52(text)
229286 for _, chunk := range chunks {
230- if err := d.boundedSendErr(ac, frameOutput(chunk)); err != nil {
231- d.detachOnSendError(sess, ac)
287+ failed, err := d.boundedSendOutputErrTransport(ac, chunk)
288+ if err != nil {
289+ d.detachOnSendError(sess, ac, failed)
232290 return
233291 }
234292 }
235- ac.copyMu.Lock()
293+ rt.copyMu.Lock()
236294 if len(chunks) > 0 {
237- ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
295+ rt.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
238296 } else {
239- ac.copyFeedback = "selection too large to copy"
297+ rt.copyFeedback = "selection too large to copy"
240298 }
241- ac.copyMu.Unlock()
299+ rt.copyMu.Unlock()
242300 }
243301 if exit {
244302 d.paint(sess, ac, true)
@@ -249,24 +307,73 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
249307 }
250308 }
251309
310+func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, snap scopy.Snapshot, data []byte) (changed bool, closeSearch bool, accepted bool) {
311+ routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{
312+ rune: func(r rune) {
313+ rt.copySearch.Insert(r)
314+ changed = true
315+ },
316+ backspace: func() {
317+ rt.copySearch.Backspace()
318+ changed = true
319+ },
320+ enter: func() {
321+ if _, ok := rt.copySearch.Selected(); ok {
322+ searchSnap := rt.copySearch.Snapshot()
323+ accepted = rt.copyMode.SetSearchMatches(searchSnap, rt.copySearch.Query(), rt.copySearch.Matches(), rt.copySearch.SelectedIndex())
324+ closeSearch = accepted
325+ }
326+ },
327+ cancel: func() { closeSearch = true },
328+ up: func() {
329+ rt.copySearch.Up()
330+ changed = true
331+ },
332+ down: func() {
333+ rt.copySearch.Down()
334+ changed = true
335+ },
336+ })
337+ if changed && !closeSearch {
338+ d.previewCopySearchSelectionLocked(rt, snap)
339+ }
340+ if closeSearch {
341+ rt.copySearch = nil
342+ rt.copySearchPending = nil
343+ }
344+ return changed, closeSearch, accepted
345+}
346+
347+func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime, snap scopy.Snapshot) {
348+ if rt == nil || rt.copyMode == nil || rt.copySearch == nil {
349+ return
350+ }
351+ match, ok := rt.copySearch.Selected()
352+ if !ok {
353+ return
354+ }
355+ rt.copyMode.SetCursor(snap, match.Row)
356+}
357+
252358 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
253- mode := ac.copyMode
254- ac.copyPending = append(ac.copyPending[:0], keys.ESC)
255- ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
256- ac.copyMu.Lock()
257- if ac.copyESC.timer != timer {
258- ac.copyMu.Unlock()
359+ rt := ac.overlays
360+ mode := rt.copyMode
361+ rt.copyPending = append(rt.copyPending[:0], keys.ESC)
362+ rt.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
363+ rt.copyMu.Lock()
364+ if rt.copyESC.timer != timer {
365+ rt.copyMu.Unlock()
259366 return
260367 }
261- ac.copyPending = nil
262- ac.copyESC.timer = nil
263- ac.copyESC.done = nil
264- if ac.copyMode != mode || ac.copyMode == nil {
265- ac.copyMu.Unlock()
368+ rt.copyPending = nil
369+ rt.copyESC.timer = nil
370+ rt.copyESC.done = nil
371+ if rt.copyMode != mode || rt.copyMode == nil {
372+ rt.copyMu.Unlock()
266373 return
267374 }
268- ac.copyMode = nil
269- ac.copyMu.Unlock()
375+ rt.clearCopyModeLocked()
376+ rt.copyMu.Unlock()
270377
271378 if sess := ac.currentSession(); sess != nil {
272379 d.paint(sess, ac, true)
@@ -275,7 +382,8 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
275382 }
276383
277384 func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
278- ac.copyESC.stop()
385+ rt := ac.overlays
386+ rt.copyESC.stop()
279387 }
280388
281389 func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
@@ -307,16 +415,41 @@ func isCopyEscapePrefix(data []byte) bool {
307415 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
308416 }
309417
418+func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
419+ styleSet := resolveThemeStyles(styles)
420+ return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
421+}
422+
310423 func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
311424 styles := newThemeStyles(bars.theme)
312- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
425+ p := tb.focusedPane()
426+ if p == nil {
427+ return renderer.NewFrame(0, 0), nil
428+ }
429+ p.mu.Lock()
430+ paneFrame := p.screen.Frame
431+ paneWidth, paneRows := paneFrame.Width, paneFrame.Height
432+ snap := scopy.NewSnapshot(p.scrollback, paneFrame)
433+ p.mu.Unlock()
313434 copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
314- width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
435+ pl, ok := focusedPlacementLocked(tb)
436+ if !ok {
437+ pl = layout.Placement{Content: domain.Rect{Width: paneWidth, Height: paneRows}}
438+ }
439+ width, screenRows := paneWidth, paneRows
440+ if len(tb.panes) > 1 && tb.size.Valid() {
441+ width, screenRows = tb.size.Cols, tb.size.Rows
442+ }
315443 frame := renderer.NewFrame(width, screenRows+2)
316- drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles)
444+ drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, bars.topRight, styles)
445+ base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme)
317446 for y := range screenRows {
318- copy(frame.Row(y+1), copyFrame.Row(y))
447+ copy(frame.Row(y+1), base.Row(y))
448+ }
449+ for y := 0; y < pl.Content.Height && y < copyFrame.Height-1; y++ {
450+ copy(frame.Row(pl.Content.Y + 1 + y)[pl.Content.X:pl.Content.X+min(pl.Content.Width, copyFrame.Width)], copyFrame.Row(y)[:min(pl.Content.Width, copyFrame.Width)])
319451 }
320- copy(frame.Row(screenRows+1), copyFrame.Row(screenRows))
452+ statusY := screenRows + 1
453+ copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(width, copyFrame.Width)])
321454 return frame, []renderer.Damage{renderer.FullRedraw()}
322455 }
M internal/usecase/daemon/copymode_test.go +242 -52
@@ -23,18 +23,81 @@ import (
2323
2424 // --- test doubles -----------------------------------------------------------
2525
26+func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) {
27+ d := newTestDaemon(t, nil, stubClock{})
28+ ac := &attachedClient{}
29+ ac.initOverlays()
30+ replacement := &closeTrackingTransport{}
31+ sendErr := errors.New("send failed")
32+ failed := &swapErrorTransport{ac: ac, replacement: replacement, err: sendErr}
33+ ac.replaceTransport(failed)
34+
35+ used, err := d.boundedSendOutputErrTransport(ac, []byte("copy"))
36+
37+ require.ErrorIs(t, err, sendErr)
38+ require.Same(t, failed, used)
39+ require.Same(t, replacement, ac.transport())
40+}
41+
42+type swapErrorTransport struct {
43+ ac *attachedClient
44+ replacement ports.Transport
45+ err error
46+}
47+
48+func (t *swapErrorTransport) Send(ports.Frame) error {
49+ t.ac.replaceTransport(t.replacement)
50+ return t.err
51+}
52+
53+func (t *swapErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
54+func (t *swapErrorTransport) Close() error { return nil }
55+
2656 // stubClock returns timers whose channel never fires, so a scheduler under it
2757 // blocks in its debounce loop until the session context is cancelled. Used by
2858
59+func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
60+ p, release := newBlockingPTY(t)
61+ defer release()
62+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
63+ tb := sess.activeTab()
64+ pane := tb.focusedPane()
65+ pane.mu.Lock()
66+ snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)
67+ pane.mu.Unlock()
68+ mode := scopy.NewMode(snap)
69+ bars := barState{status: sess.statusSegments()}
70+
71+ var wg sync.WaitGroup
72+ wg.Add(2)
73+ go func() {
74+ defer wg.Done()
75+ for range 200 {
76+ pane.mu.Lock()
77+ pane.screen.Write([]byte("output\n"))
78+ pane.mu.Unlock()
79+ }
80+ }()
81+ go func() {
82+ defer wg.Done()
83+ for range 200 {
84+ tb.mu.Lock()
85+ _, _ = composeCopyClientFrame(mode, tb, bars)
86+ tb.mu.Unlock()
87+ }
88+ }()
89+ wg.Wait()
90+}
91+
2992 func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
3093 p, release := newBlockingPTY(t)
3194 _, sess, _, _ := newManualSessionWithPTYs(t, p)
3295 defer release()
3396 sess.name = "work"
3497 tb := sess.activeTab()
35- tb.screen = vt.NewScreen(12, 3)
36- tb.screen.Write([]byte("live"))
37- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
98+ tb.focusedPane().screen = vt.NewScreen(12, 3)
99+ tb.focusedPane().screen.Write([]byte("live"))
100+ snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
38101 mode := scopy.NewMode(snap)
39102
40103 frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})
@@ -42,7 +105,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
42105 require.Equal(t, 12, frame.Width)
43106 require.Equal(t, 5, frame.Height)
44107 require.Equal(t, " 1 ", rowText(frame.Row(0)))
45- require.Contains(t, rowText(frame.Row(4)), "[VISUAL]")
108+ require.Contains(t, rowText(frame.Row(4)), "[SCROLL]")
46109 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
47110 }
48111
@@ -50,14 +113,14 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
50113 writes := make(chan []byte, 1)
51114 p, _ := newBlockingPTYWithWrites(t, writes)
52115 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
53- sess.tabs[0].scrollback = scopy.NewScrollback(4)
54- sess.tabs[0].screen.Write([]byte("live"))
116+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
117+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
55118
56119 d.handleInput(sess, ac, []byte("\x1b "))
57120 awaitFrame(t, sends, ports.MsgOutput)
58121 d.handleInput(sess, ac, []byte("VIS\r"))
59122
60- if ac.copyMode == nil {
123+ if ac.overlays.copyMode == nil {
61124 t.Fatal("scrollback mode not entered")
62125 }
63126 select {
@@ -68,8 +131,8 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
68131 out := awaitFrame(t, sends, ports.MsgOutput)
69132 msg, err := ports.UnmarshalOutput(out.Payload)
70133 require.NoError(t, err)
71- if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[COPY]") {
72- t.Fatalf("visual mode paint = %q, want [VISUAL] without [COPY]", got)
134+ if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[SELECT]") || strings.Contains(got, "[COPY]") {
135+ t.Fatalf("passive scrollback paint = %q, want [SCROLL] without [SELECT]/[COPY]", got)
73136 }
74137
75138 d.handleInput(sess, ac, []byte(" "))
@@ -81,14 +144,135 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
81144 }
82145 }
83146
147+func TestCopyModeSearchModalRoutesBatchedInputAfterSlash(t *testing.T) {
148+ writes := make(chan []byte, 1)
149+ p, _ := newBlockingPTYWithWrites(t, writes)
150+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
151+ pane := sess.tabs[0].focusedPane()
152+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
153+ copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
154+
155+ d.enterCopyMode(sess, ac)
156+ awaitFrame(t, sends, ports.MsgOutput)
157+ d.handleInput(sess, ac, []byte("/alpha\r"))
158+ awaitFrame(t, sends, ports.MsgOutput)
159+
160+ require.Nil(t, ac.overlays.copySearch)
161+ require.NotNil(t, ac.overlays.copyMode)
162+ require.Equal(t, 0, ac.overlays.copyMode.Cursor)
163+ select {
164+ case got := <-writes:
165+ t.Fatalf("batched visual search input forwarded to PTY: %q", got)
166+ default:
167+ }
168+}
169+
170+func TestCopyModeSearchModalJumpsAndKeepsNavigation(t *testing.T) {
171+ writes := make(chan []byte, 1)
172+ p, _ := newBlockingPTYWithWrites(t, writes)
173+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
174+ pane := sess.tabs[0].focusedPane()
175+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
176+ copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
177+ copy(pane.screen.Frame.Row(2), testRow("gamma"))
178+
179+ d.enterCopyMode(sess, ac)
180+ awaitFrame(t, sends, ports.MsgOutput)
181+ d.handleInput(sess, ac, []byte("/"))
182+ out := awaitFrame(t, sends, ports.MsgOutput)
183+ msg, err := ports.UnmarshalOutput(out.Payload)
184+ require.NoError(t, err)
185+ require.Contains(t, string(msg.Data), "Search")
186+ require.NotNil(t, ac.overlays.copySearch)
187+
188+ d.handleInput(sess, ac, []byte("alpha"))
189+ out = awaitFrame(t, sends, ports.MsgOutput)
190+ msg, err = ports.UnmarshalOutput(out.Payload)
191+ require.NoError(t, err)
192+ require.Contains(t, string(msg.Data), "/alpha")
193+ require.Contains(t, string(msg.Data), "1:1 alpha")
194+ require.Contains(t, string(msg.Data), "2:6 beta alpha")
195+
196+ d.handleInput(sess, ac, []byte("\r"))
197+ awaitFrame(t, sends, ports.MsgOutput)
198+ require.Nil(t, ac.overlays.copySearch)
199+ require.Equal(t, 0, ac.overlays.copyMode.Cursor)
200+
201+ d.handleInput(sess, ac, []byte("n"))
202+ awaitFrame(t, sends, ports.MsgOutput)
203+ require.Equal(t, 1, ac.overlays.copyMode.Cursor)
204+ d.handleInput(sess, ac, []byte("N"))
205+ awaitFrame(t, sends, ports.MsgOutput)
206+ require.Equal(t, 0, ac.overlays.copyMode.Cursor)
207+
208+ select {
209+ case got := <-writes:
210+ t.Fatalf("visual search input forwarded to PTY: %q", got)
211+ default:
212+ }
213+}
214+
215+func TestCopyModeSearchModalSelectionPreviewsBehindModal(t *testing.T) {
216+ p, _ := newBlockingPTY(t)
217+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
218+ pane := sess.tabs[0].focusedPane()
219+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
220+ copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
221+ copy(pane.screen.Frame.Row(2), testRow("gamma"))
222+
223+ d.enterCopyMode(sess, ac)
224+ awaitFrame(t, sends, ports.MsgOutput)
225+
226+ d.handleInput(sess, ac, []byte("/"))
227+ awaitFrame(t, sends, ports.MsgOutput)
228+ d.handleInput(sess, ac, []byte("alpha"))
229+ awaitFrame(t, sends, ports.MsgOutput)
230+ require.NotNil(t, ac.overlays.copySearch)
231+ require.Equal(t, 0, ac.overlays.copyMode.Cursor, "typing a query previews the selected first result behind the modal")
232+
233+ d.handleInput(sess, ac, []byte{0x0e})
234+ awaitFrame(t, sends, ports.MsgOutput)
235+ require.NotNil(t, ac.overlays.copySearch)
236+ require.Equal(t, 1, ac.overlays.copyMode.Cursor, "moving modal selection previews that result without Enter")
237+}
238+
239+func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
240+ p, _ := newBlockingPTY(t)
241+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
242+ pane := sess.tabs[0].focusedPane()
243+ pane.scrollback = scopy.NewScrollback(4)
244+ pane.scrollback.Append(testRow("old alpha"))
245+ copy(pane.screen.Frame.Row(0), testRow("live alpha"))
246+
247+ d.enterCopyMode(sess, ac)
248+ awaitFrame(t, sends, ports.MsgOutput)
249+ d.handleInput(sess, ac, []byte("/alpha"))
250+ awaitFrame(t, sends, ports.MsgOutput)
251+ require.NotNil(t, ac.overlays.copySearch)
252+ cursor := ac.overlays.copyMode.Cursor
253+
254+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
255+ require.NotNil(t, ac.overlays.copySearch)
256+ require.Equal(t, cursor, ac.overlays.copyMode.Cursor)
257+
258+ d.handleInput(sess, ac, []byte("\x1b"))
259+ awaitFrame(t, sends, ports.MsgOutput)
260+ require.Nil(t, ac.overlays.copySearch)
261+ require.NotNil(t, ac.overlays.copyMode)
262+ d.handleInput(sess, ac, []byte("q"))
263+ awaitFrame(t, sends, ports.MsgOutput)
264+ require.Nil(t, ac.overlays.copySearch)
265+ require.Nil(t, ac.overlays.copyMode)
266+}
267+
84268 func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
85269 writes := make(chan []byte, 1)
86270 p, _ := newBlockingPTYWithWrites(t, writes)
87271 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
88- sess.tabs[0].scrollback = scopy.NewScrollback(4)
89- sess.tabs[0].scrollback.Append(testRow("old1 "))
90- sess.tabs[0].scrollback.Append(testRow("old2 "))
91- sess.tabs[0].screen.Write([]byte("live"))
272+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
273+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
274+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
275+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
92276
93277 d.enterCopyMode(sess, ac)
94278 awaitFrame(t, sends, ports.MsgOutput)
@@ -105,7 +289,7 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
105289 if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {
106290 t.Fatalf("OSC52 = %q, want %q", got, want)
107291 }
108- if ac.copyMode != nil {
292+ if ac.overlays.copyMode != nil {
109293 t.Fatal("copy mode still active after yank")
110294 }
111295 live := awaitFrame(t, sends, ports.MsgOutput)
@@ -168,8 +352,14 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
168352 return false
169353 }
170354 win.mu.Lock()
171- defer win.mu.Unlock()
172- return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12
355+ p := win.focusedPane()
356+ win.mu.Unlock()
357+ if p == nil {
358+ return false
359+ }
360+ p.mu.Lock()
361+ defer p.mu.Unlock()
362+ return len(scopy.NewSnapshot(p.scrollback, p.screen.Frame).Rows) >= 12
173363 }, 2*time.Second, 5*time.Millisecond)
174364
175365 sess := firstSession(d)
@@ -205,14 +395,14 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
205395 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
206396 p, _ := newBlockingPTY(t)
207397 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
208- sess.tabs[0].scrollback = scopy.NewScrollback(4)
209- sess.tabs[0].screen.Write([]byte("live"))
398+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
399+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
210400
211401 d.enterCopyMode(sess, ac)
212402 awaitFrame(t, sends, ports.MsgOutput)
213403 d.handleInput(sess, ac, []byte("q"))
214404
215- if ac.copyMode != nil {
405+ if ac.overlays.copyMode != nil {
216406 t.Fatal("copy mode still active after q")
217407 }
218408 out := awaitFrame(t, sends, ports.MsgOutput)
@@ -239,10 +429,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
239429 t.Run(tc.name, func(t *testing.T) {
240430 p, _ := newBlockingPTY(t)
241431 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
242- sess.tabs[0].scrollback = scopy.NewScrollback(4)
243- sess.tabs[0].scrollback.Append(testRow("old1 "))
244- sess.tabs[0].scrollback.Append(testRow("old2 "))
245- sess.tabs[0].screen.Write([]byte("live"))
432+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
433+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
434+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
435+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
246436
247437 d.enterCopyMode(sess, ac)
248438 awaitFrame(t, sends, ports.MsgOutput)
@@ -250,8 +440,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
250440 d.handleInput(sess, ac, input)
251441 }
252442
253- require.NotNil(t, ac.copyMode)
254- require.Equal(t, tc.wantCursor, ac.copyMode.Cursor)
443+ require.NotNil(t, ac.overlays.copyMode)
444+ require.Equal(t, tc.wantCursor, ac.overlays.copyMode.Cursor)
255445 })
256446 }
257447 }
@@ -259,10 +449,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
259449 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
260450 p, _ := newBlockingPTY(t)
261451 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
262- sess.tabs[0].scrollback = scopy.NewScrollback(1)
452+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(1)
263453 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
264- sess.tabs[0].screen.Frame = renderer.NewFrame(len(longLine), 1)
265- copy(sess.tabs[0].screen.Frame.Row(0), testRow(longLine))
454+ sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)
455+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))
266456
267457 d.enterCopyMode(sess, ac)
268458 awaitFrame(t, sends, ports.MsgOutput)
@@ -284,21 +474,21 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
284474 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
285475 clk := &signalClock{timers: make(chan *signalTimer, 1)}
286476 d.clock = clk
287- sess.tabs[0].scrollback = scopy.NewScrollback(4)
288- sess.tabs[0].screen.Write([]byte("live"))
477+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
478+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
289479
290480 d.enterCopyMode(sess, ac)
291481 awaitFrame(t, sends, ports.MsgOutput)
292482 d.handleInput(sess, ac, []byte("\x1b"))
293483 timer := <-clk.timers
294- ac.copyMu.Lock()
295- require.NotNil(t, ac.copyMode)
296- ac.copyMu.Unlock()
484+ ac.overlays.copyMu.Lock()
485+ require.NotNil(t, ac.overlays.copyMode)
486+ ac.overlays.copyMu.Unlock()
297487 timer.ch <- time.Now()
298488 require.Eventually(t, func() bool {
299- ac.copyMu.Lock()
300- defer ac.copyMu.Unlock()
301- return ac.copyMode == nil
489+ ac.overlays.copyMu.Lock()
490+ defer ac.overlays.copyMu.Unlock()
491+ return ac.overlays.copyMode == nil
302492 }, time.Second, 5*time.Millisecond)
303493 }
304494
@@ -307,8 +497,8 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
307497 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
308498 clk := &signalClock{timers: make(chan *signalTimer, 1)}
309499 d.clock = clk
310- sess.tabs[0].scrollback = scopy.NewScrollback(4)
311- sess.tabs[0].screen.Write([]byte("live"))
500+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
501+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
312502
313503 d.enterCopyMode(sess, ac)
314504 awaitFrame(t, sends, ports.MsgOutput)
@@ -319,15 +509,15 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
319509
320510 timer.ch <- time.Now()
321511 require.Never(t, func() bool {
322- ac.copyMu.Lock()
323- defer ac.copyMu.Unlock()
324- return ac.copyMode == nil
512+ ac.overlays.copyMu.Lock()
513+ defer ac.overlays.copyMu.Unlock()
514+ return ac.overlays.copyMode == nil
325515 }, 50*time.Millisecond, 5*time.Millisecond)
326- ac.copyMu.Lock()
327- require.Nil(t, ac.copyESC.timer)
328- require.Nil(t, ac.copyESC.done)
329- require.Empty(t, ac.copyPending)
330- ac.copyMu.Unlock()
516+ ac.overlays.copyMu.Lock()
517+ require.Nil(t, ac.overlays.copyESC.timer)
518+ require.Nil(t, ac.overlays.copyESC.done)
519+ require.Empty(t, ac.overlays.copyPending)
520+ ac.overlays.copyMu.Unlock()
331521 }
332522
333523 func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
@@ -342,14 +532,14 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
342532 t.Run(tc.name, func(t *testing.T) {
343533 p, _ := newBlockingPTY(t)
344534 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
345- sess.tabs[0].scrollback = scopy.NewScrollback(4)
346- sess.tabs[0].screen.Write([]byte("live"))
535+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
536+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
347537
348538 d.enterCopyMode(sess, ac)
349539 awaitFrame(t, sends, ports.MsgOutput)
350540 d.handleInput(sess, ac, tc.input)
351541
352- if ac.copyMode != nil {
542+ if ac.overlays.copyMode != nil {
353543 t.Fatal("copy mode still active after empty yank")
354544 }
355545 out := awaitFrame(t, sends, ports.MsgOutput)
@@ -368,8 +558,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
368558 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
369559 p, _ := newBlockingPTY(t)
370560 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
371- sess.tabs[0].scrollback = scopy.NewScrollback(4)
372- sess.tabs[0].screen.Write([]byte("live"))
561+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
562+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
373563
374564 done := make(chan struct{})
375565 var drain sync.WaitGroup
@@ -401,7 +591,7 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
401591 func TestCursorTailHidesInCopyMode(t *testing.T) {
402592 p, _ := newBlockingPTY(t)
403593 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
404- sess.tabs[0].screen.Write([]byte("live"))
594+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
405595
406596 d.enterCopyMode(sess, ac)
407597 data := mustOutputData(t, sends)
M internal/usecase/daemon/daemon.go +278 -52
@@ -15,14 +15,15 @@
1515 // scheduler).
1616 // - The daemon exits (Serve returns) when the last session is removed, or
1717 // when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
18+// attached clients with ReasonServerShutdown). Client detach does not end
19+// a session: ephemeral sessions stay in memory until explicitly killed or
20+// until the daemon exits, and named sessions can also persist on disk.
1921 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded
21-// by tab.mu; the attached-client pointer by session.mu; the registry by
22-// Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25-// a slow client.
22+// Locking: a pane's screen/scrollback and per-client renderer shadow are
23+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
24+// session.mu; the registry by Daemon.mu. When more than one is held the order
25+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
26+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2627 package daemon
2728
2829 import (
@@ -32,12 +33,13 @@ import (
3233 "os"
3334 "sort"
3435 "sync"
36+ "sync/atomic"
3537 "time"
3638
3739 "github.com/bnema/vev/internal/domain"
3840 "github.com/bnema/vev/internal/persist"
39- "github.com/bnema/vev/internal/platform"
4041 "github.com/bnema/vev/internal/ports"
42+ "github.com/bnema/vev/internal/usecase/keys"
4143 )
4244
4345 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -63,6 +65,16 @@ const defaultScrollbackRows = 10_000
6365 // the in-flight send. Teardown is never gated on a client draining its socket.
6466 const detachNotifyTimeout = time.Second
6567
68+// maxUnackedOutputStates caps how many output states may be in flight (sent but
69+// not yet acked by the client) before paint defers rather than composing
70+// another diff. It bounds the daemon's paint rate to the client's ack rate, so
71+// heavy output degrades to lower fps on a slow link instead of overflowing the
72+// transport. It must stay well under the UDP proxy's 32-frame client window so
73+// the proxy's reliable queue never fills from painting alone.
74+const maxUnackedOutputStates = 8
75+
76+const defaultResumeParkGrace = 15 * time.Minute
77+
6678 // defaultSize is used when a client's Hello carries no valid dimensions.
6779 var defaultSize = domain.Size{Cols: 80, Rows: 24}
6880
@@ -71,6 +83,7 @@ type Daemon struct {
7183 sessions map[domain.SessionID]*session
7284 stopped map[string]stoppedSession
7385 nextID uint64
86+ mruSeq atomic.Uint64
7487 // closing marks that shutdown has irreversibly begun. It is set under mu,
7588 // atomically with the event that makes shutdown inevitable (the registry
7689 // emptying in killSession, or shutdownAll starting), and checked by route
@@ -83,20 +96,41 @@ type Daemon struct {
8396 // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent
8497 // with Wait.
8598 notifies []chan struct{}
99+ parked map[uint64]*parkedAttachment
86100
87101 attnMu sync.Mutex
88102 animFrame int
89103 animWake chan struct{}
90104
91- ptys ports.PTYFactory
92- clock ports.Clock
93- log *slog.Logger
94- baseEnv []string
95- shell string
96- shellArgs []string
97- persist *persist.Persister
98- persistEnabled bool
99- procCwd func(int) (string, error)
105+ paletteRecentMu sync.Mutex
106+ paletteRecent []string
107+
108+ ptys ports.PTYFactory
109+ clock ports.Clock
110+ log *slog.Logger
111+ baseEnv []string
112+ shell string
113+ shellArgs []string
114+ persist *persist.Persister
115+ persistEnabled bool
116+ snaps ports.SnapshotStore
117+ snapsEnabled bool
118+ restoreDone chan struct{}
119+ restoreOnce sync.Once
120+ procCwd func(int) (string, error)
121+ procComm func(int) (string, error)
122+ procArgv func(int) ([]string, error)
123+ procGroupArgv func(int, int) ([]string, error)
124+ dirOrHome func(string) string
125+ bindings atomic.Pointer[keys.Bindings]
126+ codeOverrides atomic.Pointer[map[string]string]
127+ restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
128+ themeMode atomic.Uint32
129+ barScripts *barScriptState
130+ resumeParkGrace time.Duration
131+ // tempDir overrides os.TempDir() for clipboard-image-transfer writes
132+ // (see clipboard.go); empty means use os.TempDir().
133+ tempDir string
100134
101135 serveCtx context.Context
102136 serveCancel context.CancelFunc
@@ -114,14 +148,22 @@ type Daemon struct {
114148 connWg sync.WaitGroup // per-connection handler goroutines
115149 }
116150
151+type parkedAttachment struct {
152+ sess *session
153+ ac *attachedClient
154+ timer ports.Timer
155+ done chan struct{}
156+ doneOnce sync.Once
157+}
158+
117159 // session is a single multiplexed session. It owns one or more full-screen
118160
119161 type stoppedSession struct {
120- name string
121- cwd string
122- createdAt int64
123- tabNames []string
124- purging bool
162+ name string
163+ cwd string
164+ createdAt int64
165+ lastUsedSeq uint64
166+ purging bool
125167 }
126168
127169 type Option func(*Daemon)
@@ -145,6 +187,15 @@ func WithStore(store ports.Store) Option {
145187 }
146188 }
147189
190+// WithSnapshotStore enables durable named session snapshots. A nil store keeps
191+// the daemon in no-op snapshot mode.
192+func WithSnapshotStore(store ports.SnapshotStore) Option {
193+ return func(d *Daemon) {
194+ d.snaps = store
195+ d.snapsEnabled = store != nil
196+ }
197+}
198+
148199 // WithCwdReader overrides the process cwd reader used for persistence tests.
149200 func WithCwdReader(fn func(int) (string, error)) Option {
150201 return func(d *Daemon) {
@@ -154,6 +205,63 @@ func WithCwdReader(fn func(int) (string, error)) Option {
154205 }
155206 }
156207
208+// WithProcessInspector installs the platform process-inspection implementation.
209+func WithProcessInspector(ins ports.ProcessInspector) Option {
210+ return func(d *Daemon) {
211+ if ins == nil {
212+ return
213+ }
214+ d.procCwd = ins.Cwd
215+ d.procComm = ins.Comm
216+ d.procArgv = ins.Argv
217+ d.procGroupArgv = ins.GroupArgv
218+ }
219+}
220+
221+// WithDirOrHome installs path fallback behavior from the application layer.
222+func WithDirOrHome(fn func(string) string) Option {
223+ return func(d *Daemon) {
224+ if fn != nil {
225+ d.dirOrHome = fn
226+ }
227+ }
228+}
229+
230+// WithTempDir overrides the directory clipboard-image-transfer writes temp
231+// files into (production default: os.TempDir()); tests use this with
232+// t.TempDir() so writes are isolated and auto-cleaned.
233+func WithTempDir(dir string) Option {
234+ return func(d *Daemon) {
235+ d.tempDir = dir
236+ }
237+}
238+
239+// WithBarScriptCommandRunner installs the shell command runner used by bar scripts.
240+func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option {
241+ return func(d *Daemon) {
242+ if runner != nil {
243+ d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv}
244+ }
245+ }
246+}
247+
248+// WithResumeParkGrace overrides how long detached resume-capable clients stay
249+// parked for reconnection. Non-positive durations keep the default.
250+func WithResumeParkGrace(grace time.Duration) Option {
251+ return func(d *Daemon) {
252+ if grace > 0 {
253+ d.resumeParkGrace = grace
254+ }
255+ }
256+}
257+
258+// WithConfig applies the initial user configuration.
259+func WithConfig(cfg domain.Config) Option {
260+ return func(d *Daemon) {
261+ d.ApplyConfig(cfg)
262+ }
263+}
264+
157265 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
158266 // render debounce, and log receives diagnostics (defaults to slog.Default).
159267 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -165,17 +273,27 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
165273 shell = "/bin/sh"
166274 }
167275 d := &Daemon{
168- sessions: make(map[domain.SessionID]*session),
169- stopped: make(map[string]stoppedSession),
170- ptys: ptys,
171- clock: clock,
172- log: log,
173- baseEnv: os.Environ(),
174- shell: shell,
175- persist: persist.New(nil),
176- procCwd: platform.ProcessCwd,
177- done: make(chan struct{}),
178- animWake: make(chan struct{}, 1),
276+ sessions: make(map[domain.SessionID]*session),
277+ stopped: make(map[string]stoppedSession),
278+ parked: make(map[uint64]*parkedAttachment),
279+ ptys: ptys,
280+ clock: clock,
281+ log: log,
282+ baseEnv: os.Environ(),
283+ shell: shell,
284+ persist: persist.New(nil),
285+ dirOrHome: dirOrHome,
286+ done: make(chan struct{}),
287+ restoreDone: make(chan struct{}),
288+ animWake: make(chan struct{}, 1),
289+ resumeParkGrace: defaultResumeParkGrace,
290+ barScripts: &barScriptState{
291+ cfg: barConfigFromDomain(domain.Defaults().Bar),
292+ outputs: make(map[domain.SessionID]barScriptOutputs),
293+ lastRefresh: make(map[domain.SessionID]time.Time),
294+ lastContext: make(map[domain.SessionID]barScriptContext),
295+ running: make(map[domain.SessionID]bool),
296+ },
179297 }
180298 for _, o := range opts {
181299 o(d)
@@ -183,15 +301,31 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
183301 if d.persist == nil {
184302 d.persist = persist.New(nil)
185303 }
186- if d.procCwd == nil {
187- d.procCwd = platform.ProcessCwd
304+ if d.dirOrHome == nil {
305+ d.dirOrHome = dirOrHome
306+ }
307+ if d.bindings.Load() == nil {
308+ d.bindings.Store(keys.DefaultBindings())
309+ }
310+ if d.codeOverrides.Load() == nil {
311+ empty := map[string]string{}
312+ d.codeOverrides.Store(&empty)
313+ }
314+ if d.restoreProcessAllowlist.Load() == nil {
315+ allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
316+ d.restoreProcessAllowlist.Store(&allow)
188317 }
189318 if records, err := d.persist.LoadAll(); err != nil {
190319 d.log.Warn("loading persisted sessions failed", "err", err)
191320 } else {
321+ var maxSeq uint64
192322 for _, r := range records {
193- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, tabNames: r.TabNames}
323+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq}
324+ if r.LastUsedSeq > maxSeq {
325+ maxSeq = r.LastUsedSeq
326+ }
194327 }
328+ d.mruSeq.Store(maxSeq)
195329 }
196330 return d
197331 }
@@ -208,11 +342,24 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
208342 d.sessWg.Go(func() {
209343 d.attentionAnimator(d.serveCtx)
210344 })
345+ d.sessWg.Go(func() {
346+ d.barScriptPoller(d.serveCtx)
347+ })
211348 if d.persistEnabled && d.procCwd != nil {
212349 d.sessWg.Go(func() {
213350 d.cwdSampler(d.serveCtx)
214351 })
215352 }
353+ if d.snapsEnabled {
354+ d.sessWg.Go(func() {
355+ d.snapshotSaver(d.serveCtx)
356+ })
357+ d.sessWg.Go(func() {
358+ d.restoreSnapshots(d.serveCtx)
359+ })
360+ } else {
361+ d.closeRestoreDone()
362+ }
216363
217364 // Break the accept loop when either the parent context is cancelled or the
218365 // registry drains to empty: both close the listener, which fails Accept.
@@ -227,6 +374,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
227374 for {
228375 tr, err := l.Accept()
229376 if err != nil {
377+ if d.serveCtx.Err() != nil {
378+ d.log.Info("accept loop exiting", "err", err, "reason", "context canceled")
379+ } else {
380+ d.log.Warn("accept loop exiting", "err", err)
381+ }
230382 break
231383 }
232384 d.connWg.Go(func() {
@@ -264,9 +416,13 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
264416 func (d *Daemon) shutdownAll(reason uint8) {
265417 d.mu.Lock()
266418 d.closing = true
419+ for token, parked := range d.parked {
420+ d.removeParkedLocked(token, parked)
421+ }
267422 snapshot := d.sessionsSnapshotLocked()
268423 empty := len(snapshot) == 0
269424 d.mu.Unlock()
425+ d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
270426 if empty {
271427 d.doneOnce.Do(func() { close(d.done) })
272428 return
@@ -293,6 +449,7 @@ func (d *Daemon) handleConn(tr ports.Transport) {
293449
294450 first, err := tr.Recv()
295451 if err != nil {
452+ d.log.Warn("connection closed before hello", "err", err)
296453 _ = tr.Close()
297454 return
298455 }
@@ -304,6 +461,7 @@ func (d *Daemon) handleConn(tr ports.Transport) {
304461 case ports.MsgHello:
305462 d.handleHello(tr, first)
306463 default:
464+ d.log.Warn("hello rejected", "err", "expected hello", "type", first.Type)
307465 _ = tr.Send(frameError(ports.ErrInternal, "expected hello"))
308466 _ = tr.Close()
309467 }
@@ -345,9 +503,9 @@ func (d *Daemon) handleList(tr ports.Transport) {
345503 _ = tr.Send(frameSessions(infos))
346504 }
347505
348-// handleKill terminates the named session (if any), or all sessions, and
349-// closes the control connection; the resulting EOF is the client's success
350-// signal.
506+// handleKill terminates the requested live session or stopped named session,
507+// or all sessions, and closes the control connection; the resulting EOF is the
508+// client's success signal.
351509 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
352510 defer func() { _ = tr.Close() }()
353511
@@ -364,14 +522,24 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
364522 d.mu.Lock()
365523 target := d.findByNameLocked(k.Name)
366524 if target == nil {
367- if _, ok := d.stopped[k.Name]; ok {
525+ if stopped, ok := d.stopped[k.Name]; ok {
526+ d.mu.Unlock()
527+ if d.snapsEnabled && d.snaps != nil {
528+ if err := d.snaps.Delete(k.Name); err != nil {
529+ d.log.Warn("deleting stopped session snapshot failed", "err", err, "session", k.Name)
530+ _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session snapshot failed"))
531+ return
532+ }
533+ }
368534 if err := d.persist.Delete(k.Name); err != nil {
369- d.mu.Unlock()
370535 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)
371536 _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))
372537 return
373538 }
374- delete(d.stopped, k.Name)
539+ d.mu.Lock()
540+ if cur, ok := d.stopped[k.Name]; ok && cur == stopped {
541+ delete(d.stopped, k.Name)
542+ }
375543 d.mu.Unlock()
376544 return
377545 }
@@ -393,14 +561,17 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
393561 h, err := ports.UnmarshalHello(f.Payload)
394562 if err != nil {
395563 if version, ok := ports.PeekHelloVersion(f.Payload); ok && version != ports.ProtocolVersion {
564+ d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", version, "expected", ports.ProtocolVersion)
396565 _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
397566 } else {
567+ d.log.Warn("hello rejected", "err", err)
398568 _ = tr.Send(frameError(ports.ErrInternal, "malformed hello"))
399569 }
400570 _ = tr.Close()
401571 return
402572 }
403573 if h.Version != ports.ProtocolVersion {
574+ d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", h.Version, "expected", ports.ProtocolVersion, "intent", h.Intent, "session", h.Name)
404575 _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
405576 _ = tr.Close()
406577 return
@@ -408,6 +579,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
408579
409580 sess, ac, rerr := d.route(h, tr)
410581 if rerr != nil {
582+ d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
411583 if pe, ok := errors.AsType[*protoErr](rerr); ok {
412584 _ = tr.Send(frameError(pe.code, pe.text))
413585 } else {
@@ -417,8 +589,8 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
417589 return
418590 }
419591
420- if err := ac.send(frameWelcome(sess)); err != nil {
421- d.clientGone(sess, ac, false)
592+ if err := ac.send(frameWelcome(sess, ac)); err != nil {
593+ d.clientGone(sess, ac, tr, false)
422594 return
423595 }
424596 d.firstPaint(sess, ac, h.Size)
@@ -441,6 +613,14 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
441613 if !sz.Valid() {
442614 sz = defaultSize
443615 }
616+ if d.snapsEnabled && (h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach || h.Intent == ports.IntentNew) {
617+ select {
618+ case <-d.restoreDone:
619+ case <-d.serveCtx.Done():
620+ return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
621+ }
622+ }
623+ term := terminalEnv{TrueColor: h.TrueColor}
444624
445625 d.mu.Lock()
446626 // Shutdown/create interlock: once shutdown has begun (last session removed,
@@ -452,14 +632,48 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
452632 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
453633 }
454634 switch h.Intent {
635+ case ports.IntentResume:
636+ if sess, ac, ok, err := d.resumeParkedLocked(h, tr, sz); ok || err != nil {
637+ d.mu.Unlock()
638+ return sess, ac, err
639+ }
640+ // Resume miss/expiry falls back to normal attach semantics.
641+ sess := d.findByNameLocked(h.Name)
642+ if sess == nil {
643+ stopped, ok := d.stopped[h.Name]
644+ if !ok || stopped.purging {
645+ d.mu.Unlock()
646+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
647+ }
648+ cwd := d.dirOrHome(stopped.cwd)
649+ var err error
650+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
651+ if err != nil {
652+ d.mu.Unlock()
653+ return nil, nil, err
654+ }
655+ }
656+ d.purgeParkedForSessionLocked(sess)
657+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
658+ sess.mu.Lock()
659+ sess.terminal = term
660+ sess.mu.Unlock()
661+ d.mu.Unlock()
662+ d.detachReplacedClient(old)
663+ return sess, ac, nil
664+
455665 case ports.IntentEphemeral:
456666 name := d.allocEphemeralNameLocked()
457- sess, err := d.createSessionLocked(name, true, h.Cwd, sz, nil)
667+ sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term)
458668 if err != nil {
459669 d.mu.Unlock()
460670 return nil, nil, err
461671 }
462- ac, old := d.attachClient(sess, tr, sz)
672+ d.purgeParkedForSessionLocked(sess)
673+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
674+ sess.mu.Lock()
675+ sess.terminal = term
676+ sess.mu.Unlock()
463677 d.mu.Unlock()
464678 d.detachReplacedClient(old)
465679 return sess, ac, nil
@@ -467,18 +681,26 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
467681 case ports.IntentNew:
468682 if h.Name == "" {
469683 d.mu.Unlock()
470- return nil, nil, &protoErr{ports.ErrInternal, "empty session name"}
684+ return nil, nil, &protoErr{ports.ErrInvalidSessionName, "empty session name"}
685+ }
686+ if err := domain.ValidateSessionName(h.Name); err != nil {
687+ d.mu.Unlock()
688+ return nil, nil, &protoErr{ports.ErrInvalidSessionName, err.Error()}
471689 }
472690 if d.nameLiveOrStoppedLocked(h.Name) {
473691 d.mu.Unlock()
474692 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
475693 }
476- sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, nil)
694+ sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term)
477695 if err != nil {
478696 d.mu.Unlock()
479697 return nil, nil, err
480698 }
481- ac, old := d.attachClient(sess, tr, sz)
699+ d.purgeParkedForSessionLocked(sess)
700+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
701+ sess.mu.Lock()
702+ sess.terminal = term
703+ sess.mu.Unlock()
482704 d.mu.Unlock()
483705 d.detachReplacedClient(old)
484706 return sess, ac, nil
@@ -491,15 +713,19 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
491713 d.mu.Unlock()
492714 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
493715 }
494- cwd := platform.DirOrHome(stopped.cwd)
716+ cwd := d.dirOrHome(stopped.cwd)
495717 var err error
496- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, stopped.tabNames)
718+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
497719 if err != nil {
498720 d.mu.Unlock()
499721 return nil, nil, err
500722 }
501723 }
502- ac, old := d.attachClient(sess, tr, sz)
724+ d.purgeParkedForSessionLocked(sess)
725+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
726+ sess.mu.Lock()
727+ sess.terminal = term
728+ sess.mu.Unlock()
503729 d.mu.Unlock()
504730 d.detachReplacedClient(old)
505731 return sess, ac, nil
M internal/usecase/daemon/daemon_test.go +16 -5
@@ -42,20 +42,21 @@ type signalClock struct {
4242 }
4343
4444 func (c *signalClock) Now() time.Time { return time.Time{} }
45-func (c *signalClock) NewTimer(time.Duration) ports.Timer {
45+func (c *signalClock) NewTimer(d time.Duration) ports.Timer {
4646 if c.called != nil {
4747 c.once.Do(func() { close(c.called) })
4848 }
4949 if c.timers == nil {
5050 return stubTimer{}
5151 }
52- t := &signalTimer{ch: make(chan time.Time, 1)}
52+ t := &signalTimer{ch: make(chan time.Time, 1), duration: d}
5353 c.timers <- t
5454 return t
5555 }
5656
5757 type signalTimer struct {
58- ch chan time.Time
58+ ch chan time.Time
59+ duration time.Duration
5960 }
6061
6162 func (t *signalTimer) C() <-chan time.Time { return t.ch }
@@ -239,15 +240,21 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
239240 d := newTestDaemon(t, nil, stubClock{})
240241 tr, sends := newCapturingTransport(t)
241242 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
243+ ac.initOverlays()
242244 sctx, cancel := context.WithCancel(d.serveCtx)
243245 tabs := make([]*tab, 0, len(ptys))
244246 for _, p := range ptys {
245247 wctx, wcancel := context.WithCancel(sctx)
246- tabs = append(tabs, &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel})
248+ tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
249+ tb.ctx, tb.cancel = wctx, wcancel
250+ for _, pane := range tb.panes {
251+ pane.ctx, pane.cancel = wctx, wcancel
252+ }
253+ tabs = append(tabs, tb)
247254 }
248255 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}
249256 ac.setSession(sess)
250- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
257+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)
251258 d.sessions[sess.id] = sess
252259 t.Cleanup(cancel)
253260 return d, sess, ac, sends
@@ -300,6 +307,10 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
300307 func rowText(row []renderer.Cell) string {
301308 runes := make([]rune, len(row))
302309 for i, c := range row {
310+ if c.Continuation {
311+ runes[i] = ' '
312+ continue
313+ }
303314 runes[i] = c.Rune
304315 }
305316 return string(runes)
A internal/usecase/daemon/dir_fallback.go +15 -0
@@ -0,0 +1,15 @@
1+package daemon
2+
3+import "os"
4+
5+func dirOrHome(dir string) string {
6+ if dir != "" {
7+ if st, err := os.Stat(dir); err == nil && st.IsDir() {
8+ return dir
9+ }
10+ }
11+ if home, err := os.UserHomeDir(); err == nil && home != "" {
12+ return home
13+ }
14+ return "/"
15+}
A internal/usecase/daemon/identity.go +58 -0
@@ -0,0 +1,58 @@
1+package daemon
2+
3+import (
4+ "crypto/rand"
5+ "encoding/base32"
6+ "fmt"
7+ "io"
8+ "strings"
9+ "sync/atomic"
10+ "time"
11+)
12+
13+var fallbackStableIDCounter atomic.Uint64
14+
15+func newStableID(prefix string) (string, error) {
16+ var b [16]byte
17+ if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
18+ return "", fmt.Errorf("generate stable id: %w", err)
19+ }
20+ enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:])
21+ return prefix + "_" + strings.ToLower(enc), nil
22+}
23+
24+func (d *Daemon) newTabPaneStableIDs() (string, string, error) {
25+ tabStableID, err := newStableID("t")
26+ if err != nil {
27+ return "", "", fmt.Errorf("daemon: generating tab identity: %w", err)
28+ }
29+ paneStableID, err := newStableID("p")
30+ if err != nil {
31+ return "", "", fmt.Errorf("daemon: generating pane identity: %w", err)
32+ }
33+ return tabStableID, paneStableID, nil
34+}
35+
36+func fallbackStableID(prefix string) string {
37+ id, err := newStableID(prefix)
38+ if err != nil {
39+ return fmt.Sprintf("%s_fallback_%x_%x", prefix, time.Now().UnixNano(), fallbackStableIDCounter.Add(1))
40+ }
41+ return id
42+}
43+
44+func escapeVEVComponent(s string) string {
45+ var b strings.Builder
46+ for i := 0; i < len(s); i++ {
47+ c := s[i]
48+ if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-' {
49+ b.WriteByte(c)
50+ continue
51+ }
52+ b.WriteByte('%')
53+ const hex = "0123456789ABCDEF"
54+ b.WriteByte(hex[c>>4])
55+ b.WriteByte(hex[c&0x0f])
56+ }
57+ return b.String()
58+}
M internal/usecase/daemon/input.go +208 -63
@@ -17,12 +17,11 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded
21-// by tab.mu; the attached-client pointer by session.mu; the registry by
22-// Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25-// a slow client.
20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2625 package daemon
2726
2827 import (
@@ -31,27 +30,24 @@ import (
3130
3231 scopy "github.com/bnema/vev/internal/usecase/copy"
3332 "github.com/bnema/vev/internal/usecase/keys"
33+ "github.com/bnema/vev/internal/usecase/layout"
3434 "github.com/bnema/vev/internal/usecase/mouse"
3535 )
3636
37+func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint64, data []byte) {
38+ // Do not acknowledge client-side echo prediction here: input has only been
39+ // accepted/routed, not necessarily echoed by the PTY and incorporated into a
40+ // rendered screen state. Until prediction is implemented against rendered
41+ // output state, EchoAck must remain conservative.
42+ d.handleInput(sess, ac, data)
43+}
44+
3745 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
46+ ac.initOverlays()
3847 ac.mouseScan.Scan(data,
3948 func(ev mouse.Event) { d.handleMouse(ac, ev) },
4049 func(b []byte) {
41- if ac.promptActive() {
42- d.handlePromptInput(ac, b)
43- return
44- }
45- if ac.paletteActive() {
46- d.handlePaletteInput(ac, b)
47- return
48- }
49- if ac.pickerActive() {
50- d.handlePickerInput(ac, b)
51- return
52- }
53- if ac.copyModeActive() {
54- d.handleCopyInput(ac, b)
50+ if ac.overlays.HandleInput(d, b) {
5551 return
5652 }
5753 ac.keys.Route(b)
@@ -60,7 +56,9 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
6056 }
6157
6258 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
63- if ac.promptActive() || ac.paletteActive() || ac.pickerActive() {
59+ ac.initOverlays()
60+ rt := ac.overlays
61+ if rt.promptActive() || rt.paletteActive() || rt.pickerActive() {
6462 return
6563 }
6664 sess := ac.currentSession()
@@ -72,7 +70,43 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
7270 return
7371 }
7472
75- if ac.copyModeActive() {
73+ if rt.copyActive() {
74+ if rt.copySearchActive() {
75+ return
76+ }
77+ if ev.Button == mouse.Left {
78+ contentRow := ev.Row - 1
79+ tb.mu.Lock()
80+ p := tb.focusedPane()
81+ var pl layout.Placement
82+ var ok bool
83+ multi := len(tb.panes) > 1
84+ if p != nil {
85+ pl, ok = focusedPlacementLocked(tb)
86+ }
87+ if ok && multi && !pointInRect(ev.Col, contentRow, pl.Content) && ev.Type == mouse.Press {
88+ if target, hit := hitTestPlacementLocked(tb, ev.Col, contentRow); hit {
89+ oldFocus := tb.tree.Focus
90+ focusPlacementLocked(tb, target.ID)
91+ d.applyLayoutLocked(tb)
92+ tb.mu.Unlock()
93+ d.exitCopyMode(ac)
94+ if target.ID != oldFocus {
95+ d.refreshPaneTitleOnFocus(sess, target.ID)
96+ }
97+ d.paint(sess, ac, true)
98+ return
99+ }
100+ }
101+ tb.mu.Unlock()
102+ if ok && multi {
103+ clampedCol := clampInt(ev.Col, pl.Content.X, pl.Content.X+pl.Content.Width-1)
104+ clampedRow := clampInt(contentRow, pl.Content.Y, pl.Content.Y+pl.Content.Height-1)
105+ ev.Col = clampedCol
106+ ev.Row = clampedRow
107+ ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
108+ }
109+ }
76110 switch ev.Button {
77111 case mouse.Left:
78112 d.copyMouse(sess, ac, ev)
@@ -85,20 +119,83 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
85119 }
86120
87121 tb.mu.Lock()
88- childRows := tb.screen.Frame.Height
89- mouseMode, mouseSGR := tb.screen.MouseMode()
90- altScreen := tb.screen.AltScreenActive()
122+ contentRow := ev.Row - 1
123+ pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
124+ multi := len(tb.panes) > 1
125+ focusedID := layout.PaneID("")
126+ if tb.tree != nil {
127+ focusedID = tb.tree.Focus
128+ }
129+ if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
130+ if !isMouseFocusPress(ev) {
131+ tb.mu.Unlock()
132+ return
133+ }
134+ oldFocus := focusedID
135+ focusPlacementLocked(tb, pl.ID)
136+ d.applyLayoutLocked(tb)
137+ tb.mu.Unlock()
138+ if pl.ID != oldFocus {
139+ d.exitCopyMode(ac)
140+ d.refreshPaneTitleOnFocus(sess, pl.ID)
141+ }
142+ d.paint(sess, ac, true)
143+ return
144+ }
145+ var p *pane
146+ translated := false
147+ hoveredFocused := true
148+ if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {
149+ oldFocus := focusedID
150+ if isMouseFocusPress(ev) {
151+ focusPlacementLocked(tb, pl.ID)
152+ d.applyLayoutLocked(tb)
153+ }
154+ p = tb.panes[pl.ID]
155+ hoveredFocused = pl.ID == oldFocus
156+ tb.mu.Unlock()
157+ if p == nil {
158+ return
159+ }
160+ if isMouseFocusPress(ev) && pl.ID != oldFocus {
161+ d.exitCopyMode(ac)
162+ d.refreshPaneTitleOnFocus(sess, pl.ID)
163+ d.paint(sess, ac, true)
164+ }
165+ if multi {
166+ ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
167+ translated = true
168+ }
169+ } else {
170+ if multi {
171+ tb.mu.Unlock()
172+ return
173+ }
174+ p = tb.focusedPane()
175+ tb.mu.Unlock()
176+ if p == nil {
177+ return
178+ }
179+ }
180+ p.mu.Lock()
181+ childRows := p.screen.Frame.Height
182+ mouseMode, mouseSGR := p.screen.MouseMode()
183+ altScreen := p.screen.AltScreenActive()
91184 scrollbackRows := 0
92- if tb.scrollback != nil {
93- scrollbackRows = tb.scrollback.Len()
185+ if p.scrollback != nil {
186+ scrollbackRows = p.scrollback.Len()
94187 }
95- tb.mu.Unlock()
188+ p.mu.Unlock()
96189
97190 if mouseMode != 0 {
98191 if !mouseSGR || ev.Row == 0 || ev.Row > childRows {
99192 return
100193 }
101- daemonKeyHandler{d: d, ac: ac}.Forward(sgrRowOffset(ev.Raw, -1))
194+ if translated {
195+ d.writeToPane(sess, p, ev.Raw)
196+ } else {
197+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1))
198+ }
102199 return
103200 }
104201
@@ -107,64 +204,98 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
107204 switch ev.Type {
108205 case mouse.Press:
109206 if altScreen || ev.Row >= childRows {
110- ac.copyMu.Lock()
111- ac.normalMousePressValid = false
112- ac.copyMu.Unlock()
207+ rt.copyMu.Lock()
208+ rt.normalMousePressValid = false
209+ rt.copyMu.Unlock()
113210 return
114211 }
115- ac.copyMu.Lock()
116- ac.normalMousePressRow = ev.Row
117- ac.normalMousePressTop = scrollbackRows
118- ac.normalMousePressValid = true
119- ac.copyMu.Unlock()
212+ rt.copyMu.Lock()
213+ rt.normalMousePressRow = ev.Row
214+ rt.normalMousePressTop = scrollbackRows
215+ rt.normalMousePressValid = true
216+ rt.copyMu.Unlock()
120217 case mouse.Motion:
121218 if altScreen || ev.Row >= childRows {
122219 return
123220 }
124- ac.copyMu.Lock()
125- pressValid := ac.normalMousePressValid
126- pressRow := ac.normalMousePressRow
127- pressTop := ac.normalMousePressTop
128- ac.copyMu.Unlock()
221+ rt.copyMu.Lock()
222+ pressValid := rt.normalMousePressValid
223+ pressRow := rt.normalMousePressRow
224+ pressTop := rt.normalMousePressTop
225+ rt.copyMu.Unlock()
129226 if !pressValid {
130227 return
131228 }
132229
133- tb.mu.Lock()
134- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
135- tb.mu.Unlock()
230+ p.mu.Lock()
231+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
232+ p.mu.Unlock()
136233
137- ac.copyMu.Lock()
234+ rt.copyMu.Lock()
138235 mode := scopy.NewMode(snap)
139236 mode.StartSelectionAt(snap, pressTop+pressRow)
140237 mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)
141- ac.copyMode = mode
142- ac.copyPressRow = pressTop + pressRow
143- ac.copyPressRowValid = true
144- ac.copyDragging = true
145- ac.normalMousePressValid = false
146- ac.copyMu.Unlock()
238+ rt.copyMode = mode
239+ rt.copyPressRow = pressTop + pressRow
240+ rt.copyPressRowValid = true
241+ rt.copyDragging = true
242+ rt.normalMousePressValid = false
243+ rt.copyMu.Unlock()
147244 d.paint(sess, ac, true)
148245 case mouse.Release:
149- ac.copyMu.Lock()
150- ac.normalMousePressValid = false
151- ac.copyMu.Unlock()
246+ rt.copyMu.Lock()
247+ rt.normalMousePressValid = false
248+ rt.copyMu.Unlock()
152249 }
153250 case mouse.WheelUp:
154251 if altScreen {
155- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
252+ d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A"))
253+ return
254+ }
255+ if !hoveredFocused {
156256 return
157257 }
158258 d.enterCopyMode(sess, ac)
159259 d.copyWheel(sess, ac, -3)
160260 case mouse.WheelDown:
161261 if altScreen {
162- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
262+ d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B"))
263+ }
264+ }
265+}
266+
267+func isMouseFocusPress(ev mouse.Event) bool {
268+ return ev.Type == mouse.Press && (ev.Button == mouse.Left || ev.Button == mouse.Middle || ev.Button == mouse.Right)
269+}
270+
271+func clampInt(v, lo, hi int) int {
272+ if v < lo {
273+ return lo
274+ }
275+ if v > hi {
276+ return hi
277+ }
278+ return v
279+}
280+
281+func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
282+ if p == nil || p.pty == nil {
283+ return
284+ }
285+ if _, err := p.pty.Write(data); err != nil {
286+ name := ""
287+ if sess != nil {
288+ name = sess.name
163289 }
290+ d.log.Error("pty write failed", "err", err, "session", name)
164291 }
165292 }
166293
167294 func sgrRowOffset(raw []byte, delta int) []byte {
295+ return sgrOffset(raw, 0, delta)
296+}
297+
298+func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
168299 if len(raw) < len("\x1b[<0;1;1M") {
169300 return raw
170301 }
@@ -177,12 +308,17 @@ func sgrRowOffset(raw []byte, delta int) []byte {
177308 if len(parts) != 3 {
178309 return raw
179310 }
311+ cx, err := strconv.Atoi(string(parts[1]))
312+ if err != nil {
313+ return raw
314+ }
180315 cy, err := strconv.Atoi(string(parts[2]))
181316 if err != nil {
182317 return raw
183318 }
184- cy += delta
185- if cy < 1 {
319+ cx += colDelta
320+ cy += rowDelta
321+ if cx < 1 || cy < 1 {
186322 return raw
187323 }
188324
@@ -190,7 +326,7 @@ func sgrRowOffset(raw []byte, delta int) []byte {
190326 out = append(out, raw[:3]...)
191327 out = append(out, parts[0]...)
192328 out = append(out, ';')
193- out = append(out, parts[1]...)
329+ out = strconv.AppendInt(out, int64(cx), 10)
194330 out = append(out, ';')
195331 out = strconv.AppendInt(out, int64(cy), 10)
196332 out = append(out, raw[end])
@@ -211,9 +347,10 @@ func (h daemonKeyHandler) Forward(data []byte) {
211347 if tb == nil {
212348 return
213349 }
214- if _, err := tb.pty.Write(data); err != nil {
215- h.d.log.Error("pty write failed", "err", err, "session", sess.name)
216- }
350+ tb.mu.Lock()
351+ p := tb.focusedPane()
352+ tb.mu.Unlock()
353+ h.d.writeToPane(sess, p, data)
217354 }
218355
219356 func (h daemonKeyHandler) Action(action keys.Action) {
@@ -226,6 +363,14 @@ func (h daemonKeyHandler) Action(action keys.Action) {
226363 h.d.enterPalette(sess, h.ac)
227364 case keys.ActionJumpAttention:
228365 h.d.jumpAttention(sess, h.ac)
366+ case keys.ActionFocusPaneLeft:
367+ _ = h.d.focusDir(sess, h.ac, layout.Left)
368+ case keys.ActionFocusPaneRight:
369+ _ = h.d.focusDir(sess, h.ac, layout.Right)
370+ case keys.ActionFocusPaneUp:
371+ _ = h.d.focusDir(sess, h.ac, layout.Up)
372+ case keys.ActionFocusPaneDown:
373+ _ = h.d.focusDir(sess, h.ac, layout.Down)
229374 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
230375 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
231376 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
M internal/usecase/daemon/input_test.go +660 -42
@@ -7,11 +7,14 @@ import (
77 "testing"
88 "time"
99
10+ "github.com/stretchr/testify/mock"
1011 "github.com/stretchr/testify/require"
1112
1213 "github.com/bnema/vev/internal/domain"
1314 "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1416 scopy "github.com/bnema/vev/internal/usecase/copy"
17+ "github.com/bnema/vev/internal/usecase/layout"
1518 )
1619
1720 // --- test doubles -----------------------------------------------------------
@@ -76,6 +79,132 @@ func TestAltCForwardsToPTY(t *testing.T) {
7679 releasePTY()
7780 }
7881
82+func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
83+ writes := make(chan []byte, 1)
84+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
85+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
86+ pane := sess.tabs[0].focusedPane()
87+ pane.screen.Write([]byte("\x1b[?2004h"))
88+ require.True(t, pane.screen.BracketedPasteMode())
89+
90+ paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~")
91+ d.handleInput(sess, ac, paste)
92+
93+ require.Equal(t, paste, <-writes)
94+ releasePTY()
95+}
96+
97+func TestOverlayInputPrecedence(t *testing.T) {
98+ cases := []struct {
99+ name string
100+ setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame)
101+ input []byte
102+ check func(t *testing.T, ac *attachedClient, writes chan []byte)
103+ }{
104+ {
105+ name: "prompt before palette picker copy and normal",
106+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
107+ t.Helper()
108+ d.enterCopyMode(sess, ac)
109+ d.enterPicker(sess, ac)
110+ d.enterPalette(sess, ac)
111+ d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
112+ },
113+ input: []byte("x"),
114+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
115+ t.Helper()
116+ require.Equal(t, "x", ac.overlays.prompt.Value())
117+ require.Equal(t, "", ac.overlays.palette.Query())
118+ require.True(t, ac.overlays.pickerActive())
119+ require.True(t, ac.overlays.copyActive())
120+ requireNoPTYWrite(t, writes)
121+ },
122+ },
123+ {
124+ name: "palette before picker copy and normal",
125+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
126+ t.Helper()
127+ d.enterCopyMode(sess, ac)
128+ d.enterPicker(sess, ac)
129+ d.enterPalette(sess, ac)
130+ },
131+ input: []byte("x"),
132+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
133+ t.Helper()
134+ require.Equal(t, "x", ac.overlays.palette.Query())
135+ require.True(t, ac.overlays.pickerActive())
136+ require.True(t, ac.overlays.copyActive())
137+ requireNoPTYWrite(t, writes)
138+ },
139+ },
140+ {
141+ name: "picker before copy and normal",
142+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
143+ t.Helper()
144+ d.enterCopyMode(sess, ac)
145+ d.enterPicker(sess, ac)
146+ },
147+ input: []byte("j"),
148+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
149+ t.Helper()
150+ target, ok := ac.overlays.picker.Selected()
151+ require.True(t, ok)
152+ require.Equal(t, 1, target.TabIndex)
153+ require.True(t, ac.overlays.copyActive())
154+ requireNoPTYWrite(t, writes)
155+ },
156+ },
157+ {
158+ name: "copy before normal",
159+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
160+ t.Helper()
161+ d.enterCopyMode(sess, ac)
162+ },
163+ input: []byte("q"),
164+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
165+ t.Helper()
166+ require.False(t, ac.overlays.copyActive())
167+ requireNoPTYWrite(t, writes)
168+ },
169+ },
170+ {
171+ name: "normal keys when no overlay is active",
172+ input: []byte("Z"),
173+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
174+ t.Helper()
175+ require.Equal(t, []byte("Z"), <-writes)
176+ },
177+ },
178+ }
179+
180+ for _, tc := range cases {
181+ t.Run(tc.name, func(t *testing.T) {
182+ writes := make(chan []byte, 1)
183+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
184+ p2, releasePTY2 := newBlockingPTY(t)
185+ d, sess, ac, sends := newManualSessionWithPTYs(t, p, p2)
186+ defer releasePTY()
187+ defer releasePTY2()
188+ if tc.setup != nil {
189+ tc.setup(t, d, sess, ac, sends)
190+ }
191+
192+ d.handleInput(sess, ac, tc.input)
193+
194+ tc.check(t, ac, writes)
195+ })
196+ }
197+}
198+
199+func requireNoPTYWrite(t *testing.T, writes chan []byte) {
200+ t.Helper()
201+ select {
202+ case got := <-writes:
203+ t.Fatalf("input forwarded to PTY: %q", got)
204+ default:
205+ }
206+}
207+
79208 func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
80209 cases := []struct {
81210 name string
@@ -107,6 +236,354 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
107236 }
108237 }
109238
239+func TestPaletteBackForwardSession(t *testing.T) {
240+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
241+ defer releaseAll(releases)
242+ recent := d.sessions[domain.SessionID("recent")]
243+ older := d.sessions[domain.SessionID("older")]
244+
245+ runPaletteCommand(t, d, current, ac, "BSK")
246+ require.Same(t, recent, ac.currentSession())
247+
248+ runPaletteCommand(t, d, recent, ac, "BSK")
249+ require.Same(t, older, ac.currentSession())
250+
251+ runPaletteCommand(t, d, older, ac, "FSK")
252+ require.Same(t, recent, ac.currentSession())
253+
254+ runPaletteCommand(t, d, recent, ac, "FSK")
255+ require.Same(t, current, ac.currentSession())
256+}
257+
258+func TestPaletteBackForwardSessionBoundaries(t *testing.T) {
259+ t.Run("forward from current does not wrap", func(t *testing.T) {
260+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
261+ defer releaseAll(releases)
262+
263+ runPaletteCommand(t, d, current, ac, "FSK")
264+ require.Same(t, current, ac.currentSession())
265+ })
266+
267+ t.Run("back with no MRU does not move", func(t *testing.T) {
268+ p, release := newBlockingPTY(t)
269+ defer release()
270+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
271+
272+ runPaletteCommand(t, d, sess, ac, "BSK")
273+ require.Same(t, sess, ac.currentSession())
274+ })
275+
276+ t.Run("back at oldest does not wrap", func(t *testing.T) {
277+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
278+ defer releaseAll(releases)
279+ older := d.sessions[domain.SessionID("older")]
280+
281+ runPaletteCommand(t, d, current, ac, "BSK")
282+ runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
283+ require.Same(t, older, ac.currentSession())
284+
285+ runPaletteCommand(t, d, older, ac, "BSK")
286+ require.Same(t, older, ac.currentSession())
287+ })
288+}
289+
290+func TestHistoryNavDisplay(t *testing.T) {
291+ ids := []domain.SessionID{"current", "recent", "older"}
292+ var h historyNavDisplay
293+
294+ h.set(ids, 1, 7)
295+ ids[1] = "mutated"
296+ require.True(t, h.active())
297+ require.Equal(t, []domain.SessionID{domain.SessionID("current"), domain.SessionID("recent"), domain.SessionID("older")}, h.ids)
298+ require.Equal(t, 1, h.index)
299+ require.Equal(t, uint64(7), h.gen)
300+
301+ h.index = -1
302+ require.False(t, h.active())
303+ h.index = len(h.ids)
304+ require.False(t, h.active())
305+
306+ h.clear()
307+ require.False(t, h.active())
308+ require.Empty(t, h.ids)
309+ require.Zero(t, h.index)
310+ require.Equal(t, uint64(7), h.gen)
311+}
312+
313+func TestHistoryNavigationCommandDetectionUsesSlug(t *testing.T) {
314+ require.True(t, isHistoryNavigationCommand("back-session"))
315+ require.True(t, isHistoryNavigationCommand("forward-session"))
316+ require.False(t, isHistoryNavigationCommand("BSK"))
317+ require.False(t, isHistoryNavigationCommand("FSK"))
318+ require.False(t, isHistoryNavigationCommand("next-tab"))
319+}
320+
321+func TestHistoryNavActivation(t *testing.T) {
322+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
323+ defer releaseAll(releases)
324+ recent := d.sessions[domain.SessionID("recent")]
325+ older := d.sessions[domain.SessionID("older")]
326+
327+ runPaletteCommand(t, d, current, ac, "BSK")
328+ require.Same(t, recent, ac.currentSession())
329+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
330+ require.GreaterOrEqual(t, drainOutputFrames(sends), 2)
331+
332+ runPaletteCommand(t, d, recent, ac, "BSK")
333+ require.Same(t, older, ac.currentSession())
334+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 2)
335+
336+ runPaletteCommand(t, d, older, ac, "FSK")
337+ require.Same(t, recent, ac.currentSession())
338+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
339+}
340+
341+func TestHistoryNavBoundaryDoesNotActivate(t *testing.T) {
342+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
343+ defer releaseAll(releases)
344+
345+ runPaletteCommand(t, d, current, ac, "FSK")
346+
347+ require.Same(t, current, ac.currentSession())
348+ ac.historyNavMu.Lock()
349+ active := ac.historyNav.active()
350+ ac.historyNavMu.Unlock()
351+ require.False(t, active)
352+}
353+
354+func TestHistoryNavTimeoutExpires(t *testing.T) {
355+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
356+ defer releaseAll(releases)
357+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
358+ d.clock = clock
359+
360+ runPaletteCommand(t, d, current, ac, "BSK")
361+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
362+ timer := <-clock.timers
363+ drainOutputFrames(sends)
364+ timer.fire()
365+
366+ require.Eventually(t, func() bool {
367+ ac.historyNavMu.Lock()
368+ defer ac.historyNavMu.Unlock()
369+ return !ac.historyNav.active()
370+ }, time.Second, time.Millisecond)
371+ require.Eventually(t, func() bool { return drainOutputFrames(sends) > 0 }, time.Second, time.Millisecond)
372+}
373+
374+func TestHistoryNavStaleTimerCannotClearFreshActivation(t *testing.T) {
375+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
376+ defer releaseAll(releases)
377+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 2)}
378+ d.clock = clock
379+
380+ runPaletteCommand(t, d, current, ac, "BSK")
381+ staleTimer := <-clock.timers
382+ ac.clearHistoryNav()
383+ runPaletteCommand(t, d, ac.currentSession(), ac, "FSK")
384+ <-clock.timers
385+
386+ staleTimer.fire()
387+
388+ require.Eventually(t, func() bool {
389+ ac.historyNavMu.Lock()
390+ defer ac.historyNavMu.Unlock()
391+ return ac.historyNav.active() && ac.historyNav.index == 0 && ac.historyNav.gen > 1
392+ }, time.Second, time.Millisecond)
393+}
394+
395+func TestHistoryNavTimerStoppedOnDetachAndReplacement(t *testing.T) {
396+ t.Run("detach stops stale timer repaint", func(t *testing.T) {
397+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
398+ defer releaseAll(releases)
399+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
400+ d.clock = clock
401+
402+ runPaletteCommand(t, d, current, ac, "BSK")
403+ timer := <-clock.timers
404+ recent := ac.currentSession()
405+ d.clientGone(recent, ac, ac.transport(), true)
406+ drainOutputFrames(sends)
407+ timer.fire()
408+
409+ requireNoHistoryNav(t, ac)
410+ require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
411+ })
412+
413+ t.Run("replacement stops stale timer repaint", func(t *testing.T) {
414+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
415+ defer releaseAll(releases)
416+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
417+ d.clock = clock
418+
419+ runPaletteCommand(t, d, current, ac, "BSK")
420+ timer := <-clock.timers
421+ d.detachReplacedClient(ac)
422+ drainOutputFrames(sends)
423+ timer.fire()
424+
425+ requireNoHistoryNav(t, ac)
426+ require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
427+ })
428+}
429+
430+func TestHistoryNavClearsOnInvalidTrailAndNonHistorySwitch(t *testing.T) {
431+ t.Run("invalid frozen trail clears", func(t *testing.T) {
432+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
433+ defer releaseAll(releases)
434+
435+ runPaletteCommand(t, d, current, ac, "BSK")
436+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
437+ d.mu.Lock()
438+ delete(d.sessions, domain.SessionID("older"))
439+ d.mu.Unlock()
440+
441+ require.Nil(t, d.historyNavBarState(ac))
442+ requireNoHistoryNav(t, ac)
443+ })
444+
445+ t.Run("non history palette command clears frozen trail", func(t *testing.T) {
446+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
447+ defer releaseAll(releases)
448+
449+ runPaletteCommand(t, d, current, ac, "BSK")
450+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
451+ runPaletteCommand(t, d, ac.currentSession(), ac, "NXT")
452+
453+ requireNoHistoryNav(t, ac)
454+ })
455+}
456+
457+func requireHistoryNav(t *testing.T, ac *attachedClient, want []domain.SessionID, wantIndex int) {
458+ t.Helper()
459+ ac.historyNavMu.Lock()
460+ defer ac.historyNavMu.Unlock()
461+ require.True(t, ac.historyNav.active())
462+ require.Equal(t, want, ac.historyNav.ids)
463+ require.Equal(t, wantIndex, ac.historyNav.index)
464+}
465+
466+func requireNoHistoryNav(t *testing.T, ac *attachedClient) {
467+ t.Helper()
468+ ac.historyNavMu.Lock()
469+ defer ac.historyNavMu.Unlock()
470+ require.False(t, ac.historyNav.active())
471+}
472+
473+func drainOutputFrames(sends chan ports.Frame) int {
474+ count := 0
475+ for {
476+ select {
477+ case f := <-sends:
478+ if f.Type == ports.MsgOutput {
479+ count++
480+ }
481+ default:
482+ return count
483+ }
484+ }
485+}
486+
487+type historyNavTestClock struct {
488+ timers chan *historyNavTestTimer
489+}
490+
491+func (c *historyNavTestClock) Now() time.Time { return time.Time{} }
492+
493+func (c *historyNavTestClock) NewTimer(time.Duration) ports.Timer {
494+ t := &historyNavTestTimer{ch: make(chan time.Time, 1)}
495+ c.timers <- t
496+ return t
497+}
498+
499+type historyNavTestTimer struct {
500+ ch chan time.Time
501+}
502+
503+func (t *historyNavTestTimer) C() <-chan time.Time { return t.ch }
504+func (t *historyNavTestTimer) Reset(time.Duration) bool { return false }
505+func (t *historyNavTestTimer) Stop() bool { return true }
506+func (t *historyNavTestTimer) fire() { t.ch <- time.Time{} }
507+
508+func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {
509+ t.Run("forward skips a killed intermediate session", func(t *testing.T) {
510+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
511+ defer releaseAll(releases)
512+ recent := d.sessions[domain.SessionID("recent")]
513+ older := d.sessions[domain.SessionID("older")]
514+
515+ runPaletteCommand(t, d, current, ac, "BSK")
516+ runPaletteCommand(t, d, recent, ac, "BSK")
517+ require.Same(t, older, ac.currentSession())
518+
519+ d.mu.Lock()
520+ delete(d.sessions, recent.id)
521+ d.mu.Unlock()
522+
523+ runPaletteCommand(t, d, older, ac, "FSK")
524+ require.Same(t, current, ac.currentSession())
525+ })
526+
527+ t.Run("back from current refreshes newly recent sessions", func(t *testing.T) {
528+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
529+ defer releaseAll(releases)
530+ p, release := newBlockingPTY(t)
531+ defer release()
532+ newer := &session{id: "newer", name: "newer", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p, domain.Size{Cols: 80, Rows: 23})}}
533+ newer.mruAt.Store(25)
534+ d.mu.Lock()
535+ d.sessions[newer.id] = newer
536+ d.mu.Unlock()
537+
538+ runPaletteCommand(t, d, current, ac, "BSK")
539+ require.Same(t, newer, ac.currentSession())
540+ })
541+
542+ t.Run("failed switch does not advance navigator index", func(t *testing.T) {
543+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
544+ defer releaseAll(releases)
545+ current.mu.Lock()
546+ current.client = nil
547+ current.mu.Unlock()
548+
549+ runPaletteCommand(t, d, current, ac, "BSK")
550+
551+ require.Same(t, current, ac.currentSession())
552+ require.Zero(t, ac.recentNav.index)
553+ })
554+}
555+
556+func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
557+ t.Helper()
558+ p1, release1 := newBlockingPTY(t)
559+ p2, release2 := newBlockingPTY(t)
560+ p3, release3 := newBlockingPTY(t)
561+ d, current, ac, sends := newManualSessionWithPTYs(t, p1)
562+ current.id = "current"
563+ delete(d.sessions, domain.SessionID("manual"))
564+ d.sessions[current.id] = current
565+ recent := &session{id: "recent", name: "recent", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
566+ older := &session{id: "older", name: "older", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}
567+ d.sessions[recent.id] = recent
568+ d.sessions[older.id] = older
569+ current.mruAt.Store(30)
570+ recent.mruAt.Store(20)
571+ older.mruAt.Store(10)
572+ return d, current, ac, sends, []func(){release1, release2, release3}
573+}
574+
575+func runPaletteCommand(t *testing.T, d *Daemon, sess *session, ac *attachedClient, code string) {
576+ t.Helper()
577+ d.handleInput(sess, ac, []byte("\x1b "))
578+ d.handleInput(sess, ac, []byte(code+"\r"))
579+}
580+
581+func releaseAll(releases []func()) {
582+ for _, release := range releases {
583+ release()
584+ }
585+}
586+
110587 func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
111588 writes := make(chan []byte, 1)
112589 p1, releasePTY1 := newBlockingPTY(t)
@@ -154,12 +631,12 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
154631 awaitFrame(t, sends, ports.MsgOutput)
155632 d.handleInput(sess, ac, []byte("RNS\r"))
156633 awaitFrame(t, sends, ports.MsgOutput)
157- require.True(t, ac.promptActive())
634+ require.True(t, ac.overlays.promptActive())
158635
159636 d.handleInput(sess, ac, []byte("\r"))
160637 awaitFrame(t, sends, ports.MsgOutput)
161638
162- require.False(t, ac.promptActive())
639+ require.False(t, ac.overlays.promptActive())
163640 require.False(t, sess.ephemeral)
164641 require.Equal(t, "0", sess.name)
165642 }
@@ -195,22 +672,22 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
195672 p, _ := newBlockingPTYWithWrites(t, writes)
196673 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
197674 win := sess.tabs[0]
198- win.screen.Write([]byte("live"))
675+ win.focusedPane().screen.Write([]byte("live"))
199676
200677 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
201678 data := mustOutputData(t, sends)
202- require.NotNil(t, ac.copyMode)
203- require.Equal(t, 19, ac.copyMode.Cursor)
204- require.Contains(t, string(data), "[VISUAL]")
679+ require.NotNil(t, ac.overlays.copyMode)
680+ require.Equal(t, 19, ac.overlays.copyMode.Cursor)
681+ require.Contains(t, string(data), "[SCROLL]")
205682
206683 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
207684 mustOutputData(t, sends)
208- require.Nil(t, ac.copyMode)
685+ require.Nil(t, ac.overlays.copyMode)
209686
210687 d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))
211688 mustOutputData(t, sends)
212689 mustOutputData(t, sends)
213- require.Nil(t, ac.copyMode, "q after wheel in same input must be routed after copy mode is entered")
690+ require.Nil(t, ac.overlays.copyMode, "q after wheel in same input must be routed after copy mode is entered")
214691 select {
215692 case got := <-writes:
216693 t.Fatalf("mouse/copy input forwarded to PTY: %q", got)
@@ -222,14 +699,14 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
222699 writes := make(chan []byte, 2)
223700 p, _ := newBlockingPTYWithWrites(t, writes)
224701 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
225- sess.tabs[0].screen.Write([]byte("\x1b[?1049h"))
702+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1049h"))
226703
227704 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
228705 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
229706
230707 require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)
231708 require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)
232- require.Nil(t, ac.copyMode)
709+ require.Nil(t, ac.overlays.copyMode)
233710 }
234711
235712 func TestSGRRowOffset(t *testing.T) {
@@ -270,9 +747,9 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
270747 t.Fatalf("press without child mouse mode forwarded: %q", got)
271748 default:
272749 }
273- require.Nil(t, ac.copyMode, "press alone must not enter visual mode")
750+ require.Nil(t, ac.overlays.copyMode, "press alone must not enter visual mode")
274751
275- sess.tabs[0].screen.Write([]byte("\x1b[?1000h"))
752+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1000h"))
276753 raw := []byte("\x1b[<0;2;3M")
277754 d.handleInput(sess, ac, raw)
278755 select {
@@ -281,7 +758,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
281758 default:
282759 }
283760
284- sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
761+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h"))
285762 d.handleInput(sess, ac, raw)
286763 require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes)
287764
@@ -300,13 +777,13 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
300777 default:
301778 }
302779
303- sess.tabs[0].screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
780+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
304781 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
305- require.Nil(t, ac.copyMode, "press alone must still not enter visual mode")
782+ require.Nil(t, ac.overlays.copyMode, "press alone must still not enter visual mode")
306783 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
307784 mustOutputData(t, sends)
308- require.NotNil(t, ac.copyMode)
309- lo, hi, ok := ac.copyMode.SelectedBounds()
785+ require.NotNil(t, ac.overlays.copyMode)
786+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
310787 require.True(t, ok)
311788 require.Equal(t, 0, lo)
312789 require.Equal(t, 2, hi)
@@ -315,16 +792,16 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
315792 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
316793 p, _ := newBlockingPTY(t)
317794 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
318- copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha"))
319- copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo"))
320- copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie"))
795+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
796+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
797+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
321798
322799 d.enterCopyMode(sess, ac)
323800 mustOutputData(t, sends)
324801 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
325802 mustOutputData(t, sends)
326- require.NotNil(t, ac.copyMode)
327- lo, hi, ok := ac.copyMode.SelectedBounds()
803+ require.NotNil(t, ac.overlays.copyMode)
804+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
328805 require.True(t, ok)
329806 require.Equal(t, 0, lo)
330807 require.Equal(t, 1, hi)
@@ -338,7 +815,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
338815 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))
339816 require.NoError(t, err)
340817 require.Equal(t, "alpha\nbravo", string(decoded))
341- require.Nil(t, ac.copyMode)
818+ require.Nil(t, ac.overlays.copyMode)
342819
343820 exitPaint := string(mustOutputData(t, sends))
344821 require.NotContains(t, exitPaint, "[SELECT]")
@@ -354,11 +831,11 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
354831 func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
355832 p, _ := newBlockingPTY(t)
356833 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
357- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")
834+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
358835
359836 // Press on a content row establishes a (soon to be stale) anchor.
360837 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
361- require.Nil(t, ac.copyMode)
838+ require.Nil(t, ac.overlays.copyMode)
362839
363840 // Release lands on the status row: must still clear the press state,
364841 // regardless of the row it landed on.
@@ -371,7 +848,7 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
371848 // Motion onto a content row must not resurrect a stale anchor.
372849 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
373850
374- require.Nil(t, ac.copyMode, "stale press state must not resurrect a selection")
851+ require.Nil(t, ac.overlays.copyMode, "stale press state must not resurrect a selection")
375852 }
376853
377854 // TestCopyModeStatusRowPressClearsDragState covers the copy-mode counterpart
@@ -383,10 +860,10 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
383860 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
384861 p, _ := newBlockingPTY(t)
385862 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
386- copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha"))
387- copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo"))
388- copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie"))
389- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")
863+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
864+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
865+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
866+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
390867
391868 d.enterCopyMode(sess, ac)
392869 mustOutputData(t, sends)
@@ -394,8 +871,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
394871 // Full drag: press row0, motion to row1 -> selection [0,1].
395872 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
396873 mustOutputData(t, sends)
397- require.NotNil(t, ac.copyMode)
398- lo, hi, ok := ac.copyMode.SelectedBounds()
874+ require.NotNil(t, ac.overlays.copyMode)
875+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
399876 require.True(t, ok)
400877 require.Equal(t, 0, lo)
401878 require.Equal(t, 1, hi)
@@ -408,8 +885,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
408885 // have cleared copyPressRowValid/copyDragging.
409886 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
410887
411- require.NotNil(t, ac.copyMode)
412- lo, hi, ok = ac.copyMode.SelectedBounds()
888+ require.NotNil(t, ac.overlays.copyMode)
889+ lo, hi, ok = ac.overlays.copyMode.SelectedBounds()
413890 require.True(t, ok)
414891 require.Equal(t, 0, lo, "anchor must not have moved")
415892 require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op")
@@ -423,8 +900,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
423900 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
424901 p, _ := newBlockingPTY(t)
425902 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
426- sess.tabs[0].scrollback = scopy.NewScrollback(50)
427- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")
903+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50)
904+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
428905
429906 // Press on a content row while scrollback is empty: the anchor is
430907 // content-stable at pressTop(0)+pressRow(0) = row 0.
@@ -432,8 +909,8 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
432909
433910 // Simulate 5 lines evicted into scrollback between the Press and the
434911 // first Motion (e.g. the child kept producing output).
435- for i := 0; i < 5; i++ {
436- sess.tabs[0].scrollback.Append(testRow("evicted"))
912+ for range 5 {
913+ sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted"))
437914 }
438915
439916 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).
@@ -442,8 +919,8 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
442919 // pressTop(0)+ev.Row(2) = 2.
443920 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
444921
445- require.NotNil(t, ac.copyMode)
446- lo, hi, ok := ac.copyMode.SelectedBounds()
922+ require.NotNil(t, ac.overlays.copyMode)
923+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
447924 require.True(t, ok)
448925 require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time")
449926 require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset")
@@ -453,17 +930,158 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
453930 writes := make(chan []byte, 2)
454931 p, _ := newBlockingPTYWithWrites(t, writes)
455932 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
456- sess.tabs[0].screen.Write([]byte("live"))
933+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
457934
458935 d.handleInput(sess, ac, []byte("\x1b[<64;"))
459936 d.handleInput(sess, ac, []byte("1;1Mq"))
460937
461938 mustOutputData(t, sends)
462939 mustOutputData(t, sends)
463- require.Nil(t, ac.copyMode)
940+ require.Nil(t, ac.overlays.copyMode)
464941 select {
465942 case got := <-writes:
466943 t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)
467944 default:
468945 }
469946 }
947+
948+func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing.T) {
949+ p1 := portsmocks.NewMockPTY(t)
950+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
951+ p2 := portsmocks.NewMockPTY(t)
952+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
953+ p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()
954+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
955+ d.procComm = nil
956+ tb := sess.activeTab()
957+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
958+ p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
959+ tb.mu.Lock()
960+ tb.size = domain.Size{Cols: 41, Rows: 5}
961+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
962+ tb.tree.Focus = "pane-1"
963+ tb.panes["pane-2"] = p2pane
964+ tb.mu.Unlock()
965+
966+ d.handleInput(sess, ac, []byte("\x1b[<64;22;2M"))
967+
968+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
969+}
970+
971+func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
972+ cases := []struct {
973+ name string
974+ raw []byte
975+ root *layout.Node
976+ size domain.Size
977+ }{
978+ {
979+ name: "divider",
980+ raw: []byte("\x1b[<0;21;2M"),
981+ root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}},
982+ size: domain.Size{Cols: 41, Rows: 5},
983+ },
984+ {
985+ name: "expanded title bar",
986+ raw: []byte("\x1b[<0;1;1M"),
987+ root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"},
988+ size: domain.Size{Cols: 20, Rows: 5},
989+ },
990+ }
991+ for _, tc := range cases {
992+ t.Run(tc.name, func(t *testing.T) {
993+ p1 := portsmocks.NewMockPTY(t)
994+ p1.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
995+ p2 := portsmocks.NewMockPTY(t)
996+ p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
997+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
998+ d.procComm = nil
999+ tb := sess.activeTab()
1000+ tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1001+ tb.mu.Lock()
1002+ tb.size = tc.size
1003+ tb.tree.Root = tc.root
1004+ tb.tree.Focus = "pane-1"
1005+ tb.panes["pane-2"] = newPane("pane-2", p2, tc.size)
1006+ tb.mu.Unlock()
1007+
1008+ d.handleInput(sess, ac, tc.raw)
1009+
1010+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
1011+ })
1012+ }
1013+}
1014+
1015+func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
1016+ p1 := portsmocks.NewMockPTY(t)
1017+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
1018+ p2 := portsmocks.NewMockPTY(t)
1019+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
1020+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1021+ d.procComm = nil
1022+ tb := sess.activeTab()
1023+ for i := range 10 {
1024+ copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i))))
1025+ }
1026+ tb.mu.Lock()
1027+ tb.size = domain.Size{Cols: 41, Rows: 10}
1028+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1029+ tb.tree.Focus = "pane-1"
1030+ tb.panes["pane-2"] = newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 10})
1031+ tb.mu.Unlock()
1032+ d.enterCopyMode(sess, ac)
1033+ mustOutputData(t, sends)
1034+
1035+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M"))
1036+ mustOutputData(t, sends)
1037+
1038+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
1039+ require.True(t, ok)
1040+ require.Equal(t, 0, lo)
1041+ require.Equal(t, 9, hi)
1042+}
1043+
1044+func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
1045+ p1 := portsmocks.NewMockPTY(t)
1046+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1047+ p2 := portsmocks.NewMockPTY(t)
1048+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1049+ p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
1050+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1051+ d.procComm = nil
1052+ tb := sess.activeTab()
1053+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1054+ p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1055+ tb.mu.Lock()
1056+ tb.size = domain.Size{Cols: 41, Rows: 5}
1057+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1058+ tb.tree.Focus = "pane-1"
1059+ tb.panes["pane-2"] = p2pane
1060+ tb.mu.Unlock()
1061+
1062+ d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1063+
1064+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
1065+}
1066+
1067+func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
1068+ p1 := portsmocks.NewMockPTY(t)
1069+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe()
1070+ p2 := portsmocks.NewMockPTY(t)
1071+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe()
1072+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1073+ d.procComm = nil
1074+ tb := sess.activeTab()
1075+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3})
1076+ tb.mu.Lock()
1077+ tb.size = domain.Size{Cols: 20, Rows: 5}
1078+ tb.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"}
1079+ tb.tree.Focus = "pane-1"
1080+ tb.panes["pane-2"] = p2pane
1081+ tb.mu.Unlock()
1082+
1083+ d.handleInput(sess, ac, []byte("\x1b[<0;1;6M"))
1084+
1085+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
1086+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
1087+}
A internal/usecase/daemon/modal_compose.go +18 -0
@@ -0,0 +1,18 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/ui"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeStyles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) {
10+ inner := modal.Composite(base, styles.border)
11+ modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle)
12+ for y := range min(inner.Height, modalFrame.Height) {
13+ for x := range min(inner.Width, modalFrame.Width) {
14+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
15+ }
16+ }
17+ return base, []renderer.Damage{renderer.FullRedraw()}
18+}
A internal/usecase/daemon/mouse_panes.go +152 -0
@@ -0,0 +1,152 @@
1+package daemon
2+
3+import (
4+ "strconv"
5+ "strings"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/layout"
9+ "github.com/bnema/vev/internal/usecase/mouse"
10+)
11+
12+func focusedPlacementLocked(tb *tab) (layout.Placement, bool) {
13+ if tb == nil || tb.tree == nil {
14+ return layout.Placement{}, false
15+ }
16+ placements, ok := solvedPlacementsLocked(tb)
17+ if !ok {
18+ return layout.Placement{}, false
19+ }
20+ for _, pl := range placements {
21+ if pl.ID == tb.tree.Focus {
22+ return pl, true
23+ }
24+ }
25+ return layout.Placement{}, false
26+}
27+
28+func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) {
29+ if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {
30+ return layout.Placement{}, false
31+ }
32+ placements, ok := solvedPlacementsLocked(tb)
33+ if !ok {
34+ return layout.Placement{}, false
35+ }
36+ for _, pl := range placements {
37+ if pointInRect(col, row, pl.TitleBar) || pointInRect(col, row, pl.Content) {
38+ return pl, true
39+ }
40+ }
41+ return layout.Placement{}, false
42+}
43+
44+type tabLayoutSnapshot struct {
45+ root *layout.Node
46+ fingerprint string
47+ area domain.Rect
48+ focus layout.PaneID
49+ placements []layout.Placement
50+ ok bool
51+}
52+
53+func solvedPlacementsLocked(tb *tab) ([]layout.Placement, bool) {
54+ snap := solveTabLayoutLocked(tb)
55+ return snap.placements, snap.ok
56+}
57+
58+func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
59+ if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {
60+ return tabLayoutSnapshot{}
61+ }
62+ area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
63+ placements, ok := layout.Solve(tb.tree.Root, area)
64+ return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}
65+}
66+
67+func (snap tabLayoutSnapshot) matchesLocked(tb *tab) bool {
68+ return snap.ok && tb != nil && tb.tree != nil && tb.tree.Root == snap.root && tb.tree.Focus == snap.focus && tb.size.Cols == snap.area.Width && tb.size.Rows == snap.area.Height && snap.fingerprint == layoutFingerprint(tb.tree.Root)
69+}
70+
71+func layoutFingerprint(root *layout.Node) string {
72+ var b strings.Builder
73+ writeLayoutFingerprint(&b, root)
74+ return b.String()
75+}
76+
77+func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {
78+ if n == nil {
79+ b.WriteByte('0')
80+ return
81+ }
82+ b.WriteByte(byte('0' + n.Kind))
83+ b.WriteByte(byte('0' + n.Dir))
84+ writePaneIDFingerprint(b, n.Leaf)
85+ b.WriteByte('|')
86+ writePaneIDFingerprint(b, n.Expanded)
87+ b.WriteByte('[')
88+ for _, child := range n.Children {
89+ writeLayoutFingerprint(b, child)
90+ b.WriteByte(',')
91+ }
92+ b.WriteByte(']')
93+}
94+
95+func writePaneIDFingerprint(b *strings.Builder, id layout.PaneID) {
96+ s := string(id)
97+ b.WriteString(strconv.Itoa(len(s)))
98+ b.WriteByte(':')
99+ b.WriteString(s)
100+}
101+
102+func pointInRect(col, row int, r domain.Rect) bool {
103+ return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height
104+}
105+
106+func focusPlacementLocked(tb *tab, id layout.PaneID) {
107+ if tb == nil || tb.tree == nil {
108+ return
109+ }
110+ tb.tree.Focus = id
111+ setExpandedLocked(tb.tree.Root, id)
112+}
113+
114+func setExpandedLocked(n *layout.Node, id layout.PaneID) bool {
115+ if n == nil {
116+ return false
117+ }
118+ if n.Kind == layout.Stack {
119+ for _, child := range n.Children {
120+ if child.Kind == layout.Leaf && child.Leaf == id {
121+ n.Expanded = id
122+ return true
123+ }
124+ }
125+ }
126+ for _, child := range n.Children {
127+ if setExpandedLocked(child, id) {
128+ return true
129+ }
130+ }
131+ return false
132+}
133+
134+func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event {
135+ ev.Col -= colOffset
136+ ev.Row -= contentYOffset
137+ ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1)
138+ return ev
139+}
140+
141+func (d *Daemon) exitCopyMode(ac *attachedClient) {
142+ if ac == nil {
143+ return
144+ }
145+ rt := ac.overlays
146+ rt.copyMu.Lock()
147+ rt.clearCopyModeLocked()
148+ rt.copyPressRowValid = false
149+ rt.copyDragging = false
150+ rt.normalMousePressValid = false
151+ rt.copyMu.Unlock()
152+}
A internal/usecase/daemon/nil_pty_test.go +66 -0
@@ -0,0 +1,66 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+)
15+
16+func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
17+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
18+ ctx, cancel := context.WithCancel(context.Background())
19+ defer cancel()
20+ sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel}
21+ first := newTab(nil, domain.Size{Cols: 41, Rows: 10})
22+ second := newTab(nil, domain.Size{Cols: 41, Rows: 10})
23+ sess.tabs = []*tab{first, second}
24+
25+ require.NotPanics(t, func() {
26+ d.resizePane(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
27+ })
28+ require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
29+ require.Equal(t, 5, first.focusedPane().screen.Frame.Height)
30+
31+ first.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
32+ first.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
33+ require.NotPanics(t, func() {
34+ require.NoError(t, d.closePane(sess, first, "pane-2", nil, false))
35+ })
36+ require.NotContains(t, first.panes, layout.PaneID("pane-2"))
37+
38+ require.NotPanics(t, func() { d.closeTab(sess, second, false) })
39+ require.Len(t, sess.tabs, 1)
40+}
41+
42+func TestKillSessionIgnoresNilPanePTYs(t *testing.T) {
43+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
44+ ctx, cancel := context.WithCancel(context.Background())
45+ sess := &session{id: "s", name: "s", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 10, Rows: 3})}}
46+ d.sessions[sess.id] = sess
47+
48+ require.NotPanics(t, func() {
49+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
50+ })
51+ require.NotContains(t, d.sessions, sess.id)
52+}
53+
54+func TestNilPTYInputPathsDoNotPanic(t *testing.T) {
55+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
56+ ctx, cancel := context.WithCancel(context.Background())
57+ defer cancel()
58+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
59+ sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel, tabs: []*tab{tb}}
60+ ac := &attachedClient{}
61+ ac.initOverlays()
62+ ac.setSession(sess)
63+
64+ require.NotPanics(t, func() { d.writeToPane(sess, tb.focusedPane(), []byte("x")) })
65+ require.NotPanics(t, func() { daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x")) })
66+}
A internal/usecase/daemon/overlay_runtime.go +225 -0
@@ -0,0 +1,225 @@
1+package daemon
2+
3+import (
4+ "sync"
5+
6+ scopy "github.com/bnema/vev/internal/usecase/copy"
7+ "github.com/bnema/vev/internal/usecase/palette"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+ promptui "github.com/bnema/vev/internal/usecase/prompt"
10+ "github.com/bnema/vev/internal/usecase/visualsearch"
11+)
12+
13+type overlayRuntime struct {
14+ ac *attachedClient
15+
16+ pickerMu sync.Mutex
17+ picker *picker.Model
18+ pickerPreview *tab
19+ pickerPending []byte
20+ pickerESC pendingByteTimer
21+
22+ paletteMu sync.Mutex
23+ palette *palette.Model
24+ palettePending []byte
25+
26+ promptMu sync.Mutex
27+ prompt *promptui.Model
28+ promptSubmit func(string) error
29+ promptPending []byte
30+
31+ copyMu sync.Mutex
32+ copyMode *scopy.Mode
33+ copyPending []byte
34+ copyESC pendingByteTimer
35+ copySearch *visualsearch.Model
36+ copySearchPending []byte
37+ copyFeedback string
38+ copyPressRow int
39+ copyPressRowValid bool
40+ copyDragging bool
41+ normalMousePressRow int
42+ normalMousePressTop int
43+ normalMousePressValid bool
44+}
45+
46+func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
47+ return &overlayRuntime{ac: ac}
48+}
49+
50+func (rt *overlayRuntime) Active() bool {
51+ if rt == nil || rt.ac == nil {
52+ return false
53+ }
54+ return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.copyActive()
55+}
56+
57+func (rt *overlayRuntime) promptActive() bool {
58+ if rt == nil {
59+ return false
60+ }
61+ rt.promptMu.Lock()
62+ defer rt.promptMu.Unlock()
63+ return rt.prompt != nil
64+}
65+
66+func (rt *overlayRuntime) paletteActive() bool {
67+ if rt == nil {
68+ return false
69+ }
70+ rt.paletteMu.Lock()
71+ defer rt.paletteMu.Unlock()
72+ return rt.palette != nil
73+}
74+
75+func (rt *overlayRuntime) pickerActive() bool {
76+ if rt == nil {
77+ return false
78+ }
79+ rt.pickerMu.Lock()
80+ defer rt.pickerMu.Unlock()
81+ return rt.picker != nil
82+}
83+
84+func (rt *overlayRuntime) copyActive() bool {
85+ if rt == nil {
86+ return false
87+ }
88+ rt.copyMu.Lock()
89+ defer rt.copyMu.Unlock()
90+ return rt.copyMode != nil
91+}
92+
93+func (rt *overlayRuntime) copySearchActive() bool {
94+ if rt == nil {
95+ return false
96+ }
97+ rt.copyMu.Lock()
98+ defer rt.copyMu.Unlock()
99+ return rt.copySearch != nil
100+}
101+
102+func (rt *overlayRuntime) clearCopyModeLocked() {
103+ rt.copyMode = nil
104+ rt.copySearch = nil
105+ rt.copySearchPending = nil
106+}
107+
108+func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
109+ if rt == nil || rt.ac == nil {
110+ return false
111+ }
112+ ac := rt.ac
113+ if rt.promptActive() {
114+ d.handlePromptInput(ac, data)
115+ return true
116+ }
117+ if rt.paletteActive() {
118+ d.handlePaletteInput(ac, data)
119+ return true
120+ }
121+ if rt.pickerActive() {
122+ d.handlePickerInput(ac, data)
123+ return true
124+ }
125+ if rt.copyActive() {
126+ d.handleCopyInput(ac, data)
127+ return true
128+ }
129+ return false
130+}
131+
132+type overlayRenderSnapshot struct {
133+ rt *overlayRuntime
134+
135+ copyActive bool
136+ copyMode *scopy.Mode
137+ copySearchModel *visualsearch.Model
138+ copyFeedback string
139+
140+ pickerActive bool
141+ pickerModel *picker.Model
142+ previewTab *tab
143+
144+ paletteActive bool
145+ paletteModel *palette.Model
146+ paletteLocked bool
147+
148+ promptActive bool
149+ promptModel *promptui.Model
150+ promptLocked bool
151+}
152+
153+// SnapshotForRender captures the overlay state needed by paint.
154+//
155+// The snapshot owns any prompt or palette locks it had to keep held so the
156+// returned model pointers stay stable during composition. Callers must release
157+// those locks with overlayRenderSnapshot.Unlock, usually with defer immediately
158+// after acquisition and before any path that may re-enter rendering.
159+func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
160+ snap := &overlayRenderSnapshot{rt: rt}
161+ if rt == nil {
162+ return snap
163+ }
164+
165+ rt.copyMu.Lock()
166+ snap.copyActive = rt.copyMode != nil
167+ if rt.copyMode != nil {
168+ copyModeValue := *rt.copyMode
169+ copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
170+ snap.copyMode = &copyModeValue
171+ }
172+ snap.copySearchModel = rt.copySearch.Clone()
173+ snap.copyFeedback = rt.copyFeedback
174+ if snap.copyFeedback != "" && !snap.copyActive {
175+ rt.copyFeedback = ""
176+ }
177+ rt.copyMu.Unlock()
178+
179+ rt.pickerMu.Lock()
180+ snap.pickerActive = rt.picker != nil
181+ snap.pickerModel = rt.picker.Clone()
182+ snap.previewTab = rt.pickerPreview
183+ rt.pickerMu.Unlock()
184+
185+ rt.paletteMu.Lock()
186+ snap.paletteModel = rt.palette
187+ snap.paletteActive = snap.paletteModel != nil
188+ if snap.paletteActive {
189+ snap.paletteLocked = true
190+ } else {
191+ rt.paletteMu.Unlock()
192+ }
193+
194+ rt.promptMu.Lock()
195+ snap.promptModel = rt.prompt
196+ snap.promptActive = snap.promptModel != nil
197+ if snap.promptActive {
198+ snap.promptLocked = true
199+ } else {
200+ rt.promptMu.Unlock()
201+ }
202+
203+ return snap
204+}
205+
206+func (rt *overlayRuntime) UnlockRenderSnapshot(snap *overlayRenderSnapshot) {
207+ if snap == nil {
208+ return
209+ }
210+ snap.Unlock()
211+}
212+
213+func (snap *overlayRenderSnapshot) Unlock() {
214+ if snap == nil || snap.rt == nil {
215+ return
216+ }
217+ if snap.promptLocked {
218+ snap.rt.promptMu.Unlock()
219+ snap.promptLocked = false
220+ }
221+ if snap.paletteLocked {
222+ snap.rt.paletteMu.Unlock()
223+ snap.paletteLocked = false
224+ }
225+}
A internal/usecase/daemon/overlay_runtime_test.go +145 -0
@@ -0,0 +1,145 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ scopy "github.com/bnema/vev/internal/usecase/copy"
9+ promptui "github.com/bnema/vev/internal/usecase/prompt"
10+ "github.com/bnema/vev/internal/usecase/visualsearch"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+func TestOverlayRuntimeActive(t *testing.T) {
15+ ac := &attachedClient{}
16+ ac.initOverlays()
17+ require.False(t, ac.overlays.Active())
18+
19+ ac.overlays.promptMu.Lock()
20+ ac.overlays.prompt = promptui.New(" Test ", "")
21+ ac.overlays.promptMu.Unlock()
22+ require.True(t, ac.overlays.Active())
23+}
24+
25+func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
26+ cases := []struct {
27+ name string
28+ setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
29+ input []byte
30+ check func(t *testing.T, ac *attachedClient)
31+ }{
32+ {
33+ name: "prompt before palette picker copy",
34+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
35+ t.Helper()
36+ d.enterCopyMode(sess, ac)
37+ d.enterPicker(sess, ac)
38+ d.enterPalette(sess, ac)
39+ d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
40+ },
41+ input: []byte("x"),
42+ check: func(t *testing.T, ac *attachedClient) {
43+ t.Helper()
44+ require.Equal(t, "x", ac.overlays.prompt.Value())
45+ require.Equal(t, "", ac.overlays.palette.Query())
46+ require.True(t, ac.overlays.pickerActive())
47+ require.True(t, ac.overlays.copyActive())
48+ },
49+ },
50+ {
51+ name: "palette before picker copy",
52+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
53+ t.Helper()
54+ d.enterCopyMode(sess, ac)
55+ d.enterPicker(sess, ac)
56+ d.enterPalette(sess, ac)
57+ },
58+ input: []byte("x"),
59+ check: func(t *testing.T, ac *attachedClient) {
60+ t.Helper()
61+ require.Equal(t, "x", ac.overlays.palette.Query())
62+ require.True(t, ac.overlays.pickerActive())
63+ require.True(t, ac.overlays.copyActive())
64+ },
65+ },
66+ {
67+ name: "picker before copy",
68+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
69+ t.Helper()
70+ d.enterCopyMode(sess, ac)
71+ d.enterPicker(sess, ac)
72+ },
73+ input: []byte("j"),
74+ check: func(t *testing.T, ac *attachedClient) {
75+ t.Helper()
76+ target, ok := ac.overlays.picker.Selected()
77+ require.True(t, ok)
78+ require.Equal(t, 1, target.TabIndex)
79+ require.True(t, ac.overlays.copyActive())
80+ },
81+ },
82+ {
83+ name: "copy",
84+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
85+ t.Helper()
86+ d.enterCopyMode(sess, ac)
87+ },
88+ input: []byte("q"),
89+ check: func(t *testing.T, ac *attachedClient) {
90+ t.Helper()
91+ require.False(t, ac.overlays.copyActive())
92+ },
93+ },
94+ }
95+
96+ for _, tc := range cases {
97+ t.Run(tc.name, func(t *testing.T) {
98+ p1, releasePTY1 := newBlockingPTY(t)
99+ p2, releasePTY2 := newBlockingPTY(t)
100+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
101+ defer releasePTY1()
102+ defer releasePTY2()
103+ tc.setup(t, d, sess, ac)
104+
105+ require.True(t, ac.overlays.HandleInput(d, tc.input))
106+
107+ tc.check(t, ac)
108+ })
109+ }
110+}
111+
112+func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) {
113+ ac := &attachedClient{}
114+ ac.initOverlays()
115+ snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, Width: 16, Height: 2}
116+ mode := scopy.NewMode(snap)
117+ require.True(t, mode.Search(snap, "alpha"))
118+ search := visualsearch.New(snap)
119+ for _, r := range "alpha" {
120+ search.Insert(r)
121+ }
122+
123+ ac.overlays.copyMu.Lock()
124+ ac.overlays.copyMode = mode
125+ ac.overlays.copySearch = search
126+ ac.overlays.copyMu.Unlock()
127+
128+ renderSnap := ac.overlays.SnapshotForRender()
129+ defer renderSnap.Unlock()
130+
131+ ac.overlays.copyMu.Lock()
132+ ac.overlays.copyMode.Searches[0].Row = 99
133+ ac.overlays.copySearch.Insert('z')
134+ ac.overlays.copyMu.Unlock()
135+
136+ require.Equal(t, 0, renderSnap.copyMode.Searches[0].Row)
137+ require.Equal(t, "alpha", renderSnap.copySearchModel.Query())
138+}
139+
140+func TestOverlayRuntimeHandleInputInactive(t *testing.T) {
141+ ac := &attachedClient{}
142+ ac.initOverlays()
143+
144+ require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))
145+}
M internal/usecase/daemon/overlay_test.go +96 -89
@@ -6,96 +6,103 @@ import (
66 "github.com/stretchr/testify/require"
77 )
88
9-func TestRouteOverlayBytesHandlesTextAndSplitUTF8(t *testing.T) {
10- var pending []byte
11- var got []rune
12- ev := overlayEvents{rune: func(r rune) { got = append(got, r) }}
13-
14- routeOverlayBytes([]byte{'w', 0xc3}, &pending, ev)
15- require.Equal(t, []rune{'w'}, got)
16- require.Equal(t, []byte{0xc3}, pending)
17-
18- routeOverlayBytes([]byte{0xb8, 'k'}, &pending, ev)
19- require.Equal(t, []rune{'w', 'ø', 'k'}, got)
20- require.Empty(t, pending)
21-}
22-
23-func TestRouteOverlayBytesHandlesSplitArrowsAndCtrlNavigation(t *testing.T) {
24- var pending []byte
25- var up, down int
26- ev := overlayEvents{
27- up: func() { up++ },
28- down: func() { down++ },
29- }
30-
31- routeOverlayBytes([]byte("\x1b["), &pending, ev)
32- require.Equal(t, []byte("\x1b["), pending)
33- routeOverlayBytes([]byte("B"), &pending, ev)
34- require.Equal(t, 1, down)
35- require.Empty(t, pending)
36-
37- routeOverlayBytes([]byte("\x1b[A\x0e\x10"), &pending, ev)
38- require.Equal(t, 2, up)
39- require.Equal(t, 2, down)
40-}
41-
42-func TestRouteOverlayBytesHandlesSS3Arrows(t *testing.T) {
43- var pending []byte
44- var up, down int
45- ev := overlayEvents{
46- up: func() { up++ },
47- down: func() { down++ },
9+func TestRouteOverlayBytes(t *testing.T) {
10+ t.Parallel()
11+
12+ tests := []struct {
13+ name string
14+ chunks [][]byte
15+ wantRunes []rune
16+ wantPending []byte
17+ wantBackspace int
18+ wantEnter int
19+ wantCancel int
20+ wantUp int
21+ wantDown int
22+ }{
23+ {
24+ name: "text and split utf8",
25+ chunks: [][]byte{{'w', 0xc3}, {0xb8, 'k'}},
26+ wantRunes: []rune{'w', 'ø', 'k'},
27+ wantPending: nil,
28+ },
29+ {
30+ name: "incomplete utf8 remains pending",
31+ chunks: [][]byte{{'w', 0xc3}},
32+ wantRunes: []rune{'w'},
33+ wantPending: []byte{0xc3},
34+ },
35+ {
36+ name: "incomplete csi resumes as arrow",
37+ chunks: [][]byte{[]byte("\x1b["), []byte("B")},
38+ wantDown: 1,
39+ },
40+ {
41+ name: "incomplete csi remains pending",
42+ chunks: [][]byte{[]byte("\x1b[")},
43+ wantPending: []byte("\x1b["),
44+ },
45+ {
46+ name: "incomplete ss3 resumes as arrow",
47+ chunks: [][]byte{[]byte("\x1bO"), []byte("A\x1bOB")},
48+ wantUp: 1,
49+ wantDown: 1,
50+ },
51+ {
52+ name: "incomplete ss3 remains pending",
53+ chunks: [][]byte{[]byte("\x1bO")},
54+ wantPending: []byte("\x1bO"),
55+ },
56+ {
57+ name: "arrows and ctrl navigation",
58+ chunks: [][]byte{[]byte("\x1b[A\x1b[B\x0e\x10")},
59+ wantUp: 2,
60+ wantDown: 2,
61+ },
62+ {
63+ name: "unhandled escapes are consumed",
64+ chunks: [][]byte{[]byte("a\x1b[D\x1b[3~b\x1bOQ")},
65+ wantRunes: []rune{'a', 'b'},
66+ },
67+ {
68+ name: "submit cancel and editing",
69+ chunks: [][]byte{{0x7f, 0x08, '\r', '\n', 0x1b, 0x03}},
70+ wantBackspace: 2,
71+ wantEnter: 2,
72+ wantCancel: 2,
73+ },
74+ {
75+ name: "controls and invalid utf8 ignored",
76+ chunks: [][]byte{{'a', 0x01, 0xff, 'b'}},
77+ wantRunes: []rune{'a', 'b'},
78+ },
4879 }
4980
50- routeOverlayBytes([]byte("\x1bO"), &pending, ev)
51- require.Equal(t, []byte("\x1bO"), pending)
52- routeOverlayBytes([]byte("A\x1bOB"), &pending, ev)
53-
54- require.Equal(t, 1, up)
55- require.Equal(t, 1, down)
56- require.Empty(t, pending)
57-}
58-
59-func TestRouteOverlayBytesConsumesUnhandledEscapeSequences(t *testing.T) {
60- var pending []byte
61- var got []rune
62- var cancel int
63- ev := overlayEvents{
64- rune: func(r rune) { got = append(got, r) },
65- cancel: func() { cancel++ },
81+ for _, tt := range tests {
82+ t.Run(tt.name, func(t *testing.T) {
83+ var pending []byte
84+ var gotRunes []rune
85+ var gotBackspace, gotEnter, gotCancel, gotUp, gotDown int
86+ ev := overlayEvents{
87+ rune: func(r rune) { gotRunes = append(gotRunes, r) },
88+ backspace: func() { gotBackspace++ },
89+ enter: func() { gotEnter++ },
90+ cancel: func() { gotCancel++ },
91+ up: func() { gotUp++ },
92+ down: func() { gotDown++ },
93+ }
94+
95+ for _, chunk := range tt.chunks {
96+ routeOverlayBytes(chunk, &pending, ev)
97+ }
98+
99+ require.Equal(t, tt.wantRunes, gotRunes)
100+ require.Equal(t, tt.wantPending, pending)
101+ require.Equal(t, tt.wantBackspace, gotBackspace)
102+ require.Equal(t, tt.wantEnter, gotEnter)
103+ require.Equal(t, tt.wantCancel, gotCancel)
104+ require.Equal(t, tt.wantUp, gotUp)
105+ require.Equal(t, tt.wantDown, gotDown)
106+ })
66107 }
67-
68- routeOverlayBytes([]byte("a\x1b[D\x1b[3~b\x1bOQ"), &pending, ev)
69-
70- require.Equal(t, []rune{'a', 'b'}, got)
71- require.Equal(t, 0, cancel)
72- require.Empty(t, pending)
73-}
74-
75-func TestRouteOverlayBytesHandlesEditingSubmitAndCancel(t *testing.T) {
76- var pending []byte
77- var backspace, enter, cancel int
78- ev := overlayEvents{
79- backspace: func() { backspace++ },
80- enter: func() { enter++ },
81- cancel: func() { cancel++ },
82- }
83-
84- routeOverlayBytes([]byte{0x7f, 0x08, '\r', '\n', 0x1b, 0x03}, &pending, ev)
85-
86- require.Equal(t, 2, backspace)
87- require.Equal(t, 2, enter)
88- require.Equal(t, 2, cancel)
89- require.Empty(t, pending)
90-}
91-
92-func TestRouteOverlayBytesIgnoresControlsAndInvalidUTF8(t *testing.T) {
93- var pending []byte
94- var got []rune
95- ev := overlayEvents{rune: func(r rune) { got = append(got, r) }}
96-
97- routeOverlayBytes([]byte{'a', 0x01, 0xff, 'b'}, &pending, ev)
98-
99- require.Equal(t, []rune{'a', 'b'}, got)
100- require.Empty(t, pending)
101108 }
A internal/usecase/daemon/paint_gate_test.go +98 -0
@@ -0,0 +1,98 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) {
14+ t.Helper()
15+ deadline := time.After(50 * time.Millisecond)
16+ for {
17+ select {
18+ case f := <-sends:
19+ if f.Type == ports.MsgOutput {
20+ t.Fatalf("unexpected output frame: %+v", f)
21+ }
22+ case <-deadline:
23+ return
24+ }
25+ }
26+}
27+
28+func TestPaintDefersWhenOutputAckLagsAtCap(t *testing.T) {
29+ p, _ := newBlockingPTY(t)
30+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
31+ sess.tabs[0].focusedPane().screen.Write([]byte("A"))
32+
33+ // Client is maxUnackedOutputStates behind (outputAck stays 0).
34+ ac.nextStateNum = maxUnackedOutputStates
35+ d.paint(sess, ac, false)
36+
37+ requireNoOutputFrame(t, sends)
38+ ac.sendMu.Lock()
39+ deferred := ac.paintDeferred
40+ ac.sendMu.Unlock()
41+ require.True(t, deferred, "diff paint at ack cap must defer")
42+}
43+
44+func TestDeferredPaintFlushesOnceWhenAcksCatchUp(t *testing.T) {
45+ p, _ := newBlockingPTY(t)
46+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
47+ sess.tabs[0].focusedPane().screen.Write([]byte("A"))
48+
49+ ac.nextStateNum = maxUnackedOutputStates
50+ d.paint(sess, ac, false)
51+ requireNoOutputFrame(t, sends)
52+
53+ // Drive a real MsgAck through the connection loop so the flush is exercised
54+ // end-to-end: UnmarshalAck → advanceOutputAck → takeDeferredPaint → paint.
55+ // One ack frame, then Recv blocks until the test ends.
56+ recvCh := make(chan ports.Frame, 1)
57+ done := make(chan struct{})
58+ t.Cleanup(func() { close(done) })
59+ tr, ok := ac.transport().(*portsmocks.MockTransport)
60+ require.True(t, ok, "manual session transport must be the capturing mock")
61+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
62+ select {
63+ case f := <-recvCh:
64+ return f, nil
65+ case <-done:
66+ return ports.Frame{}, io.EOF
67+ }
68+ }).Maybe()
69+ recvCh <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: maxUnackedOutputStates})}
70+ go d.runConnLoop(ac)
71+
72+ // The cumulative flush paint must be triggered by the MsgAck handler itself
73+ // (the renderer shadow only advanced on send, so the skip coalesces here).
74+ awaitFrame(t, sends, ports.MsgOutput)
75+ requireNoOutputFrame(t, sends)
76+ ac.sendMu.Lock()
77+ deferred := ac.paintDeferred
78+ ac.sendMu.Unlock()
79+ require.False(t, deferred, "flag must clear after the flush paint")
80+}
81+
82+func TestResetPaintBypassesAckGate(t *testing.T) {
83+ p, _ := newBlockingPTY(t)
84+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
85+ sess.tabs[0].focusedPane().screen.Write([]byte("A"))
86+
87+ // At the cap and already deferred: a reset paint is a full invalidation that
88+ // must paint anyway and clear the deferral.
89+ ac.nextStateNum = maxUnackedOutputStates
90+ ac.paintDeferred = true
91+ d.paint(sess, ac, true)
92+
93+ awaitFrame(t, sends, ports.MsgOutput)
94+ ac.sendMu.Lock()
95+ deferred := ac.paintDeferred
96+ ac.sendMu.Unlock()
97+ require.False(t, deferred, "reset paint must clear the deferral")
98+}
M internal/usecase/daemon/palette.go +134 -30
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "github.com/bnema/vev/internal/domain"
55 "github.com/bnema/vev/internal/usecase/command"
6+ "github.com/bnema/vev/internal/usecase/layout"
67 "github.com/bnema/vev/internal/usecase/palette"
78 "github.com/bnema/vev/internal/usecase/ui"
89 "github.com/bnema/vev/pkg/renderer"
@@ -12,18 +13,63 @@ var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title:
1213
1314 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
1415 d.closePalette(ac)
15- ac.paletteMu.Lock()
16- ac.palette = palette.NewRegistry()
17- ac.palettePending = nil
18- ac.paletteMu.Unlock()
16+ ac.overlays.paletteMu.Lock()
17+ ac.overlays.palette = palette.New(d.paletteCommands())
18+ ac.overlays.palettePending = nil
19+ ac.overlays.paletteMu.Unlock()
1920 d.paint(sess, ac, true)
2021 }
2122
2223 func (d *Daemon) closePalette(ac *attachedClient) {
23- ac.paletteMu.Lock()
24- ac.palette = nil
25- ac.palettePending = nil
26- ac.paletteMu.Unlock()
24+ ac.overlays.paletteMu.Lock()
25+ ac.overlays.palette = nil
26+ ac.overlays.palettePending = nil
27+ ac.overlays.paletteMu.Unlock()
28+}
29+
30+func (d *Daemon) paletteCommands() []command.Command {
31+ commands := command.Registry()
32+ d.paletteRecentMu.Lock()
33+ recent := append([]string(nil), d.paletteRecent...)
34+ d.paletteRecentMu.Unlock()
35+ overrides := d.codeOverrideSnapshot()
36+
37+ byCode := make(map[string]command.Command, len(commands))
38+ for _, cmd := range commands {
39+ cmd = commandWithOverrides(cmd, overrides)
40+ byCode[cmd.Code] = cmd
41+ }
42+ out := make([]command.Command, 0, len(commands))
43+ used := make(map[string]bool, len(recent))
44+ for _, code := range recent {
45+ cmd, ok := byCode[code]
46+ if !ok || used[code] {
47+ continue
48+ }
49+ out = append(out, cmd)
50+ used[code] = true
51+ }
52+ for _, cmd := range commands {
53+ cmd = commandWithOverrides(cmd, overrides)
54+ if !used[cmd.Code] {
55+ out = append(out, cmd)
56+ }
57+ }
58+ return out
59+}
60+
61+func (d *Daemon) recordPaletteUse(code string) {
62+ d.paletteRecentMu.Lock()
63+ defer d.paletteRecentMu.Unlock()
64+
65+ recent := make([]string, 0, len(d.paletteRecent)+1)
66+ recent = append(recent, code)
67+ for _, existing := range d.paletteRecent {
68+ if existing != code {
69+ recent = append(recent, existing)
70+ }
71+ }
72+ d.paletteRecent = recent
2773 }
2874
2975 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
@@ -32,10 +78,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
3278 return
3379 }
3480
35- ac.paletteMu.Lock()
36- if ac.palette == nil {
37- ac.palettePending = nil
38- ac.paletteMu.Unlock()
81+ ac.overlays.paletteMu.Lock()
82+ if ac.overlays.palette == nil {
83+ ac.overlays.palettePending = nil
84+ ac.overlays.paletteMu.Unlock()
3985 return
4086 }
4187 changed := false
@@ -44,17 +90,17 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
4490 var cmd command.Command
4591 var ok bool
4692
47- routeOverlayBytes(data, &ac.palettePending, overlayEvents{
93+ routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
4894 rune: func(r rune) {
49- ac.palette.Insert(r)
95+ ac.overlays.palette.Insert(r)
5096 changed = true
5197 },
5298 backspace: func() {
53- ac.palette.Backspace()
99+ ac.overlays.palette.Backspace()
54100 changed = true
55101 },
56102 enter: func() {
57- cmd, ok = ac.palette.Selected()
103+ cmd, ok = ac.overlays.palette.Selected()
58104 if ok {
59105 run = true
60106 exit = true
@@ -62,26 +108,31 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
62108 },
63109 cancel: func() { exit = true },
64110 up: func() {
65- ac.palette.Up()
111+ ac.overlays.palette.Up()
66112 changed = true
67113 },
68114 down: func() {
69- ac.palette.Down()
115+ ac.overlays.palette.Down()
70116 changed = true
71117 },
72118 })
73119 if exit {
74- ac.palette = nil
75- ac.palettePending = nil
120+ ac.overlays.palette = nil
121+ ac.overlays.palettePending = nil
76122 }
77- ac.paletteMu.Unlock()
123+ ac.overlays.paletteMu.Unlock()
78124
79125 if run && ok {
126+ if !isHistoryNavigationCommand(cmd.Slug) {
127+ d.clearHistoryNav(ac)
128+ }
80129 if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {
81130 sess.mu.Lock()
82131 name := sess.name
83132 sess.mu.Unlock()
84133 d.log.Error("palette command failed", "err", err, "session", name, "command", cmd.Code)
134+ } else {
135+ d.recordPaletteUse(cmd.Code)
85136 }
86137 return
87138 }
@@ -90,6 +141,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
90141 }
91142 }
92143
144+func isHistoryNavigationCommand(slug string) bool {
145+ return slug == "back-session" || slug == "forward-session"
146+}
147+
93148 type paletteExec struct {
94149 d *Daemon
95150 sess *session
@@ -115,6 +170,50 @@ func (e paletteExec) CloseTab() error {
115170 return nil
116171 }
117172
173+func (e paletteExec) SplitRight() error {
174+ return e.d.splitPane(e.sess, e.ac, layout.Right)
175+}
176+
177+func (e paletteExec) SplitLeft() error {
178+ return e.d.splitPane(e.sess, e.ac, layout.Left)
179+}
180+
181+func (e paletteExec) SplitUp() error {
182+ return e.d.splitPane(e.sess, e.ac, layout.Up)
183+}
184+
185+func (e paletteExec) SplitDown() error {
186+ return e.d.splitPane(e.sess, e.ac, layout.Down)
187+}
188+
189+func (e paletteExec) StackPane() error {
190+ return e.d.stackPane(e.sess, e.ac)
191+}
192+
193+func (e paletteExec) ToggleStack() error {
194+ return e.d.toggleStack(e.sess, e.ac)
195+}
196+
197+func (e paletteExec) ClosePane() error {
198+ return e.d.closeFocusedPane(e.sess, e.ac)
199+}
200+
201+func (e paletteExec) FocusPaneLeft() error {
202+ return e.d.focusDir(e.sess, e.ac, layout.Left)
203+}
204+
205+func (e paletteExec) FocusPaneRight() error {
206+ return e.d.focusDir(e.sess, e.ac, layout.Right)
207+}
208+
209+func (e paletteExec) FocusPaneUp() error {
210+ return e.d.focusDir(e.sess, e.ac, layout.Up)
211+}
212+
213+func (e paletteExec) FocusPaneDown() error {
214+ return e.d.focusDir(e.sess, e.ac, layout.Down)
215+}
216+
118217 func (e paletteExec) NextTab() error {
119218 e.sess.switchRelative(1)
120219 e.d.paint(e.sess, e.ac, true)
@@ -127,8 +226,18 @@ func (e paletteExec) PrevTab() error {
127226 return nil
128227 }
129228
229+func (e paletteExec) BackSession() error {
230+ e.d.navigateRecentSession(e.sess, e.ac, 1)
231+ return nil
232+}
233+
234+func (e paletteExec) ForwardSession() error {
235+ e.d.navigateRecentSession(e.sess, e.ac, -1)
236+ return nil
237+}
238+
130239 func (e paletteExec) Detach() error {
131- e.d.clientGone(e.sess, e.ac, true)
240+ e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
132241 return nil
133242 }
134243
@@ -168,12 +277,7 @@ func (e paletteExec) OpenSessionPicker() error {
168277
169278 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
170279 styleSet := resolveThemeStyles(styles)
171- inner := paletteModal.Composite(base, styleSet.border)
172- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.selection)
173- for y := range min(inner.Height, modalFrame.Height) {
174- for x := range min(inner.Width, modalFrame.Width) {
175- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
176- }
177- }
178- return base, []renderer.Damage{renderer.FullRedraw()}
280+ return composeModalClientFrame(base, paletteModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
281+ return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc})
282+ })
179283 }
M internal/usecase/daemon/palette_test.go +93 -24 collapsed to keep the page light
A internal/usecase/daemon/pane.go +62 -0
@@ -0,0 +1,62 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+ "github.com/bnema/vev/pkg/vt"
13+)
14+
15+// pane owns one PTY, its terminal screen state, and render scheduling channels.
16+// Lock order when multiple locks are held is:
17+// attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
18+// The PTY reader takes only pane.mu, so child output never waits on client IO.
19+type pane struct {
20+ id layout.PaneID
21+ stableID string
22+ pty ports.PTY
23+ mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
24+ screen *vt.Screen
25+ scrollback *scopy.Scrollback
26+ dirty chan struct{}
27+ flush chan struct{}
28+ syncGen uint64
29+ rect domain.Rect
30+ title string
31+ titleAt time.Time
32+ titleValid bool
33+ ctx context.Context
34+ cancel context.CancelFunc
35+}
36+
37+func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
38+ return newPaneWithStableID(id, fallbackStableID("p"), pty, sz)
39+}
40+
41+func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
42+ sb := scopy.NewScrollback(defaultScrollbackRows)
43+ screen := vt.NewScreen(sz.Cols, sz.Rows)
44+ screen.OnLineEvicted = sb.Append
45+ return &pane{
46+ id: id,
47+ stableID: stableID,
48+ pty: pty,
49+ screen: screen,
50+ scrollback: sb,
51+ dirty: make(chan struct{}, 1),
52+ flush: make(chan struct{}, 1),
53+ rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
54+ }
55+}
56+
57+func paneDone(p *pane) <-chan struct{} {
58+ if p.ctx == nil {
59+ return nil
60+ }
61+ return p.ctx.Done()
62+}
A internal/usecase/daemon/pane_actions.go +307 -0 collapsed to keep the page light
A internal/usecase/daemon/pane_actions_test.go +369 -0 collapsed to keep the page light
A internal/usecase/daemon/pane_titles.go +64 -0 collapsed to keep the page light
A internal/usecase/daemon/pane_titles_test.go +70 -0 collapsed to keep the page light
M internal/usecase/daemon/picker.go +106 -80 collapsed to keep the page light
M internal/usecase/daemon/picker_test.go +263 -21 collapsed to keep the page light
A internal/usecase/daemon/process_restore.go +164 -0 collapsed to keep the page light
A internal/usecase/daemon/process_restore_test.go +156 -0 collapsed to keep the page light
M internal/usecase/daemon/prompt.go +27 -33 collapsed to keep the page light
M internal/usecase/daemon/prompt_test.go +7 -9
@@ -4,9 +4,7 @@ import (
44 "context"
55 "testing"
66
7- "github.com/bnema/vev/internal/domain"
87 "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/pkg/vt"
108 "github.com/stretchr/testify/require"
119 )
1210
@@ -21,7 +19,7 @@ func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
2119 require.NoError(t, err)
2220 require.Contains(t, string(msg.Data), "Rename session")
2321 require.Contains(t, string(msg.Data), "> 0")
24- require.True(t, ac.promptActive())
22+ require.True(t, ac.overlays.promptActive())
2523 }
2624
2725 func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
@@ -36,7 +34,7 @@ func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
3634 d.handlePromptInput(ac, []byte("work\r"))
3735 repaint := awaitFrame(t, sends, ports.MsgOutput)
3836
39- require.False(t, ac.promptActive())
37+ require.False(t, ac.overlays.promptActive())
4038 require.Equal(t, "0work", sess.name)
4139 require.False(t, sess.ephemeral)
4240 out, err := ports.UnmarshalOutput(repaint.Payload)
@@ -53,14 +51,14 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
5351 defer release2()
5452 ctx, cancel := context.WithCancel(context.Background())
5553 defer cancel()
56- d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: ctx, cancel: cancel}}}
54+ d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}}
5755
5856 d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
5957 awaitFrame(t, sends, ports.MsgOutput)
6058 d.handlePromptInput(ac, []byte("taken\r"))
6159 repaint := awaitFrame(t, sends, ports.MsgOutput)
6260
63- require.True(t, ac.promptActive())
61+ require.True(t, ac.overlays.promptActive())
6462 require.NotEqual(t, "taken", sess.name)
6563 out, err := ports.UnmarshalOutput(repaint.Payload)
6664 require.NoError(t, err)
@@ -78,7 +76,7 @@ func TestPromptEscapeCancelsWithoutRename(t *testing.T) {
7876 d.handlePromptInput(ac, []byte("new\x1b"))
7977 awaitFrame(t, sends, ports.MsgOutput)
8078
81- require.False(t, ac.promptActive())
79+ require.False(t, ac.overlays.promptActive())
8280 require.Equal(t, oldName, sess.name)
8381 }
8482
@@ -94,8 +92,8 @@ func TestPaletteRNSOpensRenamePrompt(t *testing.T) {
9492 d.handlePaletteInput(ac, []byte("RNS\r"))
9593 out := awaitFrame(t, sends, ports.MsgOutput)
9694
97- require.False(t, ac.paletteActive())
98- require.True(t, ac.promptActive())
95+ require.False(t, ac.overlays.paletteActive())
96+ require.True(t, ac.overlays.promptActive())
9997 msg, err := ports.UnmarshalOutput(out.Payload)
10098 require.NoError(t, err)
10199 require.Contains(t, string(msg.Data), "Rename session")
A internal/usecase/daemon/recent_nav.go +163 -0 collapsed to keep the page light
M internal/usecase/daemon/render.go +489 -144 collapsed to keep the page light
M internal/usecase/daemon/render_test.go +666 -80 collapsed to keep the page light
A internal/usecase/daemon/resume.go +158 -0 collapsed to keep the page light
A internal/usecase/daemon/resume_test.go +440 -0 collapsed to keep the page light
M internal/usecase/daemon/scrollback_test.go +6 -6
@@ -24,19 +24,19 @@ func TestNewTabInitializesScrollback(t *testing.T) {
2424 for _, tt := range tests {
2525 t.Run(tt.name, func(t *testing.T) {
2626 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})
27- if win.scrollback == nil {
27+ if win.focusedPane().scrollback == nil {
2828 t.Fatal("scrollback is nil")
2929 }
30- if win.scrollback.Cap() != defaultScrollbackRows {
31- t.Fatalf("scrollback cap = %d, want %d", win.scrollback.Cap(), defaultScrollbackRows)
30+ if win.focusedPane().scrollback.Cap() != defaultScrollbackRows {
31+ t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows)
3232 }
3333
34- win.screen.Write(tt.write)
34+ win.focusedPane().screen.Write(tt.write)
3535
36- if got := win.scrollback.Len(); got != tt.wantLen {
36+ if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {
3737 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
3838 }
39- row := win.scrollback.Row(0)
39+ row := win.focusedPane().scrollback.Row(0)
4040 runes := make([]rune, len(row))
4141 for i, c := range row {
4242 runes[i] = c.Rune
M internal/usecase/daemon/session.go +273 -115 collapsed to keep the page light
M internal/usecase/daemon/session_test.go +396 -16 collapsed to keep the page light
A internal/usecase/daemon/snapshot.go +403 -0 collapsed to keep the page light
A internal/usecase/daemon/snapshot_restore_test.go +445 -0 collapsed to keep the page light
A internal/usecase/daemon/snapshot_test.go +447 -0 collapsed to keep the page light
M internal/usecase/daemon/status.go +294 -70 collapsed to keep the page light
M internal/usecase/daemon/status_test.go +595 -64 collapsed to keep the page light
A internal/usecase/daemon/theme_resolve.go +48 -0 collapsed to keep the page light
M internal/usecase/daemon/wire.go +16 -14 collapsed to keep the page light
A internal/usecase/keys/bindings.go +133 -0 collapsed to keep the page light
A internal/usecase/keys/keyspec.go +154 -0 collapsed to keep the page light
A internal/usecase/keys/keyspec_test.go +232 -0 collapsed to keep the page light
M internal/usecase/keys/router.go +166 -26 collapsed to keep the page light
M internal/usecase/keys/router_test.go +374 -16 collapsed to keep the page light
A internal/usecase/layout/geometry.go +118 -0
@@ -0,0 +1,118 @@
1+package layout
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// Solve returns deterministic pane placements for root inside area.
6+func Solve(root *Node, area domain.Rect) ([]Placement, bool) {
7+ if root == nil || area.Width <= 0 || area.Height <= 0 {
8+ return nil, false
9+ }
10+ var out []Placement
11+ if !solve(root, area, &out) {
12+ return nil, false
13+ }
14+ return out, true
15+}
16+
17+func solve(n *Node, r domain.Rect, out *[]Placement) bool {
18+ switch n.Kind {
19+ case Leaf:
20+ if r.Width < MinPaneCols || r.Height < MinPaneRows {
21+ return false
22+ }
23+ *out = append(*out, Placement{ID: n.Leaf, Content: r})
24+ return true
25+ case Split:
26+ return solveSplit(n, r, out)
27+ case Stack:
28+ return solveStack(n, r, out)
29+ default:
30+ return false
31+ }
32+}
33+
34+func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool {
35+ count := len(n.Children)
36+ if count == 0 {
37+ return false
38+ }
39+ if count == 1 {
40+ return solve(n.Children[0], r, out)
41+ }
42+ if n.Dir == Horizontal {
43+ usable := r.Width - (count - 1)
44+ if usable < count*MinPaneCols || r.Height < MinPaneRows {
45+ return false
46+ }
47+ base, rem := usable/count, usable%count
48+ x := r.X
49+ for i, child := range n.Children {
50+ w := base
51+ if i < rem {
52+ w++
53+ }
54+ if !solve(child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, out) {
55+ return false
56+ }
57+ x += w + 1
58+ }
59+ return true
60+ }
61+
62+ usable := r.Height - (count - 1)
63+ if usable < count*MinPaneRows || r.Width < MinPaneCols {
64+ return false
65+ }
66+ base, rem := usable/count, usable%count
67+ y := r.Y
68+ for i, child := range n.Children {
69+ h := base
70+ if i < rem {
71+ h++
72+ }
73+ if !solve(child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, out) {
74+ return false
75+ }
76+ y += h + 1
77+ }
78+ return true
79+}
80+
81+func solveStack(n *Node, r domain.Rect, out *[]Placement) bool {
82+ count := len(n.Children)
83+ if count == 0 || r.Width < MinPaneCols || r.Height < count+1 {
84+ return false
85+ }
86+ idx := -1
87+ for i, child := range n.Children {
88+ if child.Kind != Leaf {
89+ return false
90+ }
91+ if child.Leaf == n.Expanded {
92+ idx = i
93+ }
94+ }
95+ if idx < 0 {
96+ return false
97+ }
98+ contentRows := r.Height - count
99+ for i, child := range n.Children {
100+ if i < idx {
101+ *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: r.Y + i, Width: r.Width, Height: 1}, Collapsed: true, InStack: true})
102+ continue
103+ }
104+ if i == idx {
105+ titleY := r.Y + i
106+ *out = append(*out, Placement{
107+ ID: child.Leaf,
108+ TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1},
109+ Content: domain.Rect{X: r.X, Y: titleY + 1, Width: r.Width, Height: contentRows},
110+ InStack: true,
111+ })
112+ continue
113+ }
114+ titleY := r.Y + idx + 1 + contentRows + (i - idx - 1)
115+ *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, Collapsed: true, InStack: true})
116+ }
117+ return true
118+}
A internal/usecase/layout/layout_test.go +453 -0 collapsed to keep the page light
A internal/usecase/layout/ops.go +517 -0 collapsed to keep the page light
A internal/usecase/layout/tree.go +99 -0 collapsed to keep the page light
M internal/usecase/palette/model.go +34 -8 collapsed to keep the page light
M internal/usecase/palette/model_test.go +73 -19 collapsed to keep the page light
A internal/usecase/snapshot/codec.go +846 -0 collapsed to keep the page light
A internal/usecase/snapshot/codec_test.go +433 -0 collapsed to keep the page light
A internal/usecase/snapshot/snapshot.go +48 -0 collapsed to keep the page light
M internal/usecase/theme/theme.go +147 -8 collapsed to keep the page light
M internal/usecase/theme/theme_test.go +167 -10 collapsed to keep the page light
A internal/usecase/ui/toast.go +275 -0 collapsed to keep the page light
A internal/usecase/ui/toast_test.go +251 -0 collapsed to keep the page light
A internal/usecase/visualsearch/model.go +197 -0 collapsed to keep the page light
A internal/usecase/visualsearch/model_test.go +100 -0 collapsed to keep the page light
A pkg/dgram/dgram.go +249 -0 collapsed to keep the page light
A pkg/dgram/dgram_test.go +176 -0 collapsed to keep the page light
M pkg/kv/kv.go +3 -1 collapsed to keep the page light
M pkg/kv/kv_test.go +21 -7 collapsed to keep the page light
A pkg/renderer/ack_test.go +85 -0 collapsed to keep the page light
M pkg/renderer/renderer.go +92 -8 collapsed to keep the page light
M pkg/renderer/renderer_test.go +91 -22 collapsed to keep the page light
M pkg/renderer/style.go +45 -4 collapsed to keep the page light
A pkg/safedir/safedir.go +39 -0 collapsed to keep the page light
A pkg/safedir/safedir_test.go +150 -0 collapsed to keep the page light
M pkg/vt/doc.go +7 -1 collapsed to keep the page light
M pkg/vt/query_test.go +80 -2 collapsed to keep the page light
M pkg/vt/screen.go +495 -62 collapsed to keep the page light
M pkg/vt/screen_test.go +593 -2 collapsed to keep the page light
A pkg/vt/snapshot.go +18 -0 collapsed to keep the page light
A pkg/vt/snapshot_test.go +39 -0 collapsed to keep the page light
A scripts/debug-remote-attach.sh +165 -0 collapsed to keep the page light
A scripts/vev-bar-bottom-right +49 -0 collapsed to keep the page light
A scripts/vev-bar-top-right +61 -0 collapsed to keep the page light

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close