detached f618fc7

merge(feature): command palette daemon split

commit
f618fc70d4cab48d6712d48ea4dd1c5796e722b1
author
brice · · 2026-07-03 12:38 UTC
merge of
c2f37ec + 61d93fc · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
47 files · +6508 -3897
M README.md +23 -8
@@ -44,14 +44,29 @@ All bindings use the Alt modifier directly, no prefix key.
4444
4545 | Key | Action |
4646 |---|---|
47-| Alt+c | new tab |
48-| Alt+n / Alt+p | next / previous tab |
49-| Alt+1 .. Alt+9 | select tab by number |
50-| Alt+x | close tab (last tab ends the session) |
51-| Alt+d | detach |
52-| Alt+r | promote the current ephemeral session to named |
53-| Alt+t | open session/tab picker |
54-| Alt+u | enter scrollback mode |
47+| Alt+Space | open the command palette |
48+| Alt+1 .. Alt+9 | switch to tab by number |
49+
50+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.
51+
52+## Command palette
53+
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.
55+
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.
57+
58+| Code | Action |
59+|---|---|
60+| CNT | create new tab |
61+| CLT | close current tab |
62+| NXT | switch to next tab |
63+| PVT | switch to previous tab |
64+| SSP | switch session or tab |
65+| CPY | enter scrollback mode |
66+| RNS | rename session |
67+| DET | detach |
68+
69+Rename prompts are prefilled with the current session name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty names and names already used by another session are rejected; the prompt stays open and shows the validation error.
5570
5671 ## Scrollback and visual copy
5772
M internal/app/daemon_integration_test.go +94 -2
@@ -86,6 +86,13 @@ func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size)
8686 // awaitText decodes MsgOutput frames into a fresh VT screen and returns once
8787 // the reconstructed grid contains want.
8888 func awaitText(t *testing.T, p *pump, sz domain.Size, want string) {
89+ t.Helper()
90+ _ = awaitScreenText(t, p, sz, want)
91+}
92+
93+// awaitScreenText is like awaitText, but returns the reconstructed screen text
94+// at the point the wanted text appears so callers can make additional checks.
95+func awaitScreenText(t *testing.T, p *pump, sz domain.Size, want string) string {
8996 t.Helper()
9097 screen := vt.NewScreen(sz.Cols, sz.Rows)
9198 timeout := time.After(5 * time.Second)
@@ -99,8 +106,9 @@ func awaitText(t *testing.T, p *pump, sz domain.Size, want string) {
99106 o, err := ports.UnmarshalOutput(f.Payload)
100107 require.NoError(t, err)
101108 screen.Write(o.Data)
102- if strings.Contains(screenText(screen), want) {
103- return
109+ text := screenText(screen)
110+ if strings.Contains(text, want) {
111+ return text
104112 }
105113 }
106114 case <-timeout:
@@ -109,6 +117,28 @@ func awaitText(t *testing.T, p *pump, sz domain.Size, want string) {
109117 }
110118 }
111119
120+func assertNoTextAfterInput(t *testing.T, p *pump, sz domain.Size, absent string) {
121+ t.Helper()
122+ screen := vt.NewScreen(sz.Cols, sz.Rows)
123+ timeout := time.After(300 * time.Millisecond)
124+ for {
125+ select {
126+ case f, ok := <-p.ch:
127+ if !ok {
128+ return
129+ }
130+ if f.Type == ports.MsgOutput {
131+ o, err := ports.UnmarshalOutput(f.Payload)
132+ require.NoError(t, err)
133+ screen.Write(o.Data)
134+ require.NotContains(t, screenText(screen), absent)
135+ }
136+ case <-timeout:
137+ return
138+ }
139+ }
140+}
141+
112142 func screenText(s *vt.Screen) string {
113143 var b strings.Builder
114144 for y := range s.Frame.Height {
@@ -144,6 +174,68 @@ func TestIntegration_InputRoundtrip(t *testing.T) {
144174 awaitText(t, p, sz, "PINGPONG")
145175 }
146176
177+func TestIntegration_CommandPaletteCreatesTab(t *testing.T) {
178+ sz := domain.Size{Cols: 80, Rows: 24}
179+ dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf READY; sleep 30"}))
180+
181+ tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)
182+ defer func() { _ = tr.Close() }()
183+ awaitText(t, p, sz, "READY")
184+
185+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("\x1b ")})}))
186+ awaitText(t, p, sz, "Commands")
187+
188+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})}))
189+ text := awaitScreenText(t, p, sz, " 1 2 ")
190+ require.Contains(t, text, " 1 2 ")
191+}
192+
193+func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) {
194+ sz := domain.Size{Cols: 80, Rows: 24}
195+ dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf READY; sleep 30"}))
196+
197+ tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)
198+ defer func() { _ = tr.Close() }()
199+ text := awaitScreenText(t, p, sz, "READY")
200+ require.Contains(t, text, " 0* ")
201+
202+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("\x1b ")})}))
203+ awaitText(t, p, sz, "Commands")
204+
205+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("RNS\r")})}))
206+ awaitText(t, p, sz, "Rename session")
207+
208+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("\x7fwork\r")})}))
209+ text = awaitScreenText(t, p, sz, " work ")
210+ require.NotContains(t, text, "work*")
211+
212+ listTr, err := ipc.Dial(dir)
213+ require.NoError(t, err)
214+ defer func() { _ = listTr.Close() }()
215+ require.NoError(t, listTr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))
216+ f, err := listTr.Recv()
217+ require.NoError(t, err)
218+ require.Equal(t, ports.MsgSessions, f.Type)
219+ sessions, err := ports.UnmarshalSessions(f.Payload)
220+ require.NoError(t, err)
221+ require.Len(t, sessions.Sessions, 1)
222+ require.Equal(t, "work", sessions.Sessions[0].Name)
223+ require.False(t, sessions.Sessions[0].Ephemeral)
224+}
225+
226+func TestIntegration_AltCWithoutPaletteDoesNotCreateTab(t *testing.T) {
227+ sz := domain.Size{Cols: 80, Rows: 24}
228+ dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf READY; sleep 30"}))
229+
230+ tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)
231+ defer func() { _ = tr.Close() }()
232+ text := awaitScreenText(t, p, sz, "READY")
233+ require.NotContains(t, text, " 1 2 ")
234+
235+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("\x1bc")})}))
236+ assertNoTextAfterInput(t, p, sz, " 1 2 ")
237+}
238+
147239 func TestIntegration_EphemeralGoneOnDetach(t *testing.T) {
148240 sz := domain.Size{Cols: 80, Rows: 24}
149241 dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf READY; sleep 30"}))
M internal/app/run.go +115 -4
@@ -16,6 +16,7 @@ import (
1616 "os/signal"
1717 "syscall"
1818 "text/tabwriter"
19+ "time"
1920
2021 "github.com/bnema/vev/internal/adapters/clock"
2122 "github.com/bnema/vev/internal/adapters/ipc"
@@ -24,6 +25,7 @@ import (
2425 "github.com/bnema/vev/internal/adapters/term"
2526 "github.com/bnema/vev/internal/ports"
2627 "github.com/bnema/vev/internal/usecase/client"
28+ "github.com/bnema/vev/internal/usecase/confirm"
2729 "github.com/bnema/vev/internal/usecase/daemon"
2830 )
2931
@@ -243,12 +245,70 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
243245 }
244246 defer func() { _ = logFile.Close() }()
245247
246- var transport ports.Transport
247248 if remoteTarget != "" {
248- transport, err = sshstdio.Dial(remoteTarget, name)
249- } else {
250- transport, err = ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
249+ transport, err := sshstdio.Dial(remoteTarget, name)
250+ if err != nil {
251+ return err
252+ }
253+ // client.Attach owns and closes transport.
254+ return client.Attach(ctx, transport, term.New(), intent, name)
255+ }
256+
257+ return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
258+ confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
259+ attach: attachLocalDaemon,
260+ killDaemon: requestDaemonStop,
261+ settleAfterKill: waitForDaemonStop,
262+ })
263+}
264+
265+type attachRecoveryDeps struct {
266+ confirmer confirm.Confirmer
267+ attach func(context.Context, uint8, string) error
268+ killDaemon func(context.Context) error
269+ settleAfterKill func(context.Context) error
270+}
271+
272+const (
273+ daemonStopTimeout = 2 * time.Second
274+ daemonRestartSettle = 50 * time.Millisecond
275+ legacyMalformedHelloSignal = "malformed hello"
276+)
277+
278+func runLocalAttachWithRecovery(ctx context.Context, intent uint8, name string, deps attachRecoveryDeps) error {
279+ err := deps.attach(ctx, intent, name)
280+ if !isDaemonVersionDrift(err) {
281+ return err
251282 }
283+
284+ ok, promptErr := deps.confirmer.Confirm("Your vev version differs from the running daemon; kill it and restart?")
285+ if promptErr != nil {
286+ return fmt.Errorf("vev: reading confirmation: %w", promptErr)
287+ }
288+ if !ok {
289+ return err
290+ }
291+ if killErr := deps.killDaemon(ctx); killErr != nil {
292+ return killErr
293+ }
294+ if deps.settleAfterKill != nil {
295+ if settleErr := deps.settleAfterKill(ctx); settleErr != nil {
296+ return settleErr
297+ }
298+ }
299+ return deps.attach(ctx, intent, name)
300+}
301+
302+func isDaemonVersionDrift(err error) bool {
303+ var protocolErr *client.ProtocolError
304+ if !errors.As(err, &protocolErr) {
305+ return false
306+ }
307+ return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal
308+}
309+
310+func attachLocalDaemon(ctx context.Context, intent uint8, name string) error {
311+ transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
252312 if err != nil {
253313 return err
254314 }
@@ -256,6 +316,57 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
256316 return client.Attach(ctx, transport, term.New(), intent, name)
257317 }
258318
319+func waitForDaemonStop(ctx context.Context) error {
320+ timer := time.NewTimer(daemonRestartSettle)
321+ defer timer.Stop()
322+ select {
323+ case <-ctx.Done():
324+ return ctx.Err()
325+ case <-timer.C:
326+ return nil
327+ }
328+}
329+
330+func requestDaemonStop(ctx context.Context) error {
331+ ctx, cancel := context.WithTimeout(ctx, daemonStopTimeout)
332+ defer cancel()
333+
334+ done := make(chan error, 1)
335+ go func() {
336+ transport, err := realDial(ipc.SocketDir())
337+ if err != nil {
338+ done <- fmt.Errorf("vev: no daemon running")
339+ return
340+ }
341+ defer func() { _ = transport.Close() }()
342+ closed := make(chan struct{})
343+ go func() {
344+ select {
345+ case <-ctx.Done():
346+ _ = transport.Close()
347+ case <-closed:
348+ }
349+ }()
350+ defer close(closed)
351+ if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}); err != nil {
352+ done <- fmt.Errorf("vev: requesting daemon stop: %w", err)
353+ return
354+ }
355+ if _, err := transport.Recv(); err != nil && !errors.Is(err, io.EOF) {
356+ done <- fmt.Errorf("vev: reading daemon stop reply: %w", err)
357+ return
358+ }
359+ done <- nil
360+ }()
361+
362+ select {
363+ case err := <-done:
364+ return err
365+ case <-ctx.Done():
366+ return fmt.Errorf("vev: stopping daemon: %w", ctx.Err())
367+ }
368+}
369+
259370 // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it
260371 // connects to the per-user daemon (auto-spawning it if needed) and proxies the
261372 // framed protocol between process stdio and the daemon socket.
M internal/app/run_test.go +128 -0
@@ -1,12 +1,15 @@
11 package app
22
33 import (
4+ "bytes"
45 "context"
56 "errors"
67 "strings"
78 "testing"
89
910 "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/usecase/client"
12+ "github.com/bnema/vev/internal/usecase/confirm"
1013 )
1114
1215 func TestParseArgs(t *testing.T) {
@@ -93,6 +96,131 @@ func TestParseArgs(t *testing.T) {
9396 }
9497 }
9598
99+func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) {
100+ var prompts bytes.Buffer
101+ answers := strings.NewReader("y\n")
102+ attachCalls := 0
103+ killCalls := 0
104+
105+ err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
106+ confirmer: confirm.NewConfirmer(answers, &prompts),
107+ attach: func(context.Context, uint8, string) error {
108+ attachCalls++
109+ if attachCalls == 1 {
110+ return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
111+ }
112+ return nil
113+ },
114+ killDaemon: func(context.Context) error {
115+ killCalls++
116+ return nil
117+ },
118+ })
119+ if err != nil {
120+ t.Fatalf("runLocalAttachWithRecovery returned error: %v", err)
121+ }
122+ if attachCalls != 2 {
123+ t.Fatalf("attach calls = %d, want 2", attachCalls)
124+ }
125+ if killCalls != 1 {
126+ t.Fatalf("kill calls = %d, want 1", killCalls)
127+ }
128+ if got := prompts.String(); !strings.Contains(got, "Your vev version differs") || !strings.Contains(got, "kill it") {
129+ t.Fatalf("prompt = %q, want version/kill prompt", got)
130+ }
131+}
132+
133+func TestRunLocalAttachPropagatesPromptError(t *testing.T) {
134+ readErr := errors.New("read failed")
135+ err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
136+ confirmer: confirm.NewConfirmer(errorReader{err: readErr}, &bytes.Buffer{}),
137+ attach: func(context.Context, uint8, string) error {
138+ return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
139+ },
140+ killDaemon: func(context.Context) error {
141+ t.Fatal("killDaemon should not be called after prompt error")
142+ return nil
143+ },
144+ })
145+ if !errors.Is(err, readErr) {
146+ t.Fatalf("error = %v, want wrapped %v", err, readErr)
147+ }
148+}
149+
150+func TestRunLocalAttachPropagatesKillError(t *testing.T) {
151+ killErr := errors.New("kill failed")
152+ err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
153+ confirmer: confirm.NewConfirmer(strings.NewReader("y\n"), &bytes.Buffer{}),
154+ attach: func(context.Context, uint8, string) error {
155+ return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
156+ },
157+ killDaemon: func(context.Context) error { return killErr },
158+ })
159+ if !errors.Is(err, killErr) {
160+ t.Fatalf("error = %v, want %v", err, killErr)
161+ }
162+}
163+
164+func TestRunLocalAttachSettlesBeforeRetry(t *testing.T) {
165+ var order []string
166+ err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
167+ confirmer: confirm.NewConfirmer(strings.NewReader("y\n"), &bytes.Buffer{}),
168+ attach: func(context.Context, uint8, string) error {
169+ order = append(order, "attach")
170+ if len(order) == 1 {
171+ return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
172+ }
173+ return nil
174+ },
175+ killDaemon: func(context.Context) error {
176+ order = append(order, "kill")
177+ return nil
178+ },
179+ settleAfterKill: func(context.Context) error {
180+ order = append(order, "settle")
181+ return nil
182+ },
183+ })
184+ if err != nil {
185+ t.Fatalf("runLocalAttachWithRecovery returned error: %v", err)
186+ }
187+ if got, want := strings.Join(order, ","), "attach,kill,settle,attach"; got != want {
188+ t.Fatalf("order = %s, want %s", got, want)
189+ }
190+}
191+
192+type errorReader struct{ err error }
193+
194+func (r errorReader) Read([]byte) (int, error) { return 0, r.err }
195+
196+func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
197+ answers := strings.NewReader("n\n")
198+ wantErr := &client.ProtocolError{Code: ports.ErrInternal, Text: "malformed hello"}
199+ attachCalls := 0
200+ killCalls := 0
201+
202+ err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
203+ confirmer: confirm.NewConfirmer(answers, &bytes.Buffer{}),
204+ attach: func(context.Context, uint8, string) error {
205+ attachCalls++
206+ return wantErr
207+ },
208+ killDaemon: func(context.Context) error {
209+ killCalls++
210+ return nil
211+ },
212+ })
213+ if !errors.Is(err, wantErr) {
214+ t.Fatalf("error = %v, want original %v", err, wantErr)
215+ }
216+ if attachCalls != 1 {
217+ t.Fatalf("attach calls = %d, want 1", attachCalls)
218+ }
219+ if killCalls != 0 {
220+ t.Fatalf("kill calls = %d, want 0", killCalls)
221+ }
222+}
223+
96224 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
97225 t.Setenv("VEV", "outer")
98226 err := runAttach(context.Background(), ports.IntentEphemeral, "", "")
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 2
4+const ProtocolVersion uint16 = 3
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
A internal/usecase/command/command.go +19 -0
@@ -0,0 +1,19 @@
1+package command
2+
3+// Context is the set of application actions available to commands.
4+type Context interface {
5+ CreateTab() error
6+ CloseTab() error
7+ NextTab() error
8+ PrevTab() error
9+ Detach() error
10+ EnterCopyMode() error
11+ RenameSession() error
12+ OpenSessionPicker() error
13+}
14+
15+// Command describes an executable command.
16+type Command struct {
17+ Code, Name, Desc string
18+ Run func(Context) error
19+}
A internal/usecase/command/registry.go +31 -0
@@ -0,0 +1,31 @@
1+package command
2+
3+import "strings"
4+
5+// Registry returns all commands in display order.
6+func Registry() []Command {
7+ commands := []Command{
8+ {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},
9+ {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},
10+ {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
11+ {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
12+ {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
13+ {Code: "CPY", Name: "Copy mode", Desc: "Enter copy mode", Run: func(ctx Context) error { return ctx.EnterCopyMode() }},
14+ {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
15+ {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
16+ }
17+
18+ return commands
19+}
20+
21+// ByCode returns the command matching code, case-insensitively.
22+func ByCode(code string) (Command, bool) {
23+ code = strings.ToUpper(code)
24+ for _, cmd := range Registry() {
25+ if cmd.Code == code {
26+ return cmd, true
27+ }
28+ }
29+
30+ return Command{}, false
31+}
A internal/usecase/command/registry_test.go +105 -0
@@ -0,0 +1,105 @@
1+package command
2+
3+import (
4+ "fmt"
5+ "regexp"
6+ "testing"
7+)
8+
9+func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
10+ commands := Registry()
11+ wantCodes := []string{"CNT", "CLT", "NXT", "PVT", "SSP", "CPY", "RNS", "DET"}
12+
13+ if len(commands) != len(wantCodes) {
14+ t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
15+ }
16+
17+ codePattern := regexp.MustCompile(`^[A-Z]{3}$`)
18+ seen := make(map[string]bool, len(commands))
19+ for i, cmd := range commands {
20+ if cmd.Code != wantCodes[i] {
21+ t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i])
22+ }
23+ if !codePattern.MatchString(cmd.Code) {
24+ t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code)
25+ }
26+ if seen[cmd.Code] {
27+ t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code)
28+ }
29+ seen[cmd.Code] = true
30+ }
31+}
32+
33+func TestByCodeIsCaseInsensitive(t *testing.T) {
34+ for _, code := range []string{"CNT", "cnt", "CnT"} {
35+ cmd, ok := ByCode(code)
36+ if !ok {
37+ t.Fatalf("ByCode(%q) ok = false, want true", code)
38+ }
39+ if cmd.Code != "CNT" {
40+ t.Fatalf("ByCode(%q).Code = %q, want CNT", code, cmd.Code)
41+ }
42+ }
43+
44+ if _, ok := ByCode("missing"); ok {
45+ t.Fatal("ByCode(unknown) ok = true, want false")
46+ }
47+}
48+
49+func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
50+ tests := []struct {
51+ code string
52+ want string
53+ }{
54+ {code: "CNT", want: "CreateTab"},
55+ {code: "CLT", want: "CloseTab"},
56+ {code: "NXT", want: "NextTab"},
57+ {code: "PVT", want: "PrevTab"},
58+ {code: "SSP", want: "OpenSessionPicker"},
59+ {code: "CPY", want: "EnterCopyMode"},
60+ {code: "RNS", want: "RenameSession"},
61+ {code: "DET", want: "Detach"},
62+ }
63+
64+ for _, tt := range tests {
65+ t.Run(tt.code, func(t *testing.T) {
66+ cmd, ok := ByCode(tt.code)
67+ if !ok {
68+ t.Fatalf("ByCode(%q) ok = false, want true", tt.code)
69+ }
70+
71+ ctx := &spyContext{}
72+ if err := cmd.Run(ctx); err != nil {
73+ t.Fatalf("Run() error = %v, want nil", err)
74+ }
75+ if got := ctx.onlyCall(); got != tt.want {
76+ t.Fatalf("Run() called %q, want %q", got, tt.want)
77+ }
78+ })
79+ }
80+}
81+
82+type spyContext struct {
83+ calls []string
84+}
85+
86+func (s *spyContext) record(name string) error {
87+ s.calls = append(s.calls, name)
88+ return nil
89+}
90+
91+func (s *spyContext) onlyCall() string {
92+ if len(s.calls) != 1 {
93+ return fmt.Sprintf("%v", s.calls)
94+ }
95+ return s.calls[0]
96+}
97+
98+func (s *spyContext) CreateTab() error { return s.record("CreateTab") }
99+func (s *spyContext) CloseTab() error { return s.record("CloseTab") }
100+func (s *spyContext) NextTab() error { return s.record("NextTab") }
101+func (s *spyContext) PrevTab() error { return s.record("PrevTab") }
102+func (s *spyContext) Detach() error { return s.record("Detach") }
103+func (s *spyContext) EnterCopyMode() error { return s.record("EnterCopyMode") }
104+func (s *spyContext) RenameSession() error { return s.record("RenameSession") }
105+func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") }
A internal/usecase/confirm/confirm.go +40 -0
@@ -0,0 +1,40 @@
1+package confirm
2+
3+import (
4+ "bufio"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "strings"
9+)
10+
11+// Confirmer asks a yes/no question on a pair of streams.
12+type Confirmer struct {
13+ in io.Reader
14+ out io.Writer
15+}
16+
17+// NewConfirmer builds a confirmation prompt. Only y/yes answers confirm; empty,
18+// n/no, and unknown answers all decline.
19+func NewConfirmer(in io.Reader, out io.Writer) Confirmer {
20+ return Confirmer{in: in, out: out}
21+}
22+
23+// Confirm writes question with a [y/N] default and reads one answer line.
24+func (c Confirmer) Confirm(question string) (bool, error) {
25+ if c.out != nil {
26+ if _, err := fmt.Fprintf(c.out, "%s [y/N] ", question); err != nil {
27+ return false, err
28+ }
29+ }
30+ answer, err := bufio.NewReader(c.in).ReadString('\n')
31+ if err != nil && !errors.Is(err, io.EOF) {
32+ return false, err
33+ }
34+ switch strings.ToLower(strings.TrimSpace(answer)) {
35+ case "y", "yes":
36+ return true, nil
37+ default:
38+ return false, nil
39+ }
40+}
A internal/usecase/confirm/confirm_test.go +51 -0
@@ -0,0 +1,51 @@
1+package confirm
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestConfirmerAcceptsYes(t *testing.T) {
10+ var out bytes.Buffer
11+ ok, err := NewConfirmer(strings.NewReader("y\n"), &out).Confirm("Kill daemon?")
12+ if err != nil {
13+ t.Fatalf("Confirm returned error: %v", err)
14+ }
15+ if !ok {
16+ t.Fatal("Confirm returned false, want true")
17+ }
18+ if got := out.String(); !strings.Contains(got, "Kill daemon?") || !strings.Contains(got, "[y/N]") {
19+ t.Fatalf("prompt output = %q, want question and default", got)
20+ }
21+}
22+
23+func TestConfirmerDefaultsNo(t *testing.T) {
24+ ok, err := NewConfirmer(strings.NewReader("\n"), &bytes.Buffer{}).Confirm("Kill daemon?")
25+ if err != nil {
26+ t.Fatalf("Confirm returned error: %v", err)
27+ }
28+ if ok {
29+ t.Fatal("Confirm returned true for empty answer, want false")
30+ }
31+}
32+
33+func TestConfirmerDefaultsNoOnEOF(t *testing.T) {
34+ ok, err := NewConfirmer(strings.NewReader(""), &bytes.Buffer{}).Confirm("Kill daemon?")
35+ if err != nil {
36+ t.Fatalf("Confirm returned error: %v", err)
37+ }
38+ if ok {
39+ t.Fatal("Confirm returned true for EOF, want false")
40+ }
41+}
42+
43+func TestConfirmerRejectsUnknownAnswer(t *testing.T) {
44+ ok, err := NewConfirmer(strings.NewReader("later\n"), &bytes.Buffer{}).Confirm("Kill daemon?")
45+ if err != nil {
46+ t.Fatalf("Confirm returned error: %v", err)
47+ }
48+ if ok {
49+ t.Fatal("Confirm returned true for unknown answer, want false")
50+ }
51+}
A internal/usecase/daemon/client.go +372 -0
@@ -0,0 +1,372 @@
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+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
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.
26+package daemon
27+
28+import (
29+ "errors"
30+ "sync"
31+ "time"
32+
33+ "github.com/bnema/vev/internal/domain"
34+ "github.com/bnema/vev/internal/ports"
35+ scopy "github.com/bnema/vev/internal/usecase/copy"
36+ "github.com/bnema/vev/internal/usecase/keys"
37+ "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"
41+ "github.com/bnema/vev/pkg/renderer"
42+)
43+
44+type attachedClient struct {
45+ tr ports.Transport
46+ rend *renderer.Renderer
47+ size domain.Size
48+ keys *keys.Router
49+ sess Guarded[*session]
50+ copyMu sync.Mutex
51+ copyMode *scopy.Mode
52+ copyPending []byte
53+ copyESC pendingByteTimer
54+ copyFeedback string
55+ pickerMu sync.Mutex
56+ picker *picker.Model
57+ pickerPreview *tab
58+ pickerPending []byte
59+ pickerESC pendingByteTimer
60+ paletteMu sync.Mutex
61+ palette *palette.Model
62+ palettePending []byte
63+ promptMu sync.Mutex
64+ prompt *promptui.Model
65+ promptSubmit func(string) error
66+ promptPending []byte
67+ mouseScan mouse.Scanner
68+ lastCursor cursorOut
69+ sendMu sync.Mutex
70+}
71+
72+type cursorOut struct {
73+ valid bool
74+ hidden bool
75+ row int
76+ col int
77+ style int
78+ hasStyle bool
79+}
80+
81+type pendingByteTimer struct {
82+ timer ports.Timer
83+ done chan struct{}
84+}
85+
86+func (p *pendingByteTimer) retain(clock ports.Clock, delay time.Duration, onFire func(ports.Timer)) {
87+ p.timer = clock.NewTimer(delay)
88+ p.done = make(chan struct{})
89+ go func(timer ports.Timer, done <-chan struct{}) {
90+ select {
91+ case <-timer.C():
92+ case <-done:
93+ return
94+ }
95+ onFire(timer)
96+ }(p.timer, p.done)
97+}
98+
99+func (p *pendingByteTimer) stop() {
100+ if p.timer != nil {
101+ p.timer.Stop()
102+ p.timer = nil
103+ }
104+ if p.done != nil {
105+ close(p.done)
106+ p.done = nil
107+ }
108+}
109+
110+func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
111+
112+func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
113+
114+func (ac *attachedClient) copyModeActive() bool {
115+ ac.copyMu.Lock()
116+ defer ac.copyMu.Unlock()
117+ return ac.copyMode != nil
118+}
119+
120+func (ac *attachedClient) pickerActive() bool {
121+ ac.pickerMu.Lock()
122+ defer ac.pickerMu.Unlock()
123+ return ac.picker != nil
124+}
125+
126+func (ac *attachedClient) paletteActive() bool {
127+ ac.paletteMu.Lock()
128+ defer ac.paletteMu.Unlock()
129+ return ac.palette != nil
130+}
131+
132+func (ac *attachedClient) promptActive() bool {
133+ ac.promptMu.Lock()
134+ defer ac.promptMu.Unlock()
135+ return ac.prompt != nil
136+}
137+
138+// send serialises a frame onto the client's transport.
139+func (ac *attachedClient) send(f ports.Frame) error {
140+ ac.sendMu.Lock()
141+ defer ac.sendMu.Unlock()
142+ return ac.tr.Send(f)
143+}
144+
145+// boundedSend sends f to ac with a deadline watchdog: if the send (including
146+// waiting on sendMu behind a wedged paint) does not complete within
147+// detachNotifyTimeout, the transport is force-closed, failing the in-flight
148+// write. Detach/kill/shutdown paths use this so they are never gated on a
149+// client that has stopped draining its socket.
150+func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
151+ _ = d.boundedSendErr(ac, f)
152+}
153+
154+func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
155+ timer := d.clock.NewTimer(detachNotifyTimeout)
156+ result := make(chan error, 1)
157+ go func() {
158+ result <- ac.send(f)
159+ }()
160+ select {
161+ case err := <-result:
162+ timer.Stop()
163+ return err
164+ case <-timer.C():
165+ select {
166+ case err := <-result:
167+ return err
168+ default:
169+ }
170+ _ = ac.tr.Close()
171+ return errors.New("send timed out")
172+ }
173+}
174+
175+// notifyDetachedAsync delivers a best-effort Detached notice off the caller's
176+// goroutine and then closes the transport. Session teardown (killSession) must
177+// complete regardless of client state, so the notice is both asynchronous and
178+// deadline-bounded; Serve waits for pending notices before force-closing
179+// connections so a graceful notice is not raced by the hard close.
180+func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
181+ done := make(chan struct{})
182+ d.mu.Lock()
183+ // Prune completed entries so the slice stays bounded by the number of
184+ // notifications actually in flight.
185+ kept := d.notifies[:0]
186+ for _, c := range d.notifies {
187+ select {
188+ case <-c:
189+ default:
190+ kept = append(kept, c)
191+ }
192+ }
193+ d.notifies = append(kept, done)
194+ d.mu.Unlock()
195+
196+ go func() {
197+ defer close(done)
198+ d.boundedSend(ac, frameDetached(reason))
199+ _ = ac.tr.Close()
200+ }()
201+}
202+
203+// waitNotifies blocks until every Detached notification in flight at the time
204+// of the call has completed. Each is deadline-bounded (boundedSend), so this
205+// wait is bounded too.
206+func (d *Daemon) waitNotifies() {
207+ for _, c := range d.notifiesSnapshot() {
208+ <-c
209+ }
210+}
211+
212+func (d *Daemon) notifiesSnapshot() []chan struct{} {
213+ d.mu.Lock()
214+ defer d.mu.Unlock()
215+ snapshot := make([]chan struct{}, len(d.notifies))
216+ copy(snapshot, d.notifies)
217+ return snapshot
218+}
219+
220+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) (*attachedClient, *attachedClient) {
221+ ac := &attachedClient{
222+ tr: tr,
223+ rend: renderer.New(renderer.Capabilities{}),
224+ size: sz,
225+ }
226+ ac.setSession(sess)
227+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
228+ sess.mu.Lock()
229+ old := sess.client
230+ sess.client = ac
231+ sess.mu.Unlock()
232+ return ac, old
233+}
234+
235+func (d *Daemon) detachReplacedClient(old *attachedClient) {
236+ if old == nil {
237+ return
238+ }
239+ d.unregisterPreview(old)
240+ old.setSession(nil)
241+ // Async + bounded: a dead or wedged old client must not stall the new
242+ // client's handshake.
243+ d.notifyDetachedAsync(old, ports.ReasonDetach)
244+}
245+
246+// firstPaint guarantees the freshly attached client sees the full screen: if
247+// the tab size differs from the client's it resizes first (which paints a
248+// full redraw), otherwise it forces a full paint against the fresh renderer.
249+func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
250+ tb := sess.activeTab()
251+ if tb == nil {
252+ return
253+ }
254+ tb.mu.Lock()
255+ wsz := tb.size
256+ tb.mu.Unlock()
257+
258+ if clientSize.Valid() && wsz != tabSize(clientSize) {
259+ d.resize(sess, ac, clientSize)
260+ return
261+ }
262+ d.paint(sess, ac, true)
263+}
264+
265+// runConnLoop is the per-connection input router: it pumps client messages
266+// until detach, EOF, or a transport error.
267+func (d *Daemon) runConnLoop(ac *attachedClient) {
268+ for {
269+ sess := ac.currentSession()
270+ if sess == nil {
271+ return
272+ }
273+ f, err := ac.tr.Recv()
274+ if err != nil {
275+ d.clientGone(sess, ac, false)
276+ return
277+ }
278+ sess = ac.currentSession()
279+ if sess == nil {
280+ return
281+ }
282+ switch f.Type {
283+ case ports.MsgInput:
284+ if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
285+ d.handleInput(sess, ac, in.Data)
286+ }
287+ case ports.MsgResize:
288+ if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
289+ d.resize(sess, ac, rz.Size)
290+ }
291+ case ports.MsgDetach:
292+ d.clientGone(sess, ac, true)
293+ return
294+ case ports.MsgPing:
295+ _ = ac.send(framePong())
296+ default:
297+ // Unknown/out-of-band client messages are ignored so a newer
298+ // client can add message types without breaking an older daemon.
299+ }
300+ }
301+}
302+
303+// clientGone detaches ac if it is still the session's current client. An
304+// ephemeral session dies with its client; a named one survives headless. When
305+
306+func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
307+ if !sess.detachIfCurrent(ac) {
308+ return // already displaced by a newer client; nothing to do
309+ }
310+ d.unregisterPreview(ac)
311+ if explicit {
312+ // Synchronous so the ack is delivered before the transport closes
313+ // (the client is actively awaiting it), but deadline-bounded so a
314+ // wedged client cannot pin this conn handler and hang Serve's
315+ // connWg.Wait.
316+ d.boundedSend(ac, frameDetached(ports.ReasonDetach))
317+ }
318+ _ = ac.tr.Close()
319+ if sess.ephemeral {
320+ d.killSession(sess, ports.ReasonSessionKilled)
321+ }
322+}
323+
324+// detachIfCurrent clears the client iff ac is the current one, reporting
325+
326+func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
327+ if !sz.Valid() {
328+ return
329+ }
330+ tbSize := tabSize(sz)
331+ ac.size = sz
332+
333+ sess.mu.Lock()
334+ tabs := append([]*tab(nil), sess.tabs...)
335+ sess.mu.Unlock()
336+ if len(tabs) == 0 {
337+ return
338+ }
339+
340+ for _, tb := range tabs {
341+ if err := tb.pty.Resize(tbSize); err != nil {
342+ d.log.Warn("pty resize failed", "err", err, "session", sess.name)
343+ }
344+ tb.mu.Lock()
345+ tb.screen.Resize(tbSize.Cols, tbSize.Rows)
346+ tb.size = tbSize
347+ tb.mu.Unlock()
348+ }
349+ d.paint(sess, ac, true)
350+}
351+
352+// detachOnSendError drops a client whose transport failed. Like every other
353+// detach path, losing the client kills an ephemeral session (its lifetime is
354+// its client's); a named session keeps running headless.
355+func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
356+ if sess.detachIfCurrent(ac) {
357+ d.unregisterPreview(ac)
358+ _ = ac.tr.Close()
359+ d.log.Warn("detached client after send error", "session", sess.name)
360+ if sess.ephemeral {
361+ d.killSession(sess, ports.ReasonSessionKilled)
362+ }
363+ }
364+}
365+
366+// killSession removes a session and tears down its resources. It is
367+// idempotent: only the caller that wins the registry delete acts. When the
368+// registry empties it marks the daemon closing (atomically with the
369+// empty-check, under d.mu) and signals shutdown.
370+//
371+// Teardown ordering matters: context cancel, pty.Close, and the done signal
372+// run first and unconditionally — never gated behind a client send. The
A internal/usecase/daemon/copymode.go +253 -0
@@ -0,0 +1,253 @@
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+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
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.
26+package daemon
27+
28+import (
29+ "strconv"
30+
31+ "github.com/bnema/vev/internal/ports"
32+ scopy "github.com/bnema/vev/internal/usecase/copy"
33+ "github.com/bnema/vev/internal/usecase/keys"
34+ "github.com/bnema/vev/pkg/renderer"
35+)
36+
37+func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
38+ tb := sess.activeTab()
39+ if tb == nil {
40+ return
41+ }
42+
43+ tb.mu.Lock()
44+ ac.copyMu.Lock()
45+ if ac.copyMode == nil {
46+ ac.copyMu.Unlock()
47+ tb.mu.Unlock()
48+ return
49+ }
50+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
51+ if delta > 0 && ac.copyMode.AtBottom(snap) {
52+ ac.copyMode = nil
53+ ac.copyMu.Unlock()
54+ tb.mu.Unlock()
55+ d.paint(sess, ac, true)
56+ return
57+ }
58+ ac.copyMode.Move(snap, delta)
59+ exit := delta > 0 && ac.copyMode.AtBottom(snap)
60+ if exit {
61+ ac.copyMode = nil
62+ }
63+ ac.copyMu.Unlock()
64+ tb.mu.Unlock()
65+
66+ d.paint(sess, ac, true)
67+}
68+
69+func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
70+ tb := sess.activeTab()
71+ if tb == nil {
72+ return
73+ }
74+ tb.mu.Lock()
75+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
76+ tb.mu.Unlock()
77+ ac.copyMu.Lock()
78+ ac.copyMode = scopy.NewMode(snap)
79+ ac.copyMu.Unlock()
80+ d.paint(sess, ac, true)
81+}
82+
83+func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
84+ sess := ac.currentSession()
85+ if sess == nil {
86+ return
87+ }
88+ tb := sess.activeTab()
89+ if tb == nil {
90+ return
91+ }
92+
93+ tb.mu.Lock()
94+ ac.copyMu.Lock()
95+ if ac.copyMode == nil {
96+ ac.copyPending = nil
97+ d.stopCopyPendingTimerLocked(ac)
98+ ac.copyMu.Unlock()
99+ tb.mu.Unlock()
100+ return
101+ }
102+ if len(ac.copyPending) > 0 {
103+ d.stopCopyPendingTimerLocked(ac)
104+ combined := make([]byte, 0, len(ac.copyPending)+len(data))
105+ combined = append(combined, ac.copyPending...)
106+ combined = append(combined, data...)
107+ data = combined
108+ ac.copyPending = nil
109+ }
110+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
111+ changed := false
112+ copyOut := false
113+ exit := false
114+ for i := 0; i < len(data); i++ {
115+ switch data[i] {
116+ case 'j':
117+ ac.copyMode.Move(snap, 1)
118+ changed = true
119+ case 'k':
120+ ac.copyMode.Move(snap, -1)
121+ changed = true
122+ case 'g':
123+ ac.copyMode.Top(snap)
124+ changed = true
125+ case 'G':
126+ ac.copyMode.Bottom(snap)
127+ changed = true
128+ case ' ':
129+ ac.copyMode.ToggleSelection()
130+ changed = true
131+ case '\r', '\n', 'y':
132+ copyOut = true
133+ exit = true
134+ case 'q', 0x03, 0x1b:
135+ if data[i] == 0x1b {
136+ tail := data[i:]
137+ consumed, ok := routeCopyEscape(ac.copyMode, snap, tail)
138+ if ok {
139+ i += consumed - 1
140+ changed = true
141+ continue
142+ }
143+ if len(tail) == 1 {
144+ d.retainCopyESCLocked(ac)
145+ break
146+ }
147+ if isCopyEscapePrefix(tail) {
148+ ac.copyPending = append(ac.copyPending[:0], tail...)
149+ break
150+ }
151+ }
152+ exit = true
153+ }
154+ }
155+ text := ""
156+ if copyOut {
157+ text = ac.copyMode.SelectedText(snap)
158+ }
159+ if exit {
160+ d.stopCopyPendingTimerLocked(ac)
161+ ac.copyMode = nil
162+ }
163+ ac.copyMu.Unlock()
164+ tb.mu.Unlock()
165+
166+ if copyOut && text != "" {
167+ chunks := scopy.OSC52(text)
168+ for _, chunk := range chunks {
169+ if err := d.boundedSendErr(ac, frameOutput(chunk)); err != nil {
170+ d.detachOnSendError(sess, ac)
171+ return
172+ }
173+ }
174+ ac.copyMu.Lock()
175+ if len(chunks) > 0 {
176+ ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
177+ } else {
178+ ac.copyFeedback = "selection too large to copy"
179+ }
180+ ac.copyMu.Unlock()
181+ }
182+ if exit {
183+ d.paint(sess, ac, true)
184+ return
185+ }
186+ if changed {
187+ d.paint(sess, ac, true)
188+ }
189+}
190+
191+func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
192+ mode := ac.copyMode
193+ ac.copyPending = append(ac.copyPending[:0], keys.ESC)
194+ ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
195+ ac.copyMu.Lock()
196+ if ac.copyESC.timer != timer {
197+ ac.copyMu.Unlock()
198+ return
199+ }
200+ ac.copyPending = nil
201+ ac.copyESC.timer = nil
202+ ac.copyESC.done = nil
203+ if ac.copyMode != mode || ac.copyMode == nil {
204+ ac.copyMu.Unlock()
205+ return
206+ }
207+ ac.copyMode = nil
208+ ac.copyMu.Unlock()
209+
210+ if sess := ac.currentSession(); sess != nil {
211+ d.paint(sess, ac, true)
212+ }
213+ })
214+}
215+
216+func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
217+ ac.copyESC.stop()
218+}
219+
220+func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
221+ if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
222+ switch data[2] {
223+ case 'A':
224+ m.Move(snap, -1)
225+ return 3, true
226+ case 'B':
227+ m.Move(snap, 1)
228+ return 3, true
229+ }
230+ }
231+ if len(data) >= 4 && data[1] == '[' && data[3] == '~' {
232+ switch data[2] {
233+ case '5':
234+ m.Page(snap, -1)
235+ return 4, true
236+ case '6':
237+ m.Page(snap, 1)
238+ return 4, true
239+ }
240+ }
241+ return 0, false
242+}
243+
244+func isCopyEscapePrefix(data []byte) bool {
245+ return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') ||
246+ len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
247+}
248+
249+func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) {
250+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
251+ frame := mode.Render(snap)
252+ return frame, []renderer.Damage{renderer.FullRedraw()}
253+}
A internal/usecase/daemon/copymode_test.go +388 -0
@@ -0,0 +1,388 @@
1+package daemon
2+
3+import (
4+ "encoding/base64"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/ports"
18+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
19+ scopy "github.com/bnema/vev/internal/usecase/copy"
20+ "github.com/bnema/vev/pkg/renderer"
21+)
22+
23+// --- test doubles -----------------------------------------------------------
24+
25+// stubClock returns timers whose channel never fires, so a scheduler under it
26+// blocks in its debounce loop until the session context is cancelled. Used by
27+
28+func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
29+ writes := make(chan []byte, 1)
30+ p, _ := newBlockingPTYWithWrites(t, writes)
31+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
32+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
33+ sess.tabs[0].screen.Write([]byte("live"))
34+
35+ d.handleInput(sess, ac, []byte("\x1b "))
36+ awaitFrame(t, sends, ports.MsgOutput)
37+ d.handleInput(sess, ac, []byte("CPY\r"))
38+
39+ if ac.copyMode == nil {
40+ t.Fatal("scrollback mode not entered")
41+ }
42+ select {
43+ case got := <-writes:
44+ t.Fatalf("scrollback binding forwarded to PTY: %q", got)
45+ default:
46+ }
47+ out := awaitFrame(t, sends, ports.MsgOutput)
48+ msg, err := ports.UnmarshalOutput(out.Payload)
49+ require.NoError(t, err)
50+ if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[COPY]") {
51+ t.Fatalf("scrollback mode paint = %q, want [SCROLL] without [COPY]", got)
52+ }
53+
54+ d.handleInput(sess, ac, []byte(" "))
55+ out = awaitFrame(t, sends, ports.MsgOutput)
56+ msg, err = ports.UnmarshalOutput(out.Payload)
57+ require.NoError(t, err)
58+ if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SCROLL]") {
59+ t.Fatalf("visual selection paint = %q, want [VISUAL] without [SCROLL]", got)
60+ }
61+}
62+
63+func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
64+ writes := make(chan []byte, 1)
65+ p, _ := newBlockingPTYWithWrites(t, writes)
66+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
67+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
68+ sess.tabs[0].scrollback.Append(testRow("old1 "))
69+ sess.tabs[0].scrollback.Append(testRow("old2 "))
70+ sess.tabs[0].screen.Write([]byte("live"))
71+
72+ d.enterCopyMode(sess, ac)
73+ awaitFrame(t, sends, ports.MsgOutput)
74+ d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'})
75+
76+ select {
77+ case got := <-writes:
78+ t.Fatalf("copy-mode navigation forwarded to PTY: %q", got)
79+ default:
80+ }
81+ out := awaitFrame(t, sends, ports.MsgOutput)
82+ msg, err := ports.UnmarshalOutput(out.Payload)
83+ require.NoError(t, err)
84+ if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {
85+ t.Fatalf("OSC52 = %q, want %q", got, want)
86+ }
87+ if ac.copyMode != nil {
88+ t.Fatal("copy mode still active after yank")
89+ }
90+ live := awaitFrame(t, sends, ports.MsgOutput)
91+ liveMsg, err := ports.UnmarshalOutput(live.Payload)
92+ require.NoError(t, err)
93+ if strings.Contains(string(liveMsg.Data), "[COPY]") || strings.Contains(string(liveMsg.Data), "[SCROLL]") {
94+ t.Fatalf("live repaint still contains copy/scroll status: %q", string(liveMsg.Data))
95+ }
96+ if !strings.Contains(string(liveMsg.Data), "copied 9 chars to clipboard") {
97+ t.Fatalf("live repaint = %q, want copy success feedback", string(liveMsg.Data))
98+ }
99+ if !strings.Contains(string(liveMsg.Data), "live") {
100+ t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
101+ }
102+
103+ d.paint(sess, ac, true)
104+ followup := awaitFrame(t, sends, ports.MsgOutput)
105+ followupMsg, err := ports.UnmarshalOutput(followup.Payload)
106+ require.NoError(t, err)
107+ if strings.Contains(string(followupMsg.Data), "copied 9 chars to clipboard") {
108+ t.Fatalf("copy feedback persisted after next repaint: %q", string(followupMsg.Data))
109+ }
110+}
111+
112+func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
113+ p := portsmocks.NewMockPTY(t)
114+ reads := make(chan []byte, 64)
115+ readDone := make(chan struct{})
116+ var closeOnce sync.Once
117+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
118+ select {
119+ case data := <-reads:
120+ return copy(b, data), nil
121+ case <-readDone:
122+ return 0, io.EOF
123+ }
124+ }).Maybe()
125+ p.EXPECT().Write(mock.Anything).Return(0, errors.New("unexpected PTY write")).Maybe()
126+ p.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
127+ p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()
128+ p.EXPECT().Pid().Return(4242).Maybe()
129+
130+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
131+ tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))
132+ var hg sync.WaitGroup
133+ hg.Go(func() { d.handleConn(tr) })
134+ awaitFrame(t, sends, ports.MsgWelcome)
135+ awaitFrame(t, sends, ports.MsgOutput)
136+
137+ for i := range 12 {
138+ reads <- fmt.Appendf(nil, "line-%02d\r\n", i)
139+ }
140+ require.Eventually(t, func() bool {
141+ sess := firstSession(d)
142+ if sess == nil {
143+ return false
144+ }
145+ win := sess.activeTab()
146+ if win == nil {
147+ return false
148+ }
149+ win.mu.Lock()
150+ defer win.mu.Unlock()
151+ return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12
152+ }, 2*time.Second, 5*time.Millisecond)
153+
154+ sess := firstSession(d)
155+ require.NotNil(t, sess)
156+ ac := sess.client
157+ require.NotNil(t, ac)
158+ d.handleInput(sess, ac, []byte("\x1b "))
159+ awaitFrame(t, sends, ports.MsgOutput)
160+ d.handleInput(sess, ac, []byte("CPY\r"))
161+ awaitFrame(t, sends, ports.MsgOutput)
162+ d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
163+
164+ var payload string
165+ require.Eventually(t, func() bool {
166+ out := awaitFrame(t, sends, ports.MsgOutput)
167+ msg, err := ports.UnmarshalOutput(out.Payload)
168+ require.NoError(t, err)
169+ payload = string(msg.Data)
170+ return strings.HasPrefix(payload, "\x1b]52;c;")
171+ }, 2*time.Second, 5*time.Millisecond)
172+ require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
173+ require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload)
174+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
175+ require.NoError(t, err)
176+ require.True(t, strings.HasPrefix(string(decoded), "line-00"), "decoded yank = %q", string(decoded))
177+
178+ releaseConn()
179+ closeOnce.Do(func() { close(readDone) })
180+ hg.Wait()
181+ d.sessWg.Wait()
182+}
183+
184+func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
185+ p, _ := newBlockingPTY(t)
186+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
187+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
188+ sess.tabs[0].screen.Write([]byte("live"))
189+
190+ d.enterCopyMode(sess, ac)
191+ awaitFrame(t, sends, ports.MsgOutput)
192+ d.handleInput(sess, ac, []byte("q"))
193+
194+ if ac.copyMode != nil {
195+ t.Fatal("copy mode still active after q")
196+ }
197+ out := awaitFrame(t, sends, ports.MsgOutput)
198+ msg, err := ports.UnmarshalOutput(out.Payload)
199+ require.NoError(t, err)
200+ if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
201+ t.Fatalf("exit repaint = %q, want live full repaint without copy status", string(msg.Data))
202+ }
203+}
204+
205+func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
206+ cases := []struct {
207+ name string
208+ input [][]byte
209+ wantCursor int
210+ }{
211+ {name: "escape then up arrow", input: [][]byte{[]byte("\x1b"), []byte("[A")}, wantCursor: 23},
212+ {name: "escape then down arrow", input: [][]byte{[]byte("g"), []byte("\x1b"), []byte("[B")}, wantCursor: 1},
213+ {name: "split up arrow", input: [][]byte{[]byte("\x1b["), []byte("A")}, wantCursor: 23},
214+ {name: "split SS3 up arrow", input: [][]byte{[]byte("\x1bO"), []byte("A")}, wantCursor: 23},
215+ {name: "split page down", input: [][]byte{[]byte("g"), []byte("\x1b[6"), []byte("~")}, wantCursor: 23},
216+ }
217+ for _, tc := range cases {
218+ t.Run(tc.name, func(t *testing.T) {
219+ p, _ := newBlockingPTY(t)
220+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
221+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
222+ sess.tabs[0].scrollback.Append(testRow("old1 "))
223+ sess.tabs[0].scrollback.Append(testRow("old2 "))
224+ sess.tabs[0].screen.Write([]byte("live"))
225+
226+ d.enterCopyMode(sess, ac)
227+ awaitFrame(t, sends, ports.MsgOutput)
228+ for _, input := range tc.input {
229+ d.handleInput(sess, ac, input)
230+ }
231+
232+ require.NotNil(t, ac.copyMode)
233+ require.Equal(t, tc.wantCursor, ac.copyMode.Cursor)
234+ })
235+ }
236+}
237+
238+func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
239+ p, _ := newBlockingPTY(t)
240+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
241+ sess.tabs[0].scrollback = scopy.NewScrollback(1)
242+ longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
243+ sess.tabs[0].screen.Frame = renderer.NewFrame(len(longLine), 1)
244+ copy(sess.tabs[0].screen.Frame.Row(0), testRow(longLine))
245+
246+ d.enterCopyMode(sess, ac)
247+ awaitFrame(t, sends, ports.MsgOutput)
248+ d.handleInput(sess, ac, []byte{' ', 'y'})
249+
250+ out := awaitFrame(t, sends, ports.MsgOutput)
251+ msg, err := ports.UnmarshalOutput(out.Payload)
252+ require.NoError(t, err)
253+ if strings.Contains(string(msg.Data), "\x1b]52;") {
254+ t.Fatalf("oversized yank emitted OSC52: %q", string(msg.Data))
255+ }
256+ if !strings.Contains(string(msg.Data), "selection too large to copy") {
257+ t.Fatalf("oversized yank repaint = %q, want too-large feedback", string(msg.Data))
258+ }
259+}
260+
261+func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
262+ p, _ := newBlockingPTY(t)
263+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
264+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
265+ d.clock = clk
266+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
267+ sess.tabs[0].screen.Write([]byte("live"))
268+
269+ d.enterCopyMode(sess, ac)
270+ awaitFrame(t, sends, ports.MsgOutput)
271+ d.handleInput(sess, ac, []byte("\x1b"))
272+ timer := <-clk.timers
273+ ac.copyMu.Lock()
274+ require.NotNil(t, ac.copyMode)
275+ ac.copyMu.Unlock()
276+ timer.ch <- time.Now()
277+ require.Eventually(t, func() bool {
278+ ac.copyMu.Lock()
279+ defer ac.copyMu.Unlock()
280+ return ac.copyMode == nil
281+ }, time.Second, 5*time.Millisecond)
282+}
283+
284+func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
285+ p, _ := newBlockingPTY(t)
286+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
287+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
288+ d.clock = clk
289+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
290+ sess.tabs[0].screen.Write([]byte("live"))
291+
292+ d.enterCopyMode(sess, ac)
293+ awaitFrame(t, sends, ports.MsgOutput)
294+ d.handleInput(sess, ac, []byte("\x1b"))
295+ timer := <-clk.timers
296+ d.enterCopyMode(sess, ac)
297+ awaitFrame(t, sends, ports.MsgOutput)
298+
299+ timer.ch <- time.Now()
300+ require.Never(t, func() bool {
301+ ac.copyMu.Lock()
302+ defer ac.copyMu.Unlock()
303+ return ac.copyMode == nil
304+ }, 50*time.Millisecond, 5*time.Millisecond)
305+ ac.copyMu.Lock()
306+ require.Nil(t, ac.copyESC.timer)
307+ require.Nil(t, ac.copyESC.done)
308+ require.Empty(t, ac.copyPending)
309+ ac.copyMu.Unlock()
310+}
311+
312+func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
313+ cases := []struct {
314+ name string
315+ input []byte
316+ }{
317+ {name: "yank", input: []byte("y")},
318+ {name: "enter", input: []byte("\r")},
319+ }
320+ for _, tc := range cases {
321+ t.Run(tc.name, func(t *testing.T) {
322+ p, _ := newBlockingPTY(t)
323+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
324+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
325+ sess.tabs[0].screen.Write([]byte("live"))
326+
327+ d.enterCopyMode(sess, ac)
328+ awaitFrame(t, sends, ports.MsgOutput)
329+ d.handleInput(sess, ac, tc.input)
330+
331+ if ac.copyMode != nil {
332+ t.Fatal("copy mode still active after empty yank")
333+ }
334+ out := awaitFrame(t, sends, ports.MsgOutput)
335+ msg, err := ports.UnmarshalOutput(out.Payload)
336+ require.NoError(t, err)
337+ if strings.Contains(string(msg.Data), "\x1b]52;") {
338+ t.Fatalf("empty yank emitted OSC52 clipboard clear: %q", string(msg.Data))
339+ }
340+ if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
341+ t.Fatalf("empty yank repaint = %q, want live full repaint", string(msg.Data))
342+ }
343+ })
344+ }
345+}
346+
347+func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
348+ p, _ := newBlockingPTY(t)
349+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
350+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
351+ sess.tabs[0].screen.Write([]byte("live"))
352+
353+ done := make(chan struct{})
354+ var drain sync.WaitGroup
355+ drain.Go(func() {
356+ for {
357+ select {
358+ case <-sends:
359+ case <-done:
360+ return
361+ }
362+ }
363+ })
364+
365+ var wg sync.WaitGroup
366+ for range 100 {
367+ wg.Go(func() {
368+ d.enterCopyMode(sess, ac)
369+ d.handleInput(sess, ac, []byte("q"))
370+ })
371+ wg.Go(func() {
372+ d.paint(sess, ac, true)
373+ })
374+ }
375+ wg.Wait()
376+ close(done)
377+ drain.Wait()
378+}
379+
380+func TestCursorTailHidesInCopyMode(t *testing.T) {
381+ p, _ := newBlockingPTY(t)
382+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
383+ sess.tabs[0].screen.Write([]byte("live"))
384+
385+ d.enterCopyMode(sess, ac)
386+ data := mustOutputData(t, sends)
387+ require.Contains(t, string(data), "\x1b[?25l")
388+}
M internal/usecase/daemon/daemon.go +24 -1739
@@ -26,27 +26,16 @@
2626 package daemon
2727
2828 import (
29- "bytes"
3029 "context"
3130 "errors"
32- "fmt"
3331 "log/slog"
3432 "os"
3533 "sort"
36- "strconv"
37- "strings"
3834 "sync"
3935 "time"
4036
4137 "github.com/bnema/vev/internal/domain"
4238 "github.com/bnema/vev/internal/ports"
43- scopy "github.com/bnema/vev/internal/usecase/copy"
44- "github.com/bnema/vev/internal/usecase/keys"
45- "github.com/bnema/vev/internal/usecase/mouse"
46- "github.com/bnema/vev/internal/usecase/picker"
47- "github.com/bnema/vev/internal/usecase/ui"
48- "github.com/bnema/vev/pkg/renderer"
49- "github.com/bnema/vev/pkg/vt"
5039 )
5140
5241 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -58,9 +47,6 @@ const (
5847 maxSyncUpdateDuration = 500 * time.Millisecond
5948 )
6049
61-var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "}
62-
63-// debounceInterval is kept as a test/back-compat alias for the initial delay.
6450 const debounceInterval = minDebounceInterval
6551
6652 // ptyReadBufSize is the PTY reader's read buffer.
@@ -78,8 +64,6 @@ const detachNotifyTimeout = time.Second
7864 // defaultSize is used when a client's Hello carries no valid dimensions.
7965 var defaultSize = domain.Size{Cols: 80, Rows: 24}
8066
81-// Daemon is vev's server-side multiplexer. Construct it with New and drive it
82-// with Serve.
8367 type Daemon struct {
8468 mu sync.Mutex
8569 sessions map[domain.SessionID]*session
@@ -121,209 +105,7 @@ type Daemon struct {
121105 }
122106
123107 // session is a single multiplexed session. It owns one or more full-screen
124-// tabs and at most one attached client.
125-type session struct {
126- id domain.SessionID
127- name string
128- ephemeral bool
129-
130- ctx context.Context
131- cancel context.CancelFunc
132-
133- mu sync.Mutex // guards tabs, active, and client
134- tabs []*tab
135- active int
136- client *attachedClient
137- cwd string
138-}
139-
140-// tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
141-// applies back-pressure by collapsing (a full buffer means "a render is
142-// already pending"), never by blocking the reader.
143-type tab struct {
144- pty ports.PTY
145- mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
146- screen *vt.Screen
147- scrollback *scopy.Scrollback
148- dirty chan struct{}
149- flush chan struct{}
150- syncGen uint64
151- // previewClient tracks the one client currently previewing this tab in the picker.
152- // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
153- previewClient *attachedClient
154- size domain.Size
155- ctx context.Context
156- cancel context.CancelFunc
157-}
158-
159-// attachedClient is a client currently attached to a session's tab. rend is
160-// its private renderer shadow (so each client gets output minimised against
161-// what it has actually seen). sendMu serialises the two senders — the render
162-// scheduler and the connection handler — so the transport's single-writer
163-// contract holds and Draw→Send stays atomic (correct output ordering).
164-type attachedClient struct {
165- tr ports.Transport
166- rend *renderer.Renderer
167- size domain.Size
168- keys *keys.Router
169- sessMu sync.Mutex
170- sess *session
171- copyMu sync.Mutex
172- copyMode *scopy.Mode
173- copyPending []byte
174- copyESC pendingByteTimer
175- copyFeedback string
176- pickerMu sync.Mutex
177- picker *picker.Model
178- pickerPreview *tab
179- pickerPending []byte
180- pickerESC pendingByteTimer
181- mouseScan mouse.Scanner
182- lastCursor cursorOut
183- sendMu sync.Mutex
184-}
185-
186-type cursorOut struct {
187- valid bool
188- hidden bool
189- row int
190- col int
191- style int
192- hasStyle bool
193-}
194-
195-type pendingByteTimer struct {
196- timer ports.Timer
197- done chan struct{}
198-}
199-
200-func (p *pendingByteTimer) retain(clock ports.Clock, delay time.Duration, onFire func(ports.Timer)) {
201- p.timer = clock.NewTimer(delay)
202- p.done = make(chan struct{})
203- go func(timer ports.Timer, done <-chan struct{}) {
204- select {
205- case <-timer.C():
206- case <-done:
207- return
208- }
209- onFire(timer)
210- }(p.timer, p.done)
211-}
212-
213-func (p *pendingByteTimer) stop() {
214- if p.timer != nil {
215- p.timer.Stop()
216- p.timer = nil
217- }
218- if p.done != nil {
219- close(p.done)
220- p.done = nil
221- }
222-}
223-
224-func (ac *attachedClient) currentSession() *session {
225- ac.sessMu.Lock()
226- defer ac.sessMu.Unlock()
227- return ac.sess
228-}
229-
230-func (ac *attachedClient) setSession(sess *session) {
231- ac.sessMu.Lock()
232- defer ac.sessMu.Unlock()
233- ac.sess = sess
234-}
235-
236-func (ac *attachedClient) copyModeActive() bool {
237- ac.copyMu.Lock()
238- defer ac.copyMu.Unlock()
239- return ac.copyMode != nil
240-}
241-
242-func (ac *attachedClient) pickerActive() bool {
243- ac.pickerMu.Lock()
244- defer ac.pickerMu.Unlock()
245- return ac.picker != nil
246-}
247-
248-// send serialises a frame onto the client's transport.
249-func (ac *attachedClient) send(f ports.Frame) error {
250- ac.sendMu.Lock()
251- defer ac.sendMu.Unlock()
252- return ac.tr.Send(f)
253-}
254-
255-// boundedSend sends f to ac with a deadline watchdog: if the send (including
256-// waiting on sendMu behind a wedged paint) does not complete within
257-// detachNotifyTimeout, the transport is force-closed, failing the in-flight
258-// write. Detach/kill/shutdown paths use this so they are never gated on a
259-// client that has stopped draining its socket.
260-func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
261- _ = d.boundedSendErr(ac, f)
262-}
263108
264-func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
265- timer := d.clock.NewTimer(detachNotifyTimeout)
266- result := make(chan error, 1)
267- go func() {
268- result <- ac.send(f)
269- }()
270- select {
271- case err := <-result:
272- timer.Stop()
273- return err
274- case <-timer.C():
275- select {
276- case err := <-result:
277- return err
278- default:
279- }
280- _ = ac.tr.Close()
281- return errors.New("send timed out")
282- }
283-}
284-
285-// notifyDetachedAsync delivers a best-effort Detached notice off the caller's
286-// goroutine and then closes the transport. Session teardown (killSession) must
287-// complete regardless of client state, so the notice is both asynchronous and
288-// deadline-bounded; Serve waits for pending notices before force-closing
289-// connections so a graceful notice is not raced by the hard close.
290-func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
291- done := make(chan struct{})
292- d.mu.Lock()
293- // Prune completed entries so the slice stays bounded by the number of
294- // notifications actually in flight.
295- kept := d.notifies[:0]
296- for _, c := range d.notifies {
297- select {
298- case <-c:
299- default:
300- kept = append(kept, c)
301- }
302- }
303- d.notifies = append(kept, done)
304- d.mu.Unlock()
305-
306- go func() {
307- defer close(done)
308- d.boundedSend(ac, frameDetached(reason))
309- _ = ac.tr.Close()
310- }()
311-}
312-
313-// waitNotifies blocks until every Detached notification in flight at the time
314-// of the call has completed. Each is deadline-bounded (boundedSend), so this
315-// wait is bounded too.
316-func (d *Daemon) waitNotifies() {
317- d.mu.Lock()
318- snapshot := make([]chan struct{}, len(d.notifies))
319- copy(snapshot, d.notifies)
320- d.mu.Unlock()
321- for _, c := range snapshot {
322- <-c
323- }
324-}
325-
326-// Option customises a Daemon at construction.
327109 type Option func(*Daemon)
328110
329111 // WithShell overrides the command (and its args) each session spawns. The
@@ -417,10 +199,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
417199 func (d *Daemon) shutdownAll(reason uint8) {
418200 d.mu.Lock()
419201 d.closing = true
420- snapshot := make([]*session, 0, len(d.sessions))
421- for _, s := range d.sessions {
422- snapshot = append(snapshot, s)
423- }
202+ snapshot := d.sessionsSnapshotLocked()
424203 empty := len(snapshot) == 0
425204 d.mu.Unlock()
426205 if empty {
@@ -432,6 +211,14 @@ func (d *Daemon) shutdownAll(reason uint8) {
432211 }
433212 }
434213
214+func (d *Daemon) sessionsSnapshotLocked() []*session {
215+ snapshot := make([]*session, 0, len(d.sessions))
216+ for _, s := range d.sessions {
217+ snapshot = append(snapshot, s)
218+ }
219+ return snapshot
220+}
221+
435222 // handleConn reads the first frame off a fresh connection and routes it. A
436223 // context watcher closes the transport on serve-context cancel so a handler
437224 // parked in Recv (mid-handshake or between input frames) unwinds on shutdown.
@@ -577,11 +364,14 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
577364 case ports.IntentEphemeral:
578365 name := d.allocEphemeralNameLocked()
579366 sess, err := d.createSessionLocked(name, true, h.Cwd, sz)
580- d.mu.Unlock()
581367 if err != nil {
368+ d.mu.Unlock()
582369 return nil, nil, err
583370 }
584- return sess, d.attachClient(sess, tr, sz), nil
371+ ac, old := d.attachClient(sess, tr, sz)
372+ d.mu.Unlock()
373+ d.detachReplacedClient(old)
374+ return sess, ac, nil
585375
586376 case ports.IntentNew:
587377 if h.Name == "" {
@@ -593,1533 +383,28 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
593383 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
594384 }
595385 sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz)
596- d.mu.Unlock()
597386 if err != nil {
387+ d.mu.Unlock()
598388 return nil, nil, err
599389 }
600- return sess, d.attachClient(sess, tr, sz), nil
390+ ac, old := d.attachClient(sess, tr, sz)
391+ d.mu.Unlock()
392+ d.detachReplacedClient(old)
393+ return sess, ac, nil
601394
602395 case ports.IntentAttach:
603396 sess := d.findByNameLocked(h.Name)
604- d.mu.Unlock()
605397 if sess == nil {
398+ d.mu.Unlock()
606399 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
607400 }
608- return sess, d.attachClient(sess, tr, sz), nil
401+ ac, old := d.attachClient(sess, tr, sz)
402+ d.mu.Unlock()
403+ d.detachReplacedClient(old)
404+ return sess, ac, nil
609405
610406 default:
611407 d.mu.Unlock()
612408 return nil, nil, &protoErr{ports.ErrInternal, "unknown intent"}
613409 }
614410 }
615-
616-// createSessionLocked spawns a PTY-backed session and starts its reader and
617-// scheduler goroutines. Caller holds d.mu.
618-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
619- tbSize := tabSize(sz)
620- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
621- if err != nil {
622- return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
623- }
624-
625- id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
626- d.nextID++
627-
628- tb := newTab(pty, tbSize)
629- sctx, cancel := context.WithCancel(d.serveCtx)
630- tb.ctx, tb.cancel = context.WithCancel(sctx)
631- sess := &session{
632- id: id,
633- name: name,
634- ephemeral: ephemeral,
635- ctx: sctx,
636- cancel: cancel,
637- tabs: []*tab{tb},
638- cwd: cwd,
639- }
640- d.sessions[id] = sess
641- d.startTabGoroutines(sess, tb)
642- return sess, nil
643-}
644-
645-func (d *Daemon) createTab(sess *session, sz domain.Size) error {
646- tbSize := tabSize(sz)
647- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sess.cwd, tbSize)
648- if err != nil {
649- return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err)
650- }
651- tb := newTab(pty, tbSize)
652- tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
653- sess.mu.Lock()
654- sess.tabs = append(sess.tabs, tb)
655- sess.active = len(sess.tabs) - 1
656- sess.mu.Unlock()
657- d.startTabGoroutines(sess, tb)
658- return nil
659-}
660-
661-func newTab(pty ports.PTY, sz domain.Size) *tab {
662- sb := scopy.NewScrollback(defaultScrollbackRows)
663- screen := vt.NewScreen(sz.Cols, sz.Rows)
664- screen.OnLineEvicted = sb.Append
665- return &tab{
666- pty: pty,
667- screen: screen,
668- scrollback: sb,
669- dirty: make(chan struct{}, 1),
670- flush: make(chan struct{}, 1),
671- size: sz,
672- }
673-}
674-
675-func tabSize(clientSize domain.Size) domain.Size {
676- if !clientSize.Valid() {
677- clientSize = defaultSize
678- }
679- rows := max(clientSize.Rows-1, 1)
680- return domain.Size{Cols: clientSize.Cols, Rows: rows}
681-}
682-
683-func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
684- d.sessWg.Add(2)
685- go d.ptyReader(sess, tb)
686- go d.scheduler(sess, tb)
687-}
688-
689-// attachClient makes ac the session's current client, displacing any prior one
690-// (which is notified with ReasonDetach and disconnected — its own conn handler
691-// then unwinds without killing the session, since it is no longer current).
692-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) *attachedClient {
693- ac := &attachedClient{
694- tr: tr,
695- rend: renderer.New(renderer.Capabilities{}),
696- size: sz,
697- }
698- ac.setSession(sess)
699- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
700- sess.mu.Lock()
701- old := sess.client
702- sess.client = ac
703- sess.mu.Unlock()
704-
705- if old != nil {
706- d.unregisterPreview(old)
707- old.setSession(nil)
708- // Async + bounded: a dead or wedged old client must not stall the new
709- // client's handshake.
710- d.notifyDetachedAsync(old, ports.ReasonDetach)
711- }
712- return ac
713-}
714-
715-// firstPaint guarantees the freshly attached client sees the full screen: if
716-// the tab size differs from the client's it resizes first (which paints a
717-// full redraw), otherwise it forces a full paint against the fresh renderer.
718-func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
719- tb := sess.activeTab()
720- if tb == nil {
721- return
722- }
723- tb.mu.Lock()
724- wsz := tb.size
725- tb.mu.Unlock()
726-
727- if clientSize.Valid() && wsz != tabSize(clientSize) {
728- d.resize(sess, ac, clientSize)
729- return
730- }
731- d.paint(sess, ac, true)
732-}
733-
734-// runConnLoop is the per-connection input router: it pumps client messages
735-// until detach, EOF, or a transport error.
736-func (d *Daemon) runConnLoop(ac *attachedClient) {
737- for {
738- sess := ac.currentSession()
739- if sess == nil {
740- return
741- }
742- f, err := ac.tr.Recv()
743- if err != nil {
744- d.clientGone(sess, ac, false)
745- return
746- }
747- sess = ac.currentSession()
748- if sess == nil {
749- return
750- }
751- switch f.Type {
752- case ports.MsgInput:
753- if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
754- d.handleInput(sess, ac, in.Data)
755- }
756- case ports.MsgResize:
757- if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
758- d.resize(sess, ac, rz.Size)
759- }
760- case ports.MsgDetach:
761- d.clientGone(sess, ac, true)
762- return
763- case ports.MsgPing:
764- _ = ac.send(framePong())
765- default:
766- // Unknown/out-of-band client messages are ignored so a newer
767- // client can add message types without breaking an older daemon.
768- }
769- }
770-}
771-
772-// clientGone detaches ac if it is still the session's current client. An
773-// ephemeral session dies with its client; a named one survives headless. When
774-// explicit (a MsgDetach), the client is acked with ReasonDetach.
775-func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
776- if !sess.detachIfCurrent(ac) {
777- return // already displaced by a newer client; nothing to do
778- }
779- d.unregisterPreview(ac)
780- if explicit {
781- // Synchronous so the ack is delivered before the transport closes
782- // (the client is actively awaiting it), but deadline-bounded so a
783- // wedged client cannot pin this conn handler and hang Serve's
784- // connWg.Wait.
785- d.boundedSend(ac, frameDetached(ports.ReasonDetach))
786- }
787- _ = ac.tr.Close()
788- if sess.ephemeral {
789- d.killSession(sess, ports.ReasonSessionKilled)
790- }
791-}
792-
793-// detachIfCurrent clears the client iff ac is the current one, reporting
794-// whether it acted.
795-func (s *session) detachIfCurrent(ac *attachedClient) bool {
796- s.mu.Lock()
797- defer s.mu.Unlock()
798- if s.client == ac {
799- s.client = nil
800- ac.setSession(nil)
801- return true
802- }
803- return false
804-}
805-
806-func (s *session) activeTab() *tab {
807- s.mu.Lock()
808- defer s.mu.Unlock()
809- if s.active < 0 || s.active >= len(s.tabs) {
810- return nil
811- }
812- return s.tabs[s.active]
813-}
814-
815-func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
816- ac.mouseScan.Scan(data,
817- func(ev mouse.Event) { d.handleMouse(ac, ev) },
818- func(b []byte) {
819- if ac.pickerActive() {
820- d.handlePickerInput(ac, b)
821- return
822- }
823- if ac.copyModeActive() {
824- d.handleCopyInput(ac, b)
825- return
826- }
827- ac.keys.Route(b)
828- },
829- )
830-}
831-
832-func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
833- if ac.pickerActive() {
834- return
835- }
836- sess := ac.currentSession()
837- if sess == nil {
838- return
839- }
840- tb := sess.activeTab()
841- if tb == nil {
842- return
843- }
844-
845- if ac.copyModeActive() {
846- switch ev.Button {
847- case mouse.WheelUp:
848- d.copyWheel(sess, ac, -3)
849- case mouse.WheelDown:
850- d.copyWheel(sess, ac, 3)
851- }
852- return
853- }
854-
855- tb.mu.Lock()
856- childRows := tb.screen.Frame.Height
857- mouseMode, mouseSGR := tb.screen.MouseMode()
858- altScreen := tb.screen.AltScreenActive()
859- tb.mu.Unlock()
860-
861- if mouseMode != 0 {
862- if !mouseSGR || ev.Row >= childRows {
863- return
864- }
865- daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw)
866- return
867- }
868-
869- switch ev.Button {
870- case mouse.WheelUp:
871- if altScreen {
872- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
873- return
874- }
875- d.enterCopyMode(sess, ac)
876- d.copyWheel(sess, ac, -3)
877- case mouse.WheelDown:
878- if altScreen {
879- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
880- }
881- }
882-}
883-
884-func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
885- tb := sess.activeTab()
886- if tb == nil {
887- return
888- }
889-
890- tb.mu.Lock()
891- ac.copyMu.Lock()
892- if ac.copyMode == nil {
893- ac.copyMu.Unlock()
894- tb.mu.Unlock()
895- return
896- }
897- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
898- if delta > 0 && ac.copyMode.AtBottom(snap) {
899- ac.copyMode = nil
900- ac.copyMu.Unlock()
901- tb.mu.Unlock()
902- d.paint(sess, ac, true)
903- return
904- }
905- ac.copyMode.Move(snap, delta)
906- exit := delta > 0 && ac.copyMode.AtBottom(snap)
907- if exit {
908- ac.copyMode = nil
909- }
910- ac.copyMu.Unlock()
911- tb.mu.Unlock()
912-
913- d.paint(sess, ac, true)
914-}
915-
916-func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
917- tb := sess.activeTab()
918- if tb == nil {
919- return
920- }
921- tb.mu.Lock()
922- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
923- tb.mu.Unlock()
924- ac.copyMu.Lock()
925- ac.copyMode = scopy.NewMode(snap)
926- ac.copyMu.Unlock()
927- d.paint(sess, ac, true)
928-}
929-
930-func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
931- sess := ac.currentSession()
932- if sess == nil {
933- return
934- }
935- tb := sess.activeTab()
936- if tb == nil {
937- return
938- }
939-
940- tb.mu.Lock()
941- ac.copyMu.Lock()
942- if ac.copyMode == nil {
943- ac.copyPending = nil
944- d.stopCopyPendingTimerLocked(ac)
945- ac.copyMu.Unlock()
946- tb.mu.Unlock()
947- return
948- }
949- if len(ac.copyPending) > 0 {
950- d.stopCopyPendingTimerLocked(ac)
951- combined := make([]byte, 0, len(ac.copyPending)+len(data))
952- combined = append(combined, ac.copyPending...)
953- combined = append(combined, data...)
954- data = combined
955- ac.copyPending = nil
956- }
957- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
958- changed := false
959- copyOut := false
960- exit := false
961- for i := 0; i < len(data); i++ {
962- switch data[i] {
963- case 'j':
964- ac.copyMode.Move(snap, 1)
965- changed = true
966- case 'k':
967- ac.copyMode.Move(snap, -1)
968- changed = true
969- case 'g':
970- ac.copyMode.Top(snap)
971- changed = true
972- case 'G':
973- ac.copyMode.Bottom(snap)
974- changed = true
975- case ' ':
976- ac.copyMode.ToggleSelection()
977- changed = true
978- case '\r', '\n', 'y':
979- copyOut = true
980- exit = true
981- case 'q', 0x03, 0x1b:
982- if data[i] == 0x1b {
983- tail := data[i:]
984- consumed, ok := routeCopyEscape(ac.copyMode, snap, tail)
985- if ok {
986- i += consumed - 1
987- changed = true
988- continue
989- }
990- if len(tail) == 1 {
991- d.retainCopyESCLocked(ac)
992- break
993- }
994- if isCopyEscapePrefix(tail) {
995- ac.copyPending = append(ac.copyPending[:0], tail...)
996- break
997- }
998- }
999- exit = true
1000- }
1001- }
1002- text := ""
1003- if copyOut {
1004- text = ac.copyMode.SelectedText(snap)
1005- }
1006- if exit {
1007- d.stopCopyPendingTimerLocked(ac)
1008- ac.copyMode = nil
1009- }
1010- ac.copyMu.Unlock()
1011- tb.mu.Unlock()
1012-
1013- if copyOut && text != "" {
1014- chunks := scopy.OSC52(text)
1015- for _, chunk := range chunks {
1016- if err := d.boundedSendErr(ac, frameOutput(chunk)); err != nil {
1017- d.detachOnSendError(sess, ac)
1018- return
1019- }
1020- }
1021- ac.copyMu.Lock()
1022- if len(chunks) > 0 {
1023- ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
1024- } else {
1025- ac.copyFeedback = "selection too large to copy"
1026- }
1027- ac.copyMu.Unlock()
1028- }
1029- if exit {
1030- d.paint(sess, ac, true)
1031- return
1032- }
1033- if changed {
1034- d.paint(sess, ac, true)
1035- }
1036-}
1037-
1038-func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
1039- mode := ac.copyMode
1040- ac.copyPending = append(ac.copyPending[:0], keys.ESC)
1041- ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
1042- ac.copyMu.Lock()
1043- if ac.copyESC.timer != timer {
1044- ac.copyMu.Unlock()
1045- return
1046- }
1047- ac.copyPending = nil
1048- ac.copyESC.timer = nil
1049- ac.copyESC.done = nil
1050- if ac.copyMode != mode || ac.copyMode == nil {
1051- ac.copyMu.Unlock()
1052- return
1053- }
1054- ac.copyMode = nil
1055- ac.copyMu.Unlock()
1056-
1057- if sess := ac.currentSession(); sess != nil {
1058- d.paint(sess, ac, true)
1059- }
1060- })
1061-}
1062-
1063-func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
1064- ac.copyESC.stop()
1065-}
1066-
1067-func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
1068- if len(data) >= 3 && data[1] == '[' {
1069- switch data[2] {
1070- case 'A':
1071- m.Move(snap, -1)
1072- return 3, true
1073- case 'B':
1074- m.Move(snap, 1)
1075- return 3, true
1076- }
1077- }
1078- if len(data) >= 4 && data[1] == '[' && data[3] == '~' {
1079- switch data[2] {
1080- case '5':
1081- m.Page(snap, -1)
1082- return 4, true
1083- case '6':
1084- m.Page(snap, 1)
1085- return 4, true
1086- }
1087- }
1088- return 0, false
1089-}
1090-
1091-func isCopyEscapePrefix(data []byte) bool {
1092- return len(data) == 2 && data[0] == 0x1b && data[1] == '[' ||
1093- len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
1094-}
1095-
1096-func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
1097- views, curTab := d.pickerViews(sess)
1098- ac.pickerMu.Lock()
1099- ac.picker = picker.New(views, sess.id, curTab)
1100- ac.pickerPending = nil
1101- ac.pickerPreview = nil
1102- ac.pickerMu.Unlock()
1103- d.registerPreviewForSelection(ac)
1104- d.paint(sess, ac, true)
1105-}
1106-
1107-func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
1108- d.mu.Lock()
1109- sessions := make([]*session, 0, len(d.sessions))
1110- for _, s := range d.sessions {
1111- sessions = append(sessions, s)
1112- }
1113- d.mu.Unlock()
1114- sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
1115-
1116- views := make([]picker.SessionView, 0, len(sessions))
1117- curTab := 0
1118- for _, s := range sessions {
1119- s.mu.Lock()
1120- view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
1121- for i := range s.tabs {
1122- view.Tabs[i] = strconv.Itoa(i + 1)
1123- }
1124- if s == cur {
1125- curTab = s.active
1126- }
1127- s.mu.Unlock()
1128- views = append(views, view)
1129- }
1130- return views, curTab
1131-}
1132-
1133-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
1134- sess := ac.currentSession()
1135- if sess == nil {
1136- return
1137- }
1138- ac.pickerMu.Lock()
1139- if ac.picker == nil {
1140- ac.pickerPending = nil
1141- d.stopPickerPendingTimerLocked(ac)
1142- ac.pickerMu.Unlock()
1143- return
1144- }
1145- if len(ac.pickerPending) > 0 {
1146- d.stopPickerPendingTimerLocked(ac)
1147- combined := make([]byte, 0, len(ac.pickerPending)+len(data))
1148- combined = append(combined, ac.pickerPending...)
1149- combined = append(combined, data...)
1150- data = combined
1151- ac.pickerPending = nil
1152- }
1153- changed := false
1154- exit := false
1155- switchTarget := false
1156- for i := 0; i < len(data); i++ {
1157- switch data[i] {
1158- case 'j':
1159- ac.picker.Down()
1160- changed = true
1161- case 'k':
1162- ac.picker.Up()
1163- changed = true
1164- case '\r', '\n':
1165- switchTarget = true
1166- exit = true
1167- case 'q', 0x03, 0x1b:
1168- if data[i] == 0x1b {
1169- tail := data[i:]
1170- consumed, ok := routePickerEscape(ac.picker, tail)
1171- if ok {
1172- i += consumed - 1
1173- changed = true
1174- continue
1175- }
1176- if len(tail) == 1 {
1177- d.retainPickerESCLocked(ac)
1178- break
1179- }
1180- if isPickerEscapePrefix(tail) {
1181- ac.pickerPending = append(ac.pickerPending[:0], tail...)
1182- break
1183- }
1184- }
1185- exit = true
1186- }
1187- }
1188- var target picker.Target
1189- var ok bool
1190- if switchTarget {
1191- target, ok = ac.picker.Selected()
1192- }
1193- ac.pickerMu.Unlock()
1194-
1195- if changed {
1196- d.registerPreviewForSelection(ac)
1197- }
1198- if exit {
1199- d.closePicker(ac)
1200- }
1201- if switchTarget && ok {
1202- d.switchToTarget(sess, ac, target)
1203- return
1204- }
1205- if exit || changed {
1206- d.paint(sess, ac, true)
1207- }
1208-}
1209-
1210-func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
1211- ac.pickerPending = append(ac.pickerPending[:0], keys.ESC)
1212- ac.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
1213- ac.pickerMu.Lock()
1214- if ac.pickerESC.timer != timer || len(ac.pickerPending) != 1 || ac.pickerPending[0] != keys.ESC || ac.picker == nil {
1215- ac.pickerMu.Unlock()
1216- return
1217- }
1218- ac.pickerPending = nil
1219- ac.pickerESC.timer = nil
1220- ac.pickerESC.done = nil
1221- ac.picker = nil
1222- ac.pickerMu.Unlock()
1223-
1224- d.unregisterPreview(ac)
1225- if sess := ac.currentSession(); sess != nil {
1226- d.paint(sess, ac, true)
1227- }
1228- })
1229-}
1230-
1231-func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
1232- ac.pickerESC.stop()
1233-}
1234-
1235-func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
1236- if len(data) >= 3 && data[1] == '[' {
1237- switch data[2] {
1238- case 'A':
1239- m.Up()
1240- return 3, true
1241- case 'B':
1242- m.Down()
1243- return 3, true
1244- }
1245- }
1246- return 0, false
1247-}
1248-
1249-func isPickerEscapePrefix(data []byte) bool {
1250- return len(data) == 2 && data[0] == 0x1b && data[1] == '['
1251-}
1252-
1253-func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
1254- ac.pickerMu.Lock()
1255- var target picker.Target
1256- var ok bool
1257- if ac.picker != nil {
1258- target, ok = ac.picker.Selected()
1259- }
1260- ac.pickerMu.Unlock()
1261-
1262- var next *tab
1263- if ok {
1264- next = d.tabByTarget(target)
1265- }
1266-
1267- ac.pickerMu.Lock()
1268- old := ac.pickerPreview
1269- if ac.picker == nil {
1270- next = nil
1271- }
1272- ac.pickerPreview = next
1273- ac.pickerMu.Unlock()
1274-
1275- if old != nil && old != next {
1276- old.mu.Lock()
1277- if old.previewClient == ac {
1278- old.previewClient = nil
1279- }
1280- old.mu.Unlock()
1281- }
1282- if next != nil {
1283- next.mu.Lock()
1284- next.previewClient = ac
1285- next.mu.Unlock()
1286-
1287- ac.pickerMu.Lock()
1288- keep := ac.pickerPreview == next
1289- ac.pickerMu.Unlock()
1290- if !keep {
1291- next.mu.Lock()
1292- if next.previewClient == ac {
1293- next.previewClient = nil
1294- }
1295- next.mu.Unlock()
1296- }
1297- }
1298-}
1299-
1300-func (d *Daemon) unregisterPreview(ac *attachedClient) {
1301- ac.pickerMu.Lock()
1302- old := ac.pickerPreview
1303- ac.pickerPreview = nil
1304- ac.pickerMu.Unlock()
1305-
1306- if old != nil {
1307- old.mu.Lock()
1308- if old.previewClient == ac {
1309- old.previewClient = nil
1310- }
1311- old.mu.Unlock()
1312- }
1313-}
1314-
1315-func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
1316- tb.mu.Lock()
1317- previewer := tb.previewClient
1318- tb.mu.Unlock()
1319- if previewer == nil {
1320- return
1321- }
1322- cleared := false
1323- previewer.pickerMu.Lock()
1324- if previewer.pickerPreview == tb {
1325- previewer.pickerPreview = nil
1326- cleared = true
1327- }
1328- previewer.pickerMu.Unlock()
1329- tb.mu.Lock()
1330- if tb.previewClient == previewer {
1331- tb.previewClient = nil
1332- }
1333- tb.mu.Unlock()
1334- if cleared {
1335- if sess := previewer.currentSession(); sess != nil {
1336- d.paint(sess, previewer, true)
1337- }
1338- }
1339-}
1340-
1341-func (d *Daemon) closePicker(ac *attachedClient) {
1342- ac.pickerMu.Lock()
1343- ac.picker = nil
1344- ac.pickerPending = nil
1345- d.stopPickerPendingTimerLocked(ac)
1346- ac.pickerMu.Unlock()
1347- d.unregisterPreview(ac)
1348-}
1349-
1350-func (d *Daemon) tabByTarget(target picker.Target) *tab {
1351- d.mu.Lock()
1352- sess := d.sessions[target.Session]
1353- d.mu.Unlock()
1354- if sess == nil {
1355- return nil
1356- }
1357- sess.mu.Lock()
1358- defer sess.mu.Unlock()
1359- if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
1360- return nil
1361- }
1362- return sess.tabs[target.TabIndex]
1363-}
1364-
1365-func (d *Daemon) sessionByID(id domain.SessionID) *session {
1366- d.mu.Lock()
1367- defer d.mu.Unlock()
1368- return d.sessions[id]
1369-}
1370-
1371-func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
1372- targetSess := d.sessionByID(target.Session)
1373- if targetSess == nil {
1374- d.paint(sess, ac, true)
1375- return
1376- }
1377- if targetSess == sess {
1378- sess.switchTab(target.TabIndex)
1379- d.paint(sess, ac, true)
1380- return
1381- }
1382- old := d.stealClientForTarget(sess, ac, targetSess, target)
1383- if ac.currentSession() != targetSess {
1384- d.paint(sess, ac, true)
1385- return
1386- }
1387- if old != nil && old != ac {
1388- d.unregisterPreview(old)
1389- old.setSession(nil)
1390- d.notifyDetachedAsync(old, ports.ReasonDetach)
1391- }
1392- d.firstPaint(targetSess, ac, ac.size)
1393-}
1394-
1395-func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
1396- d.mu.Lock()
1397- if d.sessions[target.Session] != targetSess {
1398- d.mu.Unlock()
1399- return nil
1400- }
1401- from.mu.Lock()
1402- if from.client != ac {
1403- from.mu.Unlock()
1404- d.mu.Unlock()
1405- return nil
1406- }
1407- from.client = nil
1408- ac.setSession(nil)
1409- from.mu.Unlock()
1410-
1411- targetSess.mu.Lock()
1412- old := targetSess.client
1413- targetSess.client = ac
1414- if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
1415- targetSess.active = target.TabIndex
1416- }
1417- targetSess.mu.Unlock()
1418- ac.setSession(targetSess)
1419- d.mu.Unlock()
1420- return old
1421-}
1422-
1423-type daemonKeyHandler struct {
1424- d *Daemon
1425- ac *attachedClient
1426-}
1427-
1428-func (h daemonKeyHandler) Forward(data []byte) {
1429- sess := h.ac.currentSession()
1430- if sess == nil {
1431- return
1432- }
1433- tb := sess.activeTab()
1434- if tb == nil {
1435- return
1436- }
1437- if _, err := tb.pty.Write(data); err != nil {
1438- h.d.log.Error("pty write failed", "err", err, "session", sess.name)
1439- }
1440-}
1441-
1442-func (h daemonKeyHandler) Action(action keys.Action) {
1443- sess := h.ac.currentSession()
1444- if sess == nil {
1445- return
1446- }
1447- switch action {
1448- case keys.ActionCreateTab:
1449- if err := h.d.createTab(sess, h.ac.size); err != nil {
1450- h.d.log.Error("create tab failed", "err", err, "session", sess.name)
1451- return
1452- }
1453- h.d.paint(sess, h.ac, true)
1454- case keys.ActionNextTab:
1455- if sess.switchRelative(1) {
1456- h.d.paint(sess, h.ac, true)
1457- }
1458- case keys.ActionPreviousTab:
1459- if sess.switchRelative(-1) {
1460- h.d.paint(sess, h.ac, true)
1461- }
1462- case keys.ActionDetach:
1463- h.d.clientGone(sess, h.ac, true)
1464- case keys.ActionCloseTab:
1465- tb := sess.activeTab()
1466- if tb != nil {
1467- h.d.closeTab(sess, tb, true)
1468- }
1469- case keys.ActionCopyMode:
1470- h.d.enterCopyMode(sess, h.ac)
1471- case keys.ActionRenameSession:
1472- sess.promoteEphemeral()
1473- h.d.paint(sess, h.ac, true)
1474- case keys.ActionOpenPicker:
1475- h.d.enterPicker(sess, h.ac)
1476- case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
1477- keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
1478- keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
1479- idx := int(action - keys.ActionSwitchTab1)
1480- if sess.switchTab(idx) {
1481- h.d.paint(sess, h.ac, true)
1482- }
1483- }
1484-}
1485-
1486-func (s *session) switchTab(idx int) bool {
1487- s.mu.Lock()
1488- defer s.mu.Unlock()
1489- if idx < 0 || idx >= len(s.tabs) || idx == s.active {
1490- return false
1491- }
1492- s.active = idx
1493- return true
1494-}
1495-
1496-func (s *session) switchRelative(delta int) bool {
1497- s.mu.Lock()
1498- defer s.mu.Unlock()
1499- if len(s.tabs) < 2 {
1500- return false
1501- }
1502- s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
1503- return true
1504-}
1505-
1506-// promoteEphemeral is M3's promptless Alt+r behavior: without a naming UI yet,
1507-// the current ephemeral numeric name is kept and the session is made persistent.
1508-func (s *session) promoteEphemeral() {
1509- s.mu.Lock()
1510- defer s.mu.Unlock()
1511- s.ephemeral = false
1512-}
1513-
1514-func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
1515- sess.mu.Lock()
1516- idx := -1
1517- for i, w := range sess.tabs {
1518- if w == tb {
1519- idx = i
1520- break
1521- }
1522- }
1523- if idx == -1 {
1524- sess.mu.Unlock()
1525- return
1526- }
1527- if len(sess.tabs) == 1 {
1528- sess.mu.Unlock()
1529- d.killSession(sess, ports.ReasonSessionKilled)
1530- return
1531- }
1532- sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
1533- if sess.active >= len(sess.tabs) {
1534- sess.active = len(sess.tabs) - 1
1535- } else if idx < sess.active {
1536- sess.active--
1537- }
1538- ac := sess.client
1539- sess.mu.Unlock()
1540-
1541- d.clearDestroyedTabPreview(tb)
1542- if tb.cancel != nil {
1543- tb.cancel()
1544- }
1545- _ = tb.pty.Close()
1546- if repaint && ac != nil {
1547- d.paint(sess, ac, true)
1548- }
1549-}
1550-
1551-// ptyReader drains child output into the VT screen and pokes the dirty channel
1552-// (non-blocking: a full channel already means a render is pending). On any read
1553-// error (EOF when the child exits) it kills the session.
1554-func signal(ch chan struct{}) {
1555- select {
1556- case ch <- struct{}{}:
1557- default:
1558- }
1559-}
1560-
1561-func (d *Daemon) ptyReader(sess *session, tb *tab) {
1562- defer d.sessWg.Done()
1563- buf := make([]byte, ptyReadBufSize)
1564- var resp []byte
1565- tb.mu.Lock()
1566- tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
1567- tb.mu.Unlock()
1568- for {
1569- n, err := tb.pty.Read(buf)
1570- if n > 0 {
1571- data := buf[:n]
1572- tb.mu.Lock()
1573- wasSyncing := tb.screen.SyncUpdateActive()
1574- tb.screen.Write(data)
1575- isSyncing := tb.screen.SyncUpdateActive()
1576- tb.mu.Unlock()
1577- if len(resp) > 0 {
1578- if _, writeErr := tb.pty.Write(resp); writeErr != nil {
1579- d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
1580- }
1581- resp = resp[:0]
1582- }
1583- if wasSyncing != isSyncing {
1584- tb.mu.Lock()
1585- tb.syncGen++
1586- gen := tb.syncGen
1587- tb.mu.Unlock()
1588- if isSyncing {
1589- go d.syncWatchdog(tb, gen)
1590- }
1591- }
1592- if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
1593- signal(tb.flush)
1594- continue
1595- }
1596- if isSyncing {
1597- continue
1598- }
1599- signal(tb.dirty)
1600- }
1601- if err != nil {
1602- d.closeTab(sess, tb, true)
1603- return
1604- }
1605- }
1606-}
1607-
1608-// scheduler debounces dirty signals. The first dirty opens a short tab;
1609-// sustained floods progressively widen that tab, while isolated updates
1610-// return to the minimum delay for interactive latency.
1611-func (d *Daemon) scheduler(sess *session, tb *tab) {
1612- defer d.sessWg.Done()
1613- delay := minDebounceInterval
1614- lastRender := d.clock.Now()
1615-outer:
1616- for {
1617- select {
1618- case <-sess.ctx.Done():
1619- return
1620- case <-tabDone(tb):
1621- return
1622- case <-tb.flush:
1623- d.render(sess, tb)
1624- lastRender = d.clock.Now()
1625- continue
1626- case <-tb.dirty:
1627- if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {
1628- delay = minDebounceInterval
1629- }
1630- }
1631-
1632- coalesced := 0
1633- timer := d.clock.NewTimer(delay)
1634- absorb:
1635- for {
1636- select {
1637- case <-sess.ctx.Done():
1638- timer.Stop()
1639- return
1640- case <-tabDone(tb):
1641- timer.Stop()
1642- return
1643- case <-tb.flush:
1644- if !timer.Stop() {
1645- select {
1646- case <-timer.C():
1647- default:
1648- }
1649- }
1650- d.render(sess, tb)
1651- lastRender = d.clock.Now()
1652- continue outer
1653- case <-tb.dirty:
1654- coalesced++
1655- case <-timer.C():
1656- break absorb
1657- }
1658- }
1659- delay = nextDebounceDelay(delay, coalesced)
1660- d.render(sess, tb)
1661- lastRender = d.clock.Now()
1662- }
1663-}
1664-
1665-func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
1666- if coalesced == 0 {
1667- return minDebounceInterval
1668- }
1669- if delay >= maxDebounceInterval {
1670- return maxDebounceInterval
1671- }
1672- delay += debounceStep
1673- if delay > maxDebounceInterval {
1674- return maxDebounceInterval
1675- }
1676- return delay
1677-}
1678-
1679-func (d *Daemon) syncWatchdog(tb *tab, gen uint64) {
1680- timer := d.clock.NewTimer(maxSyncUpdateDuration)
1681- select {
1682- case <-tabDone(tb):
1683- timer.Stop()
1684- return
1685- case <-timer.C():
1686- }
1687-
1688- tb.mu.Lock()
1689- if tb.syncGen != gen || !tb.screen.SyncUpdateActive() {
1690- tb.mu.Unlock()
1691- return
1692- }
1693- tb.screen.ForceSyncEnd()
1694- tb.mu.Unlock()
1695- signal(tb.flush)
1696-}
1697-
1698-func tabDone(tb *tab) <-chan struct{} {
1699- if tb.ctx == nil {
1700- return nil
1701- }
1702- return tb.ctx.Done()
1703-}
1704-
1705-func syncUpdateEndIn(data []byte) bool {
1706- return bytes.Contains(data, []byte("\x1b[?2026l"))
1707-}
1708-
1709-// render paints the current client, or (when detached) just clears accumulated
1710-// damage so it never grows unbounded while headless.
1711-func (d *Daemon) render(sess *session, tb *tab) {
1712- tb.mu.Lock()
1713- if tb.screen.SyncUpdateActive() {
1714- tb.mu.Unlock()
1715- return
1716- }
1717- tb.mu.Unlock()
1718-
1719- tb.mu.Lock()
1720- previewer := tb.previewClient
1721- tb.mu.Unlock()
1722-
1723- sess.mu.Lock()
1724- ac := sess.client
1725- active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
1726- sess.mu.Unlock()
1727-
1728- if previewer != nil {
1729- if previewSess := previewer.currentSession(); previewSess != nil {
1730- d.paint(previewSess, previewer, false)
1731- }
1732- if ac != nil && active && ac != previewer {
1733- d.paint(sess, ac, false)
1734- return
1735- }
1736- tb.mu.Lock()
1737- tb.screen.ClearDamage()
1738- tb.mu.Unlock()
1739- return
1740- }
1741-
1742- if ac == nil || !active {
1743- tb.mu.Lock()
1744- tb.screen.ClearDamage()
1745- tb.mu.Unlock()
1746- return
1747- }
1748- d.paint(sess, ac, false)
1749-}
1750-
1751-// paint draws the composed client frame (active tab plus status bar) and
1752-// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
1753-// such as switch/create/close/rename/resize so the repaint is complete.
1754-func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1755- tb := sess.activeTab()
1756- if tb == nil {
1757- return
1758- }
1759-
1760- ac.sendMu.Lock()
1761- ac.copyMu.Lock()
1762- copyActive := ac.copyMode != nil
1763- var copyMode *scopy.Mode
1764- if ac.copyMode != nil {
1765- copyModeValue := *ac.copyMode
1766- copyMode = &copyModeValue
1767- }
1768- copyFeedback := ac.copyFeedback
1769- if copyFeedback != "" && !copyActive {
1770- ac.copyFeedback = ""
1771- }
1772- ac.copyMu.Unlock()
1773- ac.pickerMu.Lock()
1774- pickerActive := ac.picker != nil
1775- pickerModel := ac.picker
1776- previewTab := ac.pickerPreview
1777- preview := snapshotPickerPreview(previewTab)
1778-
1779- tb.mu.Lock()
1780- if reset || copyActive || pickerActive {
1781- ac.rend.Reset()
1782- }
1783- if reset || pickerActive {
1784- ac.lastCursor.valid = false
1785- }
1786- frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
1787- if copyActive {
1788- frame, damage = composeCopyClientFrame(copyMode, tb)
1789- }
1790- if pickerActive {
1791- if previewTab == tb {
1792- preview = pickerPreviewFromLockedTab(tb)
1793- }
1794- frame, damage = composePickerClientFrame(pickerModel, preview, frame)
1795- }
1796- ac.pickerMu.Unlock()
1797- desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive)
1798- data, err := ac.rend.Draw(frame, damage)
1799- var cursorTail []byte
1800- if err == nil {
1801- cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
1802- }
1803- tb.screen.ClearDamage()
1804- tb.mu.Unlock()
1805-
1806- var serr error
1807- if err == nil {
1808- data = append(data, cursorTail...)
1809- if len(data) > 0 {
1810- serr = ac.tr.Send(frameOutput(data))
1811- }
1812- }
1813- ac.sendMu.Unlock()
1814-
1815- if err != nil {
1816- d.log.Error("render draw failed", "err", err, "session", sess.name)
1817- return
1818- }
1819- if serr != nil {
1820- d.detachOnSendError(sess, ac)
1821- }
1822-}
1823-
1824-// resize applies a client size change to every tab using rows-1 for the PTY
1825-// and VT screen, then resets the renderer shadow and repaints the composed
1826-// client-sized frame (including the status row).
1827-func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
1828- if copyActive || !s.CursorVisible() {
1829- return cursorOut{hidden: true}
1830- }
1831- style, ok := s.CursorStyle()
1832- if !ok {
1833- style = 5
1834- }
1835- return cursorOut{row: s.CursorRow(), col: s.CursorCol(), style: style, hasStyle: true}
1836-}
1837-
1838-func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
1839- changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
1840- if !changed {
1841- return nil
1842- }
1843- prev := ac.lastCursor
1844- ac.lastCursor = desired
1845- ac.lastCursor.valid = true
1846- if desired.hidden {
1847- return []byte("\x1b[?25l")
1848- }
1849- var b []byte
1850- b = append(b, "\x1b["...)
1851- b = strconv.AppendInt(b, int64(desired.row+1), 10)
1852- b = append(b, ';')
1853- b = strconv.AppendInt(b, int64(desired.col+1), 10)
1854- b = append(b, 'H')
1855- if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
1856- b = append(b, "\x1b["...)
1857- b = strconv.AppendInt(b, int64(desired.style), 10)
1858- b = append(b, " q"...)
1859- }
1860- b = append(b, "\x1b[?25h"...)
1861- return b
1862-}
1863-
1864-func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) {
1865- width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
1866- frame := renderer.NewFrame(width, screenRows+1)
1867- for y := range screenRows {
1868- copy(frame.Row(y), tb.screen.Frame.Row(y))
1869- }
1870- drawStatus(frame.Row(screenRows), sess, rightStatus)
1871- if full {
1872- return frame, []renderer.Damage{renderer.FullRedraw()}
1873- }
1874- damage := append([]renderer.Damage(nil), tb.screen.Damage()...)
1875- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1})
1876- return frame, damage
1877-}
1878-
1879-func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) {
1880- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
1881- frame := mode.Render(snap)
1882- return frame, []renderer.Damage{renderer.FullRedraw()}
1883-}
1884-
1885-func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
1886- inner := pickerModal.Composite(base, renderer.DefaultStyle())
1887- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview)
1888- for y := range min(inner.Height, modalFrame.Height) {
1889- for x := range min(inner.Width, modalFrame.Width) {
1890- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
1891- }
1892- }
1893- return base, []renderer.Damage{renderer.FullRedraw()}
1894-}
1895-
1896-func snapshotPickerPreview(tb *tab) picker.Preview {
1897- if tb == nil {
1898- return picker.Preview{}
1899- }
1900- tb.mu.Lock()
1901- defer tb.mu.Unlock()
1902- return pickerPreviewFromLockedTab(tb)
1903-}
1904-
1905-func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
1906- rows := make([][]renderer.Cell, tb.screen.Frame.Height)
1907- for y := range rows {
1908- rows[y] = append([]renderer.Cell(nil), tb.screen.Frame.Row(y)...)
1909- }
1910- return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}
1911-}
1912-
1913-func drawStatus(row []renderer.Cell, sess *session, rightText string) {
1914- for i := range row {
1915- row[i] = renderer.BlankCell()
1916- }
1917- status := sess.statusSegments()
1918- x := 0
1919- writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle())
1920- for _, w := range status.tabs {
1921- style := renderer.DefaultStyle()
1922- if w.active {
1923- style.Inverse = true
1924- }
1925- writeStatusText(row, &x, " "+w.name+" ", style)
1926- }
1927- if rightText == "" {
1928- return
1929- }
1930- style := renderer.DefaultStyle()
1931- rightWidth := len([]rune(rightText)) + 1
1932- if len(row)-x-1 < rightWidth {
1933- return
1934- }
1935- x = len(row) - rightWidth
1936- writeStatusText(row, &x, " "+rightText, style)
1937-}
1938-
1939-type statusSnapshot struct {
1940- session string
1941- tabs []statusTab
1942-}
1943-
1944-type statusTab struct {
1945- name string
1946- active bool
1947-}
1948-
1949-func (s *session) statusSegments() statusSnapshot {
1950- s.mu.Lock()
1951- defer s.mu.Unlock()
1952- snap := statusSnapshot{session: s.name, tabs: make([]statusTab, len(s.tabs))}
1953- for i := range s.tabs {
1954- name := strconv.Itoa(i + 1)
1955- snap.tabs[i] = statusTab{name: name, active: i == s.active}
1956- }
1957- return snap
1958-}
1959-
1960-func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
1961- for _, r := range text {
1962- if *x >= len(row) {
1963- return
1964- }
1965- row[*x] = renderer.Cell{Rune: r, Style: style}
1966- (*x)++
1967- }
1968-}
1969-
1970-func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
1971- if !sz.Valid() {
1972- return
1973- }
1974- tbSize := tabSize(sz)
1975- ac.size = sz
1976-
1977- sess.mu.Lock()
1978- tabs := append([]*tab(nil), sess.tabs...)
1979- sess.mu.Unlock()
1980- if len(tabs) == 0 {
1981- return
1982- }
1983-
1984- for _, tb := range tabs {
1985- if err := tb.pty.Resize(tbSize); err != nil {
1986- d.log.Warn("pty resize failed", "err", err, "session", sess.name)
1987- }
1988- tb.mu.Lock()
1989- tb.screen.Resize(tbSize.Cols, tbSize.Rows)
1990- tb.size = tbSize
1991- tb.mu.Unlock()
1992- }
1993- d.paint(sess, ac, true)
1994-}
1995-
1996-// detachOnSendError drops a client whose transport failed. Like every other
1997-// detach path, losing the client kills an ephemeral session (its lifetime is
1998-// its client's); a named session keeps running headless.
1999-func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
2000- if sess.detachIfCurrent(ac) {
2001- d.unregisterPreview(ac)
2002- _ = ac.tr.Close()
2003- d.log.Warn("detached client after send error", "session", sess.name)
2004- if sess.ephemeral {
2005- d.killSession(sess, ports.ReasonSessionKilled)
2006- }
2007- }
2008-}
2009-
2010-// killSession removes a session and tears down its resources. It is
2011-// idempotent: only the caller that wins the registry delete acts. When the
2012-// registry empties it marks the daemon closing (atomically with the
2013-// empty-check, under d.mu) and signals shutdown.
2014-//
2015-// Teardown ordering matters: context cancel, pty.Close, and the done signal
2016-// run first and unconditionally — never gated behind a client send. The
2017-// Detached notice is best-effort, asynchronous, and deadline-bounded.
2018-func (d *Daemon) killSession(sess *session, reason uint8) {
2019- d.mu.Lock()
2020- if _, ok := d.sessions[sess.id]; !ok {
2021- d.mu.Unlock()
2022- return
2023- }
2024- delete(d.sessions, sess.id)
2025- empty := len(d.sessions) == 0
2026- if empty {
2027- // Shutdown is now inevitable and irreversible (doneOnce below): stop
2028- // route from inserting new sessions from this instant, while we still
2029- // hold the same lock route checks under.
2030- d.closing = true
2031- }
2032- d.mu.Unlock()
2033-
2034- sess.mu.Lock()
2035- ac := sess.client
2036- sess.client = nil
2037- sess.mu.Unlock()
2038- if ac != nil {
2039- d.unregisterPreview(ac)
2040- ac.setSession(nil)
2041- }
2042-
2043- sess.cancel()
2044- sess.mu.Lock()
2045- tabs := append([]*tab(nil), sess.tabs...)
2046- sess.mu.Unlock()
2047- for _, tb := range tabs {
2048- d.clearDestroyedTabPreview(tb)
2049- _ = tb.pty.Close()
2050- }
2051- if empty {
2052- d.doneOnce.Do(func() { close(d.done) })
2053- }
2054-
2055- if ac != nil {
2056- d.notifyDetachedAsync(ac, reason)
2057- }
2058-}
2059-
2060-// allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
2061-// d.mu.
2062-func (d *Daemon) allocEphemeralNameLocked() string {
2063- used := make(map[string]struct{}, len(d.sessions))
2064- for _, s := range d.sessions {
2065- used[s.name] = struct{}{}
2066- }
2067- for i := 0; ; i++ {
2068- n := strconv.Itoa(i)
2069- if _, taken := used[n]; !taken {
2070- return n
2071- }
2072- }
2073-}
2074-
2075-func (d *Daemon) findByNameLocked(name string) *session {
2076- for _, s := range d.sessions {
2077- if s.name == name {
2078- return s
2079- }
2080- }
2081- return nil
2082-}
2083-
2084-func (d *Daemon) nameTakenLocked(name string) bool { return d.findByNameLocked(name) != nil }
2085-
2086-// childEnv builds the session child's environment: the daemon's own, with TERM
2087-// and VEV forced to well-known values.
2088-func (d *Daemon) childEnv(name string) []string {
2089- out := make([]string, 0, len(d.baseEnv)+2)
2090- for _, e := range d.baseEnv {
2091- if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {
2092- continue
2093- }
2094- out = append(out, e)
2095- }
2096- return append(out, "TERM=xterm-256color", "VEV="+name)
2097-}
2098-
2099-func frameWelcome(s *session) ports.Frame {
2100- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
2101- SessionID: string(s.id),
2102- SessionName: s.name,
2103- Ephemeral: s.ephemeral,
2104- })}
2105-}
2106-
2107-func frameError(code uint16, text string) ports.Frame {
2108- return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
2109-}
2110-
2111-func frameOutput(b []byte) ports.Frame {
2112- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: b})}
2113-}
2114-
2115-func frameDetached(reason uint8) ports.Frame {
2116- return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
2117-}
2118-
2119-func framePong() ports.Frame {
2120- return ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}
2121-}
2122-
2123-func frameSessions(infos []ports.SessionInfo) ports.Frame {
2124- return ports.Frame{Type: ports.MsgSessions, Payload: ports.MarshalSessions(ports.Sessions{Sessions: infos})}
2125-}
M internal/usecase/daemon/daemon_test.go +21 -2062
@@ -1,16 +1,10 @@
11 package daemon
22
33 import (
4- "bytes"
54 "context"
6- "encoding/base64"
7- "errors"
8- "fmt"
95 "io"
106 "log/slog"
11- "strings"
127 "sync"
13- "sync/atomic"
148 "testing"
159 "time"
1610
@@ -20,7 +14,6 @@ import (
2014 "github.com/bnema/vev/internal/domain"
2115 "github.com/bnema/vev/internal/ports"
2216 portsmocks "github.com/bnema/vev/internal/ports/mocks"
23- scopy "github.com/bnema/vev/internal/usecase/copy"
2417 "github.com/bnema/vev/internal/usecase/keys"
2518 "github.com/bnema/vev/pkg/renderer"
2619 "github.com/bnema/vev/pkg/vt"
@@ -176,45 +169,6 @@ func mustHello(intent uint8, name string, sz domain.Size) ports.Frame {
176169 })}
177170 }
178171
179-func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
180- sz := domain.Size{Cols: 80, Rows: 24}
181- first, releaseFirst := newBlockingPTY(t)
182- defer releaseFirst()
183- second, releaseSecond := newBlockingPTY(t)
184- defer releaseSecond()
185- f := portsmocks.NewMockPTYFactory(t)
186- var dirs []string
187- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
188- func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
189- dirs = append(dirs, dir)
190- if len(dirs) == 1 {
191- return first, nil
192- }
193- return second, nil
194- },
195- ).Twice()
196-
197- d := newTestDaemon(t, f, stubClock{})
198- tr := portsmocks.NewMockTransport(t)
199- tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
200- tr.EXPECT().Close().Return(nil).Maybe()
201-
202- sess, ac, err := d.route(ports.Hello{
203- Version: ports.ProtocolVersion,
204- Intent: ports.IntentNew,
205- Name: "work",
206- Size: sz,
207- TermEnv: "xterm-256color",
208- Cwd: "/tmp/work",
209- }, tr)
210- require.NoError(t, err)
211- require.NotNil(t, ac)
212- require.Equal(t, "/tmp/work", sess.cwd)
213-
214- require.NoError(t, d.createTab(sess, sz))
215- require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)
216-}
217-
218172 func frameInput(data []byte) ports.Frame {
219173 return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})}
220174 }
@@ -299,25 +253,6 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
299253 return d, sess, ac, sends
300254 }
301255
302-func TestPTYWriteErrorIsLogged(t *testing.T) {
303- var logs bytes.Buffer
304- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))
305- errBoom := errors.New("boom")
306- p := portsmocks.NewMockPTY(t)
307- p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
308- win := &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}
309- sess := &session{id: "manual", name: "work", tabs: []*tab{win}}
310- ac := &attachedClient{}
311- ac.setSession(sess)
312-
313- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input"))
314-
315- got := logs.String()
316- if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") {
317- t.Fatalf("log output %q does not contain PTY write failure details", got)
318- }
319-}
320-
321256 func newManualTabSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
322257 t.Helper()
323258 ptys := make([]ports.PTY, 0, n)
@@ -345,353 +280,6 @@ func testRow(text string) []renderer.Cell {
345280 return cells
346281 }
347282
348-func TestScrollbackModeAltUInterceptsAndDoesNotForward(t *testing.T) {
349- writes := make(chan []byte, 1)
350- p, _ := newBlockingPTYWithWrites(t, writes)
351- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
352- sess.tabs[0].scrollback = scopy.NewScrollback(4)
353- sess.tabs[0].screen.Write([]byte("live"))
354-
355- d.handleInput(sess, ac, []byte("\x1bu"))
356-
357- if ac.copyMode == nil {
358- t.Fatal("scrollback mode not entered")
359- }
360- select {
361- case got := <-writes:
362- t.Fatalf("scrollback binding forwarded to PTY: %q", got)
363- default:
364- }
365- out := awaitFrame(t, sends, ports.MsgOutput)
366- msg, err := ports.UnmarshalOutput(out.Payload)
367- require.NoError(t, err)
368- if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[COPY]") {
369- t.Fatalf("scrollback mode paint = %q, want [SCROLL] without [COPY]", got)
370- }
371-
372- d.handleInput(sess, ac, []byte(" "))
373- out = awaitFrame(t, sends, ports.MsgOutput)
374- msg, err = ports.UnmarshalOutput(out.Payload)
375- require.NoError(t, err)
376- if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SCROLL]") {
377- t.Fatalf("visual selection paint = %q, want [VISUAL] without [SCROLL]", got)
378- }
379-}
380-
381-func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
382- writes := make(chan []byte, 1)
383- p, _ := newBlockingPTYWithWrites(t, writes)
384- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
385- sess.tabs[0].scrollback = scopy.NewScrollback(4)
386- sess.tabs[0].scrollback.Append(testRow("old1 "))
387- sess.tabs[0].scrollback.Append(testRow("old2 "))
388- sess.tabs[0].screen.Write([]byte("live"))
389-
390- d.enterCopyMode(sess, ac)
391- awaitFrame(t, sends, ports.MsgOutput)
392- d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'})
393-
394- select {
395- case got := <-writes:
396- t.Fatalf("copy-mode navigation forwarded to PTY: %q", got)
397- default:
398- }
399- out := awaitFrame(t, sends, ports.MsgOutput)
400- msg, err := ports.UnmarshalOutput(out.Payload)
401- require.NoError(t, err)
402- if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {
403- t.Fatalf("OSC52 = %q, want %q", got, want)
404- }
405- if ac.copyMode != nil {
406- t.Fatal("copy mode still active after yank")
407- }
408- live := awaitFrame(t, sends, ports.MsgOutput)
409- liveMsg, err := ports.UnmarshalOutput(live.Payload)
410- require.NoError(t, err)
411- if strings.Contains(string(liveMsg.Data), "[COPY]") || strings.Contains(string(liveMsg.Data), "[SCROLL]") {
412- t.Fatalf("live repaint still contains copy/scroll status: %q", string(liveMsg.Data))
413- }
414- if !strings.Contains(string(liveMsg.Data), "copied 9 chars to clipboard") {
415- t.Fatalf("live repaint = %q, want copy success feedback", string(liveMsg.Data))
416- }
417- if !strings.Contains(string(liveMsg.Data), "live") {
418- t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
419- }
420-
421- d.paint(sess, ac, true)
422- followup := awaitFrame(t, sends, ports.MsgOutput)
423- followupMsg, err := ports.UnmarshalOutput(followup.Payload)
424- require.NoError(t, err)
425- if strings.Contains(string(followupMsg.Data), "copied 9 chars to clipboard") {
426- t.Fatalf("copy feedback persisted after next repaint: %q", string(followupMsg.Data))
427- }
428-}
429-
430-func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
431- p := portsmocks.NewMockPTY(t)
432- reads := make(chan []byte, 64)
433- readDone := make(chan struct{})
434- var closeOnce sync.Once
435- p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
436- select {
437- case data := <-reads:
438- return copy(b, data), nil
439- case <-readDone:
440- return 0, io.EOF
441- }
442- }).Maybe()
443- p.EXPECT().Write(mock.Anything).Return(0, errors.New("unexpected PTY write")).Maybe()
444- p.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
445- p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()
446- p.EXPECT().Pid().Return(4242).Maybe()
447-
448- d := newTestDaemon(t, newFactory(t, p), stubClock{})
449- tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))
450- var hg sync.WaitGroup
451- hg.Go(func() { d.handleConn(tr) })
452- awaitFrame(t, sends, ports.MsgWelcome)
453- awaitFrame(t, sends, ports.MsgOutput)
454-
455- for i := range 12 {
456- reads <- fmt.Appendf(nil, "line-%02d\r\n", i)
457- }
458- require.Eventually(t, func() bool {
459- sess := firstSession(d)
460- if sess == nil {
461- return false
462- }
463- win := sess.activeTab()
464- if win == nil {
465- return false
466- }
467- win.mu.Lock()
468- defer win.mu.Unlock()
469- return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12
470- }, 2*time.Second, 5*time.Millisecond)
471-
472- sess := firstSession(d)
473- require.NotNil(t, sess)
474- ac := sess.client
475- require.NotNil(t, ac)
476- d.handleInput(sess, ac, []byte("\x1bu"))
477- awaitFrame(t, sends, ports.MsgOutput)
478- d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
479-
480- var payload string
481- require.Eventually(t, func() bool {
482- out := awaitFrame(t, sends, ports.MsgOutput)
483- msg, err := ports.UnmarshalOutput(out.Payload)
484- require.NoError(t, err)
485- payload = string(msg.Data)
486- return strings.HasPrefix(payload, "\x1b]52;c;")
487- }, 2*time.Second, 5*time.Millisecond)
488- require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
489- require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload)
490- decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
491- require.NoError(t, err)
492- require.True(t, strings.HasPrefix(string(decoded), "line-00"), "decoded yank = %q", string(decoded))
493-
494- releaseConn()
495- closeOnce.Do(func() { close(readDone) })
496- hg.Wait()
497- d.sessWg.Wait()
498-}
499-
500-func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
501- p, _ := newBlockingPTY(t)
502- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
503- sess.tabs[0].scrollback = scopy.NewScrollback(4)
504- sess.tabs[0].screen.Write([]byte("live"))
505-
506- d.enterCopyMode(sess, ac)
507- awaitFrame(t, sends, ports.MsgOutput)
508- d.handleInput(sess, ac, []byte("q"))
509-
510- if ac.copyMode != nil {
511- t.Fatal("copy mode still active after q")
512- }
513- out := awaitFrame(t, sends, ports.MsgOutput)
514- msg, err := ports.UnmarshalOutput(out.Payload)
515- require.NoError(t, err)
516- if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
517- t.Fatalf("exit repaint = %q, want live full repaint without copy status", string(msg.Data))
518- }
519-}
520-
521-func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
522- cases := []struct {
523- name string
524- input [][]byte
525- wantCursor int
526- }{
527- {name: "escape then up arrow", input: [][]byte{[]byte("\x1b"), []byte("[A")}, wantCursor: 23},
528- {name: "escape then down arrow", input: [][]byte{[]byte("g"), []byte("\x1b"), []byte("[B")}, wantCursor: 1},
529- {name: "split up arrow", input: [][]byte{[]byte("\x1b["), []byte("A")}, wantCursor: 23},
530- {name: "split page down", input: [][]byte{[]byte("g"), []byte("\x1b[6"), []byte("~")}, wantCursor: 23},
531- }
532- for _, tc := range cases {
533- t.Run(tc.name, func(t *testing.T) {
534- p, _ := newBlockingPTY(t)
535- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
536- sess.tabs[0].scrollback = scopy.NewScrollback(4)
537- sess.tabs[0].scrollback.Append(testRow("old1 "))
538- sess.tabs[0].scrollback.Append(testRow("old2 "))
539- sess.tabs[0].screen.Write([]byte("live"))
540-
541- d.enterCopyMode(sess, ac)
542- awaitFrame(t, sends, ports.MsgOutput)
543- for _, input := range tc.input {
544- d.handleInput(sess, ac, input)
545- }
546-
547- require.NotNil(t, ac.copyMode)
548- require.Equal(t, tc.wantCursor, ac.copyMode.Cursor)
549- })
550- }
551-}
552-
553-func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
554- p, _ := newBlockingPTY(t)
555- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
556- sess.tabs[0].scrollback = scopy.NewScrollback(1)
557- longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
558- sess.tabs[0].screen.Frame = renderer.NewFrame(len(longLine), 1)
559- copy(sess.tabs[0].screen.Frame.Row(0), testRow(longLine))
560-
561- d.enterCopyMode(sess, ac)
562- awaitFrame(t, sends, ports.MsgOutput)
563- d.handleInput(sess, ac, []byte{' ', 'y'})
564-
565- out := awaitFrame(t, sends, ports.MsgOutput)
566- msg, err := ports.UnmarshalOutput(out.Payload)
567- require.NoError(t, err)
568- if strings.Contains(string(msg.Data), "\x1b]52;") {
569- t.Fatalf("oversized yank emitted OSC52: %q", string(msg.Data))
570- }
571- if !strings.Contains(string(msg.Data), "selection too large to copy") {
572- t.Fatalf("oversized yank repaint = %q, want too-large feedback", string(msg.Data))
573- }
574-}
575-
576-func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
577- p, _ := newBlockingPTY(t)
578- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
579- clk := &signalClock{timers: make(chan *signalTimer, 1)}
580- d.clock = clk
581- sess.tabs[0].scrollback = scopy.NewScrollback(4)
582- sess.tabs[0].screen.Write([]byte("live"))
583-
584- d.enterCopyMode(sess, ac)
585- awaitFrame(t, sends, ports.MsgOutput)
586- d.handleInput(sess, ac, []byte("\x1b"))
587- timer := <-clk.timers
588- ac.copyMu.Lock()
589- require.NotNil(t, ac.copyMode)
590- ac.copyMu.Unlock()
591- timer.ch <- time.Now()
592- require.Eventually(t, func() bool {
593- ac.copyMu.Lock()
594- defer ac.copyMu.Unlock()
595- return ac.copyMode == nil
596- }, time.Second, 5*time.Millisecond)
597-}
598-
599-func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
600- p, _ := newBlockingPTY(t)
601- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
602- clk := &signalClock{timers: make(chan *signalTimer, 1)}
603- d.clock = clk
604- sess.tabs[0].scrollback = scopy.NewScrollback(4)
605- sess.tabs[0].screen.Write([]byte("live"))
606-
607- d.enterCopyMode(sess, ac)
608- awaitFrame(t, sends, ports.MsgOutput)
609- d.handleInput(sess, ac, []byte("\x1b"))
610- timer := <-clk.timers
611- d.enterCopyMode(sess, ac)
612- awaitFrame(t, sends, ports.MsgOutput)
613-
614- timer.ch <- time.Now()
615- require.Never(t, func() bool {
616- ac.copyMu.Lock()
617- defer ac.copyMu.Unlock()
618- return ac.copyMode == nil
619- }, 50*time.Millisecond, 5*time.Millisecond)
620- ac.copyMu.Lock()
621- require.Nil(t, ac.copyESC.timer)
622- require.Nil(t, ac.copyESC.done)
623- require.Empty(t, ac.copyPending)
624- ac.copyMu.Unlock()
625-}
626-
627-func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
628- cases := []struct {
629- name string
630- input []byte
631- }{
632- {name: "yank", input: []byte("y")},
633- {name: "enter", input: []byte("\r")},
634- }
635- for _, tc := range cases {
636- t.Run(tc.name, func(t *testing.T) {
637- p, _ := newBlockingPTY(t)
638- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
639- sess.tabs[0].scrollback = scopy.NewScrollback(4)
640- sess.tabs[0].screen.Write([]byte("live"))
641-
642- d.enterCopyMode(sess, ac)
643- awaitFrame(t, sends, ports.MsgOutput)
644- d.handleInput(sess, ac, tc.input)
645-
646- if ac.copyMode != nil {
647- t.Fatal("copy mode still active after empty yank")
648- }
649- out := awaitFrame(t, sends, ports.MsgOutput)
650- msg, err := ports.UnmarshalOutput(out.Payload)
651- require.NoError(t, err)
652- if strings.Contains(string(msg.Data), "\x1b]52;") {
653- t.Fatalf("empty yank emitted OSC52 clipboard clear: %q", string(msg.Data))
654- }
655- if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
656- t.Fatalf("empty yank repaint = %q, want live full repaint", string(msg.Data))
657- }
658- })
659- }
660-}
661-
662-func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
663- p, _ := newBlockingPTY(t)
664- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
665- sess.tabs[0].scrollback = scopy.NewScrollback(4)
666- sess.tabs[0].screen.Write([]byte("live"))
667-
668- done := make(chan struct{})
669- var drain sync.WaitGroup
670- drain.Go(func() {
671- for {
672- select {
673- case <-sends:
674- case <-done:
675- return
676- }
677- }
678- })
679-
680- var wg sync.WaitGroup
681- for range 100 {
682- wg.Go(func() {
683- d.enterCopyMode(sess, ac)
684- d.handleInput(sess, ac, []byte("q"))
685- })
686- wg.Go(func() {
687- d.paint(sess, ac, true)
688- })
689- }
690- wg.Wait()
691- close(done)
692- drain.Wait()
693-}
694-
695283 func tabCount(sess *session) int {
696284 sess.mu.Lock()
697285 defer sess.mu.Unlock()
@@ -700,263 +288,6 @@ func tabCount(sess *session) int {
700288
701289 // --- handshake --------------------------------------------------------------
702290
703-func TestHandshakeEphemeralHappy(t *testing.T) {
704- p, releasePTY := newBlockingPTY(t)
705- d := newTestDaemon(t, newFactory(t, p), stubClock{})
706- tr, sends, releaseConn := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
707-
708- var hg sync.WaitGroup
709- hg.Go(func() { d.handleConn(tr) })
710-
711- w := awaitFrame(t, sends, ports.MsgWelcome)
712- welcome, err := ports.UnmarshalWelcome(w.Payload)
713- require.NoError(t, err)
714- require.Equal(t, "0", welcome.SessionName)
715- require.True(t, welcome.Ephemeral)
716- awaitFrame(t, sends, ports.MsgOutput) // guaranteed first paint
717- require.Equal(t, 1, sessionCount(d))
718-
719- releaseConn()
720- releasePTY()
721- hg.Wait()
722- d.sessWg.Wait()
723-}
724-
725-func TestHandshakeNewHappy(t *testing.T) {
726- p, releasePTY := newBlockingPTY(t)
727- d := newTestDaemon(t, newFactory(t, p), stubClock{})
728- tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
729-
730- var hg sync.WaitGroup
731- hg.Go(func() { d.handleConn(tr) })
732-
733- w := awaitFrame(t, sends, ports.MsgWelcome)
734- welcome, err := ports.UnmarshalWelcome(w.Payload)
735- require.NoError(t, err)
736- require.Equal(t, "work", welcome.SessionName)
737- require.False(t, welcome.Ephemeral)
738-
739- sess := firstSession(d)
740- require.NotNil(t, sess)
741-
742- releaseConn()
743- d.killSession(sess, ports.ReasonServerShutdown)
744- releasePTY()
745- hg.Wait()
746- d.sessWg.Wait()
747- d.waitNotifies()
748-}
749-
750-func TestHandshakeVersionMismatch(t *testing.T) {
751- // No Open expectation: the factory must never be asked to spawn a PTY.
752- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
753- bad := ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
754- Version: ports.ProtocolVersion + 99, Intent: ports.IntentEphemeral, Size: domain.Size{Cols: 80, Rows: 24},
755- })}
756- tr, sends, _ := newConn(t, bad)
757-
758- d.handleConn(tr) // returns after the rejection; no session, no goroutines
759-
760- e := awaitFrame(t, sends, ports.MsgError)
761- em, err := ports.UnmarshalErrorMsg(e.Payload)
762- require.NoError(t, err)
763- require.Equal(t, ports.ErrVersionMismatch, em.Code)
764- require.Equal(t, 0, sessionCount(d))
765-}
766-
767-func TestHandshakeOldHelloLayoutReportsVersionMismatch(t *testing.T) {
768- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
769- oldLayout := []byte{
770- 0x00, byte(ports.ProtocolVersion - 1),
771- ports.IntentEphemeral,
772- 0x00, 0x00,
773- 0x00, 0x50,
774- 0x00, 0x18,
775- 0x00, 0x00,
776- }
777- tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: oldLayout})
778- d.handleConn(tr)
779-
780- e := awaitFrame(t, sends, ports.MsgError)
781- em, err := ports.UnmarshalErrorMsg(e.Payload)
782- require.NoError(t, err)
783- require.Equal(t, ports.ErrVersionMismatch, em.Code)
784- require.Equal(t, 0, sessionCount(d))
785-}
786-
787-func TestHandshakeNameTaken(t *testing.T) {
788- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
789- // Pre-populate a bare session (no goroutines) to collide with.
790- d.sessions[domain.SessionID("x")] = &session{id: "x", name: "work"}
791-
792- tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
793- d.handleConn(tr)
794-
795- e := awaitFrame(t, sends, ports.MsgError)
796- em, _ := ports.UnmarshalErrorMsg(e.Payload)
797- require.Equal(t, ports.ErrNameTaken, em.Code)
798-}
799-
800-func TestHandshakeNoSuchSession(t *testing.T) {
801- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
802- tr, sends, _ := newConn(t, mustHello(ports.IntentAttach, "ghost", domain.Size{Cols: 80, Rows: 24}))
803- d.handleConn(tr)
804-
805- e := awaitFrame(t, sends, ports.MsgError)
806- em, _ := ports.UnmarshalErrorMsg(e.Payload)
807- require.Equal(t, ports.ErrNoSuchSession, em.Code)
808-}
809-
810-func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
811- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
812- tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})})
813- d.handleConn(tr)
814-
815- select {
816- case <-d.done:
817- case <-time.After(time.Second):
818- t.Fatal("kill all on empty daemon did not signal shutdown")
819- }
820-}
821-
822-// --- ephemeral numbering ----------------------------------------------------
823-
824-func TestEphemeralNumberingReuse(t *testing.T) {
825- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
826- // Emulate two ephemeral sessions "0" and "1".
827- d.sessions["a"] = &session{id: "a", name: "0"}
828- d.sessions["b"] = &session{id: "b", name: "1"}
829- require.Equal(t, "2", d.allocEphemeralNameLocked())
830-
831- // Kill "0": the freed number is reused before "2".
832- delete(d.sessions, "a")
833- require.Equal(t, "0", d.allocEphemeralNameLocked())
834-}
835-
836-// --- attach replace ---------------------------------------------------------
837-
838-func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
839- writes1 := make(chan []byte, 1)
840- writes2 := make(chan []byte, 1)
841- writes3 := make(chan []byte, 1)
842- p1, releasePTY1 := newBlockingPTYWithWrites(t, writes1)
843- p2, releasePTY2 := newBlockingPTYWithWrites(t, writes2)
844- p3, releasePTY3 := newBlockingPTYWithWrites(t, writes3)
845- d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{})
846- tr, sends, releaseConn := newConn(t,
847- mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
848- frameInput([]byte("\x1bc")),
849- frameInput([]byte("\x1bc")),
850- frameInput([]byte("\x1b1")),
851- frameInput([]byte("A")),
852- frameInput([]byte("\x1b2")),
853- frameInput([]byte("B")),
854- frameInput([]byte("\x1b3")),
855- frameInput([]byte("C")),
856- )
857-
858- var hg sync.WaitGroup
859- hg.Go(func() { d.handleConn(tr) })
860- awaitFrame(t, sends, ports.MsgWelcome)
861- awaitFrame(t, sends, ports.MsgOutput)
862-
863- require.Eventually(t, func() bool {
864- sessions := listSessions(t, d)
865- return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3
866- }, 2*time.Second, 5*time.Millisecond)
867- require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)
868- require.Equal(t, []byte("A"), <-writes1)
869- require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond)
870- require.Equal(t, []byte("B"), <-writes2)
871- require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond)
872- require.Equal(t, []byte("C"), <-writes3)
873-
874- releaseConn()
875- releasePTY1()
876- releasePTY2()
877- releasePTY3()
878- hg.Wait()
879- d.sessWg.Wait()
880-}
881-
882-func TestAltCCreatesSecondActiveTab(t *testing.T) {
883- p1, releasePTY1 := newBlockingPTY(t)
884- p2, releasePTY2 := newBlockingPTY(t)
885- d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
886- tr, sends, releaseConn := newConn(t,
887- mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
888- frameInput([]byte("\x1bc")),
889- )
890-
891- var hg sync.WaitGroup
892- hg.Go(func() { d.handleConn(tr) })
893- awaitFrame(t, sends, ports.MsgWelcome)
894- awaitFrame(t, sends, ports.MsgOutput)
895-
896- require.Eventually(t, func() bool {
897- sessions := listSessions(t, d)
898- return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2
899- }, 2*time.Second, 5*time.Millisecond)
900-
901- releaseConn()
902- releasePTY1()
903- releasePTY2()
904- hg.Wait()
905- d.sessWg.Wait()
906-}
907-
908-func TestAltNextPreviousSwitchActiveTab(t *testing.T) {
909- cases := []struct {
910- name string
911- start int
912- input []byte
913- wantIndex int
914- }{
915- {name: "next advances", start: 0, input: []byte("\x1bn"), wantIndex: 1},
916- {name: "next wraps", start: 2, input: []byte("\x1bn"), wantIndex: 0},
917- {name: "previous moves back", start: 2, input: []byte("\x1bp"), wantIndex: 1},
918- {name: "previous wraps", start: 0, input: []byte("\x1bp"), wantIndex: 2},
919- }
920-
921- for _, tc := range cases {
922- t.Run(tc.name, func(t *testing.T) {
923- d, sess, ac, _, releases := newManualTabSession(t, 3)
924- defer func() {
925- for _, release := range releases {
926- release()
927- }
928- }()
929- sess.active = tc.start
930-
931- d.handleInput(sess, ac, tc.input)
932-
933- require.Equal(t, tc.wantIndex, activeTabIndex(sess))
934- })
935- }
936-}
937-
938-func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
939- writes := make(chan []byte, 1)
940- p1, releasePTY1 := newBlockingPTY(t)
941- p2, releasePTY2 := newBlockingPTY(t)
942- p3, releasePTY3 := newBlockingPTYWithWrites(t, writes)
943- d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)
944- sess.active = 1
945-
946- d.handleInput(sess, ac, []byte("\x1bx"))
947-
948- require.Equal(t, 1, sessionCount(d))
949- require.Len(t, sess.tabs, 2)
950- require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab")
951- d.handleInput(sess, ac, []byte("Z"))
952- require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
953- require.Equal(t, []byte("Z"), <-writes)
954-
955- releasePTY1()
956- releasePTY2()
957- releasePTY3()
958-}
959-
960291 func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
961292 done := make(chan struct{})
962293 go func() {
@@ -966,1411 +297,39 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
966297 return done
967298 }
968299
969-func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
970- p1, releasePTY1 := newBlockingPTY(t)
971- p2, releasePTY2 := newBlockingPTY(t)
972- clk := &signalClock{called: make(chan struct{})}
973- d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
974- d.clock = clk
975- defer releasePTY1()
976- defer releasePTY2()
977-
978- d.sessWg.Add(1)
979- go d.scheduler(sess, sess.tabs[1])
980- sess.tabs[1].dirty <- struct{}{}
981- <-clk.called
982-
983- d.handleInput(sess, ac, []byte("\x1b2"))
984- d.handleInput(sess, ac, []byte("\x1bx"))
985-
986- select {
987- case <-waitGroupDone(&d.sessWg):
988- case <-time.After(2 * time.Second):
989- t.Fatal("scheduler for removed tab did not exit")
300+func rowText(row []renderer.Cell) string {
301+ runes := make([]rune, len(row))
302+ for i, c := range row {
303+ runes[i] = c.Rune
990304 }
991- require.Equal(t, 1, sessionCount(d))
305+ return string(runes)
992306 }
993307
994-func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) {
995- p1, releasePTY1 := newBlockingPTY(t)
996- p2, releasePTY2 := newBlockingPTY(t)
997- clk := &signalClock{called: make(chan struct{})}
998- d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
999- d.clock = clk
1000- defer releasePTY1()
1001- defer releasePTY2()
1002-
1003- d.sessWg.Add(2)
1004- go d.scheduler(sess, sess.tabs[1])
1005- go d.ptyReader(sess, sess.tabs[1])
1006- sess.tabs[1].dirty <- struct{}{}
1007- <-clk.called
1008- releasePTY2()
308+type notifyClock struct{}
1009309
1010- select {
1011- case <-waitGroupDone(&d.sessWg):
1012- case <-time.After(2 * time.Second):
1013- t.Fatal("scheduler for EOF-removed tab did not exit")
310+func (notifyClock) Now() time.Time { return time.Now() }
311+func (notifyClock) NewTimer(d time.Duration) ports.Timer {
312+ if d == debounceInterval {
313+ return stubTimer{}
1014314 }
1015- require.Equal(t, 1, sessionCount(d))
1016-}
1017-
1018-func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
1019- d, sess, ac, sends, releases := newManualTabSession(t, 1)
1020- defer releases[0]()
1021-
1022- d.handleInput(sess, ac, []byte("\x1bx"))
1023-
1024- require.Equal(t, 0, sessionCount(d))
1025- f := awaitFrame(t, sends, ports.MsgDetached)
1026- det, err := ports.UnmarshalDetached(f.Payload)
1027- require.NoError(t, err)
1028- require.Equal(t, ports.ReasonSessionKilled, det.Reason)
315+ return realTimer{t: time.NewTimer(5 * time.Millisecond)}
1029316 }
1030317
1031-func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
1032- p1, releasePTY1 := newBlockingPTY(t)
1033- p2, releasePTY2 := newBlockingPTY(t)
1034- d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
1035- defer releasePTY2()
1036- sess.active = 0
1037- sess.tabs[1].screen.Write([]byte("remaining"))
1038-
1039- d.sessWg.Add(1)
1040- go d.ptyReader(sess, sess.tabs[0])
1041- releasePTY1()
1042-
1043- require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
1044- require.Equal(t, 1, sessionCount(d))
1045- require.Equal(t, 0, activeTabIndex(sess))
1046- f := awaitFrame(t, sends, ports.MsgOutput)
1047- out, err := ports.UnmarshalOutput(f.Payload)
1048- require.NoError(t, err)
1049- data := string(out.Data)
1050- require.Contains(t, data, "remaining")
1051- require.Contains(t, data, "work")
1052- require.Contains(t, data, ";7m")
1053-
1054- d.sessWg.Wait()
1055-}
318+type realTimer struct{ t *time.Timer }
1056319
1057-func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
1058- p1, releasePTY1 := newBlockingPTY(t)
1059- p2, releasePTY2 := newBlockingPTY(t)
1060- d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
1061- defer releasePTY1()
1062- sess.active = 0
1063- sess.tabs[0].screen.Write([]byte("active"))
320+func (r realTimer) C() <-chan time.Time { return r.t.C }
321+func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
322+func (r realTimer) Stop() bool { return r.t.Stop() }
1064323
1065- d.sessWg.Add(1)
1066- go d.ptyReader(sess, sess.tabs[1])
1067- releasePTY2()
324+// TestServeReturnsDespiteWedgedClientOnShutdown: killSession's teardown must
325+// not be gated behind the Detached notice. The client transport wedges every
326+// MsgDetached send until the transport is closed (mirroring a full kernel
327+// send buffer: only Close fails the in-flight write). Serve must still tear
1068328
1069- require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
1070- require.Equal(t, 1, sessionCount(d))
1071- require.Equal(t, 0, activeTabIndex(sess))
1072- f := awaitFrame(t, sends, ports.MsgOutput)
1073- out, err := ports.UnmarshalOutput(f.Payload)
1074- require.NoError(t, err)
1075- data := string(out.Data)
1076- require.Contains(t, data, "active")
1077- require.Contains(t, data, "work")
1078- require.NotContains(t, data, " 2 ")
1079-
1080- d.sessWg.Wait()
1081-}
1082-
1083-func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
1084- d, sess, _, sends, releases := newManualTabSession(t, 1)
1085-
1086- d.sessWg.Add(1)
1087- go d.ptyReader(sess, sess.tabs[0])
1088- releases[0]()
1089-
1090- require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond)
1091- f := awaitFrame(t, sends, ports.MsgDetached)
1092- det, err := ports.UnmarshalDetached(f.Payload)
1093- require.NoError(t, err)
1094- require.Equal(t, ports.ReasonSessionKilled, det.Reason)
1095-
1096- d.sessWg.Wait()
1097-}
1098-
1099-func TestAltDDetachesCurrentClient(t *testing.T) {
1100- d, sess, ac, sends, releases := newManualTabSession(t, 1)
1101- defer releases[0]()
1102-
1103- d.handleInput(sess, ac, []byte("\x1bd"))
1104-
1105- require.Nil(t, sess.client)
1106- f := awaitFrame(t, sends, ports.MsgDetached)
1107- det, err := ports.UnmarshalDetached(f.Payload)
1108- require.NoError(t, err)
1109- require.Equal(t, ports.ReasonDetach, det.Reason)
1110-}
1111-
1112-func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
1113- d, sess, ac, sends, releases := newManualTabSession(t, 1)
1114- defer releases[0]()
1115- sess.ephemeral = true
1116- sess.name = "0"
1117-
1118- d.handleInput(sess, ac, []byte("\x1br"))
1119-
1120- require.False(t, sess.ephemeral)
1121- require.Equal(t, "0", sess.name)
1122- awaitFrame(t, sends, ports.MsgOutput)
1123-}
1124-
1125-func TestAltTOpensPickerModalAndDoesNotForward(t *testing.T) {
1126- writes := make(chan []byte, 1)
1127- p, releasePTY := newBlockingPTYWithWrites(t, writes)
1128- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1129- defer releasePTY()
1130-
1131- d.handleInput(sess, ac, []byte("\x1bt"))
1132-
1133- require.True(t, ac.pickerActive())
1134- select {
1135- case got := <-writes:
1136- t.Fatalf("picker binding forwarded to PTY: %q", got)
1137- default:
1138- }
1139- out := awaitFrame(t, sends, ports.MsgOutput)
1140- msg, err := ports.UnmarshalOutput(out.Payload)
1141- require.NoError(t, err)
1142- data := string(msg.Data)
1143- require.Contains(t, data, "┌")
1144- require.Contains(t, data, "Sessions")
1145-}
1146-
1147-func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
1148- d, sess, ac, sends, releases := newManualTabSession(t, 2)
1149- defer func() {
1150- for _, release := range releases {
1151- release()
1152- }
1153- }()
1154-
1155- d.handleInput(sess, ac, []byte("\x1bt"))
1156- awaitFrame(t, sends, ports.MsgOutput)
1157- d.handleInput(sess, ac, []byte("j"))
1158- awaitFrame(t, sends, ports.MsgOutput)
1159- d.handleInput(sess, ac, []byte("\r"))
1160-
1161- require.Equal(t, 1, activeTabIndex(sess))
1162- awaitFrame(t, sends, ports.MsgOutput)
1163- d.handleInput(sess, ac, []byte("\x1bt"))
1164- awaitFrame(t, sends, ports.MsgOutput)
1165- clk := &signalClock{timers: make(chan *signalTimer, 1)}
1166- d.clock = clk
1167- d.handleInput(sess, ac, []byte("\x1b"))
1168- timer := <-clk.timers
1169- require.True(t, ac.pickerActive())
1170- timer.ch <- time.Now()
1171- require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
1172- awaitFrame(t, sends, ports.MsgOutput)
1173-}
1174-
1175-func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
1176- cases := []struct {
1177- name string
1178- input [][]byte
1179- wantActive int
1180- }{
1181- {name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1},
1182- {name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0},
1183- {name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1},
1184- }
1185- for _, tc := range cases {
1186- t.Run(tc.name, func(t *testing.T) {
1187- d, sess, ac, sends, releases := newManualTabSession(t, 2)
1188- defer func() {
1189- for _, release := range releases {
1190- release()
1191- }
1192- }()
1193-
1194- d.handleInput(sess, ac, []byte("\x1bt"))
1195- awaitFrame(t, sends, ports.MsgOutput)
1196- for _, input := range tc.input {
1197- d.handleInput(sess, ac, input)
1198- }
1199- require.True(t, ac.pickerActive())
1200- d.handleInput(sess, ac, []byte("\r"))
1201- require.Equal(t, tc.wantActive, activeTabIndex(sess))
1202- })
1203- }
1204-}
1205-
1206-func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
1207- d, sess, ac, sends, releases := newManualTabSession(t, 1)
1208- defer releases[0]()
1209-
1210- d.handleInput(sess, ac, []byte("\x1bt"))
1211- awaitFrame(t, sends, ports.MsgOutput)
1212- clk := &signalClock{timers: make(chan *signalTimer, 1)}
1213- d.clock = clk
1214- d.handleInput(sess, ac, []byte("\x1b"))
1215- timer := <-clk.timers
1216- require.True(t, ac.pickerActive())
1217- timer.ch <- time.Now()
1218- require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
1219-}
1220-
1221-func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
1222- p1, releasePTY1 := newBlockingPTY(t)
1223- p2, releasePTY2 := newBlockingPTY(t)
1224- defer releasePTY1()
1225- defer releasePTY2()
1226- d := newTestDaemon(t, nil, stubClock{})
1227- tr1, sends1 := newCapturingTransport(t)
1228- tr2, sends2 := newCapturingTransport(t)
1229- ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1230- ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1231- sctx1, cancel1 := context.WithCancel(d.serveCtx)
1232- sctx2, cancel2 := context.WithCancel(d.serveCtx)
1233- defer cancel1()
1234- defer cancel2()
1235- sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
1236- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
1237- ac1.setSession(sess1)
1238- ac2.setSession(sess2)
1239- ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
1240- ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
1241- d.sessions[sess1.id] = sess1
1242- d.sessions[sess2.id] = sess2
1243-
1244- d.handleInput(sess1, ac1, []byte("\x1bt"))
1245- awaitFrame(t, sends1, ports.MsgOutput)
1246- d.handleInput(sess1, ac1, []byte("j"))
1247- awaitFrame(t, sends1, ports.MsgOutput)
1248- d.handleInput(sess1, ac1, []byte("\r"))
1249-
1250- require.Same(t, sess2, ac1.currentSession())
1251- require.Same(t, ac1, sess2.client)
1252- require.Nil(t, sess1.client)
1253- require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")
1254- det := awaitFrame(t, sends2, ports.MsgDetached)
1255- dm, err := ports.UnmarshalDetached(det.Payload)
1256- require.NoError(t, err)
1257- require.Equal(t, ports.ReasonDetach, dm.Reason)
1258- awaitFrame(t, sends1, ports.MsgOutput)
1259-}
1260-
1261-func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
1262- d, sess, ac, sends, releases := newManualTabSession(t, 2)
1263- defer func() {
1264- for _, release := range releases {
1265- release()
1266- }
1267- }()
1268-
1269- d.handleInput(sess, ac, []byte("\x1bt"))
1270- awaitFrame(t, sends, ports.MsgOutput)
1271- d.handleInput(sess, ac, []byte("j"))
1272- awaitFrame(t, sends, ports.MsgOutput)
1273-
1274- previewTab := sess.tabs[1]
1275- previewTab.mu.Lock()
1276- previewTab.screen.Write([]byte("inactive-preview-live"))
1277- previewTab.mu.Unlock()
1278- d.render(sess, previewTab)
1279-
1280- previewOut := awaitFrame(t, sends, ports.MsgOutput)
1281- previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
1282- require.NoError(t, err)
1283- require.Contains(t, string(previewMsg.Data), "inactive-preview-live")
1284-}
1285-
1286-func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
1287- p1, releasePTY1 := newBlockingPTY(t)
1288- p2, releasePTY2 := newBlockingPTY(t)
1289- defer releasePTY1()
1290- defer releasePTY2()
1291- d := newTestDaemon(t, nil, stubClock{})
1292- tr1, sends1 := newCapturingTransport(t)
1293- tr2, _ := newCapturingTransport(t)
1294- ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1295- ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1296- sctx1, cancel1 := context.WithCancel(d.serveCtx)
1297- sctx2, cancel2 := context.WithCancel(d.serveCtx)
1298- defer cancel1()
1299- defer cancel2()
1300- sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
1301- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
1302- ac1.setSession(sess1)
1303- ac2.setSession(sess2)
1304- ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
1305- ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
1306- d.sessions[sess1.id] = sess1
1307- d.sessions[sess2.id] = sess2
1308-
1309- d.handleInput(sess1, ac1, []byte("\x1bt"))
1310- awaitFrame(t, sends1, ports.MsgOutput)
1311- d.handleInput(sess1, ac1, []byte("j"))
1312- awaitFrame(t, sends1, ports.MsgOutput)
1313-
1314- previewTab := sess2.tabs[0]
1315- previewTab.mu.Lock()
1316- previewTab.screen.Write([]byte("cross-session-preview-live"))
1317- previewTab.mu.Unlock()
1318- d.render(sess2, previewTab)
1319-
1320- previewOut := awaitFrame(t, sends1, ports.MsgOutput)
1321- previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
1322- require.NoError(t, err)
1323- require.Contains(t, string(previewMsg.Data), "cross-session-preview-live")
1324-}
1325-
1326-func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
1327- d, sess, ac, sends, releases := newManualTabSession(t, 3)
1328- defer func() {
1329- for _, release := range releases {
1330- release()
1331- }
1332- }()
1333-
1334- done := make(chan struct{})
1335- var drain sync.WaitGroup
1336- drain.Go(func() {
1337- for {
1338- select {
1339- case <-sends:
1340- case <-done:
1341- return
1342- }
1343- }
1344- })
1345-
1346- var wg sync.WaitGroup
1347- for i := range 100 {
1348- wg.Go(func() {
1349- d.enterPicker(sess, ac)
1350- })
1351- wg.Go(func() {
1352- d.handlePickerInput(ac, []byte("j"))
1353- d.handlePickerInput(ac, []byte("k"))
1354- })
1355- wg.Go(func() {
1356- d.closePicker(ac)
1357- })
1358- tb := sess.tabs[i%len(sess.tabs)]
1359- wg.Go(func() {
1360- tb.mu.Lock()
1361- tb.screen.Write([]byte("render-race"))
1362- tb.mu.Unlock()
1363- d.render(sess, tb)
1364- })
1365- }
1366- wg.Wait()
1367- close(done)
1368- drain.Wait()
1369-}
1370-
1371-func TestPickerResizeRecomposesModal(t *testing.T) {
1372- p, releasePTY := newBlockingPTY(t)
1373- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1374- defer releasePTY()
1375-
1376- d.handleInput(sess, ac, []byte("\x1bt"))
1377- awaitFrame(t, sends, ports.MsgOutput)
1378- d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
1379-
1380- out := awaitFrame(t, sends, ports.MsgOutput)
1381- msg, err := ports.UnmarshalOutput(out.Payload)
1382- require.NoError(t, err)
1383- require.Contains(t, string(msg.Data), "┌")
1384- require.Contains(t, string(msg.Data), "Sessions")
1385-}
1386-
1387-func TestStatusCompositionGolden(t *testing.T) {
1388- p1, releasePTY1 := newBlockingPTY(t)
1389- p2, releasePTY2 := newBlockingPTY(t)
1390- _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
1391- defer releasePTY1()
1392- defer releasePTY2()
1393- sess.active = 1
1394- sess.name = "work"
1395-
1396- win := sess.activeTab()
1397- win.screen = vt.NewScreen(12, 2)
1398- win.size = domain.Size{Cols: 12, Rows: 2}
1399- win.screen.Write([]byte("hello"))
1400-
1401- frame, damage := composeClientFrame(sess, win, true, "")
1402-
1403- require.Equal(t, 12, frame.Width)
1404- require.Equal(t, 3, frame.Height)
1405- require.Equal(t, "hello ", rowText(frame.Row(0)))
1406- require.Equal(t, " work 1 2 ", rowText(frame.Row(2)))
1407- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1408- for i, c := range frame.Row(2) {
1409- if i >= len(" work 1 ") && i < len(" work 1 2 ") {
1410- require.True(t, c.Style.Inverse, "active tab segment cell %d should be inverse", i)
1411- }
1412- }
1413-}
1414-
1415-func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
1416- p, releasePTY := newBlockingPTY(t)
1417- _, sess, _, _ := newManualSessionWithPTYs(t, p)
1418- defer releasePTY()
1419- sess.name = "work"
1420- win := sess.activeTab()
1421- win.screen = vt.NewScreen(30, 2)
1422- win.size = domain.Size{Cols: 30, Rows: 2}
1423-
1424- frame, _ := composeClientFrame(sess, win, true, "ok")
1425- require.Equal(t, " work 1 ok", rowText(frame.Row(2)))
1426-
1427- frame, _ = composeClientFrame(sess, win, true, "1234567890123456789")
1428- require.Equal(t, " work 1 1234567890123456789", rowText(frame.Row(2)))
1429-
1430- frame, _ = composeClientFrame(sess, win, true, "selection too large to copy")
1431- require.Equal(t, " work 1 ", rowText(frame.Row(2)))
1432-}
1433-
1434-func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
1435- p1, releasePTY1 := newBlockingPTY(t)
1436- p2, releasePTY2 := newBlockingPTY(t)
1437- d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
1438- tr, sends, releaseConn := newConn(t,
1439- mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
1440- frameInput([]byte("\x1bc")),
1441- frameInput([]byte("\x1b1")),
1442- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},
1443- )
1444-
1445- var hg sync.WaitGroup
1446- hg.Go(func() { d.handleConn(tr) })
1447- awaitFrame(t, sends, ports.MsgWelcome)
1448- first := awaitFrame(t, sends, ports.MsgOutput)
1449- created := awaitFrame(t, sends, ports.MsgOutput)
1450- switched := awaitFrame(t, sends, ports.MsgOutput)
1451- resized := awaitFrame(t, sends, ports.MsgOutput)
1452-
1453- for _, f := range []ports.Frame{first, created, switched, resized} {
1454- out, err := ports.UnmarshalOutput(f.Payload)
1455- require.NoError(t, err)
1456- require.Contains(t, string(out.Data), "work")
1457- require.Contains(t, string(out.Data), ";7m", "active status tab should be inverse-highlighted")
1458- }
1459-
1460- releaseConn()
1461- releasePTY1()
1462- releasePTY2()
1463- hg.Wait()
1464- d.sessWg.Wait()
1465-}
1466-
1467-func rowText(row []renderer.Cell) string {
1468- runes := make([]rune, len(row))
1469- for i, c := range row {
1470- runes[i] = c.Rune
1471- }
1472- return string(runes)
1473-}
1474-
1475-func TestAttachReplaceDetachesOld(t *testing.T) {
1476- p, releasePTY := newBlockingPTY(t)
1477- d := newTestDaemon(t, newFactory(t, p), stubClock{})
1478-
1479- // Client A creates and attaches to ephemeral "0".
1480- trA, sendsA, releaseA := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
1481- var hg sync.WaitGroup
1482- hg.Go(func() { d.handleConn(trA) })
1483- awaitFrame(t, sendsA, ports.MsgWelcome)
1484- awaitFrame(t, sendsA, ports.MsgOutput)
1485- sess := firstSession(d)
1486- require.NotNil(t, sess)
1487-
1488- // Client B attaches to the same session, displacing A.
1489- trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))
1490- hg.Go(func() { d.handleConn(trB) })
1491- awaitFrame(t, sendsB, ports.MsgWelcome)
1492-
1493- // A is notified it was detached.
1494- dA := awaitFrame(t, sendsA, ports.MsgDetached)
1495- det, _ := ports.UnmarshalDetached(dA.Payload)
1496- require.Equal(t, ports.ReasonDetach, det.Reason)
1497-
1498- // B is now the current client.
1499- sess.mu.Lock()
1500- require.NotNil(t, sess.client)
1501- sess.mu.Unlock()
1502-
1503- releaseA()
1504- releaseB()
1505- d.killSession(sess, ports.ReasonServerShutdown)
1506- releasePTY()
1507- hg.Wait()
1508- d.sessWg.Wait()
1509- d.waitNotifies()
1510-}
1511-
1512-// --- detach semantics -------------------------------------------------------
1513-
1514-func TestDetachKillsEphemeral(t *testing.T) {
1515- p, releasePTY := newBlockingPTY(t)
1516- d := newTestDaemon(t, newFactory(t, p), stubClock{})
1517- tr, sends, _ := newConn(t,
1518- mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}),
1519- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
1520- )
1521-
1522- var hg sync.WaitGroup
1523- hg.Go(func() { d.handleConn(tr) })
1524- awaitFrame(t, sends, ports.MsgWelcome)
1525-
1526- require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond,
1527- "ephemeral session must die on client detach")
1528-
1529- releasePTY()
1530- hg.Wait()
1531- d.sessWg.Wait()
1532-}
1533-
1534-func TestDetachKeepsNamed(t *testing.T) {
1535- p, releasePTY := newBlockingPTY(t)
1536- d := newTestDaemon(t, newFactory(t, p), stubClock{})
1537- tr, sends, _ := newConn(t,
1538- mustHello(ports.IntentNew, "keep", domain.Size{Cols: 80, Rows: 24}),
1539- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
1540- )
1541-
1542- var hg sync.WaitGroup
1543- hg.Go(func() { d.handleConn(tr) })
1544- awaitFrame(t, sends, ports.MsgWelcome)
1545- awaitFrame(t, sends, ports.MsgDetached) // ack for explicit detach
1546- hg.Wait() // handler returns after detach
1547-
1548- require.Equal(t, 1, sessionCount(d), "named session survives detach")
1549- sess := firstSession(d)
1550- sess.mu.Lock()
1551- require.Nil(t, sess.client, "named session is headless after detach")
1552- sess.mu.Unlock()
1553-
1554- d.killSession(sess, ports.ReasonServerShutdown)
1555- releasePTY()
1556- d.sessWg.Wait()
1557- d.waitNotifies()
1558-}
1559-
1560-// --- render scheduler debounce ----------------------------------------------
1561-
1562-func TestSchedulerDebounceCoalesces(t *testing.T) {
1563- mc := portsmocks.NewMockClock(t)
1564- mc.EXPECT().Now().Return(time.Now()).Maybe()
1565- mt := portsmocks.NewMockTimer(t)
1566- timerCh := make(chan time.Time, 1)
1567- newTimerCalled := make(chan struct{}, 4)
1568-
1569- mc.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
1570- select {
1571- case newTimerCalled <- struct{}{}:
1572- default:
1573- }
1574- return mt
1575- }).Maybe()
1576- mt.EXPECT().C().Return(timerCh).Maybe()
1577- mt.EXPECT().Stop().Return(true).Maybe()
1578-
1579- var outputs atomic.Int32
1580- gotOutput := make(chan struct{}, 1)
1581- tr := portsmocks.NewMockTransport(t)
1582- tr.EXPECT().Close().Return(nil).Maybe()
1583- tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
1584- if f.Type == ports.MsgOutput {
1585- outputs.Add(1)
1586- select {
1587- case gotOutput <- struct{}{}:
1588- default:
1589- }
1590- }
1591- return nil
1592- }).Maybe()
1593-
1594- d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
1595- sctx, cancel := context.WithCancel(context.Background())
1596- win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
1597- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1598- sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1599- ac.setSession(sess)
1600-
1601- win.mu.Lock()
1602- win.screen.Write([]byte("hi"))
1603- win.mu.Unlock()
1604-
1605- d.sessWg.Add(1)
1606- go d.scheduler(sess, win)
1607-
1608- // First dirty opens the debounce window.
1609- win.dirty <- struct{}{}
1610- <-newTimerCalled
1611-
1612- // A burst of further dirties inside the window must be absorbed.
1613- for range 5 {
1614- select {
1615- case win.dirty <- struct{}{}:
1616- default:
1617- }
1618- }
1619-
1620- // Fire the timer once: exactly one render.
1621- timerCh <- time.Now()
1622- <-gotOutput
1623-
1624- cancel()
1625- d.sessWg.Wait()
1626- require.Equal(t, int32(1), outputs.Load(), "N dirties in one tab must render exactly once")
1627-}
1628-
1629-func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) {
1630- delay := nextDebounceDelay(minDebounceInterval, 3)
1631- require.Greater(t, delay, minDebounceInterval, "sustained flood should widen debounce")
1632- delay = nextDebounceDelay(delay, 3)
1633- require.Greater(t, delay, minDebounceInterval+debounceStep, "continued flood should keep adapting")
1634-}
1635-
1636-func TestSchedulerAdaptiveDebounceResetsAfterQuietPeriod(t *testing.T) {
1637- delay := nextDebounceDelay(minDebounceInterval, 2)
1638- require.Greater(t, delay, minDebounceInterval)
1639- require.Equal(t, minDebounceInterval, nextDebounceDelay(delay, 0), "isolated update after quiet window should restore idle latency")
1640-}
1641-
1642-// --- resize ordering --------------------------------------------------------
1643-
1644-func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
1645- newSize := domain.Size{Cols: 100, Rows: 30}
1646-
1647- p := portsmocks.NewMockPTY(t)
1648- var screenWidthAtResize int
1649- win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
1650- p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error {
1651- // The screen must not yet be resized when the PTY is: proves order.
1652- screenWidthAtResize = win.screen.Frame.Width
1653- return nil
1654- }).Once()
1655- win.pty = p
1656-
1657- var gotOutput atomic.Bool
1658- tr := portsmocks.NewMockTransport(t)
1659- tr.EXPECT().Close().Return(nil).Maybe()
1660- tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
1661- if f.Type == ports.MsgOutput {
1662- gotOutput.Store(true)
1663- }
1664- return nil
1665- }).Maybe()
1666-
1667- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
1668- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1669- sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
1670- ac.setSession(sess)
1671-
1672- d.resize(sess, ac, newSize)
1673-
1674- require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
1675- require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")
1676- require.Equal(t, 29, win.screen.Frame.Height, "screen reserves bottom status row")
1677- require.True(t, gotOutput.Load(), "resize forces a full redraw output")
1678-}
1679-
1680-// --- reader EOF -> registry-empty shutdown ----------------------------------
1681-
1682-func TestReaderEOFRemovesSessionAndSignalsShutdown(t *testing.T) {
1683- p := portsmocks.NewMockPTY(t)
1684- // Read returns EOF immediately (child already gone).
1685- p.EXPECT().Read(mock.Anything).Return(0, io.EOF).Maybe()
1686- p.EXPECT().Close().Return(nil).Maybe()
1687- p.EXPECT().Pid().Return(1).Maybe()
1688-
1689- d := newTestDaemon(t, newFactory(t, p), stubClock{})
1690- tr, sends, _ := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
1691-
1692- var hg sync.WaitGroup
1693- hg.Go(func() { d.handleConn(tr) })
1694-
1695- // The client is detached with ReasonSessionKilled when the child exits.
1696- det := awaitFrame(t, sends, ports.MsgDetached)
1697- dm, _ := ports.UnmarshalDetached(det.Payload)
1698- require.Equal(t, ports.ReasonSessionKilled, dm.Reason)
1699-
1700- select {
1701- case <-d.done:
1702- case <-time.After(2 * time.Second):
1703- t.Fatal("registry-empty shutdown was not signalled")
1704- }
1705- require.Equal(t, 0, sessionCount(d))
1706-
1707- hg.Wait()
1708- d.sessWg.Wait()
1709- d.waitNotifies()
1710-}
1711-
1712-// --- full Serve lifecycle ---------------------------------------------------
1713-
1714-func TestServeReturnsWhenLastSessionExits(t *testing.T) {
1715- p, releasePTY := newBlockingPTY(t)
1716- tr, sends, _ := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
1717-
1718- l := portsmocks.NewMockListener(t)
1719- connCh := make(chan ports.Transport, 1)
1720- connCh <- tr
1721- closed := make(chan struct{})
1722- var once sync.Once
1723- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
1724- select {
1725- case c := <-connCh:
1726- return c, nil
1727- case <-closed:
1728- return nil, io.EOF
1729- }
1730- }).Maybe()
1731- l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
1732- l.EXPECT().Addr().Return("mock").Maybe()
1733-
1734- d := New(newFactory(t, p), stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
1735-
1736- served := make(chan error, 1)
1737- go func() { served <- d.Serve(context.Background(), l) }()
1738-
1739- awaitFrame(t, sends, ports.MsgWelcome)
1740- awaitFrame(t, sends, ports.MsgOutput)
1741-
1742- // Child exits -> session removed -> registry empties -> Serve returns.
1743- releasePTY()
1744-
1745- select {
1746- case err := <-served:
1747- require.NoError(t, err)
1748- case <-time.After(3 * time.Second):
1749- t.Fatal("Serve did not return after last session exited")
1750- }
1751-}
1752-
1753-func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
1754- p, releasePTY := newBlockingPTY(t)
1755- defer releasePTY()
1756- tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "long", domain.Size{Cols: 80, Rows: 24}))
1757-
1758- l := portsmocks.NewMockListener(t)
1759- connCh := make(chan ports.Transport, 1)
1760- connCh <- tr
1761- closed := make(chan struct{})
1762- var once sync.Once
1763- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
1764- select {
1765- case c := <-connCh:
1766- return c, nil
1767- case <-closed:
1768- return nil, io.EOF
1769- }
1770- }).Maybe()
1771- l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
1772- l.EXPECT().Addr().Return("mock").Maybe()
1773-
1774- d := New(newFactory(t, p), stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
1775- ctx, cancel := context.WithCancel(context.Background())
1776-
1777- served := make(chan error, 1)
1778- go func() { served <- d.Serve(ctx, l) }()
1779-
1780- awaitFrame(t, sends, ports.MsgWelcome)
1781- awaitFrame(t, sends, ports.MsgOutput)
1782-
1783- cancel() // graceful shutdown
1784-
1785- det := awaitFrame(t, sends, ports.MsgDetached)
1786- dm, _ := ports.UnmarshalDetached(det.Payload)
1787- require.Equal(t, ports.ReasonServerShutdown, dm.Reason)
1788-
1789- select {
1790- case err := <-served:
1791- require.NoError(t, err)
1792- case <-time.After(3 * time.Second):
1793- t.Fatal("Serve did not return after context cancel")
1794- }
1795-}
1796-
1797-// --- shutdown/create interlock ------------------------------------------------
1798-
1799-// TestHelloRacingShutdownIsRejected covers the interlock between the
1800-// registry-empty shutdown decision and session creation: once killSession has
1801-// removed the last session (making shutdown irreversible via doneOnce), a
1802-// Hello landing at any later instant must be rejected cleanly — never spawn a
1803-// child that no teardown pass would reap. The interleaving is deterministic:
1804-// killSession runs to completion before the racing Hellos are handled, which
1805-// models the worst case (insertion attempt after the shutdown snapshot).
1806-func TestHelloRacingShutdownIsRejected(t *testing.T) {
1807- p, releasePTY := newBlockingPTY(t)
1808- f := portsmocks.NewMockPTYFactory(t)
1809- // Exactly one child may ever be spawned; a second Open call (a leaked
1810- // child) fails the test via mock expectations.
1811- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()
1812- d := newTestDaemon(t, f, stubClock{})
1813-
1814- tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
1815- var hg sync.WaitGroup
1816- hg.Go(func() { d.handleConn(tr1) })
1817- awaitFrame(t, sends1, ports.MsgWelcome)
1818- sess := firstSession(d)
1819- require.NotNil(t, sess)
1820-
1821- // The last session dies: shutdown begins irreversibly.
1822- d.killSession(sess, ports.ReasonSessionKilled)
1823- select {
1824- case <-d.done:
1825- default:
1826- t.Fatal("registry-empty shutdown must be signalled synchronously by killSession")
1827- }
1828-
1829- // Racing Hellos — both creation intents must be rejected with a clean
1830- // typed error, and nothing may be inserted into the registry.
1831- for _, intent := range []struct {
1832- name string
1833- intent uint8
1834- sess string
1835- }{
1836- {"ephemeral", ports.IntentEphemeral, ""},
1837- {"named", ports.IntentNew, "latecomer"},
1838- } {
1839- tr2, sends2, _ := newConn(t, mustHello(intent.intent, intent.sess, domain.Size{Cols: 80, Rows: 24}))
1840- d.handleConn(tr2)
1841- e := awaitFrame(t, sends2, ports.MsgError)
1842- em, err := ports.UnmarshalErrorMsg(e.Payload)
1843- require.NoError(t, err)
1844- require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)
1845- }
1846- require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")
1847-
1848- release1()
1849- releasePTY()
1850- hg.Wait()
1851- d.sessWg.Wait()
1852- d.waitNotifies()
1853-}
1854-
1855-// --- wedged-client teardown ---------------------------------------------------
1856-
1857-// notifyClock drives the daemon with a stub debounce timer (schedulers park)
1858-// but a short real timer for the detach-notify deadline, so a wedged client's
1859-// transport is force-closed quickly and deterministically.
1860-type notifyClock struct{}
1861-
1862-func (notifyClock) Now() time.Time { return time.Now() }
1863-func (notifyClock) NewTimer(d time.Duration) ports.Timer {
1864- if d == debounceInterval {
1865- return stubTimer{}
1866- }
1867- return realTimer{t: time.NewTimer(5 * time.Millisecond)}
1868-}
1869-
1870-type realTimer struct{ t *time.Timer }
1871-
1872-func (r realTimer) C() <-chan time.Time { return r.t.C }
1873-func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
1874-func (r realTimer) Stop() bool { return r.t.Stop() }
1875-
1876-// TestServeReturnsDespiteWedgedClientOnShutdown: killSession's teardown must
1877-// not be gated behind the Detached notice. The client transport wedges every
1878-// MsgDetached send until the transport is closed (mirroring a full kernel
1879-// send buffer: only Close fails the in-flight write). Serve must still tear
1880-// down the PTY and return.
1881-func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
1882- p, _ := newBlockingPTY(t) // Close unblocks the parked reader
1883-
1884- tr := portsmocks.NewMockTransport(t)
1885- sends := make(chan ports.Frame, 64)
1886- recvCh := make(chan ports.Frame, 1)
1887- recvCh <- mustHello(ports.IntentNew, "wedge", domain.Size{Cols: 80, Rows: 24})
1888- connDone := make(chan struct{})
1889- var connOnce sync.Once
1890- closeConn := func() { connOnce.Do(func() { close(connDone) }) }
1891-
1892- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1893- select {
1894- case f := <-recvCh:
1895- return f, nil
1896- case <-connDone:
1897- return ports.Frame{}, io.EOF
1898- }
1899- }).Maybe()
1900- tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
1901- if f.Type == ports.MsgDetached {
1902- // Wedged: blocks until the transport is force-closed.
1903- <-connDone
1904- return io.ErrClosedPipe
1905- }
1906- sends <- f
1907- return nil
1908- }).Maybe()
1909- tr.EXPECT().Close().RunAndReturn(func() error { closeConn(); return nil }).Maybe()
1910-
1911- l := portsmocks.NewMockListener(t)
1912- connCh := make(chan ports.Transport, 1)
1913- connCh <- tr
1914- lnClosed := make(chan struct{})
1915- var lnOnce sync.Once
1916- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
1917- select {
1918- case c := <-connCh:
1919- return c, nil
1920- case <-lnClosed:
1921- return nil, io.EOF
1922- }
1923- }).Maybe()
1924- l.EXPECT().Close().RunAndReturn(func() error { lnOnce.Do(func() { close(lnClosed) }); return nil }).Maybe()
1925- l.EXPECT().Addr().Return("mock").Maybe()
1926-
1927- d := New(newFactory(t, p), notifyClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
1928- ctx, cancel := context.WithCancel(context.Background())
1929-
1930- served := make(chan error, 1)
1931- go func() { served <- d.Serve(ctx, l) }()
1932-
1933- awaitFrame(t, sends, ports.MsgWelcome)
1934- awaitFrame(t, sends, ports.MsgOutput)
1935-
1936- cancel() // shutdown with the wedged client still attached
1937-
1938- select {
1939- case err := <-served:
1940- require.NoError(t, err)
1941- case <-time.After(3 * time.Second):
1942- t.Fatal("Serve hung on a wedged client during teardown")
1943- }
1944- require.Equal(t, 0, sessionCount(d), "session must be torn down despite the wedged client")
1945-}
1946-
1947-// --- send-error kills ephemeral -----------------------------------------------
1948-
1949-// TestSendErrorKillsEphemeral: a failed output send detaches the client, and —
1950-// like every other detach path — that kills an ephemeral session rather than
1951-// leaving it headless-but-alive forever.
1952-func TestSendErrorKillsEphemeral(t *testing.T) {
1953- p := portsmocks.NewMockPTY(t)
1954- p.EXPECT().Close().Return(nil).Maybe()
1955-
1956- tr := portsmocks.NewMockTransport(t)
1957- tr.EXPECT().Send(mock.Anything).Return(io.ErrClosedPipe).Maybe()
1958- tr.EXPECT().Close().Return(nil).Maybe()
1959-
1960- d := newTestDaemon(t, nil, stubClock{})
1961- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
1962- sctx, cancel := context.WithCancel(context.Background())
1963- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1964- sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1965- ac.setSession(sess)
1966- d.sessions[sess.id] = sess
1967-
1968- win.mu.Lock()
1969- win.screen.Write([]byte("x"))
1970- win.mu.Unlock()
1971-
1972- d.paint(sess, ac, true) // send fails -> detach -> ephemeral killed
1973-
1974- require.Equal(t, 0, sessionCount(d), "ephemeral session must die when its client's send fails")
1975- d.waitNotifies()
1976-}
1977-
1978-func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) {
1979- mc := portsmocks.NewMockClock(t)
1980- mc.EXPECT().Now().Return(time.Now()).Maybe()
1981- mt := portsmocks.NewMockTimer(t)
1982- timerCh := make(chan time.Time, 1)
1983- mc.EXPECT().NewTimer(mock.Anything).Return(mt).Maybe()
1984- mt.EXPECT().C().Return(timerCh).Maybe()
1985- mt.EXPECT().Stop().Return(true).Maybe()
1986-
1987- var outputs atomic.Int32
1988- gotOutput := make(chan struct{}, 1)
1989- tr := portsmocks.NewMockTransport(t)
1990- tr.EXPECT().Close().Return(nil).Maybe()
1991- tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
1992- if f.Type == ports.MsgOutput {
1993- outputs.Add(1)
1994- select {
1995- case gotOutput <- struct{}{}:
1996- default:
1997- }
1998- }
1999- return nil
2000- }).Maybe()
2001-
2002- d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
2003- sctx, cancel := context.WithCancel(context.Background())
2004- win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
2005- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
2006- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
2007- ac.setSession(sess)
2008-
2009- win.mu.Lock()
2010- win.screen.Write([]byte("before"))
2011- win.mu.Unlock()
2012-
2013- d.sessWg.Add(1)
2014- go d.scheduler(sess, win)
2015- win.dirty <- struct{}{}
2016-
2017- win.mu.Lock()
2018- win.screen.Write([]byte("\x1b[?2026hafter"))
2019- win.mu.Unlock()
2020- timerCh <- time.Now()
2021-
2022- select {
2023- case <-gotOutput:
2024- t.Fatal("scheduler rendered while synchronized update was active")
2025- case <-time.After(20 * time.Millisecond):
2026- }
2027- require.Equal(t, int32(0), outputs.Load())
2028-
2029- win.mu.Lock()
2030- win.screen.Write([]byte(" done\x1b[?2026l"))
2031- win.mu.Unlock()
2032- win.flush <- struct{}{}
2033- select {
2034- case <-gotOutput:
2035- case <-time.After(time.Second):
2036- t.Fatal("sync end did not flush deferred damage")
2037- }
2038-
2039- cancel()
2040- d.sessWg.Wait()
2041-}
2042-
2043-func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
2044- d := newTestDaemon(t, nil, stubClock{})
2045- p := portsmocks.NewMockPTY(t)
2046- chunks := [][]byte{[]byte("\x1b[6n")}
2047- writes := make(chan []byte, 1)
2048- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
2049- if len(chunks) == 0 {
2050- return 0, io.EOF
2051- }
2052- n := copy(buf, chunks[0])
2053- chunks = chunks[1:]
2054- return n, nil
2055- })
2056- p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
2057- writes <- append([]byte(nil), b...)
2058- return len(b), nil
2059- }).Once()
2060- p.EXPECT().Close().Return(nil).Maybe()
2061-
2062- tr, sends := newCapturingTransport(t)
2063- sctx, cancel := context.WithCancel(context.Background())
2064- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
2065- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
2066- sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
2067- ac.setSession(sess)
2068- d.sessions[sess.id] = sess
2069- d.sessWg.Add(1)
2070- d.ptyReader(sess, win)
2071-
2072- select {
2073- case got := <-writes:
2074- require.Equal(t, []byte("\x1b[1;1R"), got)
2075- case <-time.After(time.Second):
2076- t.Fatal("timed out waiting for PTY response write")
2077- }
2078- select {
2079- case f := <-sends:
2080- require.NotEqual(t, ports.MsgOutput, f.Type)
2081- default:
2082- }
2083-}
2084-
2085-func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) {
2086- d := newTestDaemon(t, nil, stubClock{})
2087- p := portsmocks.NewMockPTY(t)
2088- chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")}
2089- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
2090- if len(chunks) == 0 {
2091- return 0, io.EOF
2092- }
2093- n := copy(buf, chunks[0])
2094- chunks = chunks[1:]
2095- return n, nil
2096- })
2097- p.EXPECT().Close().Return(nil).Maybe()
2098-
2099- sctx, cancel := context.WithCancel(context.Background())
2100- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
2101- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
2102- d.sessions[sess.id] = sess
2103- d.sessWg.Add(1)
2104- d.ptyReader(sess, win)
2105-
2106- select {
2107- case <-win.dirty:
2108- t.Fatal("same-read synchronized update end should request flush, not dirty")
2109- default:
2110- }
2111- select {
2112- case <-win.flush:
2113- case <-time.After(time.Second):
2114- t.Fatal("same-read synchronized update end did not request immediate flush")
2115- }
2116-}
2117-
2118-func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.T) {
2119- d := newTestDaemon(t, nil, stubClock{})
2120- p := portsmocks.NewMockPTY(t)
2121- chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
2122- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
2123- if len(chunks) == 0 {
2124- return 0, io.EOF
2125- }
2126- n := copy(buf, chunks[0])
2127- chunks = chunks[1:]
2128- return n, nil
2129- })
2130- p.EXPECT().Close().Return(nil).Maybe()
2131-
2132- sctx, cancel := context.WithCancel(context.Background())
2133- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
2134- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
2135- d.sessions[sess.id] = sess
2136- d.sessWg.Add(1)
2137- d.ptyReader(sess, win)
2138-
2139- select {
2140- case <-win.dirty:
2141- t.Fatal("dirty signaled while synchronized update was active")
2142- default:
2143- }
2144- select {
2145- case <-win.flush:
2146- case <-time.After(time.Second):
2147- t.Fatal("sync end did not request immediate flush")
2148- }
2149-}
2150-
2151-func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
2152- clk := &signalClock{timers: make(chan *signalTimer, 1)}
2153- d := newTestDaemon(t, nil, clk)
2154- p := portsmocks.NewMockPTY(t)
2155- release := make(chan struct{})
2156- read := 0
2157- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
2158- if read == 0 {
2159- read++
2160- return copy(buf, []byte("\x1b[?2026habandoned")), nil
2161- }
2162- <-release
2163- return 0, io.EOF
2164- })
2165- p.EXPECT().Close().Return(nil).Maybe()
2166-
2167- tr, sends := newCapturingTransport(t)
2168- sctx, cancel := context.WithCancel(context.Background())
2169- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
2170- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
2171- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
2172- ac.setSession(sess)
2173- d.sessions[sess.id] = sess
2174- d.sessWg.Add(2)
2175- go d.scheduler(sess, win)
2176- go d.ptyReader(sess, win)
2177-
2178- timer := <-clk.timers
2179- timer.ch <- time.Now()
2180- awaitFrame(t, sends, ports.MsgOutput)
2181-
2182- win.mu.Lock()
2183- active := win.screen.SyncUpdateActive()
2184- got := screenLineText(win.screen, 0)
2185- win.mu.Unlock()
2186- require.False(t, active)
2187- require.Contains(t, got, "abandoned")
2188-
2189- close(release)
2190- cancel()
2191- d.sessWg.Wait()
2192-}
2193-
2194-func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
2195- clk := &signalClock{timers: make(chan *signalTimer, 1)}
2196- d := newTestDaemon(t, nil, clk)
2197- p := portsmocks.NewMockPTY(t)
2198- release := make(chan struct{})
2199- chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
2200- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
2201- if len(chunks) > 0 {
2202- n := copy(buf, chunks[0])
2203- chunks = chunks[1:]
2204- return n, nil
2205- }
2206- <-release
2207- return 0, io.EOF
2208- })
2209- p.EXPECT().Close().Return(nil).Maybe()
2210-
2211- tr, sends := newCapturingTransport(t)
2212- sctx, cancel := context.WithCancel(context.Background())
2213- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
2214- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
2215- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
2216- ac.setSession(sess)
2217- d.sessions[sess.id] = sess
2218- d.sessWg.Add(2)
2219- go d.scheduler(sess, win)
2220- go d.ptyReader(sess, win)
2221-
2222- timer := <-clk.timers
2223- awaitFrame(t, sends, ports.MsgOutput)
2224- timer.ch <- time.Now()
2225-
2226- select {
2227- case f := <-sends:
2228- if f.Type == ports.MsgOutput {
2229- t.Fatal("stale synchronized update watchdog produced an extra render")
2230- }
2231- case <-time.After(50 * time.Millisecond):
2232- }
2233- win.mu.Lock()
2234- active := win.screen.SyncUpdateActive()
2235- win.mu.Unlock()
2236- require.False(t, active)
2237-
2238- close(release)
2239- cancel()
2240- d.sessWg.Wait()
2241-}
2242-
2243-func mustOutputData(t *testing.T, sends chan ports.Frame) []byte {
2244- t.Helper()
329+func mustOutputData(t *testing.T, sends chan ports.Frame) []byte {
330+ t.Helper()
2245331 f := awaitFrame(t, sends, ports.MsgOutput)
2246332 out, err := ports.UnmarshalOutput(f.Payload)
2247333 require.NoError(t, err)
2248334 return out.Data
2249335 }
2250-
2251-func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
2252- writes := make(chan []byte, 4)
2253- p, _ := newBlockingPTYWithWrites(t, writes)
2254- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
2255- win := sess.tabs[0]
2256- win.screen.Write([]byte("live"))
2257-
2258- d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
2259- data := mustOutputData(t, sends)
2260- require.NotNil(t, ac.copyMode)
2261- require.Equal(t, 19, ac.copyMode.Cursor)
2262- require.Contains(t, string(data), "[SCROLL]")
2263-
2264- d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
2265- mustOutputData(t, sends)
2266- require.Nil(t, ac.copyMode)
2267-
2268- d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))
2269- mustOutputData(t, sends)
2270- mustOutputData(t, sends)
2271- require.Nil(t, ac.copyMode, "q after wheel in same input must be routed after copy mode is entered")
2272- select {
2273- case got := <-writes:
2274- t.Fatalf("mouse/copy input forwarded to PTY: %q", got)
2275- default:
2276- }
2277-}
2278-
2279-func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
2280- writes := make(chan []byte, 2)
2281- p, _ := newBlockingPTYWithWrites(t, writes)
2282- d, sess, ac, _ := newManualSessionWithPTYs(t, p)
2283- sess.tabs[0].screen.Write([]byte("\x1b[?1049h"))
2284-
2285- d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
2286- d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
2287-
2288- require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)
2289- require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)
2290- require.Nil(t, ac.copyMode)
2291-}
2292-
2293-func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
2294- writes := make(chan []byte, 4)
2295- p, _ := newBlockingPTYWithWrites(t, writes)
2296- d, sess, ac, _ := newManualSessionWithPTYs(t, p)
2297-
2298- d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
2299- select {
2300- case got := <-writes:
2301- t.Fatalf("press without child mouse mode forwarded: %q", got)
2302- default:
2303- }
2304-
2305- sess.tabs[0].screen.Write([]byte("\x1b[?1000h"))
2306- raw := []byte("\x1b[<0;2;3M")
2307- d.handleInput(sess, ac, raw)
2308- select {
2309- case got := <-writes:
2310- t.Fatalf("SGR report forwarded to child without SGR mode: %q", got)
2311- default:
2312- }
2313-
2314- sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
2315- d.handleInput(sess, ac, raw)
2316- require.Equal(t, raw, <-writes)
2317-
2318- d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))
2319- select {
2320- case got := <-writes:
2321- t.Fatalf("status-row mouse report forwarded: %q", got)
2322- default:
2323- }
2324-}
2325-
2326-func TestMouseSplitReportPreservesOrder(t *testing.T) {
2327- writes := make(chan []byte, 2)
2328- p, _ := newBlockingPTYWithWrites(t, writes)
2329- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
2330- sess.tabs[0].screen.Write([]byte("live"))
2331-
2332- d.handleInput(sess, ac, []byte("\x1b[<64;"))
2333- d.handleInput(sess, ac, []byte("1;1Mq"))
2334-
2335- mustOutputData(t, sends)
2336- mustOutputData(t, sends)
2337- require.Nil(t, ac.copyMode)
2338- select {
2339- case got := <-writes:
2340- t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)
2341- default:
2342- }
2343-}
2344-
2345-func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
2346- p, _ := newBlockingPTY(t)
2347- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
2348- win := sess.tabs[0]
2349- win.screen.Write([]byte("A"))
2350-
2351- d.paint(sess, ac, true)
2352- data := mustOutputData(t, sends)
2353- require.Contains(t, string(data), "\x1b[5 q")
2354- require.Contains(t, string(data), "\x1b[?25h")
2355-
2356- win.screen.Write([]byte("\x1b[2;3H"))
2357- d.paint(sess, ac, false)
2358- data = mustOutputData(t, sends)
2359- require.Contains(t, string(data), "\x1b[2;3H")
2360- require.Contains(t, string(data), "\x1b[?25h")
2361-
2362- win.screen.Write([]byte("\x1b[?25l"))
2363- d.paint(sess, ac, false)
2364- data = mustOutputData(t, sends)
2365- require.Contains(t, string(data), "\x1b[?25l")
2366-}
2367-
2368-func TestCursorTailHidesInCopyMode(t *testing.T) {
2369- p, _ := newBlockingPTY(t)
2370- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
2371- sess.tabs[0].screen.Write([]byte("live"))
2372-
2373- d.enterCopyMode(sess, ac)
2374- data := mustOutputData(t, sends)
2375- require.Contains(t, string(data), "\x1b[?25l")
2376-}
A internal/usecase/daemon/guarded.go +27 -0
@@ -0,0 +1,27 @@
1+package daemon
2+
3+import "sync"
4+
5+// Guarded protects one value behind a mutex.
6+type Guarded[T any] struct {
7+ mu sync.Mutex
8+ v T
9+}
10+
11+func (g *Guarded[T]) Get() T {
12+ g.mu.Lock()
13+ defer g.mu.Unlock()
14+ return g.v
15+}
16+
17+func (g *Guarded[T]) Set(v T) {
18+ g.mu.Lock()
19+ defer g.mu.Unlock()
20+ g.v = v
21+}
22+
23+func (g *Guarded[T]) With(fn func(*T)) {
24+ g.mu.Lock()
25+ defer g.mu.Unlock()
26+ fn(&g.v)
27+}
A internal/usecase/daemon/guarded_test.go +33 -0
@@ -0,0 +1,33 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestGuardedGetSetWith(t *testing.T) {
11+ var g Guarded[int]
12+
13+ g.Set(10)
14+ require.Equal(t, 10, g.Get())
15+
16+ g.With(func(v *int) { *v += 5 })
17+ require.Equal(t, 15, g.Get())
18+}
19+
20+func TestGuardedConcurrentAccess(t *testing.T) {
21+ var g Guarded[int]
22+ var wg sync.WaitGroup
23+ for range 20 {
24+ wg.Go(func() {
25+ for range 1000 {
26+ g.With(func(v *int) { *v++ })
27+ }
28+ })
29+ }
30+ wg.Wait()
31+
32+ require.Equal(t, 20000, g.Get())
33+}
A internal/usecase/daemon/input.go +145 -0
@@ -0,0 +1,145 @@
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+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
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.
26+package daemon
27+
28+import (
29+ "github.com/bnema/vev/internal/usecase/keys"
30+ "github.com/bnema/vev/internal/usecase/mouse"
31+)
32+
33+func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
34+ ac.mouseScan.Scan(data,
35+ func(ev mouse.Event) { d.handleMouse(ac, ev) },
36+ func(b []byte) {
37+ if ac.promptActive() {
38+ d.handlePromptInput(ac, b)
39+ return
40+ }
41+ if ac.paletteActive() {
42+ d.handlePaletteInput(ac, b)
43+ return
44+ }
45+ if ac.pickerActive() {
46+ d.handlePickerInput(ac, b)
47+ return
48+ }
49+ if ac.copyModeActive() {
50+ d.handleCopyInput(ac, b)
51+ return
52+ }
53+ ac.keys.Route(b)
54+ },
55+ )
56+}
57+
58+func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
59+ if ac.promptActive() || ac.paletteActive() || ac.pickerActive() {
60+ return
61+ }
62+ sess := ac.currentSession()
63+ if sess == nil {
64+ return
65+ }
66+ tb := sess.activeTab()
67+ if tb == nil {
68+ return
69+ }
70+
71+ if ac.copyModeActive() {
72+ switch ev.Button {
73+ case mouse.WheelUp:
74+ d.copyWheel(sess, ac, -3)
75+ case mouse.WheelDown:
76+ d.copyWheel(sess, ac, 3)
77+ }
78+ return
79+ }
80+
81+ tb.mu.Lock()
82+ childRows := tb.screen.Frame.Height
83+ mouseMode, mouseSGR := tb.screen.MouseMode()
84+ altScreen := tb.screen.AltScreenActive()
85+ tb.mu.Unlock()
86+
87+ if mouseMode != 0 {
88+ if !mouseSGR || ev.Row >= childRows {
89+ return
90+ }
91+ daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw)
92+ return
93+ }
94+
95+ switch ev.Button {
96+ case mouse.WheelUp:
97+ if altScreen {
98+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
99+ return
100+ }
101+ d.enterCopyMode(sess, ac)
102+ d.copyWheel(sess, ac, -3)
103+ case mouse.WheelDown:
104+ if altScreen {
105+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
106+ }
107+ }
108+}
109+
110+type daemonKeyHandler struct {
111+ d *Daemon
112+ ac *attachedClient
113+}
114+
115+func (h daemonKeyHandler) Forward(data []byte) {
116+ sess := h.ac.currentSession()
117+ if sess == nil {
118+ return
119+ }
120+ tb := sess.activeTab()
121+ if tb == nil {
122+ return
123+ }
124+ if _, err := tb.pty.Write(data); err != nil {
125+ h.d.log.Error("pty write failed", "err", err, "session", sess.name)
126+ }
127+}
128+
129+func (h daemonKeyHandler) Action(action keys.Action) {
130+ sess := h.ac.currentSession()
131+ if sess == nil {
132+ return
133+ }
134+ switch action {
135+ case keys.ActionOpenPalette:
136+ h.d.enterPalette(sess, h.ac)
137+ case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
138+ keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
139+ keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
140+ idx := int(action - keys.ActionSwitchTab1)
141+ if sess.switchTab(idx) {
142+ h.d.paint(sess, h.ac, true)
143+ }
144+ }
145+}
A internal/usecase/daemon/input_test.go +256 -0
@@ -0,0 +1,256 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// --- test doubles -----------------------------------------------------------
15+
16+// stubClock returns timers whose channel never fires, so a scheduler under it
17+// blocks in its debounce loop until the session context is cancelled. Used by
18+
19+func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
20+ writes1 := make(chan []byte, 1)
21+ writes2 := make(chan []byte, 1)
22+ writes3 := make(chan []byte, 1)
23+ p1, releasePTY1 := newBlockingPTYWithWrites(t, writes1)
24+ p2, releasePTY2 := newBlockingPTYWithWrites(t, writes2)
25+ p3, releasePTY3 := newBlockingPTYWithWrites(t, writes3)
26+ d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{})
27+ tr, sends, releaseConn := newConn(t,
28+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
29+ frameInput([]byte("\x1b ")),
30+ frameInput([]byte("CNT\r")),
31+ frameInput([]byte("\x1b ")),
32+ frameInput([]byte("CNT\r")),
33+ frameInput([]byte("\x1b1")),
34+ frameInput([]byte("A")),
35+ frameInput([]byte("\x1b2")),
36+ frameInput([]byte("B")),
37+ frameInput([]byte("\x1b3")),
38+ frameInput([]byte("C")),
39+ )
40+
41+ var hg sync.WaitGroup
42+ hg.Go(func() { d.handleConn(tr) })
43+ awaitFrame(t, sends, ports.MsgWelcome)
44+ awaitFrame(t, sends, ports.MsgOutput)
45+
46+ require.Eventually(t, func() bool {
47+ sessions := listSessions(t, d)
48+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3
49+ }, 2*time.Second, 5*time.Millisecond)
50+ require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)
51+ require.Equal(t, []byte("A"), <-writes1)
52+ require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond)
53+ require.Equal(t, []byte("B"), <-writes2)
54+ require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond)
55+ require.Equal(t, []byte("C"), <-writes3)
56+
57+ releaseConn()
58+ releasePTY1()
59+ releasePTY2()
60+ releasePTY3()
61+ hg.Wait()
62+ d.sessWg.Wait()
63+}
64+
65+func TestAltCForwardsToPTY(t *testing.T) {
66+ writes := make(chan []byte, 2)
67+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
68+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
69+
70+ d.handleInput(sess, ac, []byte("\x1bc"))
71+
72+ require.Equal(t, []byte("\x1bc"), <-writes)
73+ releasePTY()
74+}
75+
76+func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
77+ cases := []struct {
78+ name string
79+ start int
80+ query []byte
81+ wantIndex int
82+ }{
83+ {name: "next advances", start: 0, query: []byte("NXT\r"), wantIndex: 1},
84+ {name: "next wraps", start: 2, query: []byte("NXT\r"), wantIndex: 0},
85+ {name: "previous moves back", start: 2, query: []byte("PVT\r"), wantIndex: 1},
86+ {name: "previous wraps", start: 0, query: []byte("PVT\r"), wantIndex: 2},
87+ }
88+
89+ for _, tc := range cases {
90+ t.Run(tc.name, func(t *testing.T) {
91+ d, sess, ac, _, releases := newManualTabSession(t, 3)
92+ defer func() {
93+ for _, release := range releases {
94+ release()
95+ }
96+ }()
97+ sess.active = tc.start
98+
99+ d.handleInput(sess, ac, []byte("\x1b "))
100+ d.handleInput(sess, ac, tc.query)
101+
102+ require.Equal(t, tc.wantIndex, activeTabIndex(sess))
103+ })
104+ }
105+}
106+
107+func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
108+ writes := make(chan []byte, 1)
109+ p1, releasePTY1 := newBlockingPTY(t)
110+ p2, releasePTY2 := newBlockingPTY(t)
111+ p3, releasePTY3 := newBlockingPTYWithWrites(t, writes)
112+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)
113+ sess.active = 1
114+
115+ d.handleInput(sess, ac, []byte("\x1b "))
116+ d.handleInput(sess, ac, []byte("CLT\r"))
117+
118+ require.Equal(t, 1, sessionCount(d))
119+ require.Len(t, sess.tabs, 2)
120+ require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab")
121+ d.handleInput(sess, ac, []byte("Z"))
122+ require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
123+ require.Equal(t, []byte("Z"), <-writes)
124+
125+ releasePTY1()
126+ releasePTY2()
127+ releasePTY3()
128+}
129+
130+func TestAltDDetachesCurrentClient(t *testing.T) {
131+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
132+ defer releases[0]()
133+
134+ d.handleInput(sess, ac, []byte("\x1b "))
135+ d.handleInput(sess, ac, []byte("DET\r"))
136+
137+ require.Nil(t, sess.client)
138+ f := awaitFrame(t, sends, ports.MsgDetached)
139+ det, err := ports.UnmarshalDetached(f.Payload)
140+ require.NoError(t, err)
141+ require.Equal(t, ports.ReasonDetach, det.Reason)
142+}
143+
144+func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
145+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
146+ defer releases[0]()
147+ sess.ephemeral = true
148+ sess.name = "0"
149+
150+ d.handleInput(sess, ac, []byte("\x1b "))
151+ awaitFrame(t, sends, ports.MsgOutput)
152+ d.handleInput(sess, ac, []byte("RNS\r"))
153+ awaitFrame(t, sends, ports.MsgOutput)
154+ require.True(t, ac.promptActive())
155+
156+ d.handleInput(sess, ac, []byte("\r"))
157+ awaitFrame(t, sends, ports.MsgOutput)
158+
159+ require.False(t, ac.promptActive())
160+ require.False(t, sess.ephemeral)
161+ require.Equal(t, "0", sess.name)
162+}
163+
164+func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
165+ writes := make(chan []byte, 4)
166+ p, _ := newBlockingPTYWithWrites(t, writes)
167+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
168+ win := sess.tabs[0]
169+ win.screen.Write([]byte("live"))
170+
171+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
172+ data := mustOutputData(t, sends)
173+ require.NotNil(t, ac.copyMode)
174+ require.Equal(t, 19, ac.copyMode.Cursor)
175+ require.Contains(t, string(data), "[SCROLL]")
176+
177+ d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
178+ mustOutputData(t, sends)
179+ require.Nil(t, ac.copyMode)
180+
181+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))
182+ mustOutputData(t, sends)
183+ mustOutputData(t, sends)
184+ require.Nil(t, ac.copyMode, "q after wheel in same input must be routed after copy mode is entered")
185+ select {
186+ case got := <-writes:
187+ t.Fatalf("mouse/copy input forwarded to PTY: %q", got)
188+ default:
189+ }
190+}
191+
192+func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
193+ writes := make(chan []byte, 2)
194+ p, _ := newBlockingPTYWithWrites(t, writes)
195+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
196+ sess.tabs[0].screen.Write([]byte("\x1b[?1049h"))
197+
198+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
199+ d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
200+
201+ require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)
202+ require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)
203+ require.Nil(t, ac.copyMode)
204+}
205+
206+func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
207+ writes := make(chan []byte, 4)
208+ p, _ := newBlockingPTYWithWrites(t, writes)
209+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
210+
211+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
212+ select {
213+ case got := <-writes:
214+ t.Fatalf("press without child mouse mode forwarded: %q", got)
215+ default:
216+ }
217+
218+ sess.tabs[0].screen.Write([]byte("\x1b[?1000h"))
219+ raw := []byte("\x1b[<0;2;3M")
220+ d.handleInput(sess, ac, raw)
221+ select {
222+ case got := <-writes:
223+ t.Fatalf("SGR report forwarded to child without SGR mode: %q", got)
224+ default:
225+ }
226+
227+ sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
228+ d.handleInput(sess, ac, raw)
229+ require.Equal(t, raw, <-writes)
230+
231+ d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))
232+ select {
233+ case got := <-writes:
234+ t.Fatalf("status-row mouse report forwarded: %q", got)
235+ default:
236+ }
237+}
238+
239+func TestMouseSplitReportPreservesOrder(t *testing.T) {
240+ writes := make(chan []byte, 2)
241+ p, _ := newBlockingPTYWithWrites(t, writes)
242+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
243+ sess.tabs[0].screen.Write([]byte("live"))
244+
245+ d.handleInput(sess, ac, []byte("\x1b[<64;"))
246+ d.handleInput(sess, ac, []byte("1;1Mq"))
247+
248+ mustOutputData(t, sends)
249+ mustOutputData(t, sends)
250+ require.Nil(t, ac.copyMode)
251+ select {
252+ case got := <-writes:
253+ t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)
254+ default:
255+ }
256+}
A internal/usecase/daemon/overlay.go +118 -0
@@ -0,0 +1,118 @@
1+package daemon
2+
3+import (
4+ "unicode"
5+ "unicode/utf8"
6+)
7+
8+type overlayEvents struct {
9+ rune func(rune)
10+ backspace func()
11+ enter func()
12+ cancel func()
13+ up func()
14+ down func()
15+}
16+
17+func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) {
18+ if pending != nil && len(*pending) > 0 {
19+ combined := make([]byte, 0, len(*pending)+len(data))
20+ combined = append(combined, (*pending)...)
21+ combined = append(combined, data...)
22+ data = combined
23+ *pending = nil
24+ }
25+
26+ for i := 0; i < len(data); {
27+ switch data[i] {
28+ case 0x1b:
29+ consumed, incomplete := routeOverlayEscape(data[i:], ev)
30+ if incomplete {
31+ if pending != nil {
32+ *pending = append((*pending)[:0], data[i:]...)
33+ }
34+ return
35+ }
36+ if consumed > 0 {
37+ i += consumed
38+ continue
39+ }
40+ call(ev.cancel)
41+ i++
42+ case 0x03:
43+ call(ev.cancel)
44+ i++
45+ case 0x0e:
46+ call(ev.down)
47+ i++
48+ case 0x10:
49+ call(ev.up)
50+ i++
51+ case '\r', '\n':
52+ call(ev.enter)
53+ i++
54+ case 0x7f, 0x08:
55+ call(ev.backspace)
56+ i++
57+ default:
58+ r, size := utf8.DecodeRune(data[i:])
59+ if r == utf8.RuneError {
60+ if !utf8.FullRune(data[i:]) {
61+ if pending != nil {
62+ *pending = append((*pending)[:0], data[i:]...)
63+ }
64+ return
65+ }
66+ i++
67+ continue
68+ }
69+ if !unicode.IsControl(r) && ev.rune != nil {
70+ ev.rune(r)
71+ }
72+ i += size
73+ }
74+ }
75+}
76+
77+func routeOverlayEscape(data []byte, ev overlayEvents) (consumed int, incomplete bool) {
78+ if len(data) < 2 {
79+ return 0, false
80+ }
81+ switch data[1] {
82+ case 'O':
83+ if len(data) < 3 {
84+ return 0, true
85+ }
86+ switch data[2] {
87+ case 'A':
88+ call(ev.up)
89+ case 'B':
90+ call(ev.down)
91+ }
92+ return 3, false
93+ case '[':
94+ for i := 2; i < len(data); i++ {
95+ if data[i] < 0x40 || data[i] > 0x7e {
96+ continue
97+ }
98+ if i == 2 {
99+ switch data[i] {
100+ case 'A':
101+ call(ev.up)
102+ case 'B':
103+ call(ev.down)
104+ }
105+ }
106+ return i + 1, false
107+ }
108+ return 0, true
109+ default:
110+ return 0, false
111+ }
112+}
113+
114+func call(fn func()) {
115+ if fn != nil {
116+ fn()
117+ }
118+}
A internal/usecase/daemon/overlay_test.go +101 -0
@@ -0,0 +1,101 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
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++ },
48+ }
49+
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++ },
66+ }
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)
101+}
A internal/usecase/daemon/palette.go +157 -0
@@ -0,0 +1,157 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/command"
6+ "github.com/bnema/vev/internal/usecase/palette"
7+ "github.com/bnema/vev/internal/usecase/ui"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: ui.AnchorBottom, BottomMargin: 1}
12+
13+func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
14+ d.closePalette(ac)
15+ ac.paletteMu.Lock()
16+ ac.palette = palette.NewRegistry()
17+ ac.palettePending = nil
18+ ac.paletteMu.Unlock()
19+ d.paint(sess, ac, true)
20+}
21+
22+func (d *Daemon) closePalette(ac *attachedClient) {
23+ ac.paletteMu.Lock()
24+ ac.palette = nil
25+ ac.palettePending = nil
26+ ac.paletteMu.Unlock()
27+}
28+
29+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
30+ sess := ac.currentSession()
31+ if sess == nil {
32+ return
33+ }
34+
35+ ac.paletteMu.Lock()
36+ if ac.palette == nil {
37+ ac.palettePending = nil
38+ ac.paletteMu.Unlock()
39+ return
40+ }
41+ changed := false
42+ exit := false
43+ run := false
44+ var cmd command.Command
45+ var ok bool
46+
47+ routeOverlayBytes(data, &ac.palettePending, overlayEvents{
48+ rune: func(r rune) {
49+ ac.palette.Insert(r)
50+ changed = true
51+ },
52+ backspace: func() {
53+ ac.palette.Backspace()
54+ changed = true
55+ },
56+ enter: func() {
57+ cmd, ok = ac.palette.Selected()
58+ if ok {
59+ run = true
60+ exit = true
61+ }
62+ },
63+ cancel: func() { exit = true },
64+ up: func() {
65+ ac.palette.Up()
66+ changed = true
67+ },
68+ down: func() {
69+ ac.palette.Down()
70+ changed = true
71+ },
72+ })
73+ if exit {
74+ ac.palette = nil
75+ ac.palettePending = nil
76+ }
77+ ac.paletteMu.Unlock()
78+
79+ if run && ok {
80+ if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {
81+ sess.mu.Lock()
82+ name := sess.name
83+ sess.mu.Unlock()
84+ d.log.Error("palette command failed", "err", err, "session", name, "command", cmd.Code)
85+ }
86+ return
87+ }
88+ if exit || changed {
89+ d.paint(sess, ac, true)
90+ }
91+}
92+
93+type paletteExec struct {
94+ d *Daemon
95+ sess *session
96+ ac *attachedClient
97+}
98+
99+func (e paletteExec) CreateTab() error {
100+ defer e.d.paint(e.sess, e.ac, true)
101+ return e.d.createTab(e.sess, e.ac.size)
102+}
103+
104+func (e paletteExec) CloseTab() error {
105+ if tb := e.sess.activeTab(); tb != nil {
106+ e.d.closeTab(e.sess, tb, true)
107+ }
108+ return nil
109+}
110+
111+func (e paletteExec) NextTab() error {
112+ e.sess.switchRelative(1)
113+ e.d.paint(e.sess, e.ac, true)
114+ return nil
115+}
116+
117+func (e paletteExec) PrevTab() error {
118+ e.sess.switchRelative(-1)
119+ e.d.paint(e.sess, e.ac, true)
120+ return nil
121+}
122+
123+func (e paletteExec) Detach() error {
124+ e.d.clientGone(e.sess, e.ac, true)
125+ return nil
126+}
127+
128+func (e paletteExec) EnterCopyMode() error {
129+ e.d.enterCopyMode(e.sess, e.ac)
130+ return nil
131+}
132+
133+func (e paletteExec) RenameSession() error {
134+ e.sess.mu.Lock()
135+ currentName := e.sess.name
136+ e.sess.mu.Unlock()
137+ e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {
138+ return e.d.renameSession(e.sess, name)
139+ })
140+ return nil
141+}
142+
143+func (e paletteExec) OpenSessionPicker() error {
144+ e.d.enterPicker(e.sess, e.ac)
145+ return nil
146+}
147+
148+func composePaletteClientFrame(model *palette.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
149+ inner := paletteModal.Composite(base, renderer.DefaultStyle())
150+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height})
151+ for y := range min(inner.Height, modalFrame.Height) {
152+ for x := range min(inner.Width, modalFrame.Width) {
153+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
154+ }
155+ }
156+ return base, []renderer.Damage{renderer.FullRedraw()}
157+}
A internal/usecase/daemon/palette_test.go +215 -0
@@ -0,0 +1,215 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/internal/usecase/ui"
15+)
16+
17+func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
18+ p1, release1 := newBlockingPTY(t)
19+ p2, release2 := newBlockingPTY(t)
20+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2)
21+ defer release1()
22+ defer release2()
23+
24+ d.handleInput(sess, ac, []byte("\x1b "))
25+ require.True(t, ac.paletteActive())
26+ out := awaitFrame(t, sends, ports.MsgOutput)
27+ msg, err := ports.UnmarshalOutput(out.Payload)
28+ require.NoError(t, err)
29+ require.Contains(t, string(msg.Data), "Commands")
30+
31+ d.handleInput(sess, ac, []byte("NXT\r"))
32+ require.False(t, ac.paletteActive())
33+ require.Equal(t, 1, activeTabIndex(sess))
34+ awaitFrame(t, sends, ports.MsgOutput)
35+
36+ d.handleInput(sess, ac, []byte("\x1b "))
37+ awaitFrame(t, sends, ports.MsgOutput)
38+ d.handleInput(sess, ac, []byte("\x1b"))
39+ require.False(t, ac.paletteActive())
40+ awaitFrame(t, sends, ports.MsgOutput)
41+}
42+
43+func TestPaletteCommandNoopRepaintsAfterClose(t *testing.T) {
44+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
45+ defer releases[0]()
46+
47+ d.handleInput(sess, ac, []byte("\x1b "))
48+ paletteFrame := awaitFrame(t, sends, ports.MsgOutput)
49+ paletteOutput, err := ports.UnmarshalOutput(paletteFrame.Payload)
50+ require.NoError(t, err)
51+ require.Contains(t, string(paletteOutput.Data), "Commands")
52+
53+ d.handleInput(sess, ac, []byte("NXT\r"))
54+ require.False(t, ac.paletteActive())
55+ repaint := awaitFrame(t, sends, ports.MsgOutput)
56+ repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)
57+ require.NoError(t, err)
58+ require.NotContains(t, string(repaintOutput.Data), "Commands")
59+}
60+
61+func TestPaletteCreateTabErrorRepaintsAfterClose(t *testing.T) {
62+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
63+ defer releases[0]()
64+ ptys := portsmocks.NewMockPTYFactory(t)
65+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))
66+ d.ptys = ptys
67+
68+ d.handleInput(sess, ac, []byte("\x1b "))
69+ awaitFrame(t, sends, ports.MsgOutput)
70+ d.handleInput(sess, ac, []byte("CNT\r"))
71+ require.False(t, ac.paletteActive())
72+ repaint := awaitFrame(t, sends, ports.MsgOutput)
73+ repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)
74+ require.NoError(t, err)
75+ require.NotContains(t, string(repaintOutput.Data), "Commands")
76+ require.Len(t, sess.tabs, 1)
77+}
78+
79+func TestPaletteEnterNoMatchKeepsOpenAndEscapeSplit(t *testing.T) {
80+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
81+ defer releases[0]()
82+
83+ d.handleInput(sess, ac, []byte("\x1b "))
84+ awaitFrame(t, sends, ports.MsgOutput)
85+ d.handleInput(sess, ac, []byte("zzzz\r"))
86+ require.True(t, ac.paletteActive())
87+ awaitFrame(t, sends, ports.MsgOutput)
88+
89+ d.handlePaletteInput(ac, []byte{0x1b, '['})
90+ require.True(t, ac.paletteActive())
91+ require.Equal(t, []byte{0x1b, '['}, ac.palettePending)
92+ d.handlePaletteInput(ac, []byte{'A'})
93+ require.True(t, ac.paletteActive())
94+ require.Empty(t, ac.palettePending)
95+ awaitFrame(t, sends, ports.MsgOutput)
96+}
97+
98+func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
99+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
100+ defer releases[0]()
101+
102+ d.handleInput(sess, ac, []byte("\x1b "))
103+ awaitFrame(t, sends, ports.MsgOutput)
104+
105+ d.handlePaletteInput(ac, []byte{0x0e})
106+ awaitFrame(t, sends, ports.MsgOutput)
107+ cmd, ok := ac.palette.Selected()
108+ require.True(t, ok)
109+ require.Equal(t, "CLT", cmd.Code)
110+
111+ d.handlePaletteInput(ac, []byte{0x10})
112+ awaitFrame(t, sends, ports.MsgOutput)
113+ cmd, ok = ac.palette.Selected()
114+ require.True(t, ok)
115+ require.Equal(t, "CNT", cmd.Code)
116+}
117+
118+func TestPaletteModalGeometry(t *testing.T) {
119+ require.Equal(t, 100, paletteModal.WidthPct)
120+ require.Equal(t, 11, paletteModal.FixedHeight)
121+ require.Equal(t, ui.AnchorBottom, paletteModal.Anchor)
122+ require.Equal(t, 1, paletteModal.BottomMargin)
123+ require.Equal(t, 32, paletteModal.MinWidth)
124+
125+ bounds := paletteModal.Bounds(domain.Size{Cols: 120, Rows: 40})
126+ require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 120, Height: 11}, bounds)
127+}
128+
129+func TestPaletteUTF8PendingCompletesFilter(t *testing.T) {
130+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
131+ defer releases[0]()
132+
133+ d.handleInput(sess, ac, []byte("\x1b "))
134+ awaitFrame(t, sends, ports.MsgOutput)
135+ d.handlePaletteInput(ac, []byte{0xc3})
136+ require.Equal(t, []byte{0xc3}, ac.palettePending)
137+ d.handlePaletteInput(ac, []byte{0xa9})
138+ require.Empty(t, ac.palettePending)
139+ require.Equal(t, "é", ac.palette.Query())
140+}
141+
142+func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
143+ p, release := newBlockingPTY(t)
144+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
145+ defer release()
146+ d.enterPalette(sess, ac)
147+
148+ drainDone := make(chan struct{})
149+ drainStopped := make(chan struct{})
150+ go func() {
151+ defer close(drainStopped)
152+ for {
153+ select {
154+ case <-sends:
155+ case <-drainDone:
156+ return
157+ }
158+ }
159+ }()
160+ defer func() {
161+ close(drainDone)
162+ <-drainStopped
163+ }()
164+
165+ var wg sync.WaitGroup
166+ wg.Add(2)
167+ go func() {
168+ defer wg.Done()
169+ for range 200 {
170+ d.paint(sess, ac, true)
171+ }
172+ }()
173+ go func() {
174+ defer wg.Done()
175+ for range 200 {
176+ d.handlePaletteInput(ac, []byte("abc\x7f"))
177+ }
178+ }()
179+ wg.Wait()
180+}
181+
182+func TestPaletteExecMethods(t *testing.T) {
183+ p1, release1 := newBlockingPTY(t)
184+ p2, release2 := newBlockingPTY(t)
185+ p3, release3 := newBlockingPTY(t)
186+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2, p3)
187+ defer release1()
188+ defer release2()
189+ defer release3()
190+ exec := paletteExec{d: d, sess: sess, ac: ac}
191+
192+ require.NoError(t, exec.NextTab())
193+ require.Equal(t, 1, activeTabIndex(sess))
194+ require.NoError(t, exec.PrevTab())
195+ require.Equal(t, 0, activeTabIndex(sess))
196+ sess.ephemeral = true
197+ require.NoError(t, exec.RenameSession())
198+ require.True(t, ac.promptActive())
199+ d.closePrompt(ac)
200+ require.True(t, sess.ephemeral)
201+ require.NoError(t, exec.OpenSessionPicker())
202+ require.True(t, ac.pickerActive())
203+ d.closePicker(ac)
204+ require.NoError(t, exec.EnterCopyMode())
205+ require.True(t, ac.copyModeActive())
206+ ac.copyMu.Lock()
207+ ac.copyMode = nil
208+ ac.copyMu.Unlock()
209+ require.NoError(t, exec.CloseTab())
210+ require.Len(t, sess.tabs, 2)
211+ // Drain paints generated by methods above.
212+ for len(sends) > 0 {
213+ <-sends
214+ }
215+}
A internal/usecase/daemon/picker.go +392 -0
@@ -0,0 +1,392 @@
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+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
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.
26+package daemon
27+
28+import (
29+ "sort"
30+ "strconv"
31+
32+ "github.com/bnema/vev/internal/domain"
33+ "github.com/bnema/vev/internal/ports"
34+ "github.com/bnema/vev/internal/usecase/keys"
35+ "github.com/bnema/vev/internal/usecase/picker"
36+ "github.com/bnema/vev/internal/usecase/ui"
37+ "github.com/bnema/vev/pkg/renderer"
38+)
39+
40+var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "}
41+
42+func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
43+ views, curTab := d.pickerViews(sess)
44+ ac.pickerMu.Lock()
45+ ac.picker = picker.New(views, sess.id, curTab)
46+ ac.pickerPending = nil
47+ ac.pickerPreview = nil
48+ ac.pickerMu.Unlock()
49+ d.registerPreviewForSelection(ac)
50+ d.paint(sess, ac, true)
51+}
52+
53+func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
54+ d.mu.Lock()
55+ sessions := d.sessionsSnapshotLocked()
56+ d.mu.Unlock()
57+ sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
58+
59+ views := make([]picker.SessionView, 0, len(sessions))
60+ curTab := 0
61+ for _, s := range sessions {
62+ s.mu.Lock()
63+ view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
64+ for i := range s.tabs {
65+ view.Tabs[i] = strconv.Itoa(i + 1)
66+ }
67+ if s == cur {
68+ curTab = s.active
69+ }
70+ s.mu.Unlock()
71+ views = append(views, view)
72+ }
73+ return views, curTab
74+}
75+
76+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
77+ sess := ac.currentSession()
78+ if sess == nil {
79+ return
80+ }
81+ ac.pickerMu.Lock()
82+ if ac.picker == nil {
83+ ac.pickerPending = nil
84+ d.stopPickerPendingTimerLocked(ac)
85+ ac.pickerMu.Unlock()
86+ return
87+ }
88+ if len(ac.pickerPending) > 0 {
89+ d.stopPickerPendingTimerLocked(ac)
90+ combined := make([]byte, 0, len(ac.pickerPending)+len(data))
91+ combined = append(combined, ac.pickerPending...)
92+ combined = append(combined, data...)
93+ data = combined
94+ ac.pickerPending = nil
95+ }
96+ changed := false
97+ exit := false
98+ switchTarget := false
99+ for i := 0; i < len(data); i++ {
100+ switch data[i] {
101+ case 'j':
102+ ac.picker.Down()
103+ changed = true
104+ case 'k':
105+ ac.picker.Up()
106+ changed = true
107+ case '\r', '\n':
108+ switchTarget = true
109+ exit = true
110+ case 'q', 0x03, 0x1b:
111+ if data[i] == 0x1b {
112+ tail := data[i:]
113+ consumed, ok := routePickerEscape(ac.picker, tail)
114+ if ok {
115+ i += consumed - 1
116+ changed = true
117+ continue
118+ }
119+ if len(tail) == 1 {
120+ d.retainPickerESCLocked(ac)
121+ break
122+ }
123+ if isPickerEscapePrefix(tail) {
124+ ac.pickerPending = append(ac.pickerPending[:0], tail...)
125+ break
126+ }
127+ }
128+ exit = true
129+ }
130+ }
131+ var target picker.Target
132+ var ok bool
133+ if switchTarget {
134+ target, ok = ac.picker.Selected()
135+ }
136+ ac.pickerMu.Unlock()
137+
138+ if changed {
139+ d.registerPreviewForSelection(ac)
140+ }
141+ if exit {
142+ d.closePicker(ac)
143+ }
144+ if switchTarget && ok {
145+ d.switchToTarget(sess, ac, target)
146+ return
147+ }
148+ if exit || changed {
149+ d.paint(sess, ac, true)
150+ }
151+}
152+
153+func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
154+ ac.pickerPending = append(ac.pickerPending[:0], keys.ESC)
155+ ac.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
156+ ac.pickerMu.Lock()
157+ if ac.pickerESC.timer != timer || len(ac.pickerPending) != 1 || ac.pickerPending[0] != keys.ESC || ac.picker == nil {
158+ ac.pickerMu.Unlock()
159+ return
160+ }
161+ ac.pickerPending = nil
162+ ac.pickerESC.timer = nil
163+ ac.pickerESC.done = nil
164+ ac.picker = nil
165+ ac.pickerMu.Unlock()
166+
167+ d.unregisterPreview(ac)
168+ if sess := ac.currentSession(); sess != nil {
169+ d.paint(sess, ac, true)
170+ }
171+ })
172+}
173+
174+func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
175+ ac.pickerESC.stop()
176+}
177+
178+func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
179+ if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
180+ switch data[2] {
181+ case 'A':
182+ m.Up()
183+ return 3, true
184+ case 'B':
185+ m.Down()
186+ return 3, true
187+ }
188+ }
189+ return 0, false
190+}
191+
192+func isPickerEscapePrefix(data []byte) bool {
193+ return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O')
194+}
195+
196+func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
197+ ac.pickerMu.Lock()
198+ var target picker.Target
199+ var ok bool
200+ if ac.picker != nil {
201+ target, ok = ac.picker.Selected()
202+ }
203+ ac.pickerMu.Unlock()
204+
205+ var next *tab
206+ if ok {
207+ next = d.tabByTarget(target)
208+ }
209+
210+ ac.pickerMu.Lock()
211+ old := ac.pickerPreview
212+ if ac.picker == nil {
213+ next = nil
214+ }
215+ ac.pickerPreview = next
216+ ac.pickerMu.Unlock()
217+
218+ if old != nil && old != next {
219+ old.mu.Lock()
220+ if old.previewClient == ac {
221+ old.previewClient = nil
222+ }
223+ old.mu.Unlock()
224+ }
225+ if next != nil {
226+ next.mu.Lock()
227+ next.previewClient = ac
228+ next.mu.Unlock()
229+
230+ ac.pickerMu.Lock()
231+ keep := ac.pickerPreview == next
232+ ac.pickerMu.Unlock()
233+ if !keep {
234+ next.mu.Lock()
235+ if next.previewClient == ac {
236+ next.previewClient = nil
237+ }
238+ next.mu.Unlock()
239+ }
240+ }
241+}
242+
243+func (d *Daemon) unregisterPreview(ac *attachedClient) {
244+ ac.pickerMu.Lock()
245+ old := ac.pickerPreview
246+ ac.pickerPreview = nil
247+ ac.pickerMu.Unlock()
248+
249+ if old != nil {
250+ old.mu.Lock()
251+ if old.previewClient == ac {
252+ old.previewClient = nil
253+ }
254+ old.mu.Unlock()
255+ }
256+}
257+
258+func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
259+ tb.mu.Lock()
260+ previewer := tb.previewClient
261+ tb.mu.Unlock()
262+ if previewer == nil {
263+ return
264+ }
265+ cleared := false
266+ previewer.pickerMu.Lock()
267+ if previewer.pickerPreview == tb {
268+ previewer.pickerPreview = nil
269+ cleared = true
270+ }
271+ previewer.pickerMu.Unlock()
272+ tb.mu.Lock()
273+ if tb.previewClient == previewer {
274+ tb.previewClient = nil
275+ }
276+ tb.mu.Unlock()
277+ if cleared {
278+ if sess := previewer.currentSession(); sess != nil {
279+ d.paint(sess, previewer, true)
280+ }
281+ }
282+}
283+
284+func (d *Daemon) closePicker(ac *attachedClient) {
285+ ac.pickerMu.Lock()
286+ ac.picker = nil
287+ ac.pickerPending = nil
288+ d.stopPickerPendingTimerLocked(ac)
289+ ac.pickerMu.Unlock()
290+ d.unregisterPreview(ac)
291+}
292+
293+func (d *Daemon) tabByTarget(target picker.Target) *tab {
294+ d.mu.Lock()
295+ sess := d.sessions[target.Session]
296+ d.mu.Unlock()
297+ if sess == nil {
298+ return nil
299+ }
300+ sess.mu.Lock()
301+ defer sess.mu.Unlock()
302+ if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
303+ return nil
304+ }
305+ return sess.tabs[target.TabIndex]
306+}
307+
308+func (d *Daemon) sessionByID(id domain.SessionID) *session {
309+ d.mu.Lock()
310+ defer d.mu.Unlock()
311+ return d.sessions[id]
312+}
313+
314+func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
315+ targetSess := d.sessionByID(target.Session)
316+ if targetSess == nil {
317+ d.paint(sess, ac, true)
318+ return
319+ }
320+ if targetSess == sess {
321+ sess.switchTab(target.TabIndex)
322+ d.paint(sess, ac, true)
323+ return
324+ }
325+ old := d.stealClientForTarget(sess, ac, targetSess, target)
326+ if ac.currentSession() != targetSess {
327+ d.paint(sess, ac, true)
328+ return
329+ }
330+ if old != nil && old != ac {
331+ d.unregisterPreview(old)
332+ old.setSession(nil)
333+ d.notifyDetachedAsync(old, ports.ReasonDetach)
334+ }
335+ d.firstPaint(targetSess, ac, ac.size)
336+}
337+
338+func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
339+ d.mu.Lock()
340+ if d.sessions[target.Session] != targetSess {
341+ d.mu.Unlock()
342+ return nil
343+ }
344+ from.mu.Lock()
345+ if from.client != ac {
346+ from.mu.Unlock()
347+ d.mu.Unlock()
348+ return nil
349+ }
350+ from.client = nil
351+ ac.setSession(nil)
352+ from.mu.Unlock()
353+
354+ targetSess.mu.Lock()
355+ old := targetSess.client
356+ targetSess.client = ac
357+ if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
358+ targetSess.active = target.TabIndex
359+ }
360+ targetSess.mu.Unlock()
361+ ac.setSession(targetSess)
362+ d.mu.Unlock()
363+ return old
364+}
365+
366+func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
367+ inner := pickerModal.Composite(base, renderer.DefaultStyle())
368+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview)
369+ for y := range min(inner.Height, modalFrame.Height) {
370+ for x := range min(inner.Width, modalFrame.Width) {
371+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
372+ }
373+ }
374+ return base, []renderer.Damage{renderer.FullRedraw()}
375+}
376+
377+func snapshotPickerPreview(tb *tab) picker.Preview {
378+ if tb == nil {
379+ return picker.Preview{}
380+ }
381+ tb.mu.Lock()
382+ defer tb.mu.Unlock()
383+ return pickerPreviewFromLockedTab(tb)
384+}
385+
386+func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
387+ rows := make([][]renderer.Cell, tb.screen.Frame.Height)
388+ for y := range rows {
389+ rows[y] = append([]renderer.Cell(nil), tb.screen.Frame.Row(y)...)
390+ }
391+ return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}
392+}
A internal/usecase/daemon/picker_test.go +274 -0
@@ -0,0 +1,274 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
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/keys"
14+ "github.com/bnema/vev/pkg/renderer"
15+ "github.com/bnema/vev/pkg/vt"
16+)
17+
18+// --- test doubles -----------------------------------------------------------
19+
20+// stubClock returns timers whose channel never fires, so a scheduler under it
21+// blocks in its debounce loop until the session context is cancelled. Used by
22+
23+func TestAltTForwardsToPTY(t *testing.T) {
24+ writes := make(chan []byte, 2)
25+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
26+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
27+ defer releasePTY()
28+
29+ d.handleInput(sess, ac, []byte("\x1bt"))
30+
31+ require.False(t, ac.pickerActive())
32+ require.Equal(t, []byte("\x1bt"), <-writes)
33+}
34+
35+func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
36+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
37+ defer func() {
38+ for _, release := range releases {
39+ release()
40+ }
41+ }()
42+
43+ d.enterPicker(sess, ac)
44+ awaitFrame(t, sends, ports.MsgOutput)
45+ d.handleInput(sess, ac, []byte("j"))
46+ awaitFrame(t, sends, ports.MsgOutput)
47+ d.handleInput(sess, ac, []byte("\r"))
48+
49+ require.Equal(t, 1, activeTabIndex(sess))
50+ awaitFrame(t, sends, ports.MsgOutput)
51+ d.enterPicker(sess, ac)
52+ awaitFrame(t, sends, ports.MsgOutput)
53+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
54+ d.clock = clk
55+ d.handleInput(sess, ac, []byte("\x1b"))
56+ timer := <-clk.timers
57+ require.True(t, ac.pickerActive())
58+ timer.ch <- time.Now()
59+ require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
60+ awaitFrame(t, sends, ports.MsgOutput)
61+}
62+
63+func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
64+ cases := []struct {
65+ name string
66+ input [][]byte
67+ wantActive int
68+ }{
69+ {name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1},
70+ {name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0},
71+ {name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1},
72+ {name: "split SS3 down arrow", input: [][]byte{[]byte("\x1bO"), []byte("B")}, wantActive: 1},
73+ }
74+ for _, tc := range cases {
75+ t.Run(tc.name, func(t *testing.T) {
76+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
77+ defer func() {
78+ for _, release := range releases {
79+ release()
80+ }
81+ }()
82+
83+ d.enterPicker(sess, ac)
84+ awaitFrame(t, sends, ports.MsgOutput)
85+ for _, input := range tc.input {
86+ d.handleInput(sess, ac, input)
87+ }
88+ require.True(t, ac.pickerActive())
89+ d.handleInput(sess, ac, []byte("\r"))
90+ require.Equal(t, tc.wantActive, activeTabIndex(sess))
91+ })
92+ }
93+}
94+
95+func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
96+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
97+ defer releases[0]()
98+
99+ d.enterPicker(sess, ac)
100+ awaitFrame(t, sends, ports.MsgOutput)
101+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
102+ d.clock = clk
103+ d.handleInput(sess, ac, []byte("\x1b"))
104+ timer := <-clk.timers
105+ require.True(t, ac.pickerActive())
106+ timer.ch <- time.Now()
107+ require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
108+}
109+
110+func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
111+ p1, releasePTY1 := newBlockingPTY(t)
112+ p2, releasePTY2 := newBlockingPTY(t)
113+ defer releasePTY1()
114+ defer releasePTY2()
115+ d := newTestDaemon(t, nil, stubClock{})
116+ tr1, sends1 := newCapturingTransport(t)
117+ tr2, sends2 := newCapturingTransport(t)
118+ ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
119+ ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
120+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
121+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
122+ defer cancel1()
123+ defer cancel2()
124+ sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
125+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
126+ ac1.setSession(sess1)
127+ ac2.setSession(sess2)
128+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
129+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
130+ d.sessions[sess1.id] = sess1
131+ d.sessions[sess2.id] = sess2
132+
133+ d.enterPicker(sess1, ac1)
134+ awaitFrame(t, sends1, ports.MsgOutput)
135+ d.handleInput(sess1, ac1, []byte("j"))
136+ awaitFrame(t, sends1, ports.MsgOutput)
137+ d.handleInput(sess1, ac1, []byte("\r"))
138+
139+ require.Same(t, sess2, ac1.currentSession())
140+ require.Same(t, ac1, sess2.client)
141+ require.Nil(t, sess1.client)
142+ require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")
143+ det := awaitFrame(t, sends2, ports.MsgDetached)
144+ dm, err := ports.UnmarshalDetached(det.Payload)
145+ require.NoError(t, err)
146+ require.Equal(t, ports.ReasonDetach, dm.Reason)
147+ awaitFrame(t, sends1, ports.MsgOutput)
148+}
149+
150+func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
151+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
152+ defer func() {
153+ for _, release := range releases {
154+ release()
155+ }
156+ }()
157+
158+ d.enterPicker(sess, ac)
159+ awaitFrame(t, sends, ports.MsgOutput)
160+ d.handleInput(sess, ac, []byte("j"))
161+ awaitFrame(t, sends, ports.MsgOutput)
162+
163+ previewTab := sess.tabs[1]
164+ previewTab.mu.Lock()
165+ previewTab.screen.Write([]byte("inactive-preview-live"))
166+ previewTab.mu.Unlock()
167+ d.render(sess, previewTab)
168+
169+ previewOut := awaitFrame(t, sends, ports.MsgOutput)
170+ previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
171+ require.NoError(t, err)
172+ require.Contains(t, string(previewMsg.Data), "inactive-preview-live")
173+}
174+
175+func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
176+ p1, releasePTY1 := newBlockingPTY(t)
177+ p2, releasePTY2 := newBlockingPTY(t)
178+ defer releasePTY1()
179+ defer releasePTY2()
180+ d := newTestDaemon(t, nil, stubClock{})
181+ tr1, sends1 := newCapturingTransport(t)
182+ tr2, _ := newCapturingTransport(t)
183+ ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
184+ ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
185+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
186+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
187+ defer cancel1()
188+ defer cancel2()
189+ sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
190+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
191+ ac1.setSession(sess1)
192+ ac2.setSession(sess2)
193+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
194+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
195+ d.sessions[sess1.id] = sess1
196+ d.sessions[sess2.id] = sess2
197+
198+ d.enterPicker(sess1, ac1)
199+ awaitFrame(t, sends1, ports.MsgOutput)
200+ d.handleInput(sess1, ac1, []byte("j"))
201+ awaitFrame(t, sends1, ports.MsgOutput)
202+
203+ previewTab := sess2.tabs[0]
204+ previewTab.mu.Lock()
205+ previewTab.screen.Write([]byte("cross-session-preview-live"))
206+ previewTab.mu.Unlock()
207+ d.render(sess2, previewTab)
208+
209+ previewOut := awaitFrame(t, sends1, ports.MsgOutput)
210+ previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
211+ require.NoError(t, err)
212+ require.Contains(t, string(previewMsg.Data), "cross-session-preview-live")
213+}
214+
215+func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
216+ d, sess, ac, sends, releases := newManualTabSession(t, 3)
217+ defer func() {
218+ for _, release := range releases {
219+ release()
220+ }
221+ }()
222+
223+ done := make(chan struct{})
224+ var drain sync.WaitGroup
225+ drain.Go(func() {
226+ for {
227+ select {
228+ case <-sends:
229+ case <-done:
230+ return
231+ }
232+ }
233+ })
234+
235+ var wg sync.WaitGroup
236+ for i := range 100 {
237+ wg.Go(func() {
238+ d.enterPicker(sess, ac)
239+ })
240+ wg.Go(func() {
241+ d.handlePickerInput(ac, []byte("j"))
242+ d.handlePickerInput(ac, []byte("k"))
243+ })
244+ wg.Go(func() {
245+ d.closePicker(ac)
246+ })
247+ tb := sess.tabs[i%len(sess.tabs)]
248+ wg.Go(func() {
249+ tb.mu.Lock()
250+ tb.screen.Write([]byte("render-race"))
251+ tb.mu.Unlock()
252+ d.render(sess, tb)
253+ })
254+ }
255+ wg.Wait()
256+ close(done)
257+ drain.Wait()
258+}
259+
260+func TestPickerResizeRecomposesModal(t *testing.T) {
261+ p, releasePTY := newBlockingPTY(t)
262+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
263+ defer releasePTY()
264+
265+ d.enterPicker(sess, ac)
266+ awaitFrame(t, sends, ports.MsgOutput)
267+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
268+
269+ out := awaitFrame(t, sends, ports.MsgOutput)
270+ msg, err := ports.UnmarshalOutput(out.Payload)
271+ require.NoError(t, err)
272+ require.Contains(t, string(msg.Data), "┌")
273+ require.Contains(t, string(msg.Data), "Sessions")
274+}
A internal/usecase/daemon/prompt.go +104 -0
@@ -0,0 +1,104 @@
1+package daemon
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ promptui "github.com/bnema/vev/internal/usecase/prompt"
8+ "github.com/bnema/vev/internal/usecase/ui"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+func promptModalFor(title string) ui.Modal {
13+ return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: ui.AnchorBottom, BottomMargin: 1}
14+}
15+
16+func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) {
17+ d.closePrompt(ac)
18+ ac.promptMu.Lock()
19+ ac.prompt = promptui.New(title, initial)
20+ ac.promptSubmit = submit
21+ ac.promptPending = nil
22+ ac.promptMu.Unlock()
23+ d.paint(sess, ac, true)
24+}
25+
26+func (d *Daemon) closePrompt(ac *attachedClient) {
27+ ac.promptMu.Lock()
28+ ac.prompt = nil
29+ ac.promptSubmit = nil
30+ ac.promptPending = nil
31+ ac.promptMu.Unlock()
32+}
33+
34+func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
35+ sess := ac.currentSession()
36+ if sess == nil {
37+ return
38+ }
39+
40+ ac.promptMu.Lock()
41+ if ac.prompt == nil {
42+ ac.promptPending = nil
43+ ac.promptMu.Unlock()
44+ return
45+ }
46+
47+ changed := false
48+ exit := false
49+ var submit func(string) error
50+ var value string
51+
52+ routeOverlayBytes(data, &ac.promptPending, overlayEvents{
53+ rune: func(r rune) {
54+ ac.prompt.Insert(r)
55+ changed = true
56+ },
57+ backspace: func() {
58+ ac.prompt.Backspace()
59+ changed = true
60+ },
61+ enter: func() {
62+ submit = ac.promptSubmit
63+ value = strings.TrimSpace(ac.prompt.Value())
64+ },
65+ cancel: func() { exit = true },
66+ up: func() {},
67+ down: func() {},
68+ })
69+ ac.promptMu.Unlock()
70+
71+ if submit != nil {
72+ if err := submit(value); err != nil {
73+ ac.promptMu.Lock()
74+ if ac.prompt != nil {
75+ ac.prompt.SetError(err.Error())
76+ }
77+ ac.promptMu.Unlock()
78+ d.paint(sess, ac, true)
79+ return
80+ }
81+ d.closePrompt(ac)
82+ d.paint(sess, ac, true)
83+ return
84+ }
85+ if exit {
86+ d.closePrompt(ac)
87+ d.paint(sess, ac, true)
88+ return
89+ }
90+ if changed {
91+ d.paint(sess, ac, true)
92+ }
93+}
94+
95+func composePromptClientFrame(model *promptui.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
96+ inner := promptModalFor(model.Title()).Composite(base, renderer.DefaultStyle())
97+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height})
98+ for y := range min(inner.Height, modalFrame.Height) {
99+ for x := range min(inner.Width, modalFrame.Width) {
100+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
101+ }
102+ }
103+ return base, []renderer.Damage{renderer.FullRedraw()}
104+}
A internal/usecase/daemon/prompt_test.go +103 -0
@@ -0,0 +1,103 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/vt"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
14+ p, release := newBlockingPTY(t)
15+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
16+ defer release()
17+
18+ d.enterPrompt(sess, ac, " Rename session ", "0", func(string) error { return nil })
19+ out := awaitFrame(t, sends, ports.MsgOutput)
20+ msg, err := ports.UnmarshalOutput(out.Payload)
21+ require.NoError(t, err)
22+ require.Contains(t, string(msg.Data), "Rename session")
23+ require.Contains(t, string(msg.Data), "> 0")
24+ require.True(t, ac.promptActive())
25+}
26+
27+func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
28+ p, release := newBlockingPTY(t)
29+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
30+ defer release()
31+ sess.name = "0"
32+ sess.ephemeral = true
33+
34+ d.enterPrompt(sess, ac, " Rename session ", sess.name, func(name string) error { return d.renameSession(sess, name) })
35+ awaitFrame(t, sends, ports.MsgOutput)
36+ d.handlePromptInput(ac, []byte("work\r"))
37+ repaint := awaitFrame(t, sends, ports.MsgOutput)
38+
39+ require.False(t, ac.promptActive())
40+ require.Equal(t, "0work", sess.name)
41+ require.False(t, sess.ephemeral)
42+ out, err := ports.UnmarshalOutput(repaint.Payload)
43+ require.NoError(t, err)
44+ require.Contains(t, string(out.Data), "0work")
45+ require.NotContains(t, string(out.Data), "0work*")
46+}
47+
48+func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
49+ p1, release1 := newBlockingPTY(t)
50+ p2, release2 := newBlockingPTY(t)
51+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
52+ defer release1()
53+ defer release2()
54+ ctx, cancel := context.WithCancel(context.Background())
55+ 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}}}
57+
58+ d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
59+ awaitFrame(t, sends, ports.MsgOutput)
60+ d.handlePromptInput(ac, []byte("taken\r"))
61+ repaint := awaitFrame(t, sends, ports.MsgOutput)
62+
63+ require.True(t, ac.promptActive())
64+ require.NotEqual(t, "taken", sess.name)
65+ out, err := ports.UnmarshalOutput(repaint.Payload)
66+ require.NoError(t, err)
67+ require.Contains(t, string(out.Data), "name already in use")
68+}
69+
70+func TestPromptEscapeCancelsWithoutRename(t *testing.T) {
71+ p, release := newBlockingPTY(t)
72+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
73+ defer release()
74+ oldName := sess.name
75+
76+ d.enterPrompt(sess, ac, " Rename session ", oldName, func(name string) error { return d.renameSession(sess, name) })
77+ awaitFrame(t, sends, ports.MsgOutput)
78+ d.handlePromptInput(ac, []byte("new\x1b"))
79+ awaitFrame(t, sends, ports.MsgOutput)
80+
81+ require.False(t, ac.promptActive())
82+ require.Equal(t, oldName, sess.name)
83+}
84+
85+func TestPaletteRNSOpensRenamePrompt(t *testing.T) {
86+ p, release := newBlockingPTY(t)
87+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
88+ defer release()
89+ sess.name = "0"
90+ sess.ephemeral = true
91+
92+ d.enterPalette(sess, ac)
93+ awaitFrame(t, sends, ports.MsgOutput)
94+ d.handlePaletteInput(ac, []byte("RNS\r"))
95+ out := awaitFrame(t, sends, ports.MsgOutput)
96+
97+ require.False(t, ac.paletteActive())
98+ require.True(t, ac.promptActive())
99+ msg, err := ports.UnmarshalOutput(out.Payload)
100+ require.NoError(t, err)
101+ require.Contains(t, string(msg.Data), "Rename session")
102+ require.Contains(t, string(msg.Data), "> 0")
103+}
A internal/usecase/daemon/render.go +380 -0
@@ -0,0 +1,380 @@
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+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
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.
26+package daemon
27+
28+import (
29+ "bytes"
30+ "strconv"
31+ "time"
32+
33+ scopy "github.com/bnema/vev/internal/usecase/copy"
34+ "github.com/bnema/vev/pkg/renderer"
35+ "github.com/bnema/vev/pkg/vt"
36+)
37+
38+func signal(ch chan struct{}) {
39+ select {
40+ case ch <- struct{}{}:
41+ default:
42+ }
43+}
44+
45+func (d *Daemon) ptyReader(sess *session, tb *tab) {
46+ defer d.sessWg.Done()
47+ buf := make([]byte, ptyReadBufSize)
48+ var resp []byte
49+ tb.mu.Lock()
50+ tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
51+ tb.mu.Unlock()
52+ for {
53+ n, err := tb.pty.Read(buf)
54+ if n > 0 {
55+ data := buf[:n]
56+ tb.mu.Lock()
57+ wasSyncing := tb.screen.SyncUpdateActive()
58+ tb.screen.Write(data)
59+ isSyncing := tb.screen.SyncUpdateActive()
60+ tb.mu.Unlock()
61+ if len(resp) > 0 {
62+ if _, writeErr := tb.pty.Write(resp); writeErr != nil {
63+ d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
64+ }
65+ resp = resp[:0]
66+ }
67+ if wasSyncing != isSyncing {
68+ tb.mu.Lock()
69+ tb.syncGen++
70+ gen := tb.syncGen
71+ tb.mu.Unlock()
72+ if isSyncing {
73+ go d.syncWatchdog(tb, gen)
74+ }
75+ }
76+ if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
77+ signal(tb.flush)
78+ continue
79+ }
80+ if isSyncing {
81+ continue
82+ }
83+ signal(tb.dirty)
84+ }
85+ if err != nil {
86+ d.closeTab(sess, tb, true)
87+ return
88+ }
89+ }
90+}
91+
92+// scheduler debounces dirty signals. The first dirty opens a short tab;
93+// sustained floods progressively widen that tab, while isolated updates
94+// return to the minimum delay for interactive latency.
95+func (d *Daemon) scheduler(sess *session, tb *tab) {
96+ defer d.sessWg.Done()
97+ delay := minDebounceInterval
98+ lastRender := d.clock.Now()
99+outer:
100+ for {
101+ select {
102+ case <-sess.ctx.Done():
103+ return
104+ case <-tabDone(tb):
105+ return
106+ case <-tb.flush:
107+ d.render(sess, tb)
108+ lastRender = d.clock.Now()
109+ continue
110+ case <-tb.dirty:
111+ if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {
112+ delay = minDebounceInterval
113+ }
114+ }
115+
116+ coalesced := 0
117+ timer := d.clock.NewTimer(delay)
118+ absorb:
119+ for {
120+ select {
121+ case <-sess.ctx.Done():
122+ timer.Stop()
123+ return
124+ case <-tabDone(tb):
125+ timer.Stop()
126+ return
127+ case <-tb.flush:
128+ if !timer.Stop() {
129+ select {
130+ case <-timer.C():
131+ default:
132+ }
133+ }
134+ d.render(sess, tb)
135+ lastRender = d.clock.Now()
136+ continue outer
137+ case <-tb.dirty:
138+ coalesced++
139+ case <-timer.C():
140+ break absorb
141+ }
142+ }
143+ delay = nextDebounceDelay(delay, coalesced)
144+ d.render(sess, tb)
145+ lastRender = d.clock.Now()
146+ }
147+}
148+
149+func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
150+ if coalesced == 0 {
151+ return minDebounceInterval
152+ }
153+ if delay >= maxDebounceInterval {
154+ return maxDebounceInterval
155+ }
156+ delay += debounceStep
157+ if delay > maxDebounceInterval {
158+ return maxDebounceInterval
159+ }
160+ return delay
161+}
162+
163+func (d *Daemon) syncWatchdog(tb *tab, gen uint64) {
164+ timer := d.clock.NewTimer(maxSyncUpdateDuration)
165+ select {
166+ case <-tabDone(tb):
167+ timer.Stop()
168+ return
169+ case <-timer.C():
170+ }
171+
172+ tb.mu.Lock()
173+ if tb.syncGen != gen || !tb.screen.SyncUpdateActive() {
174+ tb.mu.Unlock()
175+ return
176+ }
177+ tb.screen.ForceSyncEnd()
178+ tb.mu.Unlock()
179+ signal(tb.flush)
180+}
181+
182+func tabDone(tb *tab) <-chan struct{} {
183+ if tb.ctx == nil {
184+ return nil
185+ }
186+ return tb.ctx.Done()
187+}
188+
189+func syncUpdateEndIn(data []byte) bool {
190+ return bytes.Contains(data, []byte("\x1b[?2026l"))
191+}
192+
193+// render paints the current client, or (when detached) just clears accumulated
194+// damage so it never grows unbounded while headless.
195+func (d *Daemon) render(sess *session, tb *tab) {
196+ tb.mu.Lock()
197+ if tb.screen.SyncUpdateActive() {
198+ tb.mu.Unlock()
199+ return
200+ }
201+ tb.mu.Unlock()
202+
203+ tb.mu.Lock()
204+ previewer := tb.previewClient
205+ tb.mu.Unlock()
206+
207+ sess.mu.Lock()
208+ ac := sess.client
209+ active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
210+ sess.mu.Unlock()
211+
212+ if previewer != nil {
213+ if previewSess := previewer.currentSession(); previewSess != nil {
214+ d.paint(previewSess, previewer, false)
215+ }
216+ if ac != nil && active && ac != previewer {
217+ d.paint(sess, ac, false)
218+ return
219+ }
220+ tb.mu.Lock()
221+ tb.screen.ClearDamage()
222+ tb.mu.Unlock()
223+ return
224+ }
225+
226+ if ac == nil || !active {
227+ tb.mu.Lock()
228+ tb.screen.ClearDamage()
229+ tb.mu.Unlock()
230+ return
231+ }
232+ d.paint(sess, ac, false)
233+}
234+
235+// paint draws the composed client frame (active tab plus status bar) and
236+// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
237+// such as switch/create/close/rename/resize so the repaint is complete.
238+func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
239+ tb := sess.activeTab()
240+ if tb == nil {
241+ return
242+ }
243+
244+ ac.sendMu.Lock()
245+ ac.copyMu.Lock()
246+ copyActive := ac.copyMode != nil
247+ var copyMode *scopy.Mode
248+ if ac.copyMode != nil {
249+ copyModeValue := *ac.copyMode
250+ copyMode = &copyModeValue
251+ }
252+ copyFeedback := ac.copyFeedback
253+ if copyFeedback != "" && !copyActive {
254+ ac.copyFeedback = ""
255+ }
256+ ac.copyMu.Unlock()
257+ ac.pickerMu.Lock()
258+ pickerActive := ac.picker != nil
259+ pickerModel := ac.picker.Clone()
260+ previewTab := ac.pickerPreview
261+ ac.pickerMu.Unlock()
262+ preview := snapshotPickerPreview(previewTab)
263+ ac.paletteMu.Lock()
264+ paletteModel := ac.palette
265+ paletteActive := paletteModel != nil
266+ if !paletteActive {
267+ ac.paletteMu.Unlock()
268+ }
269+ ac.promptMu.Lock()
270+ promptModel := ac.prompt
271+ promptActive := promptModel != nil
272+ if !promptActive {
273+ ac.promptMu.Unlock()
274+ }
275+
276+ tb.mu.Lock()
277+ if reset || copyActive || pickerActive || paletteActive || promptActive {
278+ ac.rend.Reset()
279+ }
280+ if reset || pickerActive || paletteActive || promptActive {
281+ ac.lastCursor.valid = false
282+ }
283+ frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
284+ if copyActive {
285+ frame, damage = composeCopyClientFrame(copyMode, tb)
286+ }
287+ if pickerActive {
288+ if previewTab == tb {
289+ preview = pickerPreviewFromLockedTab(tb)
290+ }
291+ frame, damage = composePickerClientFrame(pickerModel, preview, frame)
292+ }
293+ if paletteActive {
294+ frame, damage = composePaletteClientFrame(paletteModel, frame)
295+ ac.paletteMu.Unlock()
296+ }
297+ if promptActive {
298+ frame, damage = composePromptClientFrame(promptModel, frame)
299+ ac.promptMu.Unlock()
300+ }
301+ desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive || promptActive)
302+ data, err := ac.rend.Draw(frame, damage)
303+ var cursorTail []byte
304+ if err == nil {
305+ cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
306+ }
307+ tb.screen.ClearDamage()
308+ tb.mu.Unlock()
309+
310+ var serr error
311+ if err == nil {
312+ data = append(data, cursorTail...)
313+ if len(data) > 0 {
314+ serr = ac.tr.Send(frameOutput(data))
315+ }
316+ }
317+ ac.sendMu.Unlock()
318+
319+ if err != nil {
320+ d.log.Error("render draw failed", "err", err, "session", sess.name)
321+ return
322+ }
323+ if serr != nil {
324+ d.detachOnSendError(sess, ac)
325+ }
326+}
327+
328+// desiredCursorOut computes the terminal cursor state that should be shown to
329+// the client for the current tab and overlay mode.
330+func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
331+ if copyActive || !s.CursorVisible() {
332+ return cursorOut{hidden: true}
333+ }
334+ style, ok := s.CursorStyle()
335+ if !ok {
336+ style = 5
337+ }
338+ return cursorOut{row: s.CursorRow(), col: s.CursorCol(), style: style, hasStyle: true}
339+}
340+
341+func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
342+ changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
343+ if !changed {
344+ return nil
345+ }
346+ prev := ac.lastCursor
347+ ac.lastCursor = desired
348+ ac.lastCursor.valid = true
349+ if desired.hidden {
350+ return []byte("\x1b[?25l")
351+ }
352+ var b []byte
353+ b = append(b, "\x1b["...)
354+ b = strconv.AppendInt(b, int64(desired.row+1), 10)
355+ b = append(b, ';')
356+ b = strconv.AppendInt(b, int64(desired.col+1), 10)
357+ b = append(b, 'H')
358+ if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
359+ b = append(b, "\x1b["...)
360+ b = strconv.AppendInt(b, int64(desired.style), 10)
361+ b = append(b, " q"...)
362+ }
363+ b = append(b, "\x1b[?25h"...)
364+ return b
365+}
366+
367+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) {
368+ width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
369+ frame := renderer.NewFrame(width, screenRows+1)
370+ for y := range screenRows {
371+ copy(frame.Row(y), tb.screen.Frame.Row(y))
372+ }
373+ drawStatus(frame.Row(screenRows), sess, rightStatus)
374+ if full {
375+ return frame, []renderer.Damage{renderer.FullRedraw()}
376+ }
377+ damage := append([]renderer.Damage(nil), tb.screen.Damage()...)
378+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1})
379+ return frame, damage
380+}
A internal/usecase/daemon/render_test.go +612 -0
@@ -0,0 +1,612 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "log/slog"
9+ "strings"
10+ "sync/atomic"
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+ "github.com/bnema/vev/pkg/vt"
22+)
23+
24+// --- test doubles -----------------------------------------------------------
25+
26+// stubClock returns timers whose channel never fires, so a scheduler under it
27+// blocks in its debounce loop until the session context is cancelled. Used by
28+
29+func TestPTYWriteErrorIsLogged(t *testing.T) {
30+ var logs bytes.Buffer
31+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))
32+ errBoom := errors.New("boom")
33+ p := portsmocks.NewMockPTY(t)
34+ p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
35+ win := &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}
36+ sess := &session{id: "manual", name: "work", tabs: []*tab{win}}
37+ ac := &attachedClient{}
38+ ac.setSession(sess)
39+
40+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input"))
41+
42+ got := logs.String()
43+ if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") {
44+ t.Fatalf("log output %q does not contain PTY write failure details", got)
45+ }
46+}
47+
48+func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
49+ p1, releasePTY1 := newBlockingPTY(t)
50+ p2, releasePTY2 := newBlockingPTY(t)
51+ clk := &signalClock{called: make(chan struct{})}
52+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
53+ d.clock = clk
54+ defer releasePTY1()
55+ defer releasePTY2()
56+
57+ d.sessWg.Add(1)
58+ go d.scheduler(sess, sess.tabs[1])
59+ sess.tabs[1].dirty <- struct{}{}
60+ <-clk.called
61+
62+ d.handleInput(sess, ac, []byte("\x1b2"))
63+ d.handleInput(sess, ac, []byte("\x1b "))
64+ d.handleInput(sess, ac, []byte("CLT\r"))
65+
66+ select {
67+ case <-waitGroupDone(&d.sessWg):
68+ case <-time.After(2 * time.Second):
69+ t.Fatal("scheduler for removed tab did not exit")
70+ }
71+ require.Equal(t, 1, sessionCount(d))
72+}
73+
74+func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) {
75+ p1, releasePTY1 := newBlockingPTY(t)
76+ p2, releasePTY2 := newBlockingPTY(t)
77+ clk := &signalClock{called: make(chan struct{})}
78+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
79+ d.clock = clk
80+ defer releasePTY1()
81+ defer releasePTY2()
82+
83+ d.sessWg.Add(2)
84+ go d.scheduler(sess, sess.tabs[1])
85+ go d.ptyReader(sess, sess.tabs[1])
86+ sess.tabs[1].dirty <- struct{}{}
87+ <-clk.called
88+ releasePTY2()
89+
90+ select {
91+ case <-waitGroupDone(&d.sessWg):
92+ case <-time.After(2 * time.Second):
93+ t.Fatal("scheduler for EOF-removed tab did not exit")
94+ }
95+ require.Equal(t, 1, sessionCount(d))
96+}
97+
98+func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
99+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
100+ defer releases[0]()
101+
102+ d.handleInput(sess, ac, []byte("\x1b "))
103+ d.handleInput(sess, ac, []byte("CLT\r"))
104+
105+ require.Equal(t, 0, sessionCount(d))
106+ f := awaitFrame(t, sends, ports.MsgDetached)
107+ det, err := ports.UnmarshalDetached(f.Payload)
108+ require.NoError(t, err)
109+ require.Equal(t, ports.ReasonSessionKilled, det.Reason)
110+}
111+
112+func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
113+ p1, releasePTY1 := newBlockingPTY(t)
114+ p2, releasePTY2 := newBlockingPTY(t)
115+ d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
116+ defer releasePTY2()
117+ sess.active = 0
118+ sess.tabs[1].screen.Write([]byte("remaining"))
119+
120+ d.sessWg.Add(1)
121+ go d.ptyReader(sess, sess.tabs[0])
122+ releasePTY1()
123+
124+ require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
125+ require.Equal(t, 1, sessionCount(d))
126+ require.Equal(t, 0, activeTabIndex(sess))
127+ f := awaitFrame(t, sends, ports.MsgOutput)
128+ out, err := ports.UnmarshalOutput(f.Payload)
129+ require.NoError(t, err)
130+ data := string(out.Data)
131+ require.Contains(t, data, "remaining")
132+ require.Contains(t, data, "work")
133+ require.Contains(t, data, ";7m")
134+
135+ d.sessWg.Wait()
136+}
137+
138+func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
139+ p1, releasePTY1 := newBlockingPTY(t)
140+ p2, releasePTY2 := newBlockingPTY(t)
141+ d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
142+ defer releasePTY1()
143+ sess.active = 0
144+ sess.tabs[0].screen.Write([]byte("active"))
145+
146+ d.sessWg.Add(1)
147+ go d.ptyReader(sess, sess.tabs[1])
148+ releasePTY2()
149+
150+ require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
151+ require.Equal(t, 1, sessionCount(d))
152+ require.Equal(t, 0, activeTabIndex(sess))
153+ f := awaitFrame(t, sends, ports.MsgOutput)
154+ out, err := ports.UnmarshalOutput(f.Payload)
155+ require.NoError(t, err)
156+ data := string(out.Data)
157+ require.Contains(t, data, "active")
158+ require.Contains(t, data, "work")
159+ require.NotContains(t, data, " 2 ")
160+
161+ d.sessWg.Wait()
162+}
163+
164+func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
165+ d, sess, _, sends, releases := newManualTabSession(t, 1)
166+
167+ d.sessWg.Add(1)
168+ go d.ptyReader(sess, sess.tabs[0])
169+ releases[0]()
170+
171+ require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond)
172+ f := awaitFrame(t, sends, ports.MsgDetached)
173+ det, err := ports.UnmarshalDetached(f.Payload)
174+ require.NoError(t, err)
175+ require.Equal(t, ports.ReasonSessionKilled, det.Reason)
176+
177+ d.sessWg.Wait()
178+}
179+
180+func TestSchedulerDebounceCoalesces(t *testing.T) {
181+ mc := portsmocks.NewMockClock(t)
182+ mc.EXPECT().Now().Return(time.Now()).Maybe()
183+ mt := portsmocks.NewMockTimer(t)
184+ timerCh := make(chan time.Time, 1)
185+ newTimerCalled := make(chan struct{}, 4)
186+
187+ mc.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
188+ select {
189+ case newTimerCalled <- struct{}{}:
190+ default:
191+ }
192+ return mt
193+ }).Maybe()
194+ mt.EXPECT().C().Return(timerCh).Maybe()
195+ mt.EXPECT().Stop().Return(true).Maybe()
196+
197+ var outputs atomic.Int32
198+ gotOutput := make(chan struct{}, 1)
199+ tr := portsmocks.NewMockTransport(t)
200+ tr.EXPECT().Close().Return(nil).Maybe()
201+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
202+ if f.Type == ports.MsgOutput {
203+ outputs.Add(1)
204+ select {
205+ case gotOutput <- struct{}{}:
206+ default:
207+ }
208+ }
209+ return nil
210+ }).Maybe()
211+
212+ d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
213+ sctx, cancel := context.WithCancel(context.Background())
214+ win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
215+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
216+ sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
217+ ac.setSession(sess)
218+
219+ win.mu.Lock()
220+ win.screen.Write([]byte("hi"))
221+ win.mu.Unlock()
222+
223+ d.sessWg.Add(1)
224+ go d.scheduler(sess, win)
225+
226+ // First dirty opens the debounce window.
227+ win.dirty <- struct{}{}
228+ <-newTimerCalled
229+
230+ // A burst of further dirties inside the window must be absorbed.
231+ for range 5 {
232+ select {
233+ case win.dirty <- struct{}{}:
234+ default:
235+ }
236+ }
237+
238+ // Fire the timer once: exactly one render.
239+ timerCh <- time.Now()
240+ <-gotOutput
241+
242+ cancel()
243+ d.sessWg.Wait()
244+ require.Equal(t, int32(1), outputs.Load(), "N dirties in one tab must render exactly once")
245+}
246+
247+func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) {
248+ delay := nextDebounceDelay(minDebounceInterval, 3)
249+ require.Greater(t, delay, minDebounceInterval, "sustained flood should widen debounce")
250+ delay = nextDebounceDelay(delay, 3)
251+ require.Greater(t, delay, minDebounceInterval+debounceStep, "continued flood should keep adapting")
252+}
253+
254+func TestSchedulerAdaptiveDebounceResetsAfterQuietPeriod(t *testing.T) {
255+ delay := nextDebounceDelay(minDebounceInterval, 2)
256+ require.Greater(t, delay, minDebounceInterval)
257+ require.Equal(t, minDebounceInterval, nextDebounceDelay(delay, 0), "isolated update after quiet window should restore idle latency")
258+}
259+
260+// --- resize ordering --------------------------------------------------------
261+
262+func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
263+ newSize := domain.Size{Cols: 100, Rows: 30}
264+
265+ p := portsmocks.NewMockPTY(t)
266+ var screenWidthAtResize int
267+ win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
268+ p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error {
269+ // The screen must not yet be resized when the PTY is: proves order.
270+ screenWidthAtResize = win.screen.Frame.Width
271+ return nil
272+ }).Once()
273+ win.pty = p
274+
275+ var gotOutput atomic.Bool
276+ tr := portsmocks.NewMockTransport(t)
277+ tr.EXPECT().Close().Return(nil).Maybe()
278+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
279+ if f.Type == ports.MsgOutput {
280+ gotOutput.Store(true)
281+ }
282+ return nil
283+ }).Maybe()
284+
285+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
286+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
287+ sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
288+ ac.setSession(sess)
289+
290+ d.resize(sess, ac, newSize)
291+
292+ require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
293+ require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")
294+ require.Equal(t, 29, win.screen.Frame.Height, "screen reserves bottom status row")
295+ require.True(t, gotOutput.Load(), "resize forces a full redraw output")
296+}
297+
298+// --- reader EOF -> registry-empty shutdown ----------------------------------
299+
300+func TestSendErrorKillsEphemeral(t *testing.T) {
301+ p := portsmocks.NewMockPTY(t)
302+ p.EXPECT().Close().Return(nil).Maybe()
303+
304+ tr := portsmocks.NewMockTransport(t)
305+ tr.EXPECT().Send(mock.Anything).Return(io.ErrClosedPipe).Maybe()
306+ tr.EXPECT().Close().Return(nil).Maybe()
307+
308+ d := newTestDaemon(t, nil, stubClock{})
309+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
310+ sctx, cancel := context.WithCancel(context.Background())
311+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
312+ sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
313+ ac.setSession(sess)
314+ d.sessions[sess.id] = sess
315+
316+ win.mu.Lock()
317+ win.screen.Write([]byte("x"))
318+ win.mu.Unlock()
319+
320+ d.paint(sess, ac, true) // send fails -> detach -> ephemeral killed
321+
322+ require.Equal(t, 0, sessionCount(d), "ephemeral session must die when its client's send fails")
323+ d.waitNotifies()
324+}
325+
326+func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) {
327+ mc := portsmocks.NewMockClock(t)
328+ mc.EXPECT().Now().Return(time.Now()).Maybe()
329+ mt := portsmocks.NewMockTimer(t)
330+ timerCh := make(chan time.Time, 1)
331+ mc.EXPECT().NewTimer(mock.Anything).Return(mt).Maybe()
332+ mt.EXPECT().C().Return(timerCh).Maybe()
333+ mt.EXPECT().Stop().Return(true).Maybe()
334+
335+ var outputs atomic.Int32
336+ gotOutput := make(chan struct{}, 1)
337+ tr := portsmocks.NewMockTransport(t)
338+ tr.EXPECT().Close().Return(nil).Maybe()
339+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
340+ if f.Type == ports.MsgOutput {
341+ outputs.Add(1)
342+ select {
343+ case gotOutput <- struct{}{}:
344+ default:
345+ }
346+ }
347+ return nil
348+ }).Maybe()
349+
350+ d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
351+ sctx, cancel := context.WithCancel(context.Background())
352+ win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
353+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
354+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
355+ ac.setSession(sess)
356+
357+ win.mu.Lock()
358+ win.screen.Write([]byte("before"))
359+ win.mu.Unlock()
360+
361+ d.sessWg.Add(1)
362+ go d.scheduler(sess, win)
363+ win.dirty <- struct{}{}
364+
365+ win.mu.Lock()
366+ win.screen.Write([]byte("\x1b[?2026hafter"))
367+ win.mu.Unlock()
368+ timerCh <- time.Now()
369+
370+ select {
371+ case <-gotOutput:
372+ t.Fatal("scheduler rendered while synchronized update was active")
373+ case <-time.After(20 * time.Millisecond):
374+ }
375+ require.Equal(t, int32(0), outputs.Load())
376+
377+ win.mu.Lock()
378+ win.screen.Write([]byte(" done\x1b[?2026l"))
379+ win.mu.Unlock()
380+ win.flush <- struct{}{}
381+ select {
382+ case <-gotOutput:
383+ case <-time.After(time.Second):
384+ t.Fatal("sync end did not flush deferred damage")
385+ }
386+
387+ cancel()
388+ d.sessWg.Wait()
389+}
390+
391+func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
392+ d := newTestDaemon(t, nil, stubClock{})
393+ p := portsmocks.NewMockPTY(t)
394+ chunks := [][]byte{[]byte("\x1b[6n")}
395+ writes := make(chan []byte, 1)
396+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
397+ if len(chunks) == 0 {
398+ return 0, io.EOF
399+ }
400+ n := copy(buf, chunks[0])
401+ chunks = chunks[1:]
402+ return n, nil
403+ })
404+ p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
405+ writes <- append([]byte(nil), b...)
406+ return len(b), nil
407+ }).Once()
408+ p.EXPECT().Close().Return(nil).Maybe()
409+
410+ tr, sends := newCapturingTransport(t)
411+ sctx, cancel := context.WithCancel(context.Background())
412+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
413+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
414+ sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
415+ ac.setSession(sess)
416+ d.sessions[sess.id] = sess
417+ d.sessWg.Add(1)
418+ d.ptyReader(sess, win)
419+
420+ select {
421+ case got := <-writes:
422+ require.Equal(t, []byte("\x1b[1;1R"), got)
423+ case <-time.After(time.Second):
424+ t.Fatal("timed out waiting for PTY response write")
425+ }
426+ select {
427+ case f := <-sends:
428+ require.NotEqual(t, ports.MsgOutput, f.Type)
429+ default:
430+ }
431+}
432+
433+func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) {
434+ d := newTestDaemon(t, nil, stubClock{})
435+ p := portsmocks.NewMockPTY(t)
436+ chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")}
437+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
438+ if len(chunks) == 0 {
439+ return 0, io.EOF
440+ }
441+ n := copy(buf, chunks[0])
442+ chunks = chunks[1:]
443+ return n, nil
444+ })
445+ p.EXPECT().Close().Return(nil).Maybe()
446+
447+ sctx, cancel := context.WithCancel(context.Background())
448+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
449+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
450+ d.sessions[sess.id] = sess
451+ d.sessWg.Add(1)
452+ d.ptyReader(sess, win)
453+
454+ select {
455+ case <-win.dirty:
456+ t.Fatal("same-read synchronized update end should request flush, not dirty")
457+ default:
458+ }
459+ select {
460+ case <-win.flush:
461+ case <-time.After(time.Second):
462+ t.Fatal("same-read synchronized update end did not request immediate flush")
463+ }
464+}
465+
466+func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.T) {
467+ d := newTestDaemon(t, nil, stubClock{})
468+ p := portsmocks.NewMockPTY(t)
469+ chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
470+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
471+ if len(chunks) == 0 {
472+ return 0, io.EOF
473+ }
474+ n := copy(buf, chunks[0])
475+ chunks = chunks[1:]
476+ return n, nil
477+ })
478+ p.EXPECT().Close().Return(nil).Maybe()
479+
480+ sctx, cancel := context.WithCancel(context.Background())
481+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
482+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
483+ d.sessions[sess.id] = sess
484+ d.sessWg.Add(1)
485+ d.ptyReader(sess, win)
486+
487+ select {
488+ case <-win.dirty:
489+ t.Fatal("dirty signaled while synchronized update was active")
490+ default:
491+ }
492+ select {
493+ case <-win.flush:
494+ case <-time.After(time.Second):
495+ t.Fatal("sync end did not request immediate flush")
496+ }
497+}
498+
499+func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
500+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
501+ d := newTestDaemon(t, nil, clk)
502+ p := portsmocks.NewMockPTY(t)
503+ release := make(chan struct{})
504+ read := 0
505+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
506+ if read == 0 {
507+ read++
508+ return copy(buf, []byte("\x1b[?2026habandoned")), nil
509+ }
510+ <-release
511+ return 0, io.EOF
512+ })
513+ p.EXPECT().Close().Return(nil).Maybe()
514+
515+ tr, sends := newCapturingTransport(t)
516+ sctx, cancel := context.WithCancel(context.Background())
517+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
518+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
519+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
520+ ac.setSession(sess)
521+ d.sessions[sess.id] = sess
522+ d.sessWg.Add(2)
523+ go d.scheduler(sess, win)
524+ go d.ptyReader(sess, win)
525+
526+ timer := <-clk.timers
527+ timer.ch <- time.Now()
528+ awaitFrame(t, sends, ports.MsgOutput)
529+
530+ win.mu.Lock()
531+ active := win.screen.SyncUpdateActive()
532+ got := screenLineText(win.screen, 0)
533+ win.mu.Unlock()
534+ require.False(t, active)
535+ require.Contains(t, got, "abandoned")
536+
537+ close(release)
538+ cancel()
539+ d.sessWg.Wait()
540+}
541+
542+func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
543+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
544+ d := newTestDaemon(t, nil, clk)
545+ p := portsmocks.NewMockPTY(t)
546+ release := make(chan struct{})
547+ chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
548+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
549+ if len(chunks) > 0 {
550+ n := copy(buf, chunks[0])
551+ chunks = chunks[1:]
552+ return n, nil
553+ }
554+ <-release
555+ return 0, io.EOF
556+ })
557+ p.EXPECT().Close().Return(nil).Maybe()
558+
559+ tr, sends := newCapturingTransport(t)
560+ sctx, cancel := context.WithCancel(context.Background())
561+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
562+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
563+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
564+ ac.setSession(sess)
565+ d.sessions[sess.id] = sess
566+ d.sessWg.Add(2)
567+ go d.scheduler(sess, win)
568+ go d.ptyReader(sess, win)
569+
570+ timer := <-clk.timers
571+ awaitFrame(t, sends, ports.MsgOutput)
572+ timer.ch <- time.Now()
573+
574+ select {
575+ case f := <-sends:
576+ if f.Type == ports.MsgOutput {
577+ t.Fatal("stale synchronized update watchdog produced an extra render")
578+ }
579+ case <-time.After(50 * time.Millisecond):
580+ }
581+ win.mu.Lock()
582+ active := win.screen.SyncUpdateActive()
583+ win.mu.Unlock()
584+ require.False(t, active)
585+
586+ close(release)
587+ cancel()
588+ d.sessWg.Wait()
589+}
590+
591+func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
592+ p, _ := newBlockingPTY(t)
593+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
594+ win := sess.tabs[0]
595+ win.screen.Write([]byte("A"))
596+
597+ d.paint(sess, ac, true)
598+ data := mustOutputData(t, sends)
599+ require.Contains(t, string(data), "\x1b[5 q")
600+ require.Contains(t, string(data), "\x1b[?25h")
601+
602+ win.screen.Write([]byte("\x1b[2;3H"))
603+ d.paint(sess, ac, false)
604+ data = mustOutputData(t, sends)
605+ require.Contains(t, string(data), "\x1b[2;3H")
606+ require.Contains(t, string(data), "\x1b[?25h")
607+
608+ win.screen.Write([]byte("\x1b[?25l"))
609+ d.paint(sess, ac, false)
610+ data = mustOutputData(t, sends)
611+ require.Contains(t, string(data), "\x1b[?25l")
612+}
A internal/usecase/daemon/session.go +341 -0
@@ -0,0 +1,341 @@
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+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
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.
26+package daemon
27+
28+import (
29+ "context"
30+ "errors"
31+ "fmt"
32+ "strconv"
33+ "strings"
34+ "sync"
35+
36+ "github.com/bnema/vev/internal/domain"
37+ "github.com/bnema/vev/internal/ports"
38+ scopy "github.com/bnema/vev/internal/usecase/copy"
39+ "github.com/bnema/vev/pkg/vt"
40+)
41+
42+type session struct {
43+ id domain.SessionID
44+ name string
45+ ephemeral bool
46+
47+ ctx context.Context
48+ cancel context.CancelFunc
49+
50+ mu sync.Mutex // guards tabs, active, and client
51+ tabs []*tab
52+ active int
53+ client *attachedClient
54+ cwd string
55+}
56+
57+// tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
58+// applies back-pressure by collapsing (a full buffer means "a render is
59+// already pending"), never by blocking the reader.
60+type tab struct {
61+ pty ports.PTY
62+ mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
63+ screen *vt.Screen
64+ scrollback *scopy.Scrollback
65+ dirty chan struct{}
66+ flush chan struct{}
67+ syncGen uint64
68+ // previewClient tracks the one client currently previewing this tab in the picker.
69+ // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
70+ previewClient *attachedClient
71+ size domain.Size
72+ ctx context.Context
73+ cancel context.CancelFunc
74+}
75+
76+// attachedClient is a client currently attached to a session's tab. rend is
77+// its private renderer shadow (so each client gets output minimised against
78+// what it has actually seen). sendMu serialises the two senders — the render
79+// scheduler and the connection handler — so the transport's single-writer
80+
81+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
82+ tbSize := tabSize(sz)
83+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
84+ if err != nil {
85+ return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
86+ }
87+
88+ id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
89+ d.nextID++
90+
91+ tb := newTab(pty, tbSize)
92+ sctx, cancel := context.WithCancel(d.serveCtx)
93+ tb.ctx, tb.cancel = context.WithCancel(sctx)
94+ sess := &session{
95+ id: id,
96+ name: name,
97+ ephemeral: ephemeral,
98+ ctx: sctx,
99+ cancel: cancel,
100+ tabs: []*tab{tb},
101+ cwd: cwd,
102+ }
103+ d.sessions[id] = sess
104+ d.startTabGoroutines(sess, tb)
105+ return sess, nil
106+}
107+
108+func (d *Daemon) createTab(sess *session, sz domain.Size) error {
109+ tbSize := tabSize(sz)
110+ sess.mu.Lock()
111+ name := sess.name
112+ cwd := sess.cwd
113+ sess.mu.Unlock()
114+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
115+ if err != nil {
116+ return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
117+ }
118+ tb := newTab(pty, tbSize)
119+ d.mu.Lock()
120+ if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil {
121+ d.mu.Unlock()
122+ _ = pty.Close()
123+ return errors.New("daemon: session closed")
124+ }
125+ tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
126+ sess.mu.Lock()
127+ sess.tabs = append(sess.tabs, tb)
128+ sess.active = len(sess.tabs) - 1
129+ sess.mu.Unlock()
130+ d.startTabGoroutines(sess, tb)
131+ d.mu.Unlock()
132+ return nil
133+}
134+
135+func newTab(pty ports.PTY, sz domain.Size) *tab {
136+ sb := scopy.NewScrollback(defaultScrollbackRows)
137+ screen := vt.NewScreen(sz.Cols, sz.Rows)
138+ screen.OnLineEvicted = sb.Append
139+ return &tab{
140+ pty: pty,
141+ screen: screen,
142+ scrollback: sb,
143+ dirty: make(chan struct{}, 1),
144+ flush: make(chan struct{}, 1),
145+ size: sz,
146+ }
147+}
148+
149+func tabSize(clientSize domain.Size) domain.Size {
150+ if !clientSize.Valid() {
151+ clientSize = defaultSize
152+ }
153+ rows := max(clientSize.Rows-1, 1)
154+ return domain.Size{Cols: clientSize.Cols, Rows: rows}
155+}
156+
157+func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
158+ d.sessWg.Add(2)
159+ go d.ptyReader(sess, tb)
160+ go d.scheduler(sess, tb)
161+}
162+
163+// attachClient makes ac the session's current client, displacing any prior one
164+// (which is notified with ReasonDetach and disconnected — its own conn handler
165+
166+func (s *session) detachIfCurrent(ac *attachedClient) bool {
167+ s.mu.Lock()
168+ defer s.mu.Unlock()
169+ if s.client == ac {
170+ s.client = nil
171+ ac.setSession(nil)
172+ return true
173+ }
174+ return false
175+}
176+
177+func (s *session) activeTab() *tab {
178+ s.mu.Lock()
179+ defer s.mu.Unlock()
180+ if s.active < 0 || s.active >= len(s.tabs) {
181+ return nil
182+ }
183+ return s.tabs[s.active]
184+}
185+
186+func (s *session) switchTab(idx int) bool {
187+ s.mu.Lock()
188+ defer s.mu.Unlock()
189+ if idx < 0 || idx >= len(s.tabs) || idx == s.active {
190+ return false
191+ }
192+ s.active = idx
193+ return true
194+}
195+
196+func (s *session) switchRelative(delta int) bool {
197+ s.mu.Lock()
198+ defer s.mu.Unlock()
199+ if len(s.tabs) < 2 {
200+ return false
201+ }
202+ s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
203+ return true
204+}
205+
206+func (d *Daemon) renameSession(sess *session, name string) error {
207+ if name == "" {
208+ return errors.New("name required")
209+ }
210+ d.mu.Lock()
211+ defer d.mu.Unlock()
212+ if taken := d.findByNameLocked(name); taken != nil && taken != sess {
213+ return errors.New("name already in use")
214+ }
215+ sess.mu.Lock()
216+ defer sess.mu.Unlock()
217+ sess.name = name
218+ sess.ephemeral = false
219+ return nil
220+}
221+
222+func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
223+ sess.mu.Lock()
224+ idx := -1
225+ for i, w := range sess.tabs {
226+ if w == tb {
227+ idx = i
228+ break
229+ }
230+ }
231+ if idx == -1 {
232+ sess.mu.Unlock()
233+ return
234+ }
235+ if len(sess.tabs) == 1 {
236+ sess.mu.Unlock()
237+ d.killSession(sess, ports.ReasonSessionKilled)
238+ return
239+ }
240+ sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
241+ if sess.active >= len(sess.tabs) {
242+ sess.active = len(sess.tabs) - 1
243+ } else if idx < sess.active {
244+ sess.active--
245+ }
246+ ac := sess.client
247+ sess.mu.Unlock()
248+
249+ d.clearDestroyedTabPreview(tb)
250+ if tb.cancel != nil {
251+ tb.cancel()
252+ }
253+ _ = tb.pty.Close()
254+ if repaint && ac != nil {
255+ d.paint(sess, ac, true)
256+ }
257+}
258+
259+// ptyReader drains child output into the VT screen and pokes the dirty channel
260+// (non-blocking: a full channel already means a render is pending). On any read
261+
262+func (d *Daemon) killSession(sess *session, reason uint8) {
263+ d.mu.Lock()
264+ if _, ok := d.sessions[sess.id]; !ok {
265+ d.mu.Unlock()
266+ return
267+ }
268+ delete(d.sessions, sess.id)
269+ empty := len(d.sessions) == 0
270+ if empty {
271+ // Shutdown is now inevitable and irreversible (doneOnce below): stop
272+ // route from inserting new sessions from this instant, while we still
273+ // hold the same lock route checks under.
274+ d.closing = true
275+ }
276+ d.mu.Unlock()
277+
278+ sess.mu.Lock()
279+ ac := sess.client
280+ sess.client = nil
281+ sess.mu.Unlock()
282+ if ac != nil {
283+ d.unregisterPreview(ac)
284+ ac.setSession(nil)
285+ }
286+
287+ sess.cancel()
288+ sess.mu.Lock()
289+ tabs := append([]*tab(nil), sess.tabs...)
290+ sess.mu.Unlock()
291+ for _, tb := range tabs {
292+ d.clearDestroyedTabPreview(tb)
293+ _ = tb.pty.Close()
294+ }
295+ if empty {
296+ d.doneOnce.Do(func() { close(d.done) })
297+ }
298+
299+ if ac != nil {
300+ d.notifyDetachedAsync(ac, reason)
301+ }
302+}
303+
304+// allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
305+// d.mu.
306+func (d *Daemon) allocEphemeralNameLocked() string {
307+ used := make(map[string]struct{}, len(d.sessions))
308+ for _, s := range d.sessions {
309+ used[s.name] = struct{}{}
310+ }
311+ for i := 0; ; i++ {
312+ n := strconv.Itoa(i)
313+ if _, taken := used[n]; !taken {
314+ return n
315+ }
316+ }
317+}
318+
319+func (d *Daemon) findByNameLocked(name string) *session {
320+ for _, s := range d.sessions {
321+ if s.name == name {
322+ return s
323+ }
324+ }
325+ return nil
326+}
327+
328+func (d *Daemon) nameTakenLocked(name string) bool { return d.findByNameLocked(name) != nil }
329+
330+// childEnv builds the session child's environment: the daemon's own, with TERM
331+// and VEV forced to well-known values.
332+func (d *Daemon) childEnv(name string) []string {
333+ out := make([]string, 0, len(d.baseEnv)+2)
334+ for _, e := range d.baseEnv {
335+ if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {
336+ continue
337+ }
338+ out = append(out, e)
339+ }
340+ return append(out, "TERM=xterm-256color", "VEV="+name)
341+}
A internal/usecase/daemon/session_test.go +582 -0
@@ -0,0 +1,582 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
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+)
18+
19+// --- test doubles -----------------------------------------------------------
20+
21+// stubClock returns timers whose channel never fires, so a scheduler under it
22+// blocks in its debounce loop until the session context is cancelled. Used by
23+
24+func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
25+ sz := domain.Size{Cols: 80, Rows: 24}
26+ first, releaseFirst := newBlockingPTY(t)
27+ defer releaseFirst()
28+ second, releaseSecond := newBlockingPTY(t)
29+ defer releaseSecond()
30+ f := portsmocks.NewMockPTYFactory(t)
31+ var dirs []string
32+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
33+ func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
34+ dirs = append(dirs, dir)
35+ if len(dirs) == 1 {
36+ return first, nil
37+ }
38+ return second, nil
39+ },
40+ ).Twice()
41+
42+ d := newTestDaemon(t, f, stubClock{})
43+ tr := portsmocks.NewMockTransport(t)
44+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
45+ tr.EXPECT().Close().Return(nil).Maybe()
46+
47+ sess, ac, err := d.route(ports.Hello{
48+ Version: ports.ProtocolVersion,
49+ Intent: ports.IntentNew,
50+ Name: "work",
51+ Size: sz,
52+ TermEnv: "xterm-256color",
53+ Cwd: "/tmp/work",
54+ }, tr)
55+ require.NoError(t, err)
56+ require.NotNil(t, ac)
57+ require.Equal(t, "/tmp/work", sess.cwd)
58+
59+ require.NoError(t, d.createTab(sess, sz))
60+ require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)
61+}
62+
63+func TestHandshakeEphemeralHappy(t *testing.T) {
64+ p, releasePTY := newBlockingPTY(t)
65+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
66+ tr, sends, releaseConn := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
67+
68+ var hg sync.WaitGroup
69+ hg.Go(func() { d.handleConn(tr) })
70+
71+ w := awaitFrame(t, sends, ports.MsgWelcome)
72+ welcome, err := ports.UnmarshalWelcome(w.Payload)
73+ require.NoError(t, err)
74+ require.Equal(t, "0", welcome.SessionName)
75+ require.True(t, welcome.Ephemeral)
76+ awaitFrame(t, sends, ports.MsgOutput) // guaranteed first paint
77+ require.Equal(t, 1, sessionCount(d))
78+
79+ releaseConn()
80+ releasePTY()
81+ hg.Wait()
82+ d.sessWg.Wait()
83+}
84+
85+func TestHandshakeNewHappy(t *testing.T) {
86+ p, releasePTY := newBlockingPTY(t)
87+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
88+ tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
89+
90+ var hg sync.WaitGroup
91+ hg.Go(func() { d.handleConn(tr) })
92+
93+ w := awaitFrame(t, sends, ports.MsgWelcome)
94+ welcome, err := ports.UnmarshalWelcome(w.Payload)
95+ require.NoError(t, err)
96+ require.Equal(t, "work", welcome.SessionName)
97+ require.False(t, welcome.Ephemeral)
98+
99+ sess := firstSession(d)
100+ require.NotNil(t, sess)
101+
102+ releaseConn()
103+ d.killSession(sess, ports.ReasonServerShutdown)
104+ releasePTY()
105+ hg.Wait()
106+ d.sessWg.Wait()
107+ d.waitNotifies()
108+}
109+
110+func TestHandshakeVersionMismatch(t *testing.T) {
111+ // No Open expectation: the factory must never be asked to spawn a PTY.
112+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
113+ bad := ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
114+ Version: ports.ProtocolVersion + 99, Intent: ports.IntentEphemeral, Size: domain.Size{Cols: 80, Rows: 24},
115+ })}
116+ tr, sends, _ := newConn(t, bad)
117+
118+ d.handleConn(tr) // returns after the rejection; no session, no goroutines
119+
120+ e := awaitFrame(t, sends, ports.MsgError)
121+ em, err := ports.UnmarshalErrorMsg(e.Payload)
122+ require.NoError(t, err)
123+ require.Equal(t, ports.ErrVersionMismatch, em.Code)
124+ require.Equal(t, 0, sessionCount(d))
125+}
126+
127+func TestHandshakeOldHelloLayoutReportsVersionMismatch(t *testing.T) {
128+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
129+ oldLayout := []byte{
130+ 0x00, byte(ports.ProtocolVersion - 1),
131+ ports.IntentEphemeral,
132+ 0x00, 0x00,
133+ 0x00, 0x50,
134+ 0x00, 0x18,
135+ 0x00, 0x00,
136+ }
137+ tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: oldLayout})
138+ d.handleConn(tr)
139+
140+ e := awaitFrame(t, sends, ports.MsgError)
141+ em, err := ports.UnmarshalErrorMsg(e.Payload)
142+ require.NoError(t, err)
143+ require.Equal(t, ports.ErrVersionMismatch, em.Code)
144+ require.Equal(t, 0, sessionCount(d))
145+}
146+
147+func TestHandshakeNameTaken(t *testing.T) {
148+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
149+ // Pre-populate a bare session (no goroutines) to collide with.
150+ d.sessions[domain.SessionID("x")] = &session{id: "x", name: "work"}
151+
152+ tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
153+ d.handleConn(tr)
154+
155+ e := awaitFrame(t, sends, ports.MsgError)
156+ em, _ := ports.UnmarshalErrorMsg(e.Payload)
157+ require.Equal(t, ports.ErrNameTaken, em.Code)
158+}
159+
160+func TestHandshakeNoSuchSession(t *testing.T) {
161+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
162+ tr, sends, _ := newConn(t, mustHello(ports.IntentAttach, "ghost", domain.Size{Cols: 80, Rows: 24}))
163+ d.handleConn(tr)
164+
165+ e := awaitFrame(t, sends, ports.MsgError)
166+ em, _ := ports.UnmarshalErrorMsg(e.Payload)
167+ require.Equal(t, ports.ErrNoSuchSession, em.Code)
168+}
169+
170+func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
171+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
172+ tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})})
173+ d.handleConn(tr)
174+
175+ select {
176+ case <-d.done:
177+ case <-time.After(time.Second):
178+ t.Fatal("kill all on empty daemon did not signal shutdown")
179+ }
180+}
181+
182+func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
183+ p1, release1 := newBlockingPTY(t)
184+ defer release1()
185+ p2 := portsmocks.NewMockPTY(t)
186+ opened := make(chan struct{})
187+ releaseOpen := make(chan struct{})
188+ closed := make(chan struct{})
189+ p2.EXPECT().Close().RunAndReturn(func() error {
190+ close(closed)
191+ return nil
192+ }).Once()
193+ p2.EXPECT().Pid().Return(4242).Maybe()
194+
195+ f := portsmocks.NewMockPTYFactory(t)
196+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p1, nil).Once()
197+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
198+ func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
199+ close(opened)
200+ <-releaseOpen
201+ return p2, nil
202+ },
203+ ).Once()
204+
205+ d := newTestDaemon(t, f, stubClock{})
206+ tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
207+ var hg sync.WaitGroup
208+ hg.Go(func() { d.handleConn(tr) })
209+ awaitFrame(t, sends, ports.MsgWelcome)
210+ awaitFrame(t, sends, ports.MsgOutput)
211+ sess := firstSession(d)
212+ require.NotNil(t, sess)
213+
214+ errCh := make(chan error, 1)
215+ go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()
216+ <-opened
217+ d.killSession(sess, ports.ReasonSessionKilled)
218+ close(releaseOpen)
219+
220+ require.Error(t, <-errCh)
221+ select {
222+ case <-closed:
223+ case <-time.After(time.Second):
224+ t.Fatal("new PTY was not closed after session died during tab creation")
225+ }
226+ require.Equal(t, 0, sessionCount(d))
227+ releaseConn()
228+ hg.Wait()
229+ d.sessWg.Wait()
230+}
231+
232+// --- ephemeral numbering ----------------------------------------------------
233+
234+func TestEphemeralNumberingReuse(t *testing.T) {
235+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
236+ // Emulate two ephemeral sessions "0" and "1".
237+ d.sessions["a"] = &session{id: "a", name: "0"}
238+ d.sessions["b"] = &session{id: "b", name: "1"}
239+ require.Equal(t, "2", d.allocEphemeralNameLocked())
240+
241+ // Kill "0": the freed number is reused before "2".
242+ delete(d.sessions, "a")
243+ require.Equal(t, "0", d.allocEphemeralNameLocked())
244+}
245+
246+// --- attach replace ---------------------------------------------------------
247+
248+func TestAttachReplaceDetachesOld(t *testing.T) {
249+ p, releasePTY := newBlockingPTY(t)
250+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
251+
252+ // Client A creates and attaches to ephemeral "0".
253+ trA, sendsA, releaseA := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
254+ var hg sync.WaitGroup
255+ hg.Go(func() { d.handleConn(trA) })
256+ awaitFrame(t, sendsA, ports.MsgWelcome)
257+ awaitFrame(t, sendsA, ports.MsgOutput)
258+ sess := firstSession(d)
259+ require.NotNil(t, sess)
260+
261+ // Client B attaches to the same session, displacing A.
262+ trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))
263+ hg.Go(func() { d.handleConn(trB) })
264+ awaitFrame(t, sendsB, ports.MsgWelcome)
265+
266+ // A is notified it was detached.
267+ dA := awaitFrame(t, sendsA, ports.MsgDetached)
268+ det, _ := ports.UnmarshalDetached(dA.Payload)
269+ require.Equal(t, ports.ReasonDetach, det.Reason)
270+
271+ // B is now the current client.
272+ sess.mu.Lock()
273+ require.NotNil(t, sess.client)
274+ sess.mu.Unlock()
275+
276+ releaseA()
277+ releaseB()
278+ d.killSession(sess, ports.ReasonServerShutdown)
279+ releasePTY()
280+ hg.Wait()
281+ d.sessWg.Wait()
282+ d.waitNotifies()
283+}
284+
285+// --- detach semantics -------------------------------------------------------
286+
287+func TestDetachKillsEphemeral(t *testing.T) {
288+ p, releasePTY := newBlockingPTY(t)
289+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
290+ tr, sends, _ := newConn(t,
291+ mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}),
292+ ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
293+ )
294+
295+ var hg sync.WaitGroup
296+ hg.Go(func() { d.handleConn(tr) })
297+ awaitFrame(t, sends, ports.MsgWelcome)
298+
299+ require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond,
300+ "ephemeral session must die on client detach")
301+
302+ releasePTY()
303+ hg.Wait()
304+ d.sessWg.Wait()
305+}
306+
307+func TestDetachKeepsNamed(t *testing.T) {
308+ p, releasePTY := newBlockingPTY(t)
309+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
310+ tr, sends, _ := newConn(t,
311+ mustHello(ports.IntentNew, "keep", domain.Size{Cols: 80, Rows: 24}),
312+ ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
313+ )
314+
315+ var hg sync.WaitGroup
316+ hg.Go(func() { d.handleConn(tr) })
317+ awaitFrame(t, sends, ports.MsgWelcome)
318+ awaitFrame(t, sends, ports.MsgDetached) // ack for explicit detach
319+ hg.Wait() // handler returns after detach
320+
321+ require.Equal(t, 1, sessionCount(d), "named session survives detach")
322+ sess := firstSession(d)
323+ sess.mu.Lock()
324+ require.Nil(t, sess.client, "named session is headless after detach")
325+ sess.mu.Unlock()
326+
327+ d.killSession(sess, ports.ReasonServerShutdown)
328+ releasePTY()
329+ d.sessWg.Wait()
330+ d.waitNotifies()
331+}
332+
333+// --- render scheduler debounce ----------------------------------------------
334+
335+func TestReaderEOFRemovesSessionAndSignalsShutdown(t *testing.T) {
336+ p := portsmocks.NewMockPTY(t)
337+ // Read returns EOF immediately (child already gone).
338+ p.EXPECT().Read(mock.Anything).Return(0, io.EOF).Maybe()
339+ p.EXPECT().Close().Return(nil).Maybe()
340+ p.EXPECT().Pid().Return(1).Maybe()
341+
342+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
343+ tr, sends, _ := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
344+
345+ var hg sync.WaitGroup
346+ hg.Go(func() { d.handleConn(tr) })
347+
348+ // The client is detached with ReasonSessionKilled when the child exits.
349+ det := awaitFrame(t, sends, ports.MsgDetached)
350+ dm, _ := ports.UnmarshalDetached(det.Payload)
351+ require.Equal(t, ports.ReasonSessionKilled, dm.Reason)
352+
353+ select {
354+ case <-d.done:
355+ case <-time.After(2 * time.Second):
356+ t.Fatal("registry-empty shutdown was not signalled")
357+ }
358+ require.Equal(t, 0, sessionCount(d))
359+
360+ hg.Wait()
361+ d.sessWg.Wait()
362+ d.waitNotifies()
363+}
364+
365+// --- full Serve lifecycle ---------------------------------------------------
366+
367+func TestServeReturnsWhenLastSessionExits(t *testing.T) {
368+ p, releasePTY := newBlockingPTY(t)
369+ tr, sends, _ := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
370+
371+ l := portsmocks.NewMockListener(t)
372+ connCh := make(chan ports.Transport, 1)
373+ connCh <- tr
374+ closed := make(chan struct{})
375+ var once sync.Once
376+ l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
377+ select {
378+ case c := <-connCh:
379+ return c, nil
380+ case <-closed:
381+ return nil, io.EOF
382+ }
383+ }).Maybe()
384+ l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
385+ l.EXPECT().Addr().Return("mock").Maybe()
386+
387+ d := New(newFactory(t, p), stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
388+
389+ served := make(chan error, 1)
390+ go func() { served <- d.Serve(context.Background(), l) }()
391+
392+ awaitFrame(t, sends, ports.MsgWelcome)
393+ awaitFrame(t, sends, ports.MsgOutput)
394+
395+ // Child exits -> session removed -> registry empties -> Serve returns.
396+ releasePTY()
397+
398+ select {
399+ case err := <-served:
400+ require.NoError(t, err)
401+ case <-time.After(3 * time.Second):
402+ t.Fatal("Serve did not return after last session exited")
403+ }
404+}
405+
406+func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
407+ p, releasePTY := newBlockingPTY(t)
408+ defer releasePTY()
409+ tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "long", domain.Size{Cols: 80, Rows: 24}))
410+
411+ l := portsmocks.NewMockListener(t)
412+ connCh := make(chan ports.Transport, 1)
413+ connCh <- tr
414+ closed := make(chan struct{})
415+ var once sync.Once
416+ l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
417+ select {
418+ case c := <-connCh:
419+ return c, nil
420+ case <-closed:
421+ return nil, io.EOF
422+ }
423+ }).Maybe()
424+ l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
425+ l.EXPECT().Addr().Return("mock").Maybe()
426+
427+ d := New(newFactory(t, p), stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
428+ ctx, cancel := context.WithCancel(context.Background())
429+
430+ served := make(chan error, 1)
431+ go func() { served <- d.Serve(ctx, l) }()
432+
433+ awaitFrame(t, sends, ports.MsgWelcome)
434+ awaitFrame(t, sends, ports.MsgOutput)
435+
436+ cancel() // graceful shutdown
437+
438+ det := awaitFrame(t, sends, ports.MsgDetached)
439+ dm, _ := ports.UnmarshalDetached(det.Payload)
440+ require.Equal(t, ports.ReasonServerShutdown, dm.Reason)
441+
442+ select {
443+ case err := <-served:
444+ require.NoError(t, err)
445+ case <-time.After(3 * time.Second):
446+ t.Fatal("Serve did not return after context cancel")
447+ }
448+}
449+
450+// --- shutdown/create interlock ------------------------------------------------
451+
452+// TestHelloRacingShutdownIsRejected covers the interlock between the
453+// registry-empty shutdown decision and session creation: once killSession has
454+// removed the last session (making shutdown irreversible via doneOnce), a
455+// Hello landing at any later instant must be rejected cleanly — never spawn a
456+// child that no teardown pass would reap. The interleaving is deterministic:
457+// killSession runs to completion before the racing Hellos are handled, which
458+// models the worst case (insertion attempt after the shutdown snapshot).
459+func TestHelloRacingShutdownIsRejected(t *testing.T) {
460+ p, releasePTY := newBlockingPTY(t)
461+ f := portsmocks.NewMockPTYFactory(t)
462+ // Exactly one child may ever be spawned; a second Open call (a leaked
463+ // child) fails the test via mock expectations.
464+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()
465+ d := newTestDaemon(t, f, stubClock{})
466+
467+ tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
468+ var hg sync.WaitGroup
469+ hg.Go(func() { d.handleConn(tr1) })
470+ awaitFrame(t, sends1, ports.MsgWelcome)
471+ sess := firstSession(d)
472+ require.NotNil(t, sess)
473+
474+ // The last session dies: shutdown begins irreversibly.
475+ d.killSession(sess, ports.ReasonSessionKilled)
476+ select {
477+ case <-d.done:
478+ default:
479+ t.Fatal("registry-empty shutdown must be signalled synchronously by killSession")
480+ }
481+
482+ // Racing Hellos — both creation intents must be rejected with a clean
483+ // typed error, and nothing may be inserted into the registry.
484+ for _, intent := range []struct {
485+ name string
486+ intent uint8
487+ sess string
488+ }{
489+ {"ephemeral", ports.IntentEphemeral, ""},
490+ {"named", ports.IntentNew, "latecomer"},
491+ } {
492+ tr2, sends2, _ := newConn(t, mustHello(intent.intent, intent.sess, domain.Size{Cols: 80, Rows: 24}))
493+ d.handleConn(tr2)
494+ e := awaitFrame(t, sends2, ports.MsgError)
495+ em, err := ports.UnmarshalErrorMsg(e.Payload)
496+ require.NoError(t, err)
497+ require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)
498+ }
499+ require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")
500+
501+ release1()
502+ releasePTY()
503+ hg.Wait()
504+ d.sessWg.Wait()
505+ d.waitNotifies()
506+}
507+
508+// --- wedged-client teardown ---------------------------------------------------
509+
510+// notifyClock drives the daemon with a stub debounce timer (schedulers park)
511+// but a short real timer for the detach-notify deadline, so a wedged client's
512+
513+func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
514+ p, _ := newBlockingPTY(t) // Close unblocks the parked reader
515+
516+ tr := portsmocks.NewMockTransport(t)
517+ sends := make(chan ports.Frame, 64)
518+ recvCh := make(chan ports.Frame, 1)
519+ recvCh <- mustHello(ports.IntentNew, "wedge", domain.Size{Cols: 80, Rows: 24})
520+ connDone := make(chan struct{})
521+ var connOnce sync.Once
522+ closeConn := func() { connOnce.Do(func() { close(connDone) }) }
523+
524+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
525+ select {
526+ case f := <-recvCh:
527+ return f, nil
528+ case <-connDone:
529+ return ports.Frame{}, io.EOF
530+ }
531+ }).Maybe()
532+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
533+ if f.Type == ports.MsgDetached {
534+ // Wedged: blocks until the transport is force-closed.
535+ <-connDone
536+ return io.ErrClosedPipe
537+ }
538+ sends <- f
539+ return nil
540+ }).Maybe()
541+ tr.EXPECT().Close().RunAndReturn(func() error { closeConn(); return nil }).Maybe()
542+
543+ l := portsmocks.NewMockListener(t)
544+ connCh := make(chan ports.Transport, 1)
545+ connCh <- tr
546+ lnClosed := make(chan struct{})
547+ var lnOnce sync.Once
548+ l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
549+ select {
550+ case c := <-connCh:
551+ return c, nil
552+ case <-lnClosed:
553+ return nil, io.EOF
554+ }
555+ }).Maybe()
556+ l.EXPECT().Close().RunAndReturn(func() error { lnOnce.Do(func() { close(lnClosed) }); return nil }).Maybe()
557+ l.EXPECT().Addr().Return("mock").Maybe()
558+
559+ d := New(newFactory(t, p), notifyClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
560+ ctx, cancel := context.WithCancel(context.Background())
561+
562+ served := make(chan error, 1)
563+ go func() { served <- d.Serve(ctx, l) }()
564+
565+ awaitFrame(t, sends, ports.MsgWelcome)
566+ awaitFrame(t, sends, ports.MsgOutput)
567+
568+ cancel() // shutdown with the wedged client still attached
569+
570+ select {
571+ case err := <-served:
572+ require.NoError(t, err)
573+ case <-time.After(3 * time.Second):
574+ t.Fatal("Serve hung on a wedged client during teardown")
575+ }
576+ require.Equal(t, 0, sessionCount(d), "session must be torn down despite the wedged client")
577+}
578+
579+// --- send-error kills ephemeral -----------------------------------------------
580+
581+// TestSendErrorKillsEphemeral: a failed output send detaches the client, and —
582+// like every other detach path — that kills an ephemeral session rather than
A internal/usecase/daemon/status.go +93 -0
@@ -0,0 +1,93 @@
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+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
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.
26+package daemon
27+
28+import (
29+ "strconv"
30+
31+ "github.com/bnema/vev/pkg/renderer"
32+)
33+
34+func drawStatus(row []renderer.Cell, sess *session, rightText string) {
35+ for i := range row {
36+ row[i] = renderer.BlankCell()
37+ }
38+ status := sess.statusSegments()
39+ x := 0
40+ writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle())
41+ for _, w := range status.tabs {
42+ style := renderer.DefaultStyle()
43+ if w.active {
44+ style.Inverse = true
45+ }
46+ writeStatusText(row, &x, " "+w.name+" ", style)
47+ }
48+ if rightText == "" {
49+ return
50+ }
51+ style := renderer.DefaultStyle()
52+ rightWidth := len([]rune(rightText)) + 1
53+ if len(row)-x-1 < rightWidth {
54+ return
55+ }
56+ x = len(row) - rightWidth
57+ writeStatusText(row, &x, " "+rightText, style)
58+}
59+
60+type statusSnapshot struct {
61+ session string
62+ tabs []statusTab
63+}
64+
65+type statusTab struct {
66+ name string
67+ active bool
68+}
69+
70+func (s *session) statusSegments() statusSnapshot {
71+ s.mu.Lock()
72+ defer s.mu.Unlock()
73+ name := s.name
74+ if s.ephemeral {
75+ name += "*"
76+ }
77+ snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
78+ for i := range s.tabs {
79+ name := strconv.Itoa(i + 1)
80+ snap.tabs[i] = statusTab{name: name, active: i == s.active}
81+ }
82+ return snap
83+}
84+
85+func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
86+ for _, r := range text {
87+ if *x >= len(row) {
88+ return
89+ }
90+ row[*x] = renderer.Cell{Rune: r, Style: style}
91+ (*x)++
92+ }
93+}
A internal/usecase/daemon/status_test.go +116 -0
@@ -0,0 +1,116 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/pkg/renderer"
12+ "github.com/bnema/vev/pkg/vt"
13+)
14+
15+// --- test doubles -----------------------------------------------------------
16+
17+// stubClock returns timers whose channel never fires, so a scheduler under it
18+// blocks in its debounce loop until the session context is cancelled. Used by
19+
20+func TestStatusCompositionGolden(t *testing.T) {
21+ p1, releasePTY1 := newBlockingPTY(t)
22+ p2, releasePTY2 := newBlockingPTY(t)
23+ _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
24+ defer releasePTY1()
25+ defer releasePTY2()
26+ sess.active = 1
27+ sess.name = "work"
28+
29+ win := sess.activeTab()
30+ win.screen = vt.NewScreen(12, 2)
31+ win.size = domain.Size{Cols: 12, Rows: 2}
32+ win.screen.Write([]byte("hello"))
33+
34+ frame, damage := composeClientFrame(sess, win, true, "")
35+
36+ require.Equal(t, 12, frame.Width)
37+ require.Equal(t, 3, frame.Height)
38+ require.Equal(t, "hello ", rowText(frame.Row(0)))
39+ require.Equal(t, " work 1 2 ", rowText(frame.Row(2)))
40+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
41+ for i, c := range frame.Row(2) {
42+ if i >= len(" work 1 ") && i < len(" work 1 2 ") {
43+ require.True(t, c.Style.Inverse, "active tab segment cell %d should be inverse", i)
44+ }
45+ }
46+}
47+
48+func TestStatusMarksEphemeralSession(t *testing.T) {
49+ p, release := newBlockingPTY(t)
50+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
51+ defer release()
52+ sess.name = "0"
53+ sess.ephemeral = true
54+ win := sess.activeTab()
55+ win.screen = vt.NewScreen(12, 2)
56+ win.size = domain.Size{Cols: 12, Rows: 2}
57+
58+ frame, _ := composeClientFrame(sess, win, true, "")
59+
60+ require.Equal(t, " 0* 1 ", rowText(frame.Row(2)))
61+}
62+
63+func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
64+ p, releasePTY := newBlockingPTY(t)
65+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
66+ defer releasePTY()
67+ sess.name = "work"
68+ win := sess.activeTab()
69+ win.screen = vt.NewScreen(30, 2)
70+ win.size = domain.Size{Cols: 30, Rows: 2}
71+
72+ frame, _ := composeClientFrame(sess, win, true, "ok")
73+ require.Equal(t, " work 1 ok", rowText(frame.Row(2)))
74+
75+ frame, _ = composeClientFrame(sess, win, true, "1234567890123456789")
76+ require.Equal(t, " work 1 1234567890123456789", rowText(frame.Row(2)))
77+
78+ frame, _ = composeClientFrame(sess, win, true, "selection too large to copy")
79+ require.Equal(t, " work 1 ", rowText(frame.Row(2)))
80+}
81+
82+func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
83+ p1, releasePTY1 := newBlockingPTY(t)
84+ p2, releasePTY2 := newBlockingPTY(t)
85+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
86+ tr, sends, releaseConn := newConn(t,
87+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
88+ frameInput([]byte("\x1b ")),
89+ frameInput([]byte("CNT\r")),
90+ frameInput([]byte("\x1b1")),
91+ ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},
92+ )
93+
94+ var hg sync.WaitGroup
95+ hg.Go(func() { d.handleConn(tr) })
96+ awaitFrame(t, sends, ports.MsgWelcome)
97+ first := awaitFrame(t, sends, ports.MsgOutput)
98+ palette := awaitFrame(t, sends, ports.MsgOutput)
99+ created := awaitFrame(t, sends, ports.MsgOutput)
100+ switched := awaitFrame(t, sends, ports.MsgOutput)
101+ resized := awaitFrame(t, sends, ports.MsgOutput)
102+
103+ _ = palette
104+ for _, f := range []ports.Frame{first, created, switched, resized} {
105+ out, err := ports.UnmarshalOutput(f.Payload)
106+ require.NoError(t, err)
107+ require.Contains(t, string(out.Data), "work")
108+ require.Contains(t, string(out.Data), ";7m", "active status tab should be inverse-highlighted")
109+ }
110+
111+ releaseConn()
112+ releasePTY1()
113+ releasePTY2()
114+ hg.Wait()
115+ d.sessWg.Wait()
116+}
A internal/usecase/daemon/wire.go +58 -0
@@ -0,0 +1,58 @@
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+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
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.
26+package daemon
27+
28+import (
29+ "github.com/bnema/vev/internal/ports"
30+)
31+
32+func frameWelcome(s *session) ports.Frame {
33+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
34+ SessionID: string(s.id),
35+ SessionName: s.name,
36+ Ephemeral: s.ephemeral,
37+ })}
38+}
39+
40+func frameError(code uint16, text string) ports.Frame {
41+ return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
42+}
43+
44+func frameOutput(b []byte) ports.Frame {
45+ return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: b})}
46+}
47+
48+func frameDetached(reason uint8) ports.Frame {
49+ return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
50+}
51+
52+func framePong() ports.Frame {
53+ return ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}
54+}
55+
56+func frameSessions(infos []ports.SessionInfo) ports.Frame {
57+ return ports.Frame{Type: ports.MsgSessions, Payload: ports.MarshalSessions(ports.Sessions{Sessions: infos})}
58+}
M internal/usecase/keys/router.go +3 -24
@@ -18,14 +18,7 @@ const (
1818 type Action int
1919
2020 const (
21- ActionCreateTab Action = iota
22- ActionNextTab
23- ActionPreviousTab
24- ActionDetach
25- ActionCloseTab
26- ActionCopyMode
27- ActionRenameSession
28- ActionOpenPicker
21+ ActionOpenPalette Action = iota
2922 ActionSwitchTab1
3023 ActionSwitchTab2
3124 ActionSwitchTab3
@@ -170,22 +163,8 @@ func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
170163
171164 func binding(b byte) (Action, bool) {
172165 switch b {
173- case 'c':
174- return ActionCreateTab, true
175- case 'n':
176- return ActionNextTab, true
177- case 'p':
178- return ActionPreviousTab, true
179- case 'd':
180- return ActionDetach, true
181- case 'x':
182- return ActionCloseTab, true
183- case 'u':
184- return ActionCopyMode, true
185- case 'r':
186- return ActionRenameSession, true
187- case 't':
188- return ActionOpenPicker, true
166+ case ' ':
167+ return ActionOpenPalette, true
189168 case '1', '2', '3', '4', '5', '6', '7', '8', '9':
190169 return ActionSwitchTab1 + Action(b-'1'), true
191170 default:
M internal/usecase/keys/router_test.go +27 -48
@@ -45,31 +45,13 @@ func (h *captureHandler) Forward(data []byte) {
4545 }
4646 func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) }
4747
48-func TestRouterInterceptsSameReadAltBindings(t *testing.T) {
49- cases := []struct {
50- name string
51- in []byte
52- want Action
53- }{
54- {"Alt+c", []byte{ESC, 'c'}, ActionCreateTab},
55- {"Alt+n", []byte{ESC, 'n'}, ActionNextTab},
56- {"Alt+p", []byte{ESC, 'p'}, ActionPreviousTab},
57- {"Alt+d", []byte{ESC, 'd'}, ActionDetach},
58- {"Alt+x", []byte{ESC, 'x'}, ActionCloseTab},
59- {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode},
60- {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession},
61- {"Alt+t", []byte{ESC, 't'}, ActionOpenPicker},
62- }
63- for _, tc := range cases {
64- t.Run(tc.name, func(t *testing.T) {
65- clk := &fakeClock{}
66- h := &captureHandler{}
67- NewRouter(clk, h).Route(tc.in)
68- require.Equal(t, []Action{tc.want}, h.actions)
69- require.Empty(t, h.forwards)
70- require.Empty(t, clk.timers)
71- })
72- }
48+func TestRouterInterceptsAltSpaceForPalette(t *testing.T) {
49+ clk := &fakeClock{}
50+ h := &captureHandler{}
51+ NewRouter(clk, h).Route([]byte{ESC, ' '})
52+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
53+ require.Empty(t, h.forwards)
54+ require.Empty(t, clk.timers)
7355 }
7456
7557 func TestRouterInterceptsAltDigits(t *testing.T) {
@@ -87,11 +69,23 @@ func TestRouterInterceptsAltDigits(t *testing.T) {
8769 require.Empty(t, h.forwards)
8870 }
8971
72+func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
73+ for _, b := range []byte("cnpdxurt") {
74+ t.Run(string(b), func(t *testing.T) {
75+ clk := &fakeClock{}
76+ h := &captureHandler{}
77+ NewRouter(clk, h).Route([]byte{ESC, b})
78+ require.Empty(t, h.actions)
79+ require.Equal(t, [][]byte{{ESC, b}}, h.forwards)
80+ })
81+ }
82+}
83+
9084 func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {
9185 clk := &fakeClock{}
9286 h := &captureHandler{}
93- NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'})
94- require.Equal(t, []Action{ActionCreateTab}, h.actions)
87+ NewRouter(clk, h).Route([]byte{'a', ESC, ' ', 'b'})
88+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
9589 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)
9690 }
9791
@@ -116,24 +110,9 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
116110 require.Equal(t, ESCDelay, clk.timers[0].d)
117111 require.Empty(t, h.forwards)
118112
119- r.Route([]byte{'n'})
120- require.True(t, clk.timers[0].stopped)
121- require.Equal(t, []Action{ActionNextTab}, h.actions)
122- require.Empty(t, h.forwards)
123-}
124-
125-func TestRouterRetainsSplitESCAndInterceptsPickerBinding(t *testing.T) {
126- clk := &fakeClock{}
127- h := &captureHandler{}
128- r := NewRouter(clk, h)
129- r.Route([]byte{ESC})
130- require.Len(t, clk.timers, 1)
131- require.Equal(t, ESCDelay, clk.timers[0].d)
132- require.Empty(t, h.forwards)
133-
134- r.Route([]byte{'t'})
113+ r.Route([]byte{' '})
135114 require.True(t, clk.timers[0].stopped)
136- require.Equal(t, []Action{ActionOpenPicker}, h.actions)
115+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
137116 require.Empty(t, h.forwards)
138117 }
139118
@@ -179,11 +158,11 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
179158 r := NewRouter(clk, h)
180159 r.Route([]byte{ESC})
181160
182- r.Route([]byte{'[', 'A', ESC, 'c'})
161+ r.Route([]byte{'[', 'A', ESC, ' '})
183162
184163 require.True(t, clk.timers[0].stopped)
185164 require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards)
186- require.Equal(t, []Action{ActionCreateTab}, h.actions)
165+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
187166 }
188167
189168 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
@@ -195,10 +174,10 @@ func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
195174 r.Route([]byte{ESC})
196175 require.Eventually(t, func() bool { return retainESCWaiters() > before }, time.Second, time.Millisecond)
197176
198- r.Route([]byte{'n'})
177+ r.Route([]byte{' '})
199178 require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond)
200179 require.True(t, clk.timers[0].stopped)
201- require.Equal(t, []Action{ActionNextTab}, h.actions)
180+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
202181 require.Empty(t, h.forwards)
203182 }
204183
A internal/usecase/palette/fuzzy.go +97 -0
@@ -0,0 +1,97 @@
1+package palette
2+
3+import (
4+ "sort"
5+ "strings"
6+
7+ "github.com/bnema/vev/internal/usecase/command"
8+)
9+
10+type Match struct {
11+ Command command.Command
12+ Positions []int
13+ rank int
14+ span int
15+ first int
16+ order int
17+}
18+
19+func Fuzzy(commands []command.Command, query string) []Match {
20+ if query == "" {
21+ out := make([]Match, len(commands))
22+ for i, cmd := range commands {
23+ out[i] = Match{Command: cmd, order: i}
24+ }
25+ return out
26+ }
27+ var out []Match
28+ for i, cmd := range commands {
29+ if m, ok := score(cmd, query, i); ok {
30+ out = append(out, m)
31+ }
32+ }
33+ sort.SliceStable(out, func(i, j int) bool {
34+ a, b := out[i], out[j]
35+ if a.rank != b.rank {
36+ return a.rank < b.rank
37+ }
38+ if a.span != b.span {
39+ return a.span < b.span
40+ }
41+ if a.first != b.first {
42+ return a.first < b.first
43+ }
44+ if a.Command.Code != b.Command.Code {
45+ return a.Command.Code < b.Command.Code
46+ }
47+ return a.order < b.order
48+ })
49+ return out
50+}
51+
52+func score(cmd command.Command, query string, order int) (Match, bool) {
53+ code := []rune(cmd.Code)
54+ q := []rune(query)
55+ codeLower := strings.ToLower(cmd.Code)
56+ qLower := strings.ToLower(query)
57+ if codeLower == qLower {
58+ return Match{Command: cmd, Positions: rangePositions(len(code)), rank: 0, span: len(code), first: 0, order: order}, true
59+ }
60+ if strings.HasPrefix(codeLower, qLower) {
61+ return Match{Command: cmd, Positions: rangePositions(len(q)), rank: 1, span: len(q), first: 0, order: order}, true
62+ }
63+ if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok {
64+ return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
65+ }
66+ text := strings.ToLower(cmd.Name + " " + cmd.Desc)
67+ if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok {
68+ return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
69+ }
70+ return Match{}, false
71+}
72+
73+func rangePositions(n int) []int {
74+ p := make([]int, n)
75+ for i := range p {
76+ p[i] = i
77+ }
78+ return p
79+}
80+
81+func subsequencePositions(haystack, needle []rune) ([]int, bool) {
82+ if len(needle) == 0 {
83+ return nil, true
84+ }
85+ positions := make([]int, 0, len(needle))
86+ j := 0
87+ for i, r := range haystack {
88+ if r == needle[j] {
89+ positions = append(positions, i)
90+ j++
91+ if j == len(needle) {
92+ return positions, true
93+ }
94+ }
95+ }
96+ return nil, false
97+}
A internal/usecase/palette/fuzzy_test.go +57 -0
@@ -0,0 +1,57 @@
1+package palette
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/usecase/command"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func cmd(code, name, desc string) command.Command {
11+ return command.Command{Code: code, Name: name, Desc: desc}
12+}
13+
14+func codes(matches []Match) []string {
15+ out := make([]string, len(matches))
16+ for i, match := range matches {
17+ out[i] = match.Command.Code
18+ }
19+ return out
20+}
21+
22+func TestFuzzyOrdersScoringTiers(t *testing.T) {
23+ matches := Fuzzy([]command.Command{
24+ cmd("ZQQ", "alpha beta", "name subsequence"),
25+ cmd("XAB", "unused", "subsequence code"),
26+ cmd("ABX", "unused", "prefix code"),
27+ cmd("AB", "unused", "exact code"),
28+ }, "ab")
29+
30+ require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))
31+ require.Empty(t, matches[3].Positions, "name/description matches do not expose code highlight positions")
32+}
33+
34+func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {
35+ matches := Fuzzy([]command.Command{
36+ cmd("ZAXQB", "", ""), // span 3, first 1
37+ cmd("BAXQ", "", ""), // span 3, first 1, code sorts first
38+ cmd("PXAQ", "", ""), // span 2, first 2
39+ cmd("BAQ", "", ""), // span 2, first 0
40+ }, "aq")
41+
42+ require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches))
43+}
44+
45+func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) {
46+ matches := Fuzzy([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}, "cy")
47+
48+ require.Len(t, matches, 1)
49+ require.Equal(t, []int{0, 2}, matches[0].Positions)
50+}
51+
52+func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
53+ commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")}
54+ matches := Fuzzy(commands, "")
55+
56+ require.Equal(t, []string{"B", "A"}, codes(matches))
57+}
A internal/usecase/palette/model.go +164 -0
@@ -0,0 +1,164 @@
1+package palette
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/command"
6+ "github.com/bnema/vev/internal/usecase/ui"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+type Model struct {
11+ commands []command.Command
12+ input ui.TextInput
13+ matches []Match
14+ selected int
15+ scroll int
16+}
17+
18+func New(commands []command.Command) *Model {
19+ m := &Model{commands: append([]command.Command(nil), commands...)}
20+ m.refresh()
21+ return m
22+}
23+
24+func NewRegistry() *Model { return New(command.Registry()) }
25+
26+func (m *Model) Insert(r rune) {
27+ if m != nil {
28+ m.input.Insert(r)
29+ m.selected = 0
30+ m.scroll = 0
31+ m.refresh()
32+ }
33+}
34+func (m *Model) Backspace() {
35+ if m != nil && m.input.Value() != "" {
36+ m.input.Backspace()
37+ m.selected = 0
38+ m.scroll = 0
39+ m.refresh()
40+ }
41+}
42+func (m *Model) Query() string {
43+ if m == nil {
44+ return ""
45+ }
46+ return m.input.Value()
47+}
48+func (m *Model) Up() {
49+ if m != nil && m.selected > 0 {
50+ m.selected--
51+ m.clamp()
52+ }
53+}
54+func (m *Model) Down() {
55+ if m != nil && m.selected+1 < len(m.matches) {
56+ m.selected++
57+ m.clamp()
58+ }
59+}
60+func (m *Model) Selected() (command.Command, bool) {
61+ if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
62+ return command.Command{}, false
63+ }
64+ return m.matches[m.selected].Command, true
65+}
66+func (m *Model) Matches() []Match {
67+ if m == nil {
68+ return nil
69+ }
70+ out := make([]Match, len(m.matches))
71+ for i, match := range m.matches {
72+ out[i] = match
73+ out[i].Positions = append([]int(nil), match.Positions...)
74+ }
75+ return out
76+}
77+
78+func (m *Model) refresh() { m.matches = Fuzzy(m.commands, m.input.Value()); m.clamp() }
79+func (m *Model) clamp() {
80+ if len(m.matches) == 0 {
81+ m.selected = -1
82+ m.scroll = 0
83+ return
84+ }
85+ if m.selected < 0 {
86+ m.selected = 0
87+ }
88+ if m.selected >= len(m.matches) {
89+ m.selected = len(m.matches) - 1
90+ }
91+ if m.scroll < 0 {
92+ m.scroll = 0
93+ }
94+ if m.scroll > m.selected {
95+ m.scroll = m.selected
96+ }
97+}
98+
99+func (m *Model) Render(inner domain.Size) renderer.Frame {
100+ frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
101+ if frame.Width == 0 || frame.Height == 0 {
102+ return frame
103+ }
104+ base := renderer.DefaultStyle()
105+ ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
106+ if m == nil {
107+ ui.DrawInputLine(frame, 0, "> ", "", base)
108+ return frame
109+ }
110+ ui.DrawInputLine(frame, 0, "> ", m.Query(), base)
111+ visible := frame.Height - 1
112+ if visible <= 0 {
113+ return frame
114+ }
115+ if len(m.matches) == 0 {
116+ return frame
117+ }
118+ if m.selected < m.scroll {
119+ m.scroll = m.selected
120+ }
121+ if m.selected >= m.scroll+visible {
122+ m.scroll = m.selected - visible + 1
123+ }
124+ codeWidth := 0
125+ for _, match := range m.matches {
126+ if len([]rune(match.Command.Code)) > codeWidth {
127+ codeWidth = len([]rune(match.Command.Code))
128+ }
129+ }
130+ for y := 0; y < visible; y++ {
131+ idx := m.scroll + y
132+ if idx >= len(m.matches) {
133+ break
134+ }
135+ match := m.matches[idx]
136+ style := base
137+ if idx == m.selected {
138+ style.Inverse = true
139+ }
140+ ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
141+ x := 0
142+ highlight := map[int]bool{}
143+ for _, p := range match.Positions {
144+ highlight[p] = true
145+ }
146+ for i, r := range []rune(match.Command.Code) {
147+ cellStyle := style
148+ if highlight[i] {
149+ cellStyle.Bold = true
150+ cellStyle.Inverse = true
151+ }
152+ if x < frame.Width {
153+ frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle})
154+ }
155+ x++
156+ }
157+ for x < codeWidth+1 && x < frame.Width {
158+ frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style})
159+ x++
160+ }
161+ ui.DrawText(frame, x, y+1, frame.Width, match.Command.Name+" — "+match.Command.Desc, style)
162+ }
163+ return frame
164+}
A internal/usecase/palette/model_test.go +91 -0
@@ -0,0 +1,91 @@
1+package palette
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/command"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
12+ m := New([]command.Command{
13+ cmd("ABC", "Alpha", "first"),
14+ cmd("DEF", "Delta", "second"),
15+ cmd("AXY", "Other", "third"),
16+ })
17+
18+ require.Equal(t, "", m.Query())
19+ selected, ok := m.Selected()
20+ require.True(t, ok)
21+ require.Equal(t, "ABC", selected.Code)
22+
23+ m.Down()
24+ m.Down()
25+ selected, ok = m.Selected()
26+ require.True(t, ok)
27+ require.Equal(t, "AXY", selected.Code)
28+
29+ m.Insert('d')
30+ require.Equal(t, "d", m.Query())
31+ selected, ok = m.Selected()
32+ require.True(t, ok)
33+ require.Equal(t, "DEF", selected.Code, "selection clamps to only match after query changes")
34+
35+ m.Up()
36+ selected, ok = m.Selected()
37+ require.True(t, ok)
38+ require.Equal(t, "DEF", selected.Code, "up at first match clamps")
39+
40+ m.Backspace()
41+ require.Equal(t, "", m.Query())
42+ require.Len(t, m.Matches(), 3)
43+}
44+
45+func TestModelNoMatchesClearsSelection(t *testing.T) {
46+ m := New([]command.Command{cmd("ABC", "Alpha", "first")})
47+ m.Insert('z')
48+
49+ _, ok := m.Selected()
50+ require.False(t, ok)
51+ require.Empty(t, m.Matches())
52+}
53+
54+func TestModelMatchesDeepCopiesPositions(t *testing.T) {
55+ m := New([]command.Command{cmd("ABC", "Alpha", "first")})
56+ m.Insert('a')
57+
58+ matches := m.Matches()
59+ require.Len(t, matches, 1)
60+ require.Equal(t, []int{0}, matches[0].Positions)
61+
62+ matches[0].Positions[0] = 2
63+
64+ fresh := m.Matches()
65+ require.Equal(t, []int{0}, fresh[0].Positions)
66+}
67+
68+func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) {
69+ m := New([]command.Command{
70+ cmd("CPY", "Copy", "Enter copy mode"),
71+ cmd("CNT", "New", "Create tab"),
72+ })
73+ m.Insert('c')
74+ m.Insert('y')
75+ frame := m.Render(domain.Size{Cols: 28, Rows: 3})
76+
77+ require.Equal(t, '>', frame.At(0, 0).Rune)
78+ require.Equal(t, ' ', frame.At(1, 0).Rune)
79+ require.Equal(t, 'c', frame.At(2, 0).Rune)
80+ require.Equal(t, 'y', frame.At(3, 0).Rune)
81+ require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query")
82+
83+ require.Equal(t, 'C', frame.At(0, 1).Rune)
84+ require.Equal(t, 'P', frame.At(1, 1).Rune)
85+ require.Equal(t, 'Y', frame.At(2, 1).Rune)
86+ require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse")
87+ require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse")
88+ require.True(t, frame.At(0, 1).Style.Bold, "matched C is bold")
89+ require.False(t, frame.At(1, 1).Style.Bold, "unmatched P is not highlighted")
90+ require.True(t, frame.At(2, 1).Style.Bold, "matched Y is bold")
91+}
M internal/usecase/picker/model.go +9 -0
@@ -125,6 +125,15 @@ func (m *Model) Selected() (Target, bool) {
125125 return Target{Session: r.session, TabIndex: r.tabIndex}, true
126126 }
127127
128+func (m *Model) Clone() *Model {
129+ if m == nil {
130+ return nil
131+ }
132+ clone := *m
133+ clone.rows = append([]row(nil), m.rows...)
134+ return &clone
135+}
136+
128137 func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame {
129138 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
130139 layout := ChooseLayout(inner)
A internal/usecase/prompt/model.go +71 -0
@@ -0,0 +1,71 @@
1+package prompt
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+type Model struct {
10+ title string
11+ text ui.TextInput
12+ errMsg string
13+}
14+
15+func New(title, initial string) *Model {
16+ m := &Model{title: title}
17+ m.text.SetValue(initial)
18+ return m
19+}
20+
21+func (m *Model) Insert(r rune) {
22+ if m == nil {
23+ return
24+ }
25+ m.errMsg = ""
26+ m.text.Insert(r)
27+}
28+
29+func (m *Model) Backspace() {
30+ if m == nil {
31+ return
32+ }
33+ m.errMsg = ""
34+ m.text.Backspace()
35+}
36+
37+func (m *Model) Value() string {
38+ if m == nil {
39+ return ""
40+ }
41+ return m.text.Value()
42+}
43+
44+func (m *Model) Title() string {
45+ if m == nil {
46+ return ""
47+ }
48+ return m.title
49+}
50+
51+func (m *Model) SetError(msg string) {
52+ if m != nil {
53+ m.errMsg = msg
54+ }
55+}
56+
57+func (m *Model) Render(inner domain.Size) renderer.Frame {
58+ frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
59+ if frame.Width == 0 || frame.Height == 0 {
60+ return frame
61+ }
62+ base := renderer.DefaultStyle()
63+ ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
64+ ui.DrawInputLine(frame, 0, "> ", m.Value(), base)
65+ if frame.Height > 1 && m != nil && m.errMsg != "" {
66+ errStyle := base
67+ errStyle.Inverse = true
68+ ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, errStyle)
69+ }
70+ return frame
71+}
A internal/usecase/prompt/model_test.go +59 -0
@@ -0,0 +1,59 @@
1+package prompt
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestModelPrefillAndEditingClearsError(t *testing.T) {
13+ m := New(" Rename session ", "0")
14+ require.Equal(t, " Rename session ", m.Title())
15+ require.Equal(t, "0", m.Value())
16+
17+ m.SetError("name already in use")
18+ m.Insert('x')
19+ require.Equal(t, "0x", m.Value())
20+ require.Empty(t, m.errMsg)
21+
22+ m.SetError("bad")
23+ m.Backspace()
24+ require.Equal(t, "0", m.Value())
25+ require.Empty(t, m.errMsg)
26+}
27+
28+func TestModelRenderDrawsInputAndError(t *testing.T) {
29+ m := New(" Rename session ", "work")
30+ m.SetError("name already in use")
31+
32+ frame := m.Render(domain.Size{Cols: 24, Rows: 2})
33+
34+ require.Equal(t, "> work ", promptRowText(frame.Row(0)))
35+ require.True(t, frame.At(6, 0).Style.Inverse, "caret follows prefilled value")
36+ require.Equal(t, "name already in use ", promptRowText(frame.Row(1)))
37+ require.True(t, frame.At(0, 1).Style.Inverse, "error line is visually distinct")
38+}
39+
40+func promptRowText(row []renderer.Cell) string {
41+ var b strings.Builder
42+ for _, c := range row {
43+ if c.Rune == 0 {
44+ b.WriteRune(' ')
45+ continue
46+ }
47+ b.WriteRune(c.Rune)
48+ }
49+ return b.String()
50+}
51+
52+func TestModelRenderHandlesTinySize(t *testing.T) {
53+ m := New(" Rename session ", "work")
54+
55+ frame := m.Render(domain.Size{Cols: 0, Rows: 0})
56+
57+ require.Equal(t, 0, frame.Width)
58+ require.Equal(t, 0, frame.Height)
59+}
M internal/usecase/ui/modal.go +35 -9
@@ -5,16 +5,30 @@ import (
55 "github.com/bnema/vev/pkg/renderer"
66 )
77
8-// Modal describes a centered rectangular overlay.
8+// Anchor describes where a modal is positioned within its base rectangle.
9+type Anchor int
10+
11+const (
12+ // AnchorCenter centers the modal within the base rectangle.
13+ AnchorCenter Anchor = iota
14+ // AnchorBottom positions the modal at the bottom above BottomMargin rows.
15+ AnchorBottom
16+)
17+
18+// Modal describes a rectangular overlay.
919 type Modal struct {
10- WidthPct int
11- HeightPct int
12- MinWidth int
13- MinHeight int
14- Title string
20+ WidthPct int
21+ HeightPct int
22+ MinWidth int
23+ MinHeight int
24+ Title string
25+ Anchor Anchor
26+ FixedWidth int
27+ FixedHeight int
28+ BottomMargin int
1529 }
1630
17-// Bounds returns the modal rectangle centered within base and clamped to base.
31+// Bounds returns the modal rectangle positioned within base and clamped to base.
1832 func (m Modal) Bounds(base domain.Size) domain.Rect {
1933 if base.Cols <= 0 || base.Rows <= 0 {
2034 return domain.Rect{}
@@ -22,6 +36,12 @@ func (m Modal) Bounds(base domain.Size) domain.Rect {
2236
2337 width := percentOf(base.Cols, m.WidthPct)
2438 height := percentOf(base.Rows, m.HeightPct)
39+ if m.FixedWidth > 0 {
40+ width = m.FixedWidth
41+ }
42+ if m.FixedHeight > 0 {
43+ height = m.FixedHeight
44+ }
2545 if width < m.MinWidth {
2646 width = m.MinWidth
2747 }
@@ -31,9 +51,15 @@ func (m Modal) Bounds(base domain.Size) domain.Rect {
3151 width = clamp(width, 0, base.Cols)
3252 height = clamp(height, 0, base.Rows)
3353
54+ x := (base.Cols - width) / 2
55+ y := (base.Rows - height) / 2
56+ if m.Anchor == AnchorBottom {
57+ y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height)
58+ }
59+
3460 return domain.Rect{
35- X: (base.Cols - width) / 2,
36- Y: (base.Rows - height) / 2,
61+ X: x,
62+ Y: y,
3763 Width: width,
3864 Height: height,
3965 }
M internal/usecase/ui/modal_test.go +30 -0
@@ -32,6 +32,36 @@ func TestModalBounds(t *testing.T) {
3232 m: Modal{WidthPct: 50, HeightPct: 50},
3333 want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3},
3434 },
35+ {
36+ name: "zero value fills base centered",
37+ base: domain.Size{Cols: 12, Rows: 8},
38+ m: Modal{},
39+ want: domain.Rect{X: 0, Y: 0, Width: 12, Height: 8},
40+ },
41+ {
42+ name: "bottom anchor honors bottom margin",
43+ base: domain.Size{Cols: 100, Rows: 40},
44+ m: Modal{WidthPct: 50, HeightPct: 25, Anchor: AnchorBottom, BottomMargin: 3},
45+ want: domain.Rect{X: 25, Y: 27, Width: 50, Height: 10},
46+ },
47+ {
48+ name: "fixed dimensions override percentages",
49+ base: domain.Size{Cols: 100, Rows: 40},
50+ m: Modal{WidthPct: 80, HeightPct: 80, FixedWidth: 30, FixedHeight: 12},
51+ want: domain.Rect{X: 35, Y: 14, Width: 30, Height: 12},
52+ },
53+ {
54+ name: "fixed dimensions clamp on small terminal",
55+ base: domain.Size{Cols: 10, Rows: 4},
56+ m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: AnchorBottom, BottomMargin: 2},
57+ want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},
58+ },
59+ {
60+ name: "bottom margin clamps to screen",
61+ base: domain.Size{Cols: 20, Rows: 10},
62+ m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: AnchorBottom, BottomMargin: 20},
63+ want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4},
64+ },
3565 }
3666 for _, tt := range tests {
3767 t.Run(tt.name, func(t *testing.T) {
A internal/usecase/ui/textinput.go +34 -0
@@ -0,0 +1,34 @@
1+package ui
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// TextInput stores editable text as runes for terminal overlay input models.
6+type TextInput struct {
7+ runes []rune
8+}
9+
10+func (t *TextInput) Insert(r rune) { t.runes = append(t.runes, r) }
11+
12+func (t *TextInput) Backspace() {
13+ if len(t.runes) == 0 {
14+ return
15+ }
16+ t.runes = t.runes[:len(t.runes)-1]
17+}
18+
19+func (t *TextInput) Value() string { return string(t.runes) }
20+
21+func (t *TextInput) SetValue(value string) { t.runes = []rune(value) }
22+
23+// DrawInputLine draws prefix + value followed by a reverse-video caret cell.
24+func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) {
25+ if y < 0 || y >= f.Height {
26+ return
27+ }
28+ x := DrawText(f, 0, y, f.Width, prefix+value, style)
29+ if x >= 0 && x < f.Width {
30+ caret := style
31+ caret.Inverse = true
32+ f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret})
33+ }
34+}
A internal/usecase/ui/textinput_test.go +59 -0
@@ -0,0 +1,59 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestTextInputEditsValue(t *testing.T) {
11+ var input TextInput
12+
13+ input.Insert('w')
14+ input.Insert('ø')
15+ input.Insert('k')
16+ require.Equal(t, "wøk", input.Value())
17+
18+ input.Backspace()
19+ require.Equal(t, "wø", input.Value())
20+
21+ input.SetValue("ship")
22+ require.Equal(t, "ship", input.Value())
23+}
24+
25+func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) {
26+ frame := renderer.NewFrame(6, 1)
27+ style := renderer.DefaultStyle()
28+
29+ DrawInputLine(frame, 0, "> ", "abc", style)
30+
31+ require.Equal(t, '>', frame.At(0, 0).Rune)
32+ require.Equal(t, ' ', frame.At(1, 0).Rune)
33+ require.Equal(t, 'a', frame.At(2, 0).Rune)
34+ require.Equal(t, 'b', frame.At(3, 0).Rune)
35+ require.Equal(t, 'c', frame.At(4, 0).Rune)
36+ require.Equal(t, ' ', frame.At(5, 0).Rune)
37+ require.True(t, frame.At(5, 0).Style.Inverse)
38+}
39+
40+func TestDrawInputLineClipsCaret(t *testing.T) {
41+ frame := renderer.NewFrame(4, 1)
42+ style := renderer.DefaultStyle()
43+
44+ DrawInputLine(frame, 0, "> ", "abcdef", style)
45+
46+ require.Equal(t, '>', frame.At(0, 0).Rune)
47+ require.Equal(t, ' ', frame.At(1, 0).Rune)
48+ require.Equal(t, 'a', frame.At(2, 0).Rune)
49+ require.Equal(t, 'b', frame.At(3, 0).Rune)
50+ require.False(t, frame.At(3, 0).Style.Inverse, "caret beyond clip should not overwrite final value cell")
51+}
52+
53+func TestDrawInputLineIgnoresInvalidRow(t *testing.T) {
54+ frame := renderer.NewFrame(4, 1)
55+
56+ DrawInputLine(frame, 2, "> ", "abc", renderer.DefaultStyle())
57+
58+ require.Equal(t, ' ', frame.At(0, 0).Rune)
59+}

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 2

Tags 13

↑↓ moveenter openesc close