detached 750b4f5

Merge pull request #124 from bnema/feat/control-api

feat: add control command API
commit
750b4f5e40cdab0900183fe7b5cfbe318d674e5d
author
bnema · · 2026-07-24 07:17 UTC
committer
GitHub · · 2026-07-24 07:17 UTC
merge of
3f7eeb6 + bf0286e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
38 files · +2947 -233
M AGENTS.md +2 -2
@@ -64,9 +64,9 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
6464 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
6565
6666 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
67-- Client message types are `1–15`; server message types are `16+`.
67+- Client message types currently occupy `1–12` (`MsgCommand` is `12`); server types currently occupy `16–22` (`MsgCommandResult` is `22`). The reserved bands are `1–15` and `16+` respectively.
6868 - Version negotiation requires strict equality.
69-- `Hello.Version` must stay first so `PeekHelloVersion` works.
69+- `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
7070 - Bump `ProtocolVersion` for any message layout change.
7171
7272 ## Session flow
@@ -64,9 +64,9 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
64 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.64 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
65 65
66 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.66 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
67-- Client message types are `1–15`; server message types are `16+`.67+- Client message types currently occupy `1–12` (`MsgCommand` is `12`); server types currently occupy `16–22` (`MsgCommandResult` is `22`). The reserved bands are `1–15` and `16+` respectively.
68 - Version negotiation requires strict equality.68 - Version negotiation requires strict equality.
69-- `Hello.Version` must stay first so `PeekHelloVersion` works.69+- `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
70 - Bump `ProtocolVersion` for any message layout change.70 - Bump `ProtocolVersion` for any message layout change.
71 71
72 ## Session flow72 ## Session flow
M README.md +12 -0
@@ -40,6 +40,18 @@ vev kill <name> kill a session (--all kills everything)
4040
4141 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, scrollback, and allowlisted processes.
4242
43+## Scripting
44+
45+`vev cmd` runs control commands against a running daemon; it never starts one. For example:
46+
47+```sh
48+vev cmd split-right
49+vev cmd toast -l warn "build failed"
50+vev cmd list-panes --json
51+```
52+
53+Target a session explicitly with `-s` (`vev cmd -s work new-tab`). Inside a pane, `--self` targets that pane; it cannot be combined with `-s`. Otherwise vev uses `$VEV` inside a pane, then the only live session; ambiguous targets fail. Run `vev cmd --help` for the scriptable command list and `vev cmd <command> --help` for command usage.
54+
4355 ## Keys
4456
4557 No prefix key; everything is Alt.
@@ -40,6 +40,18 @@ vev kill <name> kill a session (--all kills everything)
40 40
41 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, scrollback, and allowlisted processes.41 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, scrollback, and allowlisted processes.
42 42
43+## Scripting
44+
45+`vev cmd` runs control commands against a running daemon; it never starts one. For example:
46+
47+```sh
48+vev cmd split-right
49+vev cmd toast -l warn "build failed"
50+vev cmd list-panes --json
51+```
52+
53+Target a session explicitly with `-s` (`vev cmd -s work new-tab`). Inside a pane, `--self` targets that pane; it cannot be combined with `-s`. Otherwise vev uses `$VEV` inside a pane, then the only live session; ambiguous targets fail. Run `vev cmd --help` for the scriptable command list and `vev cmd <command> --help` for command usage.
54+
43 ## Keys55 ## Keys
44 56
45 No prefix key; everything is Alt.57 No prefix key; everything is Alt.
M internal/adapters/shellcmd/runner.go +2 -2
@@ -18,7 +18,7 @@ type Runner struct{}
1818 func New() Runner { return Runner{} }
1919
2020 // Run executes spec.Command through sh -c, capturing bounded stdout and stderr.
21-func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) {
21+func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) {
2222 cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)
2323 cmd.Env = spec.Env
2424 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -36,7 +36,7 @@ func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandRes
3636 cmd.Stderr = &stderr
3737
3838 err := cmd.Run()
39- res := ports.CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
39+ res := ports.ShellCommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
4040 if err != nil {
4141 var exitErr *exec.ExitError
4242 if errors.As(err, &exitErr) {
@@ -18,7 +18,7 @@ type Runner struct{}
18 func New() Runner { return Runner{} }18 func New() Runner { return Runner{} }
19 19
20 // Run executes spec.Command through sh -c, capturing bounded stdout and stderr.20 // Run executes spec.Command through sh -c, capturing bounded stdout and stderr.
21-func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) {21+func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) {
22 cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)22 cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)
23 cmd.Env = spec.Env23 cmd.Env = spec.Env
24 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}24 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -36,7 +36,7 @@ func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandRes
36 cmd.Stderr = &stderr36 cmd.Stderr = &stderr
37 37
38 err := cmd.Run()38 err := cmd.Run()
39- res := ports.CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}39+ res := ports.ShellCommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
40 if err != nil {40 if err != nil {
41 var exitErr *exec.ExitError41 var exitErr *exec.ExitError
42 if errors.As(err, &exitErr) {42 if errors.As(err, &exitErr) {
A internal/app/cmd.go +205 -0
@@ -0,0 +1,205 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "os"
9+ "sort"
10+ "strings"
11+ "text/tabwriter"
12+
13+ "github.com/bnema/vev/internal/adapters/ipc"
14+ "github.com/bnema/vev/internal/ports"
15+ commandusecase "github.com/bnema/vev/internal/usecase/command"
16+)
17+
18+type cmdInvocation struct {
19+ slug string
20+ args []string
21+ session string
22+ self bool
23+ jsonOut bool
24+ help bool
25+}
26+
27+type exitCoded struct {
28+ code int
29+ err error
30+}
31+
32+func (e *exitCoded) Error() string { return e.err.Error() }
33+func (e *exitCoded) Unwrap() error { return e.err }
34+
35+func exitCode(err error) int {
36+ if err == nil {
37+ return 0
38+ }
39+ if coded, ok := errors.AsType[*exitCoded](err); ok {
40+ return coded.code
41+ }
42+ if _, ok := errors.AsType[*usageError](err); ok {
43+ return 2
44+ }
45+ return 1
46+}
47+
48+// ExitCode maps application outcomes to the CLI contract.
49+func ExitCode(err error) int { return exitCode(err) }
50+
51+func parseCmdArgs(args []string) (cmdInvocation, error) {
52+ invocation := cmdInvocation{}
53+ index := 0
54+ for index < len(args) && invocation.slug == "" {
55+ switch args[index] {
56+ case "--help", "-h":
57+ invocation.help = true
58+ index++
59+ case "-s":
60+ if index+1 >= len(args) || args[index+1] == "" {
61+ return cmdInvocation{}, usagef("`cmd -s` requires a session name")
62+ }
63+ invocation.session = args[index+1]
64+ index += 2
65+ case "--self":
66+ invocation.self = true
67+ index++
68+ case "--json":
69+ invocation.jsonOut = true
70+ index++
71+ default:
72+ if strings.HasPrefix(args[index], "-") {
73+ return cmdInvocation{}, usagef("unknown flag %q for `cmd`", args[index])
74+ }
75+ invocation.slug = args[index]
76+ index++
77+ }
78+ }
79+ if invocation.slug == "" {
80+ invocation.help = true
81+ return invocation, nil
82+ }
83+ if invocation.self && invocation.session != "" {
84+ return cmdInvocation{}, usagef("`cmd --self` cannot be combined with `-s`")
85+ }
86+ cmd, ok := commandusecase.BySlug(invocation.slug)
87+ if !ok || !cmd.Scriptable {
88+ return cmdInvocation{}, usagef("unknown command %q; see `vev cmd --help`", invocation.slug)
89+ }
90+ for _, arg := range args[index:] {
91+ switch arg {
92+ case "--help", "-h":
93+ invocation.help = true
94+ return invocation, nil
95+ case "--json":
96+ invocation.jsonOut = true
97+ default:
98+ invocation.args = append(invocation.args, arg)
99+ }
100+ }
101+ return invocation, nil
102+}
103+
104+func cmdHelp(invocation cmdInvocation) string {
105+ if invocation.slug != "" {
106+ cmd, _ := commandusecase.BySlug(invocation.slug)
107+ return fmt.Sprintf("usage: vev cmd [-s <session>] [--self] %s\n\n%s", cmd.Usage, cmd.Desc)
108+ }
109+ var out strings.Builder
110+ out.WriteString("usage: vev cmd [-s <session>] [--self] <command> [args]\n\ncommands:\n")
111+ writer := tabwriter.NewWriter(&out, 0, 4, 2, ' ', 0)
112+ commands := commandusecase.Registry()
113+ sort.Slice(commands, func(i, j int) bool { return commands[i].Slug < commands[j].Slug })
114+ for _, cmd := range commands {
115+ if cmd.Scriptable {
116+ _, _ = fmt.Fprintf(writer, " %s\t%s\n", cmd.Usage, cmd.Desc)
117+ }
118+ }
119+ _ = writer.Flush()
120+ return out.String()
121+}
122+
123+var errDaemonUnreachable = errors.New("daemon not running")
124+
125+type cmdDeps struct {
126+ stdout io.Writer
127+ getenv func(string) string
128+ dial func(context.Context, string) (ports.Transport, error)
129+}
130+
131+func runCmd(ctx context.Context, invocation cmdInvocation) error {
132+ return runCmdWithDeps(ctx, invocation, cmdDeps{stdout: os.Stdout, getenv: os.Getenv, dial: realDial})
133+}
134+
135+func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
136+ if invocation.help {
137+ _, err := fmt.Fprintln(deps.stdout, cmdHelp(invocation))
138+ return err
139+ }
140+ request := ports.CommandRequest{
141+ Version: ports.ProtocolVersion,
142+ Self: invocation.self,
143+ Slug: invocation.slug,
144+ Args: invocation.args,
145+ TargetSession: invocation.session,
146+ JSON: invocation.jsonOut,
147+ }
148+ if identity, ok := parseVEVEnv(deps.getenv("VEV")); ok {
149+ if request.TargetSession == "" {
150+ request.TargetSession = identity.session
151+ }
152+ if invocation.self || invocation.session == "" {
153+ request.TargetTab = identity.tab
154+ request.TargetPane = identity.pane
155+ }
156+ } else if invocation.self {
157+ return usagef("--self requires running inside a vev pane")
158+ }
159+
160+ payload, err := ports.MarshalCommandRequest(request)
161+ if errors.Is(err, ports.ErrTooManyCommandArgs) {
162+ return usagef("too many command arguments")
163+ }
164+ if err != nil {
165+ return fmt.Errorf("encoding command: %w", err)
166+ }
167+
168+ transport, err := deps.dial(ctx, ipc.SocketDir())
169+ if err != nil {
170+ return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
171+ }
172+ defer func() { _ = transport.Close() }()
173+
174+ frame := ports.Frame{Type: ports.MsgCommand, Payload: payload}
175+ if err := transport.Send(frame); err != nil {
176+ return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}
177+ }
178+ reply, err := transport.Recv()
179+ if err != nil {
180+ return &exitCoded{code: 3, err: fmt.Errorf("reading command reply: %w", err)}
181+ }
182+ if reply.Type != ports.MsgCommandResult {
183+ return fmt.Errorf("unexpected command reply type %d", reply.Type)
184+ }
185+ result, err := ports.UnmarshalCommandResult(reply.Payload)
186+ if err != nil {
187+ return fmt.Errorf("decoding command reply: %w", err)
188+ }
189+ if !result.OK {
190+ if result.Code == ports.ErrInvalidCommandArgs {
191+ return &exitCoded{code: 2, err: errors.New(result.Text)}
192+ }
193+ return errors.New(result.Text)
194+ }
195+ if result.Output != "" {
196+ if _, err := io.WriteString(deps.stdout, result.Output); err != nil {
197+ return err
198+ }
199+ if !strings.HasSuffix(result.Output, "\n") {
200+ _, err = fmt.Fprintln(deps.stdout)
201+ return err
202+ }
203+ }
204+ return nil
205+}
@@ -0,0 +1,205 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "os"
9+ "sort"
10+ "strings"
11+ "text/tabwriter"
12+
13+ "github.com/bnema/vev/internal/adapters/ipc"
14+ "github.com/bnema/vev/internal/ports"
15+ commandusecase "github.com/bnema/vev/internal/usecase/command"
16+)
17+
18+type cmdInvocation struct {
19+ slug string
20+ args []string
21+ session string
22+ self bool
23+ jsonOut bool
24+ help bool
25+}
26+
27+type exitCoded struct {
28+ code int
29+ err error
30+}
31+
32+func (e *exitCoded) Error() string { return e.err.Error() }
33+func (e *exitCoded) Unwrap() error { return e.err }
34+
35+func exitCode(err error) int {
36+ if err == nil {
37+ return 0
38+ }
39+ if coded, ok := errors.AsType[*exitCoded](err); ok {
40+ return coded.code
41+ }
42+ if _, ok := errors.AsType[*usageError](err); ok {
43+ return 2
44+ }
45+ return 1
46+}
47+
48+// ExitCode maps application outcomes to the CLI contract.
49+func ExitCode(err error) int { return exitCode(err) }
50+
51+func parseCmdArgs(args []string) (cmdInvocation, error) {
52+ invocation := cmdInvocation{}
53+ index := 0
54+ for index < len(args) && invocation.slug == "" {
55+ switch args[index] {
56+ case "--help", "-h":
57+ invocation.help = true
58+ index++
59+ case "-s":
60+ if index+1 >= len(args) || args[index+1] == "" {
61+ return cmdInvocation{}, usagef("`cmd -s` requires a session name")
62+ }
63+ invocation.session = args[index+1]
64+ index += 2
65+ case "--self":
66+ invocation.self = true
67+ index++
68+ case "--json":
69+ invocation.jsonOut = true
70+ index++
71+ default:
72+ if strings.HasPrefix(args[index], "-") {
73+ return cmdInvocation{}, usagef("unknown flag %q for `cmd`", args[index])
74+ }
75+ invocation.slug = args[index]
76+ index++
77+ }
78+ }
79+ if invocation.slug == "" {
80+ invocation.help = true
81+ return invocation, nil
82+ }
83+ if invocation.self && invocation.session != "" {
84+ return cmdInvocation{}, usagef("`cmd --self` cannot be combined with `-s`")
85+ }
86+ cmd, ok := commandusecase.BySlug(invocation.slug)
87+ if !ok || !cmd.Scriptable {
88+ return cmdInvocation{}, usagef("unknown command %q; see `vev cmd --help`", invocation.slug)
89+ }
90+ for _, arg := range args[index:] {
91+ switch arg {
92+ case "--help", "-h":
93+ invocation.help = true
94+ return invocation, nil
95+ case "--json":
96+ invocation.jsonOut = true
97+ default:
98+ invocation.args = append(invocation.args, arg)
99+ }
100+ }
101+ return invocation, nil
102+}
103+
104+func cmdHelp(invocation cmdInvocation) string {
105+ if invocation.slug != "" {
106+ cmd, _ := commandusecase.BySlug(invocation.slug)
107+ return fmt.Sprintf("usage: vev cmd [-s <session>] [--self] %s\n\n%s", cmd.Usage, cmd.Desc)
108+ }
109+ var out strings.Builder
110+ out.WriteString("usage: vev cmd [-s <session>] [--self] <command> [args]\n\ncommands:\n")
111+ writer := tabwriter.NewWriter(&out, 0, 4, 2, ' ', 0)
112+ commands := commandusecase.Registry()
113+ sort.Slice(commands, func(i, j int) bool { return commands[i].Slug < commands[j].Slug })
114+ for _, cmd := range commands {
115+ if cmd.Scriptable {
116+ _, _ = fmt.Fprintf(writer, " %s\t%s\n", cmd.Usage, cmd.Desc)
117+ }
118+ }
119+ _ = writer.Flush()
120+ return out.String()
121+}
122+
123+var errDaemonUnreachable = errors.New("daemon not running")
124+
125+type cmdDeps struct {
126+ stdout io.Writer
127+ getenv func(string) string
128+ dial func(context.Context, string) (ports.Transport, error)
129+}
130+
131+func runCmd(ctx context.Context, invocation cmdInvocation) error {
132+ return runCmdWithDeps(ctx, invocation, cmdDeps{stdout: os.Stdout, getenv: os.Getenv, dial: realDial})
133+}
134+
135+func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
136+ if invocation.help {
137+ _, err := fmt.Fprintln(deps.stdout, cmdHelp(invocation))
138+ return err
139+ }
140+ request := ports.CommandRequest{
141+ Version: ports.ProtocolVersion,
142+ Self: invocation.self,
143+ Slug: invocation.slug,
144+ Args: invocation.args,
145+ TargetSession: invocation.session,
146+ JSON: invocation.jsonOut,
147+ }
148+ if identity, ok := parseVEVEnv(deps.getenv("VEV")); ok {
149+ if request.TargetSession == "" {
150+ request.TargetSession = identity.session
151+ }
152+ if invocation.self || invocation.session == "" {
153+ request.TargetTab = identity.tab
154+ request.TargetPane = identity.pane
155+ }
156+ } else if invocation.self {
157+ return usagef("--self requires running inside a vev pane")
158+ }
159+
160+ payload, err := ports.MarshalCommandRequest(request)
161+ if errors.Is(err, ports.ErrTooManyCommandArgs) {
162+ return usagef("too many command arguments")
163+ }
164+ if err != nil {
165+ return fmt.Errorf("encoding command: %w", err)
166+ }
167+
168+ transport, err := deps.dial(ctx, ipc.SocketDir())
169+ if err != nil {
170+ return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
171+ }
172+ defer func() { _ = transport.Close() }()
173+
174+ frame := ports.Frame{Type: ports.MsgCommand, Payload: payload}
175+ if err := transport.Send(frame); err != nil {
176+ return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}
177+ }
178+ reply, err := transport.Recv()
179+ if err != nil {
180+ return &exitCoded{code: 3, err: fmt.Errorf("reading command reply: %w", err)}
181+ }
182+ if reply.Type != ports.MsgCommandResult {
183+ return fmt.Errorf("unexpected command reply type %d", reply.Type)
184+ }
185+ result, err := ports.UnmarshalCommandResult(reply.Payload)
186+ if err != nil {
187+ return fmt.Errorf("decoding command reply: %w", err)
188+ }
189+ if !result.OK {
190+ if result.Code == ports.ErrInvalidCommandArgs {
191+ return &exitCoded{code: 2, err: errors.New(result.Text)}
192+ }
193+ return errors.New(result.Text)
194+ }
195+ if result.Output != "" {
196+ if _, err := io.WriteString(deps.stdout, result.Output); err != nil {
197+ return err
198+ }
199+ if !strings.HasSuffix(result.Output, "\n") {
200+ _, err = fmt.Fprintln(deps.stdout)
201+ return err
202+ }
203+ }
204+ return nil
205+}
A internal/app/cmd_test.go +261 -0
@@ -0,0 +1,261 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "math"
8+ "reflect"
9+ "strings"
10+ "testing"
11+
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+func TestParseCmdArgs(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ args []string
19+ want cmdInvocation
20+ wantErr bool
21+ }{
22+ {"bare cmd is help", []string{"cmd"}, cmdInvocation{help: true}, false},
23+ {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false},
24+ {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
25+ {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
26+ {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
27+ {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
28+ {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false},
29+ {"self and session conflict", []string{"cmd", "-s", "other", "--self", "split-right"}, cmdInvocation{}, true},
30+ {"passthrough args", []string{"cmd", "toast", "-l", "warn", "hello"}, cmdInvocation{slug: "toast", args: []string{"-l", "warn", "hello"}}, false},
31+ {"unknown slug", []string{"cmd", "frobnicate"}, cmdInvocation{}, true},
32+ {"non-scriptable slug", []string{"cmd", "detach"}, cmdInvocation{}, true},
33+ {"session missing value", []string{"cmd", "-s"}, cmdInvocation{}, true},
34+ {"unknown leading flag", []string{"cmd", "--wat"}, cmdInvocation{}, true},
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ got, err := parseArgs(tt.args)
39+ if tt.wantErr {
40+ var usage *usageError
41+ if !errors.As(err, &usage) {
42+ t.Fatalf("parseArgs(%q) error = %v, want usage error", tt.args, err)
43+ }
44+ return
45+ }
46+ if err != nil {
47+ t.Fatalf("parseArgs(%q): %v", tt.args, err)
48+ }
49+ if got.kind != kindCmd || !reflect.DeepEqual(got.cmd, tt.want) {
50+ t.Fatalf("parseArgs(%q) = kind %v, cmd %#v, want %#v", tt.args, got.kind, got.cmd, tt.want)
51+ }
52+ })
53+ }
54+}
55+
56+func TestExitCodes(t *testing.T) {
57+ tests := []struct {
58+ name string
59+ err error
60+ want int
61+ }{
62+ {"success", nil, 0},
63+ {"command", errors.New("command failed"), 1},
64+ {"usage", &usageError{msg: "bad args"}, 2},
65+ {"unreachable", &exitCoded{code: 3, err: errDaemonUnreachable}, 3},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ if got := ExitCode(tt.err); got != tt.want {
70+ t.Fatalf("ExitCode(%v) = %d, want %d", tt.err, got, tt.want)
71+ }
72+ })
73+ }
74+}
75+
76+func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
77+ called := false
78+ out := new(strings.Builder)
79+ err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{
80+ stdout: out,
81+ getenv: func(string) string { return "" },
82+ dial: func(context.Context, string) (ports.Transport, error) {
83+ called = true
84+ return nil, errors.New("must not dial")
85+ },
86+ })
87+ if err != nil {
88+ t.Fatalf("runCmdWithDeps(help): %v", err)
89+ }
90+ if called {
91+ t.Fatal("help dialed daemon")
92+ }
93+ for _, want := range []string{"usage: vev cmd", "split-right", "list-panes"} {
94+ if !strings.Contains(out.String(), want) {
95+ t.Fatalf("help %q missing %q", out.String(), want)
96+ }
97+ }
98+ if strings.Contains(out.String(), "session-picker") {
99+ t.Fatalf("help leaked non-scriptable command: %q", out.String())
100+ }
101+}
102+
103+func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
104+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}}
105+ out := new(strings.Builder)
106+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{
107+ stdout: out,
108+ getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
109+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
110+ })
111+ if err != nil {
112+ t.Fatalf("runCmdWithDeps: %v", err)
113+ }
114+ if len(transport.sent) != 1 || transport.recvCalls != 1 || transport.closeCalls != 1 {
115+ t.Fatalf("transport calls: sent=%d recv=%d close=%d", len(transport.sent), transport.recvCalls, transport.closeCalls)
116+ }
117+ req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
118+ if err != nil {
119+ t.Fatalf("decode request: %v", err)
120+ }
121+ if transport.sent[0].Type != ports.MsgCommand || req.Slug != "split-right" || !req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
122+ t.Fatalf("request = type %d %+v", transport.sent[0].Type, req)
123+ }
124+ if out.String() != "done\n" {
125+ t.Fatalf("output = %q", out.String())
126+ }
127+}
128+
129+func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) {
130+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
131+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
132+ stdout: io.Discard,
133+ getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
134+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
135+ })
136+ if err != nil {
137+ t.Fatalf("runCmdWithDeps: %v", err)
138+ }
139+ req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
140+ if err != nil {
141+ t.Fatalf("decode request: %v", err)
142+ }
143+ if req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
144+ t.Fatalf("VEV locator request = %+v", req)
145+ }
146+}
147+
148+func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {
149+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
150+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{
151+ stdout: io.Discard,
152+ getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
153+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
154+ })
155+ if err != nil {
156+ t.Fatalf("runCmdWithDeps: %v", err)
157+ }
158+ req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
159+ if err != nil {
160+ t.Fatalf("decode request: %v", err)
161+ }
162+ if req.TargetSession != "current" || req.TargetTab != "" || req.TargetPane != "" {
163+ t.Fatalf("explicit targeting request = %+v", req)
164+ }
165+}
166+
167+func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) {
168+ dialed := false
169+ err := runCmdWithDeps(context.Background(), cmdInvocation{
170+ slug: "toast",
171+ args: make([]string, math.MaxUint16+1),
172+ }, cmdDeps{
173+ stdout: io.Discard,
174+ getenv: func(string) string { return "" },
175+ dial: func(context.Context, string) (ports.Transport, error) {
176+ dialed = true
177+ return nil, errors.New("must not dial")
178+ },
179+ })
180+
181+ if ExitCode(err) != 2 {
182+ t.Fatalf("error=%v code=%d, want usage error/2", err, ExitCode(err))
183+ }
184+ if dialed {
185+ t.Fatal("oversized command dialed daemon")
186+ }
187+}
188+
189+func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
190+ dials := 0
191+ dialErr := errors.New("unreachable")
192+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
193+ stdout: io.Discard,
194+ getenv: func(string) string { return "" },
195+ dial: func(context.Context, string) (ports.Transport, error) {
196+ dials++
197+ return nil, dialErr
198+ },
199+ })
200+ if ExitCode(err) != 3 || dials != 1 {
201+ t.Fatalf("error=%v code=%d dials=%d, want unreachable/3 and one dial", err, ExitCode(err), dials)
202+ }
203+ if !errors.Is(err, dialErr) {
204+ t.Fatalf("error=%v does not wrap dial error", err)
205+ }
206+}
207+
208+func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
209+ tests := []struct {
210+ name string
211+ result ports.CommandResult
212+ wantCode int
213+ }{
214+ {
215+ name: "invalid command arguments are usage errors",
216+ result: ports.CommandResult{Code: ports.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"},
217+ wantCode: 2,
218+ },
219+ {
220+ name: "missing runtime target is a command failure",
221+ result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"},
222+ wantCode: 1,
223+ },
224+ }
225+ for _, tt := range tests {
226+ t.Run(tt.name, func(t *testing.T) {
227+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(tt.result)}}
228+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
229+ stdout: io.Discard,
230+ getenv: func(string) string { return "" },
231+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
232+ })
233+ if err == nil || ExitCode(err) != tt.wantCode || err.Error() != tt.result.Text {
234+ t.Fatalf("error=%v code=%d, want message %q and code %d", err, ExitCode(err), tt.result.Text, tt.wantCode)
235+ }
236+ })
237+ }
238+}
239+
240+type cmdTestTransport struct {
241+ sent []ports.Frame
242+ recv ports.Frame
243+ recvErr error
244+ recvCalls int
245+ closeCalls int
246+}
247+
248+func (t *cmdTestTransport) Send(frame ports.Frame) error {
249+ t.sent = append(t.sent, frame)
250+ return nil
251+}
252+
253+func (t *cmdTestTransport) Recv() (ports.Frame, error) {
254+ t.recvCalls++
255+ return t.recv, t.recvErr
256+}
257+
258+func (t *cmdTestTransport) Close() error {
259+ t.closeCalls++
260+ return nil
261+}
@@ -0,0 +1,261 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "math"
8+ "reflect"
9+ "strings"
10+ "testing"
11+
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+func TestParseCmdArgs(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ args []string
19+ want cmdInvocation
20+ wantErr bool
21+ }{
22+ {"bare cmd is help", []string{"cmd"}, cmdInvocation{help: true}, false},
23+ {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false},
24+ {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
25+ {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
26+ {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
27+ {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
28+ {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false},
29+ {"self and session conflict", []string{"cmd", "-s", "other", "--self", "split-right"}, cmdInvocation{}, true},
30+ {"passthrough args", []string{"cmd", "toast", "-l", "warn", "hello"}, cmdInvocation{slug: "toast", args: []string{"-l", "warn", "hello"}}, false},
31+ {"unknown slug", []string{"cmd", "frobnicate"}, cmdInvocation{}, true},
32+ {"non-scriptable slug", []string{"cmd", "detach"}, cmdInvocation{}, true},
33+ {"session missing value", []string{"cmd", "-s"}, cmdInvocation{}, true},
34+ {"unknown leading flag", []string{"cmd", "--wat"}, cmdInvocation{}, true},
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ got, err := parseArgs(tt.args)
39+ if tt.wantErr {
40+ var usage *usageError
41+ if !errors.As(err, &usage) {
42+ t.Fatalf("parseArgs(%q) error = %v, want usage error", tt.args, err)
43+ }
44+ return
45+ }
46+ if err != nil {
47+ t.Fatalf("parseArgs(%q): %v", tt.args, err)
48+ }
49+ if got.kind != kindCmd || !reflect.DeepEqual(got.cmd, tt.want) {
50+ t.Fatalf("parseArgs(%q) = kind %v, cmd %#v, want %#v", tt.args, got.kind, got.cmd, tt.want)
51+ }
52+ })
53+ }
54+}
55+
56+func TestExitCodes(t *testing.T) {
57+ tests := []struct {
58+ name string
59+ err error
60+ want int
61+ }{
62+ {"success", nil, 0},
63+ {"command", errors.New("command failed"), 1},
64+ {"usage", &usageError{msg: "bad args"}, 2},
65+ {"unreachable", &exitCoded{code: 3, err: errDaemonUnreachable}, 3},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ if got := ExitCode(tt.err); got != tt.want {
70+ t.Fatalf("ExitCode(%v) = %d, want %d", tt.err, got, tt.want)
71+ }
72+ })
73+ }
74+}
75+
76+func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
77+ called := false
78+ out := new(strings.Builder)
79+ err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{
80+ stdout: out,
81+ getenv: func(string) string { return "" },
82+ dial: func(context.Context, string) (ports.Transport, error) {
83+ called = true
84+ return nil, errors.New("must not dial")
85+ },
86+ })
87+ if err != nil {
88+ t.Fatalf("runCmdWithDeps(help): %v", err)
89+ }
90+ if called {
91+ t.Fatal("help dialed daemon")
92+ }
93+ for _, want := range []string{"usage: vev cmd", "split-right", "list-panes"} {
94+ if !strings.Contains(out.String(), want) {
95+ t.Fatalf("help %q missing %q", out.String(), want)
96+ }
97+ }
98+ if strings.Contains(out.String(), "session-picker") {
99+ t.Fatalf("help leaked non-scriptable command: %q", out.String())
100+ }
101+}
102+
103+func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
104+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}}
105+ out := new(strings.Builder)
106+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{
107+ stdout: out,
108+ getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
109+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
110+ })
111+ if err != nil {
112+ t.Fatalf("runCmdWithDeps: %v", err)
113+ }
114+ if len(transport.sent) != 1 || transport.recvCalls != 1 || transport.closeCalls != 1 {
115+ t.Fatalf("transport calls: sent=%d recv=%d close=%d", len(transport.sent), transport.recvCalls, transport.closeCalls)
116+ }
117+ req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
118+ if err != nil {
119+ t.Fatalf("decode request: %v", err)
120+ }
121+ if transport.sent[0].Type != ports.MsgCommand || req.Slug != "split-right" || !req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
122+ t.Fatalf("request = type %d %+v", transport.sent[0].Type, req)
123+ }
124+ if out.String() != "done\n" {
125+ t.Fatalf("output = %q", out.String())
126+ }
127+}
128+
129+func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) {
130+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
131+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
132+ stdout: io.Discard,
133+ getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
134+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
135+ })
136+ if err != nil {
137+ t.Fatalf("runCmdWithDeps: %v", err)
138+ }
139+ req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
140+ if err != nil {
141+ t.Fatalf("decode request: %v", err)
142+ }
143+ if req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
144+ t.Fatalf("VEV locator request = %+v", req)
145+ }
146+}
147+
148+func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {
149+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
150+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{
151+ stdout: io.Discard,
152+ getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
153+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
154+ })
155+ if err != nil {
156+ t.Fatalf("runCmdWithDeps: %v", err)
157+ }
158+ req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
159+ if err != nil {
160+ t.Fatalf("decode request: %v", err)
161+ }
162+ if req.TargetSession != "current" || req.TargetTab != "" || req.TargetPane != "" {
163+ t.Fatalf("explicit targeting request = %+v", req)
164+ }
165+}
166+
167+func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) {
168+ dialed := false
169+ err := runCmdWithDeps(context.Background(), cmdInvocation{
170+ slug: "toast",
171+ args: make([]string, math.MaxUint16+1),
172+ }, cmdDeps{
173+ stdout: io.Discard,
174+ getenv: func(string) string { return "" },
175+ dial: func(context.Context, string) (ports.Transport, error) {
176+ dialed = true
177+ return nil, errors.New("must not dial")
178+ },
179+ })
180+
181+ if ExitCode(err) != 2 {
182+ t.Fatalf("error=%v code=%d, want usage error/2", err, ExitCode(err))
183+ }
184+ if dialed {
185+ t.Fatal("oversized command dialed daemon")
186+ }
187+}
188+
189+func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
190+ dials := 0
191+ dialErr := errors.New("unreachable")
192+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
193+ stdout: io.Discard,
194+ getenv: func(string) string { return "" },
195+ dial: func(context.Context, string) (ports.Transport, error) {
196+ dials++
197+ return nil, dialErr
198+ },
199+ })
200+ if ExitCode(err) != 3 || dials != 1 {
201+ t.Fatalf("error=%v code=%d dials=%d, want unreachable/3 and one dial", err, ExitCode(err), dials)
202+ }
203+ if !errors.Is(err, dialErr) {
204+ t.Fatalf("error=%v does not wrap dial error", err)
205+ }
206+}
207+
208+func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
209+ tests := []struct {
210+ name string
211+ result ports.CommandResult
212+ wantCode int
213+ }{
214+ {
215+ name: "invalid command arguments are usage errors",
216+ result: ports.CommandResult{Code: ports.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"},
217+ wantCode: 2,
218+ },
219+ {
220+ name: "missing runtime target is a command failure",
221+ result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"},
222+ wantCode: 1,
223+ },
224+ }
225+ for _, tt := range tests {
226+ t.Run(tt.name, func(t *testing.T) {
227+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(tt.result)}}
228+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
229+ stdout: io.Discard,
230+ getenv: func(string) string { return "" },
231+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
232+ })
233+ if err == nil || ExitCode(err) != tt.wantCode || err.Error() != tt.result.Text {
234+ t.Fatalf("error=%v code=%d, want message %q and code %d", err, ExitCode(err), tt.result.Text, tt.wantCode)
235+ }
236+ })
237+ }
238+}
239+
240+type cmdTestTransport struct {
241+ sent []ports.Frame
242+ recv ports.Frame
243+ recvErr error
244+ recvCalls int
245+ closeCalls int
246+}
247+
248+func (t *cmdTestTransport) Send(frame ports.Frame) error {
249+ t.sent = append(t.sent, frame)
250+ return nil
251+}
252+
253+func (t *cmdTestTransport) Recv() (ports.Frame, error) {
254+ t.recvCalls++
255+ return t.recv, t.recvErr
256+}
257+
258+func (t *cmdTestTransport) Close() error {
259+ t.closeCalls++
260+ return nil
261+}
M internal/app/run.go +11 -0
@@ -59,6 +59,7 @@ const (
5959 kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent
6060 kindList
6161 kindKill
62+ kindCmd
6263 kindDaemon
6364 kindStdio
6465 kindUDPBootstrap
@@ -76,6 +77,7 @@ type command struct {
7677 remoteTarget string
7778 killAll bool
7879 killDaemon bool
80+ cmd cmdInvocation
7981 }
8082
8183 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -100,6 +102,7 @@ usage:
100102 vev kill <name> kill a session
101103 vev kill --all kill all sessions and stop the daemon
102104 vev kill --daemon stop the active vev daemon
105+ vev cmd <command> run a control command (vev cmd --help)
103106 vev --help show this help
104107 vev --version show version`
105108
@@ -194,6 +197,12 @@ func parseArgs(args []string) (command, error) {
194197 return cmd, nil
195198 case "ls", "list":
196199 return command{kind: kindList}, nil
200+ case "cmd":
201+ invocation, err := parseCmdArgs(args[1:])
202+ if err != nil {
203+ return command{}, err
204+ }
205+ return command{kind: kindCmd, cmd: invocation}, nil
197206 case "kill":
198207 if len(args) < 2 || args[1] == "" {
199208 return command{}, usagef("`kill` requires a session name, --all, or --daemon")
@@ -245,6 +254,8 @@ func dispatch(ctx context.Context, cmd command) error {
245254 return runList(ctx)
246255 case kindKill:
247256 return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)
257+ case kindCmd:
258+ return runCmd(ctx, cmd.cmd)
248259 case kindAttach:
249260 return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)
250261 default:
@@ -59,6 +59,7 @@ const (
59 kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent59 kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent
60 kindList60 kindList
61 kindKill61 kindKill
62+ kindCmd
62 kindDaemon63 kindDaemon
63 kindStdio64 kindStdio
64 kindUDPBootstrap65 kindUDPBootstrap
@@ -76,6 +77,7 @@ type command struct {
76 remoteTarget string77 remoteTarget string
77 killAll bool78 killAll bool
78 killDaemon bool79 killDaemon bool
80+ cmd cmdInvocation
79 }81 }
80 82
81 // usageError is a user-facing argument error; the app prints it (with usage)83 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -100,6 +102,7 @@ usage:
100 vev kill <name> kill a session102 vev kill <name> kill a session
101 vev kill --all kill all sessions and stop the daemon103 vev kill --all kill all sessions and stop the daemon
102 vev kill --daemon stop the active vev daemon104 vev kill --daemon stop the active vev daemon
105+ vev cmd <command> run a control command (vev cmd --help)
103 vev --help show this help106 vev --help show this help
104 vev --version show version`107 vev --version show version`
105 108
@@ -194,6 +197,12 @@ func parseArgs(args []string) (command, error) {
194 return cmd, nil197 return cmd, nil
195 case "ls", "list":198 case "ls", "list":
196 return command{kind: kindList}, nil199 return command{kind: kindList}, nil
200+ case "cmd":
201+ invocation, err := parseCmdArgs(args[1:])
202+ if err != nil {
203+ return command{}, err
204+ }
205+ return command{kind: kindCmd, cmd: invocation}, nil
197 case "kill":206 case "kill":
198 if len(args) < 2 || args[1] == "" {207 if len(args) < 2 || args[1] == "" {
199 return command{}, usagef("`kill` requires a session name, --all, or --daemon")208 return command{}, usagef("`kill` requires a session name, --all, or --daemon")
@@ -245,6 +254,8 @@ func dispatch(ctx context.Context, cmd command) error {
245 return runList(ctx)254 return runList(ctx)
246 case kindKill:255 case kindKill:
247 return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)256 return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)
257+ case kindCmd:
258+ return runCmd(ctx, cmd.cmd)
248 case kindAttach:259 case kindAttach:
249 return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)260 return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)
250 default:261 default:
A internal/app/vevenv.go +87 -0
@@ -0,0 +1,87 @@
1+package app
2+
3+import "strings"
4+
5+type vevEnv struct {
6+ session string
7+ tab string
8+ pane string
9+}
10+
11+// parseVEVEnv strictly parses the identity injected into pane PTYs. All three
12+// unique fields are required and components use the daemon's uppercase %XX
13+// escaping.
14+func parseVEVEnv(value string) (vevEnv, bool) {
15+ if value == "" {
16+ return vevEnv{}, false
17+ }
18+ var out vevEnv
19+ seen := make(map[string]bool, 3)
20+ for part := range strings.SplitSeq(value, ",") {
21+ key, encoded, found := strings.Cut(part, "=")
22+ if !found || seen[key] {
23+ return vevEnv{}, false
24+ }
25+ decoded, ok := unescapeVEVComponent(encoded)
26+ if !ok || decoded == "" {
27+ return vevEnv{}, false
28+ }
29+ switch key {
30+ case "session":
31+ out.session = decoded
32+ case "tab":
33+ out.tab = decoded
34+ case "pane":
35+ out.pane = decoded
36+ default:
37+ return vevEnv{}, false
38+ }
39+ seen[key] = true
40+ }
41+ if len(seen) != 3 || out.session == "" || out.tab == "" || out.pane == "" {
42+ return vevEnv{}, false
43+ }
44+ return out, true
45+}
46+
47+func unescapeVEVComponent(value string) (string, bool) {
48+ var out strings.Builder
49+ for i := 0; i < len(value); i++ {
50+ if value[i] != '%' {
51+ if !isUnescapedVEVByte(value[i]) {
52+ return "", false
53+ }
54+ out.WriteByte(value[i])
55+ continue
56+ }
57+ if i+2 >= len(value) {
58+ return "", false
59+ }
60+ hi, highOK := upperHexValue(value[i+1])
61+ lo, lowOK := upperHexValue(value[i+2])
62+ if !highOK || !lowOK {
63+ return "", false
64+ }
65+ out.WriteByte(hi<<4 | lo)
66+ i += 2
67+ }
68+ return out.String(), true
69+}
70+
71+func isUnescapedVEVByte(value byte) bool {
72+ return value >= 'A' && value <= 'Z' ||
73+ value >= 'a' && value <= 'z' ||
74+ value >= '0' && value <= '9' ||
75+ value == '.' || value == '_' || value == '-'
76+}
77+
78+func upperHexValue(value byte) (byte, bool) {
79+ switch {
80+ case value >= '0' && value <= '9':
81+ return value - '0', true
82+ case value >= 'A' && value <= 'F':
83+ return value - 'A' + 10, true
84+ default:
85+ return 0, false
86+ }
87+}
@@ -0,0 +1,87 @@
1+package app
2+
3+import "strings"
4+
5+type vevEnv struct {
6+ session string
7+ tab string
8+ pane string
9+}
10+
11+// parseVEVEnv strictly parses the identity injected into pane PTYs. All three
12+// unique fields are required and components use the daemon's uppercase %XX
13+// escaping.
14+func parseVEVEnv(value string) (vevEnv, bool) {
15+ if value == "" {
16+ return vevEnv{}, false
17+ }
18+ var out vevEnv
19+ seen := make(map[string]bool, 3)
20+ for part := range strings.SplitSeq(value, ",") {
21+ key, encoded, found := strings.Cut(part, "=")
22+ if !found || seen[key] {
23+ return vevEnv{}, false
24+ }
25+ decoded, ok := unescapeVEVComponent(encoded)
26+ if !ok || decoded == "" {
27+ return vevEnv{}, false
28+ }
29+ switch key {
30+ case "session":
31+ out.session = decoded
32+ case "tab":
33+ out.tab = decoded
34+ case "pane":
35+ out.pane = decoded
36+ default:
37+ return vevEnv{}, false
38+ }
39+ seen[key] = true
40+ }
41+ if len(seen) != 3 || out.session == "" || out.tab == "" || out.pane == "" {
42+ return vevEnv{}, false
43+ }
44+ return out, true
45+}
46+
47+func unescapeVEVComponent(value string) (string, bool) {
48+ var out strings.Builder
49+ for i := 0; i < len(value); i++ {
50+ if value[i] != '%' {
51+ if !isUnescapedVEVByte(value[i]) {
52+ return "", false
53+ }
54+ out.WriteByte(value[i])
55+ continue
56+ }
57+ if i+2 >= len(value) {
58+ return "", false
59+ }
60+ hi, highOK := upperHexValue(value[i+1])
61+ lo, lowOK := upperHexValue(value[i+2])
62+ if !highOK || !lowOK {
63+ return "", false
64+ }
65+ out.WriteByte(hi<<4 | lo)
66+ i += 2
67+ }
68+ return out.String(), true
69+}
70+
71+func isUnescapedVEVByte(value byte) bool {
72+ return value >= 'A' && value <= 'Z' ||
73+ value >= 'a' && value <= 'z' ||
74+ value >= '0' && value <= '9' ||
75+ value == '.' || value == '_' || value == '-'
76+}
77+
78+func upperHexValue(value byte) (byte, bool) {
79+ switch {
80+ case value >= '0' && value <= '9':
81+ return value - '0', true
82+ case value >= 'A' && value <= 'F':
83+ return value - 'A' + 10, true
84+ default:
85+ return 0, false
86+ }
87+}
A internal/app/vevenv_test.go +38 -0
@@ -0,0 +1,38 @@
1+package app
2+
3+import "testing"
4+
5+func TestParseVEVEnv(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ env string
9+ session string
10+ tab string
11+ pane string
12+ ok bool
13+ }{
14+ {"normal", "session=dev,tab=t_abc,pane=p_def", "dev", "t_abc", "p_def", true},
15+ {"escaped components", "session=a%20b,tab=t%2Cx,pane=p%3Dy", "a b", "t,x", "p=y", true},
16+ {"empty", "", "", "", "", false},
17+ {"missing pane", "session=dev,tab=t_abc", "", "", "", false},
18+ {"garbage", "not-a-vev-value", "", "", "", false},
19+ {"unknown key", "session=dev,tab=t_abc,pane=p_def,extra=x", "", "", "", false},
20+ {"duplicate key", "session=dev,session=other,tab=t_abc,pane=p_def", "", "", "", false},
21+ {"bad escape", "session=dev%2,tab=t_abc,pane=p_def", "", "", "", false},
22+ {"lowercase escape is invalid", "session=dev%2fwork,tab=t_abc,pane=p_def", "", "", "", false},
23+ {"unescaped byte is invalid", "session=dev work,tab=t_abc,pane=p_def", "", "", "", false},
24+ {"raw UTF-8 is invalid", "session=dév,tab=t_abc,pane=p_def", "", "", "", false},
25+ {"empty component", "session=dev,tab=,pane=p_def", "", "", "", false},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ got, ok := parseVEVEnv(tt.env)
30+ if ok != tt.ok {
31+ t.Fatalf("parseVEVEnv(%q) ok = %v, want %v", tt.env, ok, tt.ok)
32+ }
33+ if ok && (got.session != tt.session || got.tab != tt.tab || got.pane != tt.pane) {
34+ t.Fatalf("parseVEVEnv(%q) = %+v", tt.env, got)
35+ }
36+ })
37+ }
38+}
@@ -0,0 +1,38 @@
1+package app
2+
3+import "testing"
4+
5+func TestParseVEVEnv(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ env string
9+ session string
10+ tab string
11+ pane string
12+ ok bool
13+ }{
14+ {"normal", "session=dev,tab=t_abc,pane=p_def", "dev", "t_abc", "p_def", true},
15+ {"escaped components", "session=a%20b,tab=t%2Cx,pane=p%3Dy", "a b", "t,x", "p=y", true},
16+ {"empty", "", "", "", "", false},
17+ {"missing pane", "session=dev,tab=t_abc", "", "", "", false},
18+ {"garbage", "not-a-vev-value", "", "", "", false},
19+ {"unknown key", "session=dev,tab=t_abc,pane=p_def,extra=x", "", "", "", false},
20+ {"duplicate key", "session=dev,session=other,tab=t_abc,pane=p_def", "", "", "", false},
21+ {"bad escape", "session=dev%2,tab=t_abc,pane=p_def", "", "", "", false},
22+ {"lowercase escape is invalid", "session=dev%2fwork,tab=t_abc,pane=p_def", "", "", "", false},
23+ {"unescaped byte is invalid", "session=dev work,tab=t_abc,pane=p_def", "", "", "", false},
24+ {"raw UTF-8 is invalid", "session=dév,tab=t_abc,pane=p_def", "", "", "", false},
25+ {"empty component", "session=dev,tab=,pane=p_def", "", "", "", false},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ got, ok := parseVEVEnv(tt.env)
30+ if ok != tt.ok {
31+ t.Fatalf("parseVEVEnv(%q) ok = %v, want %v", tt.env, ok, tt.ok)
32+ }
33+ if ok && (got.session != tt.session || got.tab != tt.tab || got.pane != tt.pane) {
34+ t.Fatalf("parseVEVEnv(%q) = %+v", tt.env, got)
35+ }
36+ })
37+ }
38+}
M internal/domain/notice.go +2 -0
@@ -41,6 +41,7 @@ const (
4141 NoticeClipboardTooLarge
4242 NoticeAutoResume
4343 NoticeConnection
44+ NoticeUser
4445
4546 // noticeCodeLimit is an append-only declaration sentinel, not a persisted
4647 // valid notice code. Keep it last when adding a NoticeCode.
@@ -68,6 +69,7 @@ var noticeSlugs = map[NoticeCode]string{
6869 NoticeClipboardTooLarge: "clipboard-too-large",
6970 NoticeAutoResume: "auto-resume",
7071 NoticeConnection: "connection",
72+ NoticeUser: "user",
7173 }
7274
7375 func (c NoticeCode) String() string {
@@ -41,6 +41,7 @@ const (
41 NoticeClipboardTooLarge41 NoticeClipboardTooLarge
42 NoticeAutoResume42 NoticeAutoResume
43 NoticeConnection43 NoticeConnection
44+ NoticeUser
44 45
45 // noticeCodeLimit is an append-only declaration sentinel, not a persisted46 // noticeCodeLimit is an append-only declaration sentinel, not a persisted
46 // valid notice code. Keep it last when adding a NoticeCode.47 // valid notice code. Keep it last when adding a NoticeCode.
@@ -68,6 +69,7 @@ var noticeSlugs = map[NoticeCode]string{
68 NoticeClipboardTooLarge: "clipboard-too-large",69 NoticeClipboardTooLarge: "clipboard-too-large",
69 NoticeAutoResume: "auto-resume",70 NoticeAutoResume: "auto-resume",
70 NoticeConnection: "connection",71 NoticeConnection: "connection",
72+ NoticeUser: "user",
71 }73 }
72 74
73 func (c NoticeCode) String() string {75 func (c NoticeCode) String() string {
M internal/ports/frame.go +9 -7
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 17
4+const ProtocolVersion uint16 = 18
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -21,13 +21,15 @@ const (
2121 MsgAck MsgType = 9
2222 MsgImagePush MsgType = 10
2323 MsgClientNotice MsgType = 11
24+ MsgCommand MsgType = 12
2425
25- MsgWelcome MsgType = 16
26- MsgError MsgType = 17
27- MsgOutput MsgType = 18
28- MsgDetached MsgType = 19
29- MsgPong MsgType = 20
30- MsgSessions MsgType = 21
26+ MsgWelcome MsgType = 16
27+ MsgError MsgType = 17
28+ MsgOutput MsgType = 18
29+ MsgDetached MsgType = 19
30+ MsgPong MsgType = 20
31+ MsgSessions MsgType = 21
32+ MsgCommandResult MsgType = 22
3133 )
3234
3335 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 174+const ProtocolVersion uint16 = 18
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
@@ -21,13 +21,15 @@ const (
21 MsgAck MsgType = 921 MsgAck MsgType = 9
22 MsgImagePush MsgType = 1022 MsgImagePush MsgType = 10
23 MsgClientNotice MsgType = 1123 MsgClientNotice MsgType = 11
24+ MsgCommand MsgType = 12
24 25
25- MsgWelcome MsgType = 1626+ MsgWelcome MsgType = 16
26- MsgError MsgType = 1727+ MsgError MsgType = 17
27- MsgOutput MsgType = 1828+ MsgOutput MsgType = 18
28- MsgDetached MsgType = 1929+ MsgDetached MsgType = 19
29- MsgPong MsgType = 2030+ MsgPong MsgType = 20
30- MsgSessions MsgType = 2131+ MsgSessions MsgType = 21
32+ MsgCommandResult MsgType = 22
31 )33 )
32 34
33 // Frame is the unit of exchange over a Transport: a typed, length-delimited35 // Frame is the unit of exchange over a Transport: a typed, length-delimited
M internal/ports/frame_test.go +13 -2
@@ -3,8 +3,17 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 17 {
7- t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion)
6+ if ProtocolVersion != 18 {
7+ t.Fatalf("ProtocolVersion = %d, want 18", ProtocolVersion)
8+ }
9+}
10+
11+func TestControlMsgTypes(t *testing.T) {
12+ if MsgCommand != 12 {
13+ t.Fatalf("MsgCommand = %d, want 12", MsgCommand)
14+ }
15+ if MsgCommandResult != 22 {
16+ t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult)
817 }
918 }
1019
@@ -24,12 +33,14 @@ func TestMsgTypeUnique(t *testing.T) {
2433 {"MsgAck", MsgAck},
2534 {"MsgImagePush", MsgImagePush},
2635 {"MsgClientNotice", MsgClientNotice},
36+ {"MsgCommand", MsgCommand},
2737 {"MsgWelcome", MsgWelcome},
2838 {"MsgError", MsgError},
2939 {"MsgOutput", MsgOutput},
3040 {"MsgDetached", MsgDetached},
3141 {"MsgPong", MsgPong},
3242 {"MsgSessions", MsgSessions},
43+ {"MsgCommandResult", MsgCommandResult},
3344 }
3445
3546 seen := make(map[MsgType]string, len(tests))
@@ -3,8 +3,17 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 17 {6+ if ProtocolVersion != 18 {
7- t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 18", ProtocolVersion)
8+ }
9+}
10+
11+func TestControlMsgTypes(t *testing.T) {
12+ if MsgCommand != 12 {
13+ t.Fatalf("MsgCommand = %d, want 12", MsgCommand)
14+ }
15+ if MsgCommandResult != 22 {
16+ t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult)
8 }17 }
9 }18 }
10 19
@@ -24,12 +33,14 @@ func TestMsgTypeUnique(t *testing.T) {
24 {"MsgAck", MsgAck},33 {"MsgAck", MsgAck},
25 {"MsgImagePush", MsgImagePush},34 {"MsgImagePush", MsgImagePush},
26 {"MsgClientNotice", MsgClientNotice},35 {"MsgClientNotice", MsgClientNotice},
36+ {"MsgCommand", MsgCommand},
27 {"MsgWelcome", MsgWelcome},37 {"MsgWelcome", MsgWelcome},
28 {"MsgError", MsgError},38 {"MsgError", MsgError},
29 {"MsgOutput", MsgOutput},39 {"MsgOutput", MsgOutput},
30 {"MsgDetached", MsgDetached},40 {"MsgDetached", MsgDetached},
31 {"MsgPong", MsgPong},41 {"MsgPong", MsgPong},
32 {"MsgSessions", MsgSessions},42 {"MsgSessions", MsgSessions},
43+ {"MsgCommandResult", MsgCommandResult},
33 }44 }
34 45
35 seen := make(map[MsgType]string, len(tests))46 seen := make(map[MsgType]string, len(tests))
M internal/ports/mocks/shell_mock.go +8 -8
@@ -39,22 +39,22 @@ func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter {
3939 }
4040
4141 // Run provides a mock function for the type MockShellCommandRunner
42-func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) {
42+func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) {
4343 ret := _mock.Called(ctx, spec)
4444
4545 if len(ret) == 0 {
4646 panic("no return value specified for Run")
4747 }
4848
49- var r0 ports.CommandResult
49+ var r0 ports.ShellCommandResult
5050 var r1 error
51- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.CommandResult, error)); ok {
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.ShellCommandResult, error)); ok {
5252 return returnFunc(ctx, spec)
5353 }
54- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.CommandResult); ok {
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.ShellCommandResult); ok {
5555 r0 = returnFunc(ctx, spec)
5656 } else {
57- r0 = ret.Get(0).(ports.CommandResult)
57+ r0 = ret.Get(0).(ports.ShellCommandResult)
5858 }
5959 if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {
6060 r1 = returnFunc(ctx, spec)
@@ -94,12 +94,12 @@ func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spe
9494 return _c
9595 }
9696
97-func (_c *MockShellCommandRunner_Run_Call) Return(commandResult ports.CommandResult, err error) *MockShellCommandRunner_Run_Call {
98- _c.Call.Return(commandResult, err)
97+func (_c *MockShellCommandRunner_Run_Call) Return(shellCommandResult ports.ShellCommandResult, err error) *MockShellCommandRunner_Run_Call {
98+ _c.Call.Return(shellCommandResult, err)
9999 return _c
100100 }
101101
102-func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error)) *MockShellCommandRunner_Run_Call {
102+func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error)) *MockShellCommandRunner_Run_Call {
103103 _c.Call.Return(run)
104104 return _c
105105 }
@@ -39,22 +39,22 @@ func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter {
39 }39 }
40 40
41 // Run provides a mock function for the type MockShellCommandRunner41 // Run provides a mock function for the type MockShellCommandRunner
42-func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) {42+func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) {
43 ret := _mock.Called(ctx, spec)43 ret := _mock.Called(ctx, spec)
44 44
45 if len(ret) == 0 {45 if len(ret) == 0 {
46 panic("no return value specified for Run")46 panic("no return value specified for Run")
47 }47 }
48 48
49- var r0 ports.CommandResult49+ var r0 ports.ShellCommandResult
50 var r1 error50 var r1 error
51- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.CommandResult, error)); ok {51+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.ShellCommandResult, error)); ok {
52 return returnFunc(ctx, spec)52 return returnFunc(ctx, spec)
53 }53 }
54- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.CommandResult); ok {54+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.ShellCommandResult); ok {
55 r0 = returnFunc(ctx, spec)55 r0 = returnFunc(ctx, spec)
56 } else {56 } else {
57- r0 = ret.Get(0).(ports.CommandResult)57+ r0 = ret.Get(0).(ports.ShellCommandResult)
58 }58 }
59 if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {59 if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {
60 r1 = returnFunc(ctx, spec)60 r1 = returnFunc(ctx, spec)
@@ -94,12 +94,12 @@ func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spe
94 return _c94 return _c
95 }95 }
96 96
97-func (_c *MockShellCommandRunner_Run_Call) Return(commandResult ports.CommandResult, err error) *MockShellCommandRunner_Run_Call {97+func (_c *MockShellCommandRunner_Run_Call) Return(shellCommandResult ports.ShellCommandResult, err error) *MockShellCommandRunner_Run_Call {
98- _c.Call.Return(commandResult, err)98+ _c.Call.Return(shellCommandResult, err)
99 return _c99 return _c
100 }100 }
101 101
102-func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error)) *MockShellCommandRunner_Run_Call {102+func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error)) *MockShellCommandRunner_Run_Call {
103 _c.Call.Return(run)103 _c.Call.Return(run)
104 return _c104 return _c
105 }105 }
M internal/ports/shell.go +6 -6
@@ -14,11 +14,11 @@ type CommandSpec struct {
1414 StderrLimit int
1515 }
1616
17-// CommandResult carries bounded output and the process exit status. ExitCode is
18-// 0 when the command ran to completion successfully, the process exit code when
19-// it ran and failed, and -1 when the command could not be started or was
20-// terminated by a signal.
21-type CommandResult struct {
17+// ShellCommandResult carries bounded output and the process exit status.
18+// ExitCode is 0 when the command ran to completion successfully, the process
19+// exit code when it ran and failed, and -1 when the command could not be
20+// started or was terminated by a signal.
21+type ShellCommandResult struct {
2222 Stdout []byte
2323 Stderr []byte
2424 ExitCode int
@@ -26,5 +26,5 @@ type CommandResult struct {
2626
2727 // ShellCommandRunner runs shell commands and returns bounded output.
2828 type ShellCommandRunner interface {
29- Run(ctx context.Context, spec CommandSpec) (CommandResult, error)
29+ Run(ctx context.Context, spec CommandSpec) (ShellCommandResult, error)
3030 }
@@ -14,11 +14,11 @@ type CommandSpec struct {
14 StderrLimit int14 StderrLimit int
15 }15 }
16 16
17-// CommandResult carries bounded output and the process exit status. ExitCode is17+// ShellCommandResult carries bounded output and the process exit status.
18-// 0 when the command ran to completion successfully, the process exit code when18+// ExitCode is 0 when the command ran to completion successfully, the process
19-// it ran and failed, and -1 when the command could not be started or was19+// exit code when it ran and failed, and -1 when the command could not be
20-// terminated by a signal.20+// started or was terminated by a signal.
21-type CommandResult struct {21+type ShellCommandResult struct {
22 Stdout []byte22 Stdout []byte
23 Stderr []byte23 Stderr []byte
24 ExitCode int24 ExitCode int
@@ -26,5 +26,5 @@ type CommandResult struct {
26 26
27 // ShellCommandRunner runs shell commands and returns bounded output.27 // ShellCommandRunner runs shell commands and returns bounded output.
28 type ShellCommandRunner interface {28 type ShellCommandRunner interface {
29- Run(ctx context.Context, spec CommandSpec) (CommandResult, error)29+ Run(ctx context.Context, spec CommandSpec) (ShellCommandResult, error)
30 }30 }
M internal/ports/wire.go +5 -0
@@ -45,6 +45,11 @@ const (
4545 ErrNameTaken uint16 = 3
4646 ErrServerShutdown uint16 = 4
4747 ErrInvalidSessionName uint16 = 5
48+ ErrUnknownCommand uint16 = 6
49+ ErrNotScriptable uint16 = 7
50+ ErrInvalidCommandArgs uint16 = 8
51+ ErrNoSuchTarget uint16 = 9
52+ ErrAmbiguousTarget uint16 = 10
4853 ErrInternal uint16 = 255
4954 )
5055
@@ -45,6 +45,11 @@ const (
45 ErrNameTaken uint16 = 345 ErrNameTaken uint16 = 3
46 ErrServerShutdown uint16 = 446 ErrServerShutdown uint16 = 4
47 ErrInvalidSessionName uint16 = 547 ErrInvalidSessionName uint16 = 5
48+ ErrUnknownCommand uint16 = 6
49+ ErrNotScriptable uint16 = 7
50+ ErrInvalidCommandArgs uint16 = 8
51+ ErrNoSuchTarget uint16 = 9
52+ ErrAmbiguousTarget uint16 = 10
48 ErrInternal uint16 = 25553 ErrInternal uint16 = 255
49 )54 )
50 55
A internal/ports/wire_command.go +165 -0
@@ -0,0 +1,165 @@
1+package ports
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "math"
7+)
8+
9+// ErrTooManyCommandArgs reports that a request exceeds the wire count limit.
10+var ErrTooManyCommandArgs = errors.New("ports: too many command arguments")
11+
12+// CommandRequest asks the daemon to run one control command. Version must
13+// stay first so a future payload layout can still be rejected cleanly.
14+type CommandRequest struct {
15+ Version uint16
16+ Self bool
17+ Slug string
18+ Args []string
19+ TargetSession string
20+ TargetTab string
21+ TargetPane string
22+ JSON bool
23+}
24+
25+// CommandResult reports a control command's outcome.
26+type CommandResult struct {
27+ OK bool
28+ Code uint16
29+ Text string
30+ Output string
31+}
32+
33+// PeekCommandVersion returns the leading protocol version from a
34+// CommandRequest payload.
35+func PeekCommandVersion(b []byte) (uint16, bool) {
36+ if len(b) < 2 {
37+ return 0, false
38+ }
39+ return binary.BigEndian.Uint16(b), true
40+}
41+
42+// MarshalCommandRequest encodes m into a CommandRequest payload.
43+func MarshalCommandRequest(m CommandRequest) ([]byte, error) {
44+ if len(m.Args) > math.MaxUint16 {
45+ return nil, ErrTooManyCommandArgs
46+ }
47+
48+ w := payloadWriter{}
49+ w.putUint16(m.Version)
50+ if m.Self {
51+ w.putUint8(1)
52+ } else {
53+ w.putUint8(0)
54+ }
55+ w.putString(m.Slug)
56+ w.putUint16(uint16(len(m.Args)))
57+ for _, arg := range m.Args {
58+ w.putLongString(arg)
59+ }
60+ w.putString(m.TargetSession)
61+ w.putString(m.TargetTab)
62+ w.putString(m.TargetPane)
63+ if m.JSON {
64+ w.putUint8(1)
65+ } else {
66+ w.putUint8(0)
67+ }
68+ return w.b, nil
69+}
70+
71+// UnmarshalCommandRequest decodes a strict CommandRequest payload.
72+func UnmarshalCommandRequest(b []byte) (CommandRequest, error) {
73+ r := payloadReader{b: b}
74+ var m CommandRequest
75+ var err error
76+
77+ if m.Version, err = r.getUint16(); err != nil {
78+ return CommandRequest{}, err
79+ }
80+ selfFlag, err := r.getUint8()
81+ if err != nil {
82+ return CommandRequest{}, err
83+ }
84+ m.Self = selfFlag != 0
85+ if m.Slug, err = r.getString(); err != nil {
86+ return CommandRequest{}, err
87+ }
88+ argCount, err := r.getUint16()
89+ if err != nil {
90+ return CommandRequest{}, err
91+ }
92+ // Every argument requires at least a uint32 length prefix. Reject an
93+ // impossible count before allocating from untrusted input.
94+ if uint64(argCount) > uint64(len(r.b)/4) {
95+ return CommandRequest{}, errShortPayload
96+ }
97+ if argCount != 0 {
98+ m.Args = make([]string, 0, int(argCount))
99+ for range int(argCount) {
100+ arg, err := r.getLongString()
101+ if err != nil {
102+ return CommandRequest{}, err
103+ }
104+ m.Args = append(m.Args, arg)
105+ }
106+ }
107+ if m.TargetSession, err = r.getString(); err != nil {
108+ return CommandRequest{}, err
109+ }
110+ if m.TargetTab, err = r.getString(); err != nil {
111+ return CommandRequest{}, err
112+ }
113+ if m.TargetPane, err = r.getString(); err != nil {
114+ return CommandRequest{}, err
115+ }
116+ jsonFlag, err := r.getUint8()
117+ if err != nil {
118+ return CommandRequest{}, err
119+ }
120+ m.JSON = jsonFlag != 0
121+ if err := r.done(); err != nil {
122+ return CommandRequest{}, err
123+ }
124+ return m, nil
125+}
126+
127+// MarshalCommandResult encodes m into a CommandResult payload.
128+func MarshalCommandResult(m CommandResult) []byte {
129+ w := payloadWriter{}
130+ if m.OK {
131+ w.putUint8(1)
132+ } else {
133+ w.putUint8(0)
134+ }
135+ w.putUint16(m.Code)
136+ w.putString(m.Text)
137+ w.putLongString(m.Output)
138+ return w.b
139+}
140+
141+// UnmarshalCommandResult decodes a strict CommandResult payload.
142+func UnmarshalCommandResult(b []byte) (CommandResult, error) {
143+ r := payloadReader{b: b}
144+ var m CommandResult
145+ var err error
146+
147+ okFlag, err := r.getUint8()
148+ if err != nil {
149+ return CommandResult{}, err
150+ }
151+ m.OK = okFlag != 0
152+ if m.Code, err = r.getUint16(); err != nil {
153+ return CommandResult{}, err
154+ }
155+ if m.Text, err = r.getString(); err != nil {
156+ return CommandResult{}, err
157+ }
158+ if m.Output, err = r.getLongString(); err != nil {
159+ return CommandResult{}, err
160+ }
161+ if err := r.done(); err != nil {
162+ return CommandResult{}, err
163+ }
164+ return m, nil
165+}
@@ -0,0 +1,165 @@
1+package ports
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "math"
7+)
8+
9+// ErrTooManyCommandArgs reports that a request exceeds the wire count limit.
10+var ErrTooManyCommandArgs = errors.New("ports: too many command arguments")
11+
12+// CommandRequest asks the daemon to run one control command. Version must
13+// stay first so a future payload layout can still be rejected cleanly.
14+type CommandRequest struct {
15+ Version uint16
16+ Self bool
17+ Slug string
18+ Args []string
19+ TargetSession string
20+ TargetTab string
21+ TargetPane string
22+ JSON bool
23+}
24+
25+// CommandResult reports a control command's outcome.
26+type CommandResult struct {
27+ OK bool
28+ Code uint16
29+ Text string
30+ Output string
31+}
32+
33+// PeekCommandVersion returns the leading protocol version from a
34+// CommandRequest payload.
35+func PeekCommandVersion(b []byte) (uint16, bool) {
36+ if len(b) < 2 {
37+ return 0, false
38+ }
39+ return binary.BigEndian.Uint16(b), true
40+}
41+
42+// MarshalCommandRequest encodes m into a CommandRequest payload.
43+func MarshalCommandRequest(m CommandRequest) ([]byte, error) {
44+ if len(m.Args) > math.MaxUint16 {
45+ return nil, ErrTooManyCommandArgs
46+ }
47+
48+ w := payloadWriter{}
49+ w.putUint16(m.Version)
50+ if m.Self {
51+ w.putUint8(1)
52+ } else {
53+ w.putUint8(0)
54+ }
55+ w.putString(m.Slug)
56+ w.putUint16(uint16(len(m.Args)))
57+ for _, arg := range m.Args {
58+ w.putLongString(arg)
59+ }
60+ w.putString(m.TargetSession)
61+ w.putString(m.TargetTab)
62+ w.putString(m.TargetPane)
63+ if m.JSON {
64+ w.putUint8(1)
65+ } else {
66+ w.putUint8(0)
67+ }
68+ return w.b, nil
69+}
70+
71+// UnmarshalCommandRequest decodes a strict CommandRequest payload.
72+func UnmarshalCommandRequest(b []byte) (CommandRequest, error) {
73+ r := payloadReader{b: b}
74+ var m CommandRequest
75+ var err error
76+
77+ if m.Version, err = r.getUint16(); err != nil {
78+ return CommandRequest{}, err
79+ }
80+ selfFlag, err := r.getUint8()
81+ if err != nil {
82+ return CommandRequest{}, err
83+ }
84+ m.Self = selfFlag != 0
85+ if m.Slug, err = r.getString(); err != nil {
86+ return CommandRequest{}, err
87+ }
88+ argCount, err := r.getUint16()
89+ if err != nil {
90+ return CommandRequest{}, err
91+ }
92+ // Every argument requires at least a uint32 length prefix. Reject an
93+ // impossible count before allocating from untrusted input.
94+ if uint64(argCount) > uint64(len(r.b)/4) {
95+ return CommandRequest{}, errShortPayload
96+ }
97+ if argCount != 0 {
98+ m.Args = make([]string, 0, int(argCount))
99+ for range int(argCount) {
100+ arg, err := r.getLongString()
101+ if err != nil {
102+ return CommandRequest{}, err
103+ }
104+ m.Args = append(m.Args, arg)
105+ }
106+ }
107+ if m.TargetSession, err = r.getString(); err != nil {
108+ return CommandRequest{}, err
109+ }
110+ if m.TargetTab, err = r.getString(); err != nil {
111+ return CommandRequest{}, err
112+ }
113+ if m.TargetPane, err = r.getString(); err != nil {
114+ return CommandRequest{}, err
115+ }
116+ jsonFlag, err := r.getUint8()
117+ if err != nil {
118+ return CommandRequest{}, err
119+ }
120+ m.JSON = jsonFlag != 0
121+ if err := r.done(); err != nil {
122+ return CommandRequest{}, err
123+ }
124+ return m, nil
125+}
126+
127+// MarshalCommandResult encodes m into a CommandResult payload.
128+func MarshalCommandResult(m CommandResult) []byte {
129+ w := payloadWriter{}
130+ if m.OK {
131+ w.putUint8(1)
132+ } else {
133+ w.putUint8(0)
134+ }
135+ w.putUint16(m.Code)
136+ w.putString(m.Text)
137+ w.putLongString(m.Output)
138+ return w.b
139+}
140+
141+// UnmarshalCommandResult decodes a strict CommandResult payload.
142+func UnmarshalCommandResult(b []byte) (CommandResult, error) {
143+ r := payloadReader{b: b}
144+ var m CommandResult
145+ var err error
146+
147+ okFlag, err := r.getUint8()
148+ if err != nil {
149+ return CommandResult{}, err
150+ }
151+ m.OK = okFlag != 0
152+ if m.Code, err = r.getUint16(); err != nil {
153+ return CommandResult{}, err
154+ }
155+ if m.Text, err = r.getString(); err != nil {
156+ return CommandResult{}, err
157+ }
158+ if m.Output, err = r.getLongString(); err != nil {
159+ return CommandResult{}, err
160+ }
161+ if err := r.done(); err != nil {
162+ return CommandResult{}, err
163+ }
164+ return m, nil
165+}
M internal/ports/wire_test.go +142 -2
@@ -3,6 +3,7 @@ package ports
33 import (
44 "bytes"
55 "fmt"
6+ "math"
67 "reflect"
78 "testing"
89
@@ -121,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
121122 Env: []string{"A=B", "XY=123"},
122123 })
123124 want := []byte{
124- 0x00, 0x11, 0x00, // version, intent
125+ 0x00, 0x12, 0x00, // version, intent
125126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -335,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
335336 }
336337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
337338 require.Equal(t, want, MarshalTheme(cleared))
338- require.Equal(t, uint16(17), ProtocolVersion)
339+ require.Equal(t, uint16(18), ProtocolVersion)
339340 }
340341
341342 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -442,6 +443,145 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
442443 assertTrailingGarbageFails(t, full, UnmarshalWelcome)
443444 }
444445
446+func TestCommandErrorCodes(t *testing.T) {
447+ require.Equal(t, uint16(6), ErrUnknownCommand)
448+ require.Equal(t, uint16(7), ErrNotScriptable)
449+ require.Equal(t, uint16(8), ErrInvalidCommandArgs)
450+ require.Equal(t, uint16(9), ErrNoSuchTarget)
451+ require.Equal(t, uint16(10), ErrAmbiguousTarget)
452+}
453+
454+func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
455+ tests := []struct {
456+ name string
457+ msg CommandRequest
458+ want []byte
459+ }{
460+ {
461+ name: "minimal",
462+ msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463+ want: []byte{
464+ 0x00, 0x12,
465+ 0x00,
466+ 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
467+ 0x00, 0x00,
468+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
469+ 0x00,
470+ },
471+ },
472+ {
473+ name: "full",
474+ msg: CommandRequest{
475+ Version: ProtocolVersion,
476+ Self: true,
477+ Slug: "toast",
478+ Args: []string{"-l", "warn", "tests KO"},
479+ TargetSession: "dev",
480+ TargetTab: "t_abc",
481+ TargetPane: "p_def",
482+ JSON: true,
483+ },
484+ want: []byte{
485+ 0x00, 0x12,
486+ 0x01,
487+ 0x00, 0x05, 't', 'o', 'a', 's', 't',
488+ 0x00, 0x03,
489+ 0x00, 0x00, 0x00, 0x02, '-', 'l',
490+ 0x00, 0x00, 0x00, 0x04, 'w', 'a', 'r', 'n',
491+ 0x00, 0x00, 0x00, 0x08, 't', 'e', 's', 't', 's', ' ', 'K', 'O',
492+ 0x00, 0x03, 'd', 'e', 'v',
493+ 0x00, 0x05, 't', '_', 'a', 'b', 'c',
494+ 0x00, 0x05, 'p', '_', 'd', 'e', 'f',
495+ 0x01,
496+ },
497+ },
498+ }
499+
500+ for _, tt := range tests {
501+ t.Run(tt.name, func(t *testing.T) {
502+ got, err := MarshalCommandRequest(tt.msg)
503+ require.NoError(t, err)
504+ require.Equal(t, tt.want, got)
505+ back, err := UnmarshalCommandRequest(got)
506+ require.NoError(t, err)
507+ require.Equal(t, tt.msg, back)
508+ version, ok := PeekCommandVersion(got)
509+ require.True(t, ok)
510+ require.Equal(t, tt.msg.Version, version)
511+ assertAllPrefixesFail(t, got, UnmarshalCommandRequest)
512+ assertTrailingGarbageFails(t, got, UnmarshalCommandRequest)
513+ })
514+ }
515+
516+ if _, ok := PeekCommandVersion([]byte{0x00}); ok {
517+ t.Fatal("PeekCommandVersion accepted a one-byte payload")
518+ }
519+}
520+
521+func TestMarshalCommandRequestRejectsTooManyArguments(t *testing.T) {
522+ payload, err := MarshalCommandRequest(CommandRequest{
523+ Version: ProtocolVersion,
524+ Slug: "toast",
525+ Args: make([]string, math.MaxUint16+1),
526+ })
527+
528+ require.Nil(t, payload)
529+ require.ErrorIs(t, err, ErrTooManyCommandArgs)
530+}
531+
532+func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) {
533+ payload, err := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"})
534+ require.NoError(t, err)
535+ argCountOffset := 2 + 1 + 2 + len("toast")
536+ payload[argCountOffset] = 0xff
537+ payload[argCountOffset+1] = 0xff
538+
539+ if _, err := UnmarshalCommandRequest(payload); err == nil {
540+ t.Fatal("UnmarshalCommandRequest accepted an impossible argument count")
541+ }
542+}
543+
544+func TestCommandResultGoldenAndRoundTrip(t *testing.T) {
545+ tests := []struct {
546+ name string
547+ msg CommandResult
548+ want []byte
549+ }{
550+ {
551+ name: "error",
552+ msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"},
553+ want: []byte{
554+ 0x00,
555+ 0x00, 0x09,
556+ 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n',
557+ 0x00, 0x00, 0x00, 0x00,
558+ },
559+ },
560+ {
561+ name: "success with output",
562+ msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"},
563+ want: []byte{
564+ 0x01,
565+ 0x00, 0x00,
566+ 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd',
567+ 0x00, 0x00, 0x00, 0x0b, 'I', 'D', '\t', 'F', 'O', 'C', 'U', 'S', 'E', 'D', '\n',
568+ },
569+ },
570+ }
571+
572+ for _, tt := range tests {
573+ t.Run(tt.name, func(t *testing.T) {
574+ got := MarshalCommandResult(tt.msg)
575+ require.Equal(t, tt.want, got)
576+ back, err := UnmarshalCommandResult(got)
577+ require.NoError(t, err)
578+ require.Equal(t, tt.msg, back)
579+ assertAllPrefixesFail(t, got, UnmarshalCommandResult)
580+ assertTrailingGarbageFails(t, got, UnmarshalCommandResult)
581+ })
582+ }
583+}
584+
445585 func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
446586 msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"}
447587 want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e}
@@ -3,6 +3,7 @@ package ports
3 import (3 import (
4 "bytes"4 "bytes"
5 "fmt"5 "fmt"
6+ "math"
6 "reflect"7 "reflect"
7 "testing"8 "testing"
8 9
@@ -121,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
121 Env: []string{"A=B", "XY=123"},122 Env: []string{"A=B", "XY=123"},
122 })123 })
123 want := []byte{124 want := []byte{
124- 0x00, 0x11, 0x00, // version, intent125+ 0x00, 0x12, 0x00, // version, intent
125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -335,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
335 }336 }
336 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
337 require.Equal(t, want, MarshalTheme(cleared))338 require.Equal(t, want, MarshalTheme(cleared))
338- require.Equal(t, uint16(17), ProtocolVersion)339+ require.Equal(t, uint16(18), ProtocolVersion)
339 }340 }
340 341
341 func TestResizeGoldenAndRoundTrip(t *testing.T) {342 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -442,6 +443,145 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
442 assertTrailingGarbageFails(t, full, UnmarshalWelcome)443 assertTrailingGarbageFails(t, full, UnmarshalWelcome)
443 }444 }
444 445
446+func TestCommandErrorCodes(t *testing.T) {
447+ require.Equal(t, uint16(6), ErrUnknownCommand)
448+ require.Equal(t, uint16(7), ErrNotScriptable)
449+ require.Equal(t, uint16(8), ErrInvalidCommandArgs)
450+ require.Equal(t, uint16(9), ErrNoSuchTarget)
451+ require.Equal(t, uint16(10), ErrAmbiguousTarget)
452+}
453+
454+func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
455+ tests := []struct {
456+ name string
457+ msg CommandRequest
458+ want []byte
459+ }{
460+ {
461+ name: "minimal",
462+ msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463+ want: []byte{
464+ 0x00, 0x12,
465+ 0x00,
466+ 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
467+ 0x00, 0x00,
468+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
469+ 0x00,
470+ },
471+ },
472+ {
473+ name: "full",
474+ msg: CommandRequest{
475+ Version: ProtocolVersion,
476+ Self: true,
477+ Slug: "toast",
478+ Args: []string{"-l", "warn", "tests KO"},
479+ TargetSession: "dev",
480+ TargetTab: "t_abc",
481+ TargetPane: "p_def",
482+ JSON: true,
483+ },
484+ want: []byte{
485+ 0x00, 0x12,
486+ 0x01,
487+ 0x00, 0x05, 't', 'o', 'a', 's', 't',
488+ 0x00, 0x03,
489+ 0x00, 0x00, 0x00, 0x02, '-', 'l',
490+ 0x00, 0x00, 0x00, 0x04, 'w', 'a', 'r', 'n',
491+ 0x00, 0x00, 0x00, 0x08, 't', 'e', 's', 't', 's', ' ', 'K', 'O',
492+ 0x00, 0x03, 'd', 'e', 'v',
493+ 0x00, 0x05, 't', '_', 'a', 'b', 'c',
494+ 0x00, 0x05, 'p', '_', 'd', 'e', 'f',
495+ 0x01,
496+ },
497+ },
498+ }
499+
500+ for _, tt := range tests {
501+ t.Run(tt.name, func(t *testing.T) {
502+ got, err := MarshalCommandRequest(tt.msg)
503+ require.NoError(t, err)
504+ require.Equal(t, tt.want, got)
505+ back, err := UnmarshalCommandRequest(got)
506+ require.NoError(t, err)
507+ require.Equal(t, tt.msg, back)
508+ version, ok := PeekCommandVersion(got)
509+ require.True(t, ok)
510+ require.Equal(t, tt.msg.Version, version)
511+ assertAllPrefixesFail(t, got, UnmarshalCommandRequest)
512+ assertTrailingGarbageFails(t, got, UnmarshalCommandRequest)
513+ })
514+ }
515+
516+ if _, ok := PeekCommandVersion([]byte{0x00}); ok {
517+ t.Fatal("PeekCommandVersion accepted a one-byte payload")
518+ }
519+}
520+
521+func TestMarshalCommandRequestRejectsTooManyArguments(t *testing.T) {
522+ payload, err := MarshalCommandRequest(CommandRequest{
523+ Version: ProtocolVersion,
524+ Slug: "toast",
525+ Args: make([]string, math.MaxUint16+1),
526+ })
527+
528+ require.Nil(t, payload)
529+ require.ErrorIs(t, err, ErrTooManyCommandArgs)
530+}
531+
532+func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) {
533+ payload, err := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"})
534+ require.NoError(t, err)
535+ argCountOffset := 2 + 1 + 2 + len("toast")
536+ payload[argCountOffset] = 0xff
537+ payload[argCountOffset+1] = 0xff
538+
539+ if _, err := UnmarshalCommandRequest(payload); err == nil {
540+ t.Fatal("UnmarshalCommandRequest accepted an impossible argument count")
541+ }
542+}
543+
544+func TestCommandResultGoldenAndRoundTrip(t *testing.T) {
545+ tests := []struct {
546+ name string
547+ msg CommandResult
548+ want []byte
549+ }{
550+ {
551+ name: "error",
552+ msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"},
553+ want: []byte{
554+ 0x00,
555+ 0x00, 0x09,
556+ 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n',
557+ 0x00, 0x00, 0x00, 0x00,
558+ },
559+ },
560+ {
561+ name: "success with output",
562+ msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"},
563+ want: []byte{
564+ 0x01,
565+ 0x00, 0x00,
566+ 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd',
567+ 0x00, 0x00, 0x00, 0x0b, 'I', 'D', '\t', 'F', 'O', 'C', 'U', 'S', 'E', 'D', '\n',
568+ },
569+ },
570+ }
571+
572+ for _, tt := range tests {
573+ t.Run(tt.name, func(t *testing.T) {
574+ got := MarshalCommandResult(tt.msg)
575+ require.Equal(t, tt.want, got)
576+ back, err := UnmarshalCommandResult(got)
577+ require.NoError(t, err)
578+ require.Equal(t, tt.msg, back)
579+ assertAllPrefixesFail(t, got, UnmarshalCommandResult)
580+ assertTrailingGarbageFails(t, got, UnmarshalCommandResult)
581+ })
582+ }
583+}
584+
445 func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {585 func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
446 msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"}586 msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"}
447 want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e}587 want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e}
M internal/usecase/command/command.go +51 -0
@@ -72,10 +72,61 @@ const (
7272 ContextHintRecentSessions
7373 )
7474
75+// TargetKind declares which resolved target a control command needs.
76+type TargetKind uint8
77+
78+const (
79+ TargetNone TargetKind = iota
80+ TargetSession
81+ TargetTab
82+ TargetPane
83+)
84+
85+// ControlOptions carries per-invocation output preferences.
86+type ControlOptions struct {
87+ JSON bool
88+}
89+
90+// ControlResult carries a control command's output.
91+type ControlResult struct {
92+ Output string
93+}
94+
95+// ControlContext exposes prompt-free daemon actions to control commands.
96+type ControlContext interface {
97+ CreateTab() error
98+ CreateSessionNamed(name string) error
99+ CloseTab() error
100+ ClosePane() error
101+ SplitRight() error
102+ SplitLeft() error
103+ SplitUp() error
104+ SplitDown() error
105+ StackPane() error
106+ ToggleStack() error
107+ FocusPaneLeft() error
108+ FocusPaneRight() error
109+ FocusPaneUp() error
110+ FocusPaneDown() error
111+ NextTab() error
112+ PrevTab() error
113+ RenameSessionTo(name string) error
114+ RenameTabTo(name string) error
115+ Toast(severity, message string) error
116+ ListSessions(json bool) (string, error)
117+ ListTabs(json bool) (string, error)
118+ ListPanes(json bool) (string, error)
119+}
120+
75121 // Command describes an executable command.
76122 type Command struct {
77123 Slug, Code, Name, Desc string
124+ Usage string
78125 Arguments Arguments
79126 ContextHint ContextHint
127+ Scriptable bool
128+ PaletteVisible bool
129+ Target TargetKind
80130 Run func(Context, []string) error
131+ Control func(ControlContext, []string, ControlOptions) (ControlResult, error)
81132 }
@@ -72,10 +72,61 @@ const (
72 ContextHintRecentSessions72 ContextHintRecentSessions
73 )73 )
74 74
75+// TargetKind declares which resolved target a control command needs.
76+type TargetKind uint8
77+
78+const (
79+ TargetNone TargetKind = iota
80+ TargetSession
81+ TargetTab
82+ TargetPane
83+)
84+
85+// ControlOptions carries per-invocation output preferences.
86+type ControlOptions struct {
87+ JSON bool
88+}
89+
90+// ControlResult carries a control command's output.
91+type ControlResult struct {
92+ Output string
93+}
94+
95+// ControlContext exposes prompt-free daemon actions to control commands.
96+type ControlContext interface {
97+ CreateTab() error
98+ CreateSessionNamed(name string) error
99+ CloseTab() error
100+ ClosePane() error
101+ SplitRight() error
102+ SplitLeft() error
103+ SplitUp() error
104+ SplitDown() error
105+ StackPane() error
106+ ToggleStack() error
107+ FocusPaneLeft() error
108+ FocusPaneRight() error
109+ FocusPaneUp() error
110+ FocusPaneDown() error
111+ NextTab() error
112+ PrevTab() error
113+ RenameSessionTo(name string) error
114+ RenameTabTo(name string) error
115+ Toast(severity, message string) error
116+ ListSessions(json bool) (string, error)
117+ ListTabs(json bool) (string, error)
118+ ListPanes(json bool) (string, error)
119+}
120+
75 // Command describes an executable command.121 // Command describes an executable command.
76 type Command struct {122 type Command struct {
77 Slug, Code, Name, Desc string123 Slug, Code, Name, Desc string
124+ Usage string
78 Arguments Arguments125 Arguments Arguments
79 ContextHint ContextHint126 ContextHint ContextHint
127+ Scriptable bool
128+ PaletteVisible bool
129+ Target TargetKind
80 Run func(Context, []string) error130 Run func(Context, []string) error
131+ Control func(ControlContext, []string, ControlOptions) (ControlResult, error)
81 }132 }
M internal/usecase/command/registry.go +101 -29
@@ -2,54 +2,126 @@ package command
22
33 import "strings"
44
5-// Registry returns all commands in display order.
5+// Registry returns all palette and control commands in stable display order.
66 func Registry() []Command {
77 commands := []Command{
8- {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context, _ []string) error { return ctx.CreateTab() }},
9- {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context, _ []string) error { return ctx.CreateSession() }},
10- {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context, _ []string) error { return ctx.CloseTab() }},
11- {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context, _ []string) error { return ctx.SplitRight() }},
12- {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context, _ []string) error { return ctx.SplitLeft() }},
13- {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context, _ []string) error { return ctx.SplitUp() }},
14- {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context, _ []string) error { return ctx.SplitDown() }},
15- {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context, _ []string) error { return ctx.StackPane() }},
16- {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context, _ []string) error { return ctx.ToggleStack() }},
17- {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }},
18- {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context, _ []string) error { return ctx.ClosePane() }},
19- {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }},
20- {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }},
21- {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }},
22- {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }},
23- {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context, _ []string) error { return ctx.NextTab() }},
24- {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context, _ []string) error { return ctx.PrevTab() }},
25- {Slug: "back-session", Code: "BSK", Name: "Previous session", Desc: "Toggle the previously active session", Run: func(ctx Context, _ []string) error { return ctx.BackSession() }},
26- {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error {
8+ paletteControl("new-tab", "CNT", "New tab", "Create a new tab", TargetSession, func(ctx Context, _ []string) error { return ctx.CreateTab() }, func(ctx ControlContext) error { return ctx.CreateTab() }),
9+ paletteControlOne("new-session", "CNS", "New session", "Create and switch to a named session", TargetSession, func(ctx Context, _ []string) error { return ctx.CreateSession() }, func(ctx ControlContext, name string) error { return ctx.CreateSessionNamed(name) }),
10+ paletteControl("close-tab", "CLT", "Close tab", "Close the current tab", TargetTab, func(ctx Context, _ []string) error { return ctx.CloseTab() }, func(ctx ControlContext) error { return ctx.CloseTab() }),
11+ paletteControl("split-right", "SPR", "Split right", "Split the focused pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitRight() }, func(ctx ControlContext) error { return ctx.SplitRight() }),
12+ paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }),
13+ paletteControl("split-up", "SPU", "Split up", "Split the focused pane upward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitUp() }, func(ctx ControlContext) error { return ctx.SplitUp() }),
14+ paletteControl("split-down", "SPD", "Split down", "Split the focused pane downward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitDown() }, func(ctx ControlContext) error { return ctx.SplitDown() }),
15+ paletteControl("stack-pane", "STP", "Stack pane", "Create a new pane in a stack", TargetPane, func(ctx Context, _ []string) error { return ctx.StackPane() }, func(ctx ControlContext) error { return ctx.StackPane() }),
16+ paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }),
17+ paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }),
18+ paletteControl("close-pane", "CLP", "Close pane", "Close the focused pane", TargetPane, func(ctx Context, _ []string) error { return ctx.ClosePane() }, func(ctx ControlContext) error { return ctx.ClosePane() }),
19+ paletteControl("focus-pane-left", "FPL", "Focus pane left", "Focus the pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }, func(ctx ControlContext) error { return ctx.FocusPaneLeft() }),
20+ paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }),
21+ paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }),
22+ paletteControl("focus-pane-down", "FPD", "Focus pane down", "Focus the pane below", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }, func(ctx ControlContext) error { return ctx.FocusPaneDown() }),
23+ paletteControl("next-tab", "NXT", "Next tab", "Switch to the next tab", TargetSession, func(ctx Context, _ []string) error { return ctx.NextTab() }, func(ctx ControlContext) error { return ctx.NextTab() }),
24+ paletteControl("previous-tab", "PVT", "Previous tab", "Switch to the previous tab", TargetSession, func(ctx Context, _ []string) error { return ctx.PrevTab() }, func(ctx ControlContext) error { return ctx.PrevTab() }),
25+ paletteOnly("back-session", "BSK", "Previous session", "Toggle the previously active session", func(ctx Context, _ []string) error { return ctx.BackSession() }),
26+ {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Usage: "jump-recent-session <rank>", PaletteVisible: true, Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error {
2727 rank, err := ParsePositiveDecimal(args)
2828 if err != nil {
2929 return err
3030 }
3131 return ctx.JumpRecentSession(rank)
3232 }},
33- {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }},
34- {Slug: "notifications", Code: "NTC", Name: "Notifications", Desc: "Show notification history", Run: func(ctx Context, _ []string) error { return ctx.OpenNotifications() }},
35- {Slug: "yank-last-notification", Code: "YLN", Name: "Yank last notification", Desc: "Copy the most recent notification's details to the clipboard", Run: func(ctx Context, _ []string) error { return ctx.YankLastNotification() }},
36- {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},
37- {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }},
38- {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }},
39- {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context, _ []string) error { return ctx.Detach() }},
33+ paletteOnly("session-picker", "SSP", "Session picker", "Open the session picker", func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }),
34+ paletteOnly("notifications", "NTC", "Notifications", "Show notification history", func(ctx Context, _ []string) error { return ctx.OpenNotifications() }),
35+ paletteOnly("yank-last-notification", "YLN", "Yank last notification", "Copy the most recent notification's details to the clipboard", func(ctx Context, _ []string) error { return ctx.YankLastNotification() }),
36+ paletteOnly("visual-mode", "VIS", "Visual mode", "Enter visual mode", func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }),
37+ paletteControlOne("rename-session", "RNS", "Rename session", "Rename the session (an ephemeral session becomes named)", TargetSession, func(ctx Context, _ []string) error { return ctx.RenameSession() }, func(ctx ControlContext, name string) error { return ctx.RenameSessionTo(name) }),
38+ paletteControlOne("rename-tab", "RNT", "Rename tab", "Rename the current tab", TargetTab, func(ctx Context, _ []string) error { return ctx.RenameTab() }, func(ctx ControlContext, name string) error { return ctx.RenameTabTo(name) }),
39+ paletteOnly("detach", "DET", "Detach", "Detach from the session", func(ctx Context, _ []string) error { return ctx.Detach() }),
40+ toastCommand(),
41+ listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),
42+ listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }),
43+ listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }),
4044 }
41-
4245 return commands
4346 }
4447
48+func paletteOnly(slug, code, name, desc string, run func(Context, []string) error) Command {
49+ return Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug, PaletteVisible: true, Run: run}
50+}
51+
52+func paletteControl(slug, code, name, desc string, target TargetKind, run func(Context, []string) error, control func(ControlContext) error) Command {
53+ return Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug, PaletteVisible: true, Scriptable: true, Target: target, Run: run, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {
54+ if len(args) != 0 {
55+ return ControlResult{}, ErrInvalidArguments
56+ }
57+ return ControlResult{}, control(ctx)
58+ }}
59+}
60+
61+func paletteControlOne(slug, code, name, desc string, target TargetKind, run func(Context, []string) error, control func(ControlContext, string) error) Command {
62+ cmd := Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug + " <name>", PaletteVisible: true, Scriptable: true, Target: target, Run: run}
63+ cmd.Control = func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {
64+ if len(args) != 1 || args[0] == "" {
65+ return ControlResult{}, ErrInvalidArguments
66+ }
67+ return ControlResult{}, control(ctx, args[0])
68+ }
69+ return cmd
70+}
71+
72+func toastCommand() Command {
73+ return Command{Slug: "toast", Name: "Toast", Desc: "Show a toast notification in the target session", Usage: "toast [-l info|warn|error] <message>", Scriptable: true, Target: TargetSession, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {
74+ severity := "info"
75+ if len(args) >= 2 && args[0] == "-l" {
76+ severity = args[1]
77+ args = args[2:]
78+ }
79+ if len(args) != 1 || args[0] == "" {
80+ return ControlResult{}, ErrInvalidArguments
81+ }
82+ return ControlResult{}, ctx.Toast(severity, args[0])
83+ }}
84+}
85+
86+func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command {
87+ return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) {
88+ if len(args) != 0 {
89+ return ControlResult{}, ErrInvalidArguments
90+ }
91+ out, err := list(ctx, opts.JSON)
92+ return ControlResult{Output: out}, err
93+ }}
94+}
95+
4596 // ByCode returns the command matching code, case-insensitively.
4697 func ByCode(code string) (Command, bool) {
4798 code = strings.ToUpper(code)
48- for _, cmd := range Registry() {
99+ for _, cmd := range PaletteRegistry() {
49100 if cmd.Code == code {
50101 return cmd, true
51102 }
52103 }
104+ return Command{}, false
105+}
53106
107+// BySlug returns the exact command slug. Slugs are stable CLI identifiers.
108+func BySlug(slug string) (Command, bool) {
109+ for _, cmd := range Registry() {
110+ if cmd.Slug == slug {
111+ return cmd, true
112+ }
113+ }
54114 return Command{}, false
55115 }
116+
117+// PaletteRegistry returns commands visible in the in-band palette.
118+func PaletteRegistry() []Command {
119+ all := Registry()
120+ out := make([]Command, 0, len(all))
121+ for _, cmd := range all {
122+ if cmd.PaletteVisible {
123+ out = append(out, cmd)
124+ }
125+ }
126+ return out
127+}
@@ -2,54 +2,126 @@ package command
2 2
3 import "strings"3 import "strings"
4 4
5-// Registry returns all commands in display order.5+// Registry returns all palette and control commands in stable display order.
6 func Registry() []Command {6 func Registry() []Command {
7 commands := []Command{7 commands := []Command{
8- {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context, _ []string) error { return ctx.CreateTab() }},8+ paletteControl("new-tab", "CNT", "New tab", "Create a new tab", TargetSession, func(ctx Context, _ []string) error { return ctx.CreateTab() }, func(ctx ControlContext) error { return ctx.CreateTab() }),
9- {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context, _ []string) error { return ctx.CreateSession() }},9+ paletteControlOne("new-session", "CNS", "New session", "Create and switch to a named session", TargetSession, func(ctx Context, _ []string) error { return ctx.CreateSession() }, func(ctx ControlContext, name string) error { return ctx.CreateSessionNamed(name) }),
10- {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context, _ []string) error { return ctx.CloseTab() }},10+ paletteControl("close-tab", "CLT", "Close tab", "Close the current tab", TargetTab, func(ctx Context, _ []string) error { return ctx.CloseTab() }, func(ctx ControlContext) error { return ctx.CloseTab() }),
11- {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context, _ []string) error { return ctx.SplitRight() }},11+ paletteControl("split-right", "SPR", "Split right", "Split the focused pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitRight() }, func(ctx ControlContext) error { return ctx.SplitRight() }),
12- {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context, _ []string) error { return ctx.SplitLeft() }},12+ paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }),
13- {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context, _ []string) error { return ctx.SplitUp() }},13+ paletteControl("split-up", "SPU", "Split up", "Split the focused pane upward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitUp() }, func(ctx ControlContext) error { return ctx.SplitUp() }),
14- {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context, _ []string) error { return ctx.SplitDown() }},14+ paletteControl("split-down", "SPD", "Split down", "Split the focused pane downward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitDown() }, func(ctx ControlContext) error { return ctx.SplitDown() }),
15- {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context, _ []string) error { return ctx.StackPane() }},15+ paletteControl("stack-pane", "STP", "Stack pane", "Create a new pane in a stack", TargetPane, func(ctx Context, _ []string) error { return ctx.StackPane() }, func(ctx ControlContext) error { return ctx.StackPane() }),
16- {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context, _ []string) error { return ctx.ToggleStack() }},16+ paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }),
17- {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }},17+ paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }),
18- {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context, _ []string) error { return ctx.ClosePane() }},18+ paletteControl("close-pane", "CLP", "Close pane", "Close the focused pane", TargetPane, func(ctx Context, _ []string) error { return ctx.ClosePane() }, func(ctx ControlContext) error { return ctx.ClosePane() }),
19- {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }},19+ paletteControl("focus-pane-left", "FPL", "Focus pane left", "Focus the pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }, func(ctx ControlContext) error { return ctx.FocusPaneLeft() }),
20- {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }},20+ paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }),
21- {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }},21+ paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }),
22- {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }},22+ paletteControl("focus-pane-down", "FPD", "Focus pane down", "Focus the pane below", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }, func(ctx ControlContext) error { return ctx.FocusPaneDown() }),
23- {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context, _ []string) error { return ctx.NextTab() }},23+ paletteControl("next-tab", "NXT", "Next tab", "Switch to the next tab", TargetSession, func(ctx Context, _ []string) error { return ctx.NextTab() }, func(ctx ControlContext) error { return ctx.NextTab() }),
24- {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context, _ []string) error { return ctx.PrevTab() }},24+ paletteControl("previous-tab", "PVT", "Previous tab", "Switch to the previous tab", TargetSession, func(ctx Context, _ []string) error { return ctx.PrevTab() }, func(ctx ControlContext) error { return ctx.PrevTab() }),
25- {Slug: "back-session", Code: "BSK", Name: "Previous session", Desc: "Toggle the previously active session", Run: func(ctx Context, _ []string) error { return ctx.BackSession() }},25+ paletteOnly("back-session", "BSK", "Previous session", "Toggle the previously active session", func(ctx Context, _ []string) error { return ctx.BackSession() }),
26- {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error {26+ {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Usage: "jump-recent-session <rank>", PaletteVisible: true, Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error {
27 rank, err := ParsePositiveDecimal(args)27 rank, err := ParsePositiveDecimal(args)
28 if err != nil {28 if err != nil {
29 return err29 return err
30 }30 }
31 return ctx.JumpRecentSession(rank)31 return ctx.JumpRecentSession(rank)
32 }},32 }},
33- {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }},33+ paletteOnly("session-picker", "SSP", "Session picker", "Open the session picker", func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }),
34- {Slug: "notifications", Code: "NTC", Name: "Notifications", Desc: "Show notification history", Run: func(ctx Context, _ []string) error { return ctx.OpenNotifications() }},34+ paletteOnly("notifications", "NTC", "Notifications", "Show notification history", func(ctx Context, _ []string) error { return ctx.OpenNotifications() }),
35- {Slug: "yank-last-notification", Code: "YLN", Name: "Yank last notification", Desc: "Copy the most recent notification's details to the clipboard", Run: func(ctx Context, _ []string) error { return ctx.YankLastNotification() }},35+ paletteOnly("yank-last-notification", "YLN", "Yank last notification", "Copy the most recent notification's details to the clipboard", func(ctx Context, _ []string) error { return ctx.YankLastNotification() }),
36- {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},36+ paletteOnly("visual-mode", "VIS", "Visual mode", "Enter visual mode", func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }),
37- {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }},37+ paletteControlOne("rename-session", "RNS", "Rename session", "Rename the session (an ephemeral session becomes named)", TargetSession, func(ctx Context, _ []string) error { return ctx.RenameSession() }, func(ctx ControlContext, name string) error { return ctx.RenameSessionTo(name) }),
38- {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }},38+ paletteControlOne("rename-tab", "RNT", "Rename tab", "Rename the current tab", TargetTab, func(ctx Context, _ []string) error { return ctx.RenameTab() }, func(ctx ControlContext, name string) error { return ctx.RenameTabTo(name) }),
39- {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context, _ []string) error { return ctx.Detach() }},39+ paletteOnly("detach", "DET", "Detach", "Detach from the session", func(ctx Context, _ []string) error { return ctx.Detach() }),
40+ toastCommand(),
41+ listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),
42+ listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }),
43+ listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }),
40 }44 }
41-
42 return commands45 return commands
43 }46 }
44 47
48+func paletteOnly(slug, code, name, desc string, run func(Context, []string) error) Command {
49+ return Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug, PaletteVisible: true, Run: run}
50+}
51+
52+func paletteControl(slug, code, name, desc string, target TargetKind, run func(Context, []string) error, control func(ControlContext) error) Command {
53+ return Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug, PaletteVisible: true, Scriptable: true, Target: target, Run: run, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {
54+ if len(args) != 0 {
55+ return ControlResult{}, ErrInvalidArguments
56+ }
57+ return ControlResult{}, control(ctx)
58+ }}
59+}
60+
61+func paletteControlOne(slug, code, name, desc string, target TargetKind, run func(Context, []string) error, control func(ControlContext, string) error) Command {
62+ cmd := Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug + " <name>", PaletteVisible: true, Scriptable: true, Target: target, Run: run}
63+ cmd.Control = func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {
64+ if len(args) != 1 || args[0] == "" {
65+ return ControlResult{}, ErrInvalidArguments
66+ }
67+ return ControlResult{}, control(ctx, args[0])
68+ }
69+ return cmd
70+}
71+
72+func toastCommand() Command {
73+ return Command{Slug: "toast", Name: "Toast", Desc: "Show a toast notification in the target session", Usage: "toast [-l info|warn|error] <message>", Scriptable: true, Target: TargetSession, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {
74+ severity := "info"
75+ if len(args) >= 2 && args[0] == "-l" {
76+ severity = args[1]
77+ args = args[2:]
78+ }
79+ if len(args) != 1 || args[0] == "" {
80+ return ControlResult{}, ErrInvalidArguments
81+ }
82+ return ControlResult{}, ctx.Toast(severity, args[0])
83+ }}
84+}
85+
86+func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command {
87+ return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) {
88+ if len(args) != 0 {
89+ return ControlResult{}, ErrInvalidArguments
90+ }
91+ out, err := list(ctx, opts.JSON)
92+ return ControlResult{Output: out}, err
93+ }}
94+}
95+
45 // ByCode returns the command matching code, case-insensitively.96 // ByCode returns the command matching code, case-insensitively.
46 func ByCode(code string) (Command, bool) {97 func ByCode(code string) (Command, bool) {
47 code = strings.ToUpper(code)98 code = strings.ToUpper(code)
48- for _, cmd := range Registry() {99+ for _, cmd := range PaletteRegistry() {
49 if cmd.Code == code {100 if cmd.Code == code {
50 return cmd, true101 return cmd, true
51 }102 }
52 }103 }
104+ return Command{}, false
105+}
53 106
107+// BySlug returns the exact command slug. Slugs are stable CLI identifiers.
108+func BySlug(slug string) (Command, bool) {
109+ for _, cmd := range Registry() {
110+ if cmd.Slug == slug {
111+ return cmd, true
112+ }
113+ }
54 return Command{}, false114 return Command{}, false
55 }115 }
116+
117+// PaletteRegistry returns commands visible in the in-band palette.
118+func PaletteRegistry() []Command {
119+ all := Registry()
120+ out := make([]Command, 0, len(all))
121+ for _, cmd := range all {
122+ if cmd.PaletteVisible {
123+ out = append(out, cmd)
124+ }
125+ }
126+ return out
127+}
M internal/usecase/command/registry_test.go +141 -1
@@ -1,12 +1,14 @@
11 package command
22
33 import (
4+ "errors"
45 "regexp"
6+ "strconv"
57 "testing"
68 )
79
810 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
9- commands := Registry()
11+ commands := PaletteRegistry()
1012 wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}
1113
1214 if len(commands) != len(wantCodes) {
@@ -37,6 +39,144 @@ func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
3739 }
3840 }
3941
42+func TestBySlugExactMatch(t *testing.T) {
43+ tests := []struct {
44+ slug string
45+ ok bool
46+ }{
47+ {slug: "split-right", ok: true},
48+ {slug: "SPLIT-RIGHT", ok: false},
49+ {slug: "toast", ok: true},
50+ {slug: "list-panes", ok: true},
51+ {slug: "does-not-exist", ok: false},
52+ }
53+ for _, tt := range tests {
54+ t.Run(tt.slug, func(t *testing.T) {
55+ if _, ok := BySlug(tt.slug); ok != tt.ok {
56+ t.Fatalf("BySlug(%q) ok = %v, want %v", tt.slug, ok, tt.ok)
57+ }
58+ })
59+ }
60+}
61+
62+func TestRegistryControlMetadata(t *testing.T) {
63+ scriptable := map[string]TargetKind{
64+ "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane,
65+ "split-down": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane,
66+ "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane,
67+ "focus-pane-up": TargetPane, "focus-pane-down": TargetPane,
68+ "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession,
69+ "previous-tab": TargetSession, "rename-session": TargetSession, "rename-tab": TargetTab,
70+ "new-session": TargetSession, "toast": TargetSession,
71+ "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
72+ }
73+ notScriptable := []string{
74+ "session-picker", "notifications", "visual-mode", "yank-last-notification",
75+ "detach", "back-session", "jump-recent-session", "toggle-floating-pane",
76+ }
77+ for slug, target := range scriptable {
78+ cmd, ok := BySlug(slug)
79+ if !ok || !cmd.Scriptable {
80+ t.Errorf("%s: want scriptable entry, ok=%v scriptable=%v", slug, ok, cmd.Scriptable)
81+ continue
82+ }
83+ if cmd.Target != target || cmd.Control == nil || cmd.Usage == "" || cmd.Desc == "" {
84+ t.Errorf("%s: incomplete control metadata: %#v", slug, cmd)
85+ }
86+ }
87+ for _, slug := range notScriptable {
88+ cmd, ok := BySlug(slug)
89+ if !ok || cmd.Scriptable || cmd.Control != nil {
90+ t.Errorf("%s: want present and non-scriptable, got %#v, ok=%v", slug, cmd, ok)
91+ }
92+ }
93+}
94+
95+func TestPaletteRegistryHidesAPIOnly(t *testing.T) {
96+ for _, cmd := range PaletteRegistry() {
97+ if !cmd.PaletteVisible || cmd.Run == nil {
98+ t.Errorf("palette command is not executable and visible: %#v", cmd)
99+ }
100+ if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" {
101+ t.Errorf("API-only command %q is visible", cmd.Slug)
102+ }
103+ }
104+}
105+
106+func TestControlHandlersValidateAndDelegate(t *testing.T) {
107+ tests := []struct {
108+ name string
109+ slug string
110+ args []string
111+ opts ControlOptions
112+ wantCall string
113+ wantOutput string
114+ wantErr error
115+ }{
116+ {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"},
117+ {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments},
118+ {name: "rename delegates name", slug: "rename-tab", args: []string{"editor"}, wantCall: "rename-tab:editor"},
119+ {name: "rename rejects empty name", slug: "rename-tab", args: []string{""}, wantErr: ErrInvalidArguments},
120+ {name: "toast delegates severity", slug: "toast", args: []string{"-l", "warn", "hello"}, wantCall: "toast:warn:hello"},
121+ {name: "toast rejects malformed flags", slug: "toast", args: []string{"-l", "warn"}, wantErr: ErrInvalidArguments},
122+ {name: "query delegates JSON option", slug: "list-panes", opts: ControlOptions{JSON: true}, wantCall: "list-panes:true", wantOutput: "panes"},
123+ {name: "query rejects args", slug: "list-panes", args: []string{"extra"}, wantErr: ErrInvalidArguments},
124+ }
125+ for _, tt := range tests {
126+ t.Run(tt.name, func(t *testing.T) {
127+ cmd, ok := BySlug(tt.slug)
128+ if !ok {
129+ t.Fatalf("BySlug(%q) missing", tt.slug)
130+ }
131+ ctx := &controlSpy{}
132+ got, err := cmd.Control(ctx, tt.args, tt.opts)
133+ if !errors.Is(err, tt.wantErr) {
134+ t.Fatalf("Control() error = %v, want %v", err, tt.wantErr)
135+ }
136+ if ctx.call != tt.wantCall || got.Output != tt.wantOutput {
137+ t.Fatalf("Control() call/output = %q/%q, want %q/%q", ctx.call, got.Output, tt.wantCall, tt.wantOutput)
138+ }
139+ })
140+ }
141+}
142+
143+type controlSpy struct{ call string }
144+
145+func (s *controlSpy) record(call string) error { s.call = call; return nil }
146+func (s *controlSpy) CreateTab() error { return s.record("new-tab") }
147+func (s *controlSpy) CreateSessionNamed(v string) error { return s.record("new-session:" + v) }
148+func (s *controlSpy) CloseTab() error { return s.record("close-tab") }
149+func (s *controlSpy) ClosePane() error { return s.record("close-pane") }
150+func (s *controlSpy) SplitRight() error { return s.record("split-right") }
151+func (s *controlSpy) SplitLeft() error { return s.record("split-left") }
152+func (s *controlSpy) SplitUp() error { return s.record("split-up") }
153+func (s *controlSpy) SplitDown() error { return s.record("split-down") }
154+func (s *controlSpy) StackPane() error { return s.record("stack-pane") }
155+func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }
156+func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") }
157+func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") }
158+func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") }
159+func (s *controlSpy) FocusPaneDown() error { return s.record("focus-pane-down") }
160+func (s *controlSpy) NextTab() error { return s.record("next-tab") }
161+func (s *controlSpy) PrevTab() error { return s.record("previous-tab") }
162+func (s *controlSpy) RenameSessionTo(v string) error { return s.record("rename-session:" + v) }
163+func (s *controlSpy) RenameTabTo(v string) error { return s.record("rename-tab:" + v) }
164+func (s *controlSpy) Toast(level, message string) error {
165+ return s.record("toast:" + level + ":" + message)
166+}
167+func (s *controlSpy) ListSessions(json bool) (string, error) {
168+ _ = s.record("list-sessions:" + strconv.FormatBool(json))
169+ return "sessions", nil
170+}
171+func (s *controlSpy) ListTabs(json bool) (string, error) {
172+ _ = s.record("list-tabs:" + strconv.FormatBool(json))
173+ return "tabs", nil
174+}
175+func (s *controlSpy) ListPanes(json bool) (string, error) {
176+ _ = s.record("list-panes:" + strconv.FormatBool(json))
177+ return "panes", nil
178+}
179+
40180 func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {
41181 cmd, ok := ByCode("FLT")
42182 if !ok {
@@ -1,12 +1,14 @@
1 package command1 package command
2 2
3 import (3 import (
4+ "errors"
4 "regexp"5 "regexp"
6+ "strconv"
5 "testing"7 "testing"
6 )8 )
7 9
8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {10 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
9- commands := Registry()11+ commands := PaletteRegistry()
10 wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}12 wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}
11 13
12 if len(commands) != len(wantCodes) {14 if len(commands) != len(wantCodes) {
@@ -37,6 +39,144 @@ func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
37 }39 }
38 }40 }
39 41
42+func TestBySlugExactMatch(t *testing.T) {
43+ tests := []struct {
44+ slug string
45+ ok bool
46+ }{
47+ {slug: "split-right", ok: true},
48+ {slug: "SPLIT-RIGHT", ok: false},
49+ {slug: "toast", ok: true},
50+ {slug: "list-panes", ok: true},
51+ {slug: "does-not-exist", ok: false},
52+ }
53+ for _, tt := range tests {
54+ t.Run(tt.slug, func(t *testing.T) {
55+ if _, ok := BySlug(tt.slug); ok != tt.ok {
56+ t.Fatalf("BySlug(%q) ok = %v, want %v", tt.slug, ok, tt.ok)
57+ }
58+ })
59+ }
60+}
61+
62+func TestRegistryControlMetadata(t *testing.T) {
63+ scriptable := map[string]TargetKind{
64+ "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane,
65+ "split-down": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane,
66+ "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane,
67+ "focus-pane-up": TargetPane, "focus-pane-down": TargetPane,
68+ "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession,
69+ "previous-tab": TargetSession, "rename-session": TargetSession, "rename-tab": TargetTab,
70+ "new-session": TargetSession, "toast": TargetSession,
71+ "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
72+ }
73+ notScriptable := []string{
74+ "session-picker", "notifications", "visual-mode", "yank-last-notification",
75+ "detach", "back-session", "jump-recent-session", "toggle-floating-pane",
76+ }
77+ for slug, target := range scriptable {
78+ cmd, ok := BySlug(slug)
79+ if !ok || !cmd.Scriptable {
80+ t.Errorf("%s: want scriptable entry, ok=%v scriptable=%v", slug, ok, cmd.Scriptable)
81+ continue
82+ }
83+ if cmd.Target != target || cmd.Control == nil || cmd.Usage == "" || cmd.Desc == "" {
84+ t.Errorf("%s: incomplete control metadata: %#v", slug, cmd)
85+ }
86+ }
87+ for _, slug := range notScriptable {
88+ cmd, ok := BySlug(slug)
89+ if !ok || cmd.Scriptable || cmd.Control != nil {
90+ t.Errorf("%s: want present and non-scriptable, got %#v, ok=%v", slug, cmd, ok)
91+ }
92+ }
93+}
94+
95+func TestPaletteRegistryHidesAPIOnly(t *testing.T) {
96+ for _, cmd := range PaletteRegistry() {
97+ if !cmd.PaletteVisible || cmd.Run == nil {
98+ t.Errorf("palette command is not executable and visible: %#v", cmd)
99+ }
100+ if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" {
101+ t.Errorf("API-only command %q is visible", cmd.Slug)
102+ }
103+ }
104+}
105+
106+func TestControlHandlersValidateAndDelegate(t *testing.T) {
107+ tests := []struct {
108+ name string
109+ slug string
110+ args []string
111+ opts ControlOptions
112+ wantCall string
113+ wantOutput string
114+ wantErr error
115+ }{
116+ {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"},
117+ {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments},
118+ {name: "rename delegates name", slug: "rename-tab", args: []string{"editor"}, wantCall: "rename-tab:editor"},
119+ {name: "rename rejects empty name", slug: "rename-tab", args: []string{""}, wantErr: ErrInvalidArguments},
120+ {name: "toast delegates severity", slug: "toast", args: []string{"-l", "warn", "hello"}, wantCall: "toast:warn:hello"},
121+ {name: "toast rejects malformed flags", slug: "toast", args: []string{"-l", "warn"}, wantErr: ErrInvalidArguments},
122+ {name: "query delegates JSON option", slug: "list-panes", opts: ControlOptions{JSON: true}, wantCall: "list-panes:true", wantOutput: "panes"},
123+ {name: "query rejects args", slug: "list-panes", args: []string{"extra"}, wantErr: ErrInvalidArguments},
124+ }
125+ for _, tt := range tests {
126+ t.Run(tt.name, func(t *testing.T) {
127+ cmd, ok := BySlug(tt.slug)
128+ if !ok {
129+ t.Fatalf("BySlug(%q) missing", tt.slug)
130+ }
131+ ctx := &controlSpy{}
132+ got, err := cmd.Control(ctx, tt.args, tt.opts)
133+ if !errors.Is(err, tt.wantErr) {
134+ t.Fatalf("Control() error = %v, want %v", err, tt.wantErr)
135+ }
136+ if ctx.call != tt.wantCall || got.Output != tt.wantOutput {
137+ t.Fatalf("Control() call/output = %q/%q, want %q/%q", ctx.call, got.Output, tt.wantCall, tt.wantOutput)
138+ }
139+ })
140+ }
141+}
142+
143+type controlSpy struct{ call string }
144+
145+func (s *controlSpy) record(call string) error { s.call = call; return nil }
146+func (s *controlSpy) CreateTab() error { return s.record("new-tab") }
147+func (s *controlSpy) CreateSessionNamed(v string) error { return s.record("new-session:" + v) }
148+func (s *controlSpy) CloseTab() error { return s.record("close-tab") }
149+func (s *controlSpy) ClosePane() error { return s.record("close-pane") }
150+func (s *controlSpy) SplitRight() error { return s.record("split-right") }
151+func (s *controlSpy) SplitLeft() error { return s.record("split-left") }
152+func (s *controlSpy) SplitUp() error { return s.record("split-up") }
153+func (s *controlSpy) SplitDown() error { return s.record("split-down") }
154+func (s *controlSpy) StackPane() error { return s.record("stack-pane") }
155+func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }
156+func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") }
157+func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") }
158+func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") }
159+func (s *controlSpy) FocusPaneDown() error { return s.record("focus-pane-down") }
160+func (s *controlSpy) NextTab() error { return s.record("next-tab") }
161+func (s *controlSpy) PrevTab() error { return s.record("previous-tab") }
162+func (s *controlSpy) RenameSessionTo(v string) error { return s.record("rename-session:" + v) }
163+func (s *controlSpy) RenameTabTo(v string) error { return s.record("rename-tab:" + v) }
164+func (s *controlSpy) Toast(level, message string) error {
165+ return s.record("toast:" + level + ":" + message)
166+}
167+func (s *controlSpy) ListSessions(json bool) (string, error) {
168+ _ = s.record("list-sessions:" + strconv.FormatBool(json))
169+ return "sessions", nil
170+}
171+func (s *controlSpy) ListTabs(json bool) (string, error) {
172+ _ = s.record("list-tabs:" + strconv.FormatBool(json))
173+ return "tabs", nil
174+}
175+func (s *controlSpy) ListPanes(json bool) (string, error) {
176+ _ = s.record("list-panes:" + strconv.FormatBool(json))
177+ return "panes", nil
178+}
179+
40 func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {180 func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {
41 cmd, ok := ByCode("FLT")181 cmd, ok := ByCode("FLT")
42 if !ok {182 if !ok {
M internal/usecase/daemon/attention_test.go +2 -2
@@ -326,7 +326,7 @@ func TestCloseRingingTabClearsClientBar(t *testing.T) {
326326 before := mustOutputData(t, sends)
327327 require.Contains(t, string(before), "")
328328
329- d.closeTab(sess, ringing, true)
329+ require.NoError(t, d.closeTab(sess, ringing, true))
330330 after := mustOutputData(t, sends)
331331 require.NotContains(t, string(after), "")
332332 }
@@ -455,7 +455,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
455455 before := mustOutputData(t, sendsB)
456456 require.Contains(t, string(before), string(ui.AttentionGlyph))
457457
458- d.closeTab(sessA, ringing, true)
458+ require.NoError(t, d.closeTab(sessA, ringing, true))
459459 _ = mustOutputData(t, sendsA)
460460
461461 after := mustOutputData(t, sendsB)
@@ -326,7 +326,7 @@ func TestCloseRingingTabClearsClientBar(t *testing.T) {
326 before := mustOutputData(t, sends)326 before := mustOutputData(t, sends)
327 require.Contains(t, string(before), "")327 require.Contains(t, string(before), "")
328 328
329- d.closeTab(sess, ringing, true)329+ require.NoError(t, d.closeTab(sess, ringing, true))
330 after := mustOutputData(t, sends)330 after := mustOutputData(t, sends)
331 require.NotContains(t, string(after), "")331 require.NotContains(t, string(after), "")
332 }332 }
@@ -455,7 +455,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
455 before := mustOutputData(t, sendsB)455 before := mustOutputData(t, sendsB)
456 require.Contains(t, string(before), string(ui.AttentionGlyph))456 require.Contains(t, string(before), string(ui.AttentionGlyph))
457 457
458- d.closeTab(sessA, ringing, true)458+ require.NoError(t, d.closeTab(sessA, ringing, true))
459 _ = mustOutputData(t, sendsA)459 _ = mustOutputData(t, sendsA)
460 460
461 after := mustOutputData(t, sendsB)461 after := mustOutputData(t, sendsB)
M internal/usecase/daemon/bar_script_test.go +5 -5
@@ -119,7 +119,7 @@ func TestBarScriptRunner(t *testing.T) {
119119 containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&
120120 containsEnv(spec.Env, "VEV_COLS=120") &&
121121 !containsEnv(spec.Env, "VEV_ANCHOR=old")
122- })).Return(ports.CommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil)
122+ })).Return(ports.ShellCommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil)
123123 runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond}
124124
125125 got, err := runner.run(context.Background(), "printf 'ok\\nignored'",
@@ -137,7 +137,7 @@ func TestBarScriptRunner(t *testing.T) {
137137 wantErr := errors.New("exit 1")
138138 portRunner := portsmocks.NewMockShellCommandRunner(t)
139139 portRunner.EXPECT().Run(mock.Anything, mock.Anything).
140- Return(ports.CommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr)
140+ Return(ports.ShellCommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr)
141141 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
142142
143143 got, err := runner.run(context.Background(), "false", nil, barScriptContext{})
@@ -152,9 +152,9 @@ func TestBarScriptRunner(t *testing.T) {
152152 t.Run("timeout propagates deadline", func(t *testing.T) {
153153 portRunner := portsmocks.NewMockShellCommandRunner(t)
154154 portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(
155- func(ctx context.Context, _ ports.CommandSpec) (ports.CommandResult, error) {
155+ func(ctx context.Context, _ ports.CommandSpec) (ports.ShellCommandResult, error) {
156156 <-ctx.Done()
157- return ports.CommandResult{ExitCode: -1}, ctx.Err()
157+ return ports.ShellCommandResult{ExitCode: -1}, ctx.Err()
158158 })
159159 runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}
160160
@@ -169,7 +169,7 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
169169 portRunner := portsmocks.NewMockShellCommandRunner(t)
170170 portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
171171 return spec.StdoutLimit == barScriptOutputLimit
172- })).Return(ports.CommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil)
172+ })).Return(ports.ShellCommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil)
173173 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
174174
175175 got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{})
@@ -119,7 +119,7 @@ func TestBarScriptRunner(t *testing.T) {
119 containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&119 containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&
120 containsEnv(spec.Env, "VEV_COLS=120") &&120 containsEnv(spec.Env, "VEV_COLS=120") &&
121 !containsEnv(spec.Env, "VEV_ANCHOR=old")121 !containsEnv(spec.Env, "VEV_ANCHOR=old")
122- })).Return(ports.CommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil)122+ })).Return(ports.ShellCommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil)
123 runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond}123 runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond}
124 124
125 got, err := runner.run(context.Background(), "printf 'ok\\nignored'",125 got, err := runner.run(context.Background(), "printf 'ok\\nignored'",
@@ -137,7 +137,7 @@ func TestBarScriptRunner(t *testing.T) {
137 wantErr := errors.New("exit 1")137 wantErr := errors.New("exit 1")
138 portRunner := portsmocks.NewMockShellCommandRunner(t)138 portRunner := portsmocks.NewMockShellCommandRunner(t)
139 portRunner.EXPECT().Run(mock.Anything, mock.Anything).139 portRunner.EXPECT().Run(mock.Anything, mock.Anything).
140- Return(ports.CommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr)140+ Return(ports.ShellCommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr)
141 runner := barScriptRunner{runner: portRunner, timeout: time.Second}141 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
142 142
143 got, err := runner.run(context.Background(), "false", nil, barScriptContext{})143 got, err := runner.run(context.Background(), "false", nil, barScriptContext{})
@@ -152,9 +152,9 @@ func TestBarScriptRunner(t *testing.T) {
152 t.Run("timeout propagates deadline", func(t *testing.T) {152 t.Run("timeout propagates deadline", func(t *testing.T) {
153 portRunner := portsmocks.NewMockShellCommandRunner(t)153 portRunner := portsmocks.NewMockShellCommandRunner(t)
154 portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(154 portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(
155- func(ctx context.Context, _ ports.CommandSpec) (ports.CommandResult, error) {155+ func(ctx context.Context, _ ports.CommandSpec) (ports.ShellCommandResult, error) {
156 <-ctx.Done()156 <-ctx.Done()
157- return ports.CommandResult{ExitCode: -1}, ctx.Err()157+ return ports.ShellCommandResult{ExitCode: -1}, ctx.Err()
158 })158 })
159 runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}159 runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}
160 160
@@ -169,7 +169,7 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
169 portRunner := portsmocks.NewMockShellCommandRunner(t)169 portRunner := portsmocks.NewMockShellCommandRunner(t)
170 portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {170 portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
171 return spec.StdoutLimit == barScriptOutputLimit171 return spec.StdoutLimit == barScriptOutputLimit
172- })).Return(ports.CommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil)172+ })).Return(ports.ShellCommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil)
173 runner := barScriptRunner{runner: portRunner, timeout: time.Second}173 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
174 174
175 got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{})175 got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{})
M internal/usecase/daemon/config.go +2 -2
@@ -113,7 +113,7 @@ func configWarningsError(warnings []domain.Warning) error {
113113 }
114114
115115 func (d *Daemon) buildCodeOverrides(configured map[string]string) (map[string]string, []domain.Warning) {
116- commands := command.Registry()
116+ commands := command.PaletteRegistry()
117117 bySlug := make(map[string]command.Command, len(commands))
118118 for _, cmd := range commands {
119119 bySlug[cmd.Slug] = cmd
@@ -294,7 +294,7 @@ func commandWithOverrides(cmd command.Command, overrides map[string]string) comm
294294 func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
295295 code = strings.ToUpper(strings.TrimSpace(code))
296296 overrides := d.codeOverrideSnapshot()
297- for _, cmd := range command.Registry() {
297+ for _, cmd := range command.PaletteRegistry() {
298298 cmd = commandWithOverrides(cmd, overrides)
299299 if cmd.Code == code {
300300 return cmd, true
@@ -113,7 +113,7 @@ func configWarningsError(warnings []domain.Warning) error {
113 }113 }
114 114
115 func (d *Daemon) buildCodeOverrides(configured map[string]string) (map[string]string, []domain.Warning) {115 func (d *Daemon) buildCodeOverrides(configured map[string]string) (map[string]string, []domain.Warning) {
116- commands := command.Registry()116+ commands := command.PaletteRegistry()
117 bySlug := make(map[string]command.Command, len(commands))117 bySlug := make(map[string]command.Command, len(commands))
118 for _, cmd := range commands {118 for _, cmd := range commands {
119 bySlug[cmd.Slug] = cmd119 bySlug[cmd.Slug] = cmd
@@ -294,7 +294,7 @@ func commandWithOverrides(cmd command.Command, overrides map[string]string) comm
294 func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {294 func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
295 code = strings.ToUpper(strings.TrimSpace(code))295 code = strings.ToUpper(strings.TrimSpace(code))
296 overrides := d.codeOverrideSnapshot()296 overrides := d.codeOverrideSnapshot()
297- for _, cmd := range command.Registry() {297+ for _, cmd := range command.PaletteRegistry() {
298 cmd = commandWithOverrides(cmd, overrides)298 cmd = commandWithOverrides(cmd, overrides)
299 if cmd.Code == code {299 if cmd.Code == code {
300 return cmd, true300 return cmd, true
A internal/usecase/daemon/control.go +574 -0
@@ -0,0 +1,574 @@
1+package daemon
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "sort"
8+ "strings"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/usecase/command"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+)
15+
16+// handleCommand serves one one-shot control request. The leading version is
17+// checked before decoding the versioned body so newer layouts fail cleanly.
18+func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) {
19+ defer func() { _ = tr.Close() }()
20+
21+ if version, ok := ports.PeekCommandVersion(f.Payload); !ok || version != ports.ProtocolVersion {
22+ _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}))
23+ return
24+ }
25+ request, err := ports.UnmarshalCommandRequest(f.Payload)
26+ if err != nil {
27+ _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"}))
28+ return
29+ }
30+ _ = tr.Send(frameCommandResult(d.dispatchCommand(request)))
31+}
32+
33+func frameCommandResult(result ports.CommandResult) ports.Frame {
34+ return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}
35+}
36+
37+func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResult {
38+ cmd, ok := command.BySlug(request.Slug)
39+ if !ok {
40+ return commandFailure(ports.ErrUnknownCommand, "unknown command: "+request.Slug)
41+ }
42+ if !cmd.Scriptable || cmd.Control == nil {
43+ return commandFailure(ports.ErrNotScriptable, request.Slug+" requires an attached client")
44+ }
45+ if cmd.Target == command.TargetNone && !request.Self {
46+ return d.runControl(cmd, controlExec{d: d}, request)
47+ }
48+
49+ sess, code, text := d.resolveTargetSession(request)
50+ if sess == nil {
51+ return commandFailure(code, text)
52+ }
53+ sess.dispatchMu.Lock()
54+ defer sess.dispatchMu.Unlock()
55+
56+ tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
57+ if code != 0 {
58+ return commandFailure(code, text)
59+ }
60+ return d.runControl(cmd, controlExec{d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
61+}
62+
63+func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports.CommandRequest) ports.CommandResult {
64+ result, err := cmd.Control(exec, request.Args, command.ControlOptions{JSON: request.JSON})
65+ if err == nil {
66+ return ports.CommandResult{OK: true, Output: result.Output}
67+ }
68+ switch {
69+ case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired), errors.Is(err, domain.ErrInvalidSessionName):
70+ return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)
71+ case errors.Is(err, errSessionNameInUse):
72+ return commandFailure(ports.ErrNameTaken, err.Error())
73+ default:
74+ return commandFailure(ports.ErrInternal, err.Error())
75+ }
76+}
77+
78+func commandFailure(code uint16, text string) ports.CommandResult {
79+ return ports.CommandResult{Code: code, Text: text}
80+}
81+
82+// resolveTargetSession applies explicit-name, stable-ID, then unique-session
83+// resolution. A name paired with IDs is advisory only when no live session has
84+// that name (the VEV value can survive a rename).
85+func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, uint16, string) {
86+ d.mu.Lock()
87+ defer d.mu.Unlock()
88+
89+ named := d.findByNameLocked(request.TargetSession)
90+ if request.Self && (request.TargetTab == "" || request.TargetPane == "") {
91+ return nil, ports.ErrNoSuchTarget, "--self requires target tab and pane IDs"
92+ }
93+ if request.TargetSession != "" && request.TargetTab == "" && request.TargetPane == "" {
94+ if named == nil {
95+ return nil, ports.ErrNoSuchTarget, "no such session: " + request.TargetSession
96+ }
97+ return named, 0, ""
98+ }
99+ if request.TargetTab != "" || request.TargetPane != "" {
100+ if request.TargetTab == "" || request.TargetPane == "" {
101+ return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
102+ }
103+ for _, sess := range d.sessions {
104+ if sess.containsStableIDs(request.TargetTab, request.TargetPane) {
105+ if named != nil && named != sess {
106+ return nil, ports.ErrNoSuchTarget, "tab/pane IDs belong to another session"
107+ }
108+ return sess, 0, ""
109+ }
110+ }
111+ return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"
112+ }
113+ if len(d.sessions) == 0 {
114+ return nil, ports.ErrNoSuchTarget, "no live sessions"
115+ }
116+ if len(d.sessions) != 1 {
117+ return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane"
118+ }
119+ for _, sess := range d.sessions {
120+ return sess, 0, ""
121+ }
122+ panic("unreachable")
123+}
124+
125+func (s *session) containsStableIDs(tabID, paneID string) bool {
126+ s.mu.Lock()
127+ tabs := append([]*tab(nil), s.tabs...)
128+ s.mu.Unlock()
129+ for _, tb := range tabs {
130+ tb.mu.Lock()
131+ pane := paneByStableIDLocked(tb, paneID)
132+ match := tb.stableID == tabID && pane != nil
133+ tb.mu.Unlock()
134+ if match {
135+ return true
136+ }
137+ }
138+ return false
139+}
140+
141+func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) {
142+ sess.mu.Lock()
143+ tabs := append([]*tab(nil), sess.tabs...)
144+ active := sess.active
145+ sess.mu.Unlock()
146+ if request.Self {
147+ for _, tb := range tabs {
148+ tb.mu.Lock()
149+ if tb.stableID != request.TargetTab {
150+ tb.mu.Unlock()
151+ continue
152+ }
153+ target := paneByStableIDLocked(tb, request.TargetPane)
154+ tb.mu.Unlock()
155+ if target == nil {
156+ return nil, nil, ports.ErrNoSuchTarget, "target pane does not belong to target tab"
157+ }
158+ return tb, target, 0, ""
159+ }
160+ return nil, nil, ports.ErrNoSuchTarget, "no such target tab"
161+ }
162+ if len(tabs) == 0 || active < 0 || active >= len(tabs) {
163+ return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab"
164+ }
165+ tb := tabs[active]
166+ if kind != command.TargetPane {
167+ return tb, nil, 0, ""
168+ }
169+ tb.mu.Lock()
170+ target := tb.focusedPane()
171+ tb.mu.Unlock()
172+ if target == nil {
173+ return nil, nil, ports.ErrNoSuchTarget, "no such target pane"
174+ }
175+ return tb, target, 0, ""
176+}
177+
178+func paneByStableIDLocked(tb *tab, stableID string) *pane {
179+ for _, pane := range tb.panes {
180+ if pane.stableID == stableID {
181+ return pane
182+ }
183+ }
184+ return nil
185+}
186+
187+type daemonActionKind uint8
188+
189+const (
190+ daemonActionCreateTab daemonActionKind = iota
191+ daemonActionCloseTab
192+ daemonActionSplitPane
193+ daemonActionStackPane
194+ daemonActionToggleStack
195+ daemonActionClosePane
196+ daemonActionFocusPane
197+ daemonActionNextTab
198+ daemonActionPreviousTab
199+ daemonActionRenameSession
200+ daemonActionRenameTab
201+)
202+
203+type daemonActionTarget struct {
204+ session *session
205+ tab *tab
206+ pane *pane
207+}
208+
209+type daemonActionRequest struct {
210+ kind daemonActionKind
211+ target daemonActionTarget
212+ direction layout.Direction
213+ viewport domain.Size
214+ name string
215+}
216+
217+type daemonActionRunner interface {
218+ Run(daemonActionRequest) error
219+}
220+
221+func resolveDaemonActionTarget(sess *session) daemonActionTarget {
222+ if sess == nil {
223+ return daemonActionTarget{}
224+ }
225+ tb := sess.activeTab()
226+ if tb == nil {
227+ return daemonActionTarget{session: sess}
228+ }
229+ tb.mu.Lock()
230+ pane := tb.focusedPane()
231+ tb.mu.Unlock()
232+ return daemonActionTarget{session: sess, tab: tb, pane: pane}
233+}
234+
235+// daemonActions is the daemon-owned mutation seam shared by palette and
236+// control. Requests contain already-resolved targets and no attachment state;
237+// adapters remain responsible for their attached-client UI lifecycle.
238+type daemonActions struct{ d *Daemon }
239+
240+func (a daemonActions) Run(request daemonActionRequest) error {
241+ target := request.target
242+ switch request.kind {
243+ case daemonActionCreateTab:
244+ return a.d.createTab(target.session, request.viewport)
245+ case daemonActionCloseTab:
246+ return a.d.closeTab(target.session, target.tab, true)
247+ case daemonActionSplitPane:
248+ return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction)
249+ case daemonActionStackPane:
250+ return a.d.stackPaneAt(target.session, target.tab, target.pane)
251+ case daemonActionToggleStack:
252+ return a.d.toggleStackAt(target.session, target.tab, target.pane)
253+ case daemonActionClosePane:
254+ if a.d.ptys == nil {
255+ return nil
256+ }
257+ if !a.d.hasDaemonActionPaneTarget(target) {
258+ return layout.ErrNotFound
259+ }
260+ if err := a.d.closePane(target.session, target.tab, target.pane.id, nil, true); err != nil {
261+ return err
262+ }
263+ if a.d.hasDaemonActionPaneTarget(target) {
264+ return errors.New("pane close did not complete")
265+ }
266+ return nil
267+ case daemonActionFocusPane:
268+ return a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
269+ case daemonActionNextTab:
270+ return a.switchRelative(target.session, 1)
271+ case daemonActionPreviousTab:
272+ return a.switchRelative(target.session, -1)
273+ case daemonActionRenameSession:
274+ return a.d.renameSession(target.session, request.name)
275+ case daemonActionRenameTab:
276+ return a.d.renameTab(target.session, target.tab, request.name)
277+ default:
278+ return errors.New("daemon: unknown action")
279+ }
280+}
281+
282+// hasDaemonActionPaneTarget verifies that an already-resolved pane still
283+// belongs to the live tab and session. Close-pane uses it both before mutation
284+// and as a postcondition because closing a final pane delegates to close-tab.
285+func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool {
286+ if target.session == nil || target.tab == nil || target.pane == nil {
287+ return false
288+ }
289+ d.mu.Lock()
290+ defer d.mu.Unlock()
291+ if d.sessions[target.session.id] != target.session {
292+ return false
293+ }
294+ target.session.mu.Lock()
295+ defer target.session.mu.Unlock()
296+ foundTab := false
297+ for _, tb := range target.session.tabs {
298+ if tb == target.tab {
299+ foundTab = true
300+ break
301+ }
302+ }
303+ if !foundTab {
304+ return false
305+ }
306+ target.tab.mu.Lock()
307+ defer target.tab.mu.Unlock()
308+ return target.tab.panes[target.pane.id] == target.pane &&
309+ target.tab.tree != nil && layout.ContainsLeaf(target.tab.tree.Root, target.pane.id)
310+}
311+
312+func (a daemonActions) switchRelative(sess *session, delta int) error {
313+ if sess.switchRelative(delta) {
314+ a.d.activateTab(sess, sess.activeTab())
315+ }
316+ return nil
317+}
318+
319+// controlExec implements command.ControlContext against resolved daemon-owned
320+// targets. It deliberately contains no attached-client state.
321+type controlExec struct {
322+ d *Daemon
323+ sess *session
324+ tab *tab
325+ target daemonActionTarget
326+ actions daemonActionRunner
327+}
328+
329+func (e controlExec) runAction(request daemonActionRequest) error {
330+ if request.target.session == nil {
331+ request.target = e.target
332+ }
333+ runner := e.actions
334+ if runner == nil {
335+ runner = daemonActions{d: e.d}
336+ }
337+ err := runner.Run(request)
338+ if err == nil && e.actions == nil {
339+ e.sess.mu.Lock()
340+ ac := e.sess.client
341+ e.sess.mu.Unlock()
342+ finishDaemonActionForClient(e.d, request, ac, "control.go")
343+ }
344+ return err
345+}
346+
347+func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
348+ if ac == nil {
349+ return
350+ }
351+ if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {
352+ tb := request.target.tab
353+ tb.mu.Lock()
354+ newFocus := tb.tree.Focus
355+ pl, hasPlacement := focusedPlacementLocked(tb)
356+ tb.mu.Unlock()
357+ if newFocus != request.target.pane.id {
358+ d.exitCopyMode(ac)
359+ if hasPlacement && pl.TitleBar.Height > 0 {
360+ d.refreshPaneTitleOnFocus(request.target.session, newFocus)
361+ }
362+ }
363+ }
364+ d.invalidateRender(request.target.session, ac, true, producer)
365+}
366+
367+func (e controlExec) CreateTab() error {
368+ return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()})
369+}
370+func (e controlExec) CreateSessionNamed(name string) error {
371+ if err := domain.ValidateSessionName(name); err != nil {
372+ return command.ErrInvalidArguments
373+ }
374+ e.sess.mu.Lock()
375+ cwd, term, env := e.sess.cwd, e.sess.terminal, copyEnvironment(e.sess.env)
376+ e.sess.mu.Unlock()
377+ size := e.sess.fullViewportSize()
378+ e.d.mu.Lock()
379+ defer e.d.mu.Unlock()
380+ if e.d.closing {
381+ return errors.New("daemon is shutting down")
382+ }
383+ if e.d.nameLiveOrStoppedLocked(name) {
384+ return errSessionNameInUse
385+ }
386+ _, err := e.d.createSessionLocked(name, false, cwd, size, term, env)
387+ return err
388+}
389+func (e controlExec) CloseTab() error {
390+ return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
391+}
392+func (e controlExec) ClosePane() error {
393+ return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
394+}
395+func (e controlExec) split(direction layout.Direction) error {
396+ return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
397+}
398+func (e controlExec) SplitRight() error { return e.split(layout.Right) }
399+func (e controlExec) SplitLeft() error { return e.split(layout.Left) }
400+func (e controlExec) SplitUp() error { return e.split(layout.Up) }
401+func (e controlExec) SplitDown() error { return e.split(layout.Down) }
402+func (e controlExec) StackPane() error {
403+ return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
404+}
405+func (e controlExec) ToggleStack() error {
406+ return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
407+}
408+func (e controlExec) focus(direction layout.Direction) error {
409+ return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
410+}
411+func (e controlExec) FocusPaneLeft() error { return e.focus(layout.Left) }
412+func (e controlExec) FocusPaneRight() error { return e.focus(layout.Right) }
413+func (e controlExec) FocusPaneUp() error { return e.focus(layout.Up) }
414+func (e controlExec) FocusPaneDown() error { return e.focus(layout.Down) }
415+func (e controlExec) NextTab() error {
416+ return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
417+}
418+func (e controlExec) PrevTab() error {
419+ return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
420+}
421+func (e controlExec) RenameSessionTo(name string) error {
422+ return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
423+}
424+func (e controlExec) RenameTabTo(name string) error {
425+ return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, name: name})
426+}
427+func (e controlExec) Toast(severity, message string) error {
428+ var level domain.NoticeSeverity
429+ switch severity {
430+ case "info":
431+ level = domain.NoticeInfo
432+ case "warn":
433+ level = domain.NoticeWarn
434+ case "error":
435+ level = domain.NoticeError
436+ default:
437+ return command.ErrInvalidArguments
438+ }
439+ if strings.TrimSpace(message) == "" {
440+ return command.ErrInvalidArguments
441+ }
442+ e.d.notify(e.sess, level, domain.NoticeUser, message, nil)
443+ return nil
444+}
445+
446+func (e controlExec) ListSessions(asJSON bool) (string, error) {
447+ type row struct {
448+ Name string `json:"name"`
449+ Ephemeral bool `json:"ephemeral"`
450+ Tabs int `json:"tabs"`
451+ Attached bool `json:"attached"`
452+ Active bool `json:"active"`
453+ }
454+ e.d.mu.Lock()
455+ sessions := make([]*session, 0, len(e.d.sessions))
456+ for _, sess := range e.d.sessions {
457+ sessions = append(sessions, sess)
458+ }
459+ e.d.mu.Unlock()
460+ var active *session
461+ for _, sess := range sessions {
462+ if active == nil || sess.mruAt.Load() > active.mruAt.Load() {
463+ active = sess
464+ }
465+ }
466+ rows := make([]row, 0, len(sessions))
467+ for _, sess := range sessions {
468+ sess.mu.Lock()
469+ rows = append(rows, row{Name: sess.name, Ephemeral: sess.ephemeral, Tabs: len(sess.tabs), Attached: sess.client != nil, Active: sess == active})
470+ sess.mu.Unlock()
471+ }
472+ sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
473+ if asJSON {
474+ return marshalListing(rows)
475+ }
476+ var out strings.Builder
477+ out.WriteString("NAME\tSTATE\tTABS\tATTACHED\tACTIVE\n")
478+ for _, row := range rows {
479+ state := "named"
480+ if row.Ephemeral {
481+ state = "ephemeral"
482+ }
483+ fmt.Fprintf(&out, "%s\t%s\t%d\t%t\t%t\n", row.Name, state, row.Tabs, row.Attached, row.Active)
484+ }
485+ return out.String(), nil
486+}
487+
488+func (e controlExec) ListTabs(asJSON bool) (string, error) {
489+ type row struct {
490+ Index int `json:"index"`
491+ ID string `json:"id"`
492+ Name string `json:"name"`
493+ Panes int `json:"panes"`
494+ Active bool `json:"active"`
495+ }
496+ e.sess.mu.Lock()
497+ tabs := append([]*tab(nil), e.sess.tabs...)
498+ active := e.sess.active
499+ e.sess.mu.Unlock()
500+ rows := make([]row, 0, len(tabs))
501+ for i, tb := range tabs {
502+ tb.mu.Lock()
503+ rows = append(rows, row{Index: i, ID: tb.stableID, Name: tb.name, Panes: len(tb.panes), Active: i == active})
504+ tb.mu.Unlock()
505+ }
506+ if asJSON {
507+ return marshalListing(rows)
508+ }
509+ var out strings.Builder
510+ out.WriteString("INDEX\tID\tNAME\tPANES\tACTIVE\n")
511+ for _, row := range rows {
512+ fmt.Fprintf(&out, "%d\t%s\t%s\t%d\t%t\n", row.Index, row.ID, row.Name, row.Panes, row.Active)
513+ }
514+ return out.String(), nil
515+}
516+
517+func (e controlExec) ListPanes(asJSON bool) (string, error) {
518+ type row struct {
519+ ID string `json:"id"`
520+ Pane string `json:"pane"`
521+ Size string `json:"size"`
522+ CWD string `json:"cwd"`
523+ Focused bool `json:"focused"`
524+ }
525+ tb := e.tab
526+ if tb == nil {
527+ tb = e.sess.activeTab()
528+ }
529+ if tb == nil {
530+ return "", errors.New("target session has no active tab")
531+ }
532+ e.sess.mu.Lock()
533+ fallbackCWD := e.sess.cwd
534+ e.sess.mu.Unlock()
535+ tb.mu.Lock()
536+ focus := tb.tree.Focus
537+ panes := tb.panesSnapshot()
538+ tb.mu.Unlock()
539+ rows := make([]row, 0, len(panes))
540+ for _, pane := range panes {
541+ pane.mu.Lock()
542+ size := domain.Size{Cols: pane.rect.Width, Rows: pane.rect.Height}
543+ pid := 0
544+ if pane.pty != nil {
545+ pid = pane.pty.Pid()
546+ }
547+ pane.mu.Unlock()
548+ cwd := fallbackCWD
549+ if e.d.procCwd != nil && pid > 0 {
550+ if live, err := e.d.procCwd(pid); err == nil && live != "" {
551+ cwd = live
552+ }
553+ }
554+ rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: pane.id == focus})
555+ }
556+ sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane })
557+ if asJSON {
558+ return marshalListing(rows)
559+ }
560+ var out strings.Builder
561+ out.WriteString("ID\tPANE\tSIZE\tCWD\tFOCUSED\n")
562+ for _, row := range rows {
563+ fmt.Fprintf(&out, "%s\t%s\t%s\t%s\t%t\n", row.ID, row.Pane, row.Size, row.CWD, row.Focused)
564+ }
565+ return out.String(), nil
566+}
567+
568+func marshalListing(value any) (string, error) {
569+ encoded, err := json.Marshal(value)
570+ if err != nil {
571+ return "", err
572+ }
573+ return string(encoded) + "\n", nil
574+}
@@ -0,0 +1,574 @@
1+package daemon
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "sort"
8+ "strings"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/usecase/command"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+)
15+
16+// handleCommand serves one one-shot control request. The leading version is
17+// checked before decoding the versioned body so newer layouts fail cleanly.
18+func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) {
19+ defer func() { _ = tr.Close() }()
20+
21+ if version, ok := ports.PeekCommandVersion(f.Payload); !ok || version != ports.ProtocolVersion {
22+ _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}))
23+ return
24+ }
25+ request, err := ports.UnmarshalCommandRequest(f.Payload)
26+ if err != nil {
27+ _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"}))
28+ return
29+ }
30+ _ = tr.Send(frameCommandResult(d.dispatchCommand(request)))
31+}
32+
33+func frameCommandResult(result ports.CommandResult) ports.Frame {
34+ return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}
35+}
36+
37+func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResult {
38+ cmd, ok := command.BySlug(request.Slug)
39+ if !ok {
40+ return commandFailure(ports.ErrUnknownCommand, "unknown command: "+request.Slug)
41+ }
42+ if !cmd.Scriptable || cmd.Control == nil {
43+ return commandFailure(ports.ErrNotScriptable, request.Slug+" requires an attached client")
44+ }
45+ if cmd.Target == command.TargetNone && !request.Self {
46+ return d.runControl(cmd, controlExec{d: d}, request)
47+ }
48+
49+ sess, code, text := d.resolveTargetSession(request)
50+ if sess == nil {
51+ return commandFailure(code, text)
52+ }
53+ sess.dispatchMu.Lock()
54+ defer sess.dispatchMu.Unlock()
55+
56+ tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
57+ if code != 0 {
58+ return commandFailure(code, text)
59+ }
60+ return d.runControl(cmd, controlExec{d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
61+}
62+
63+func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports.CommandRequest) ports.CommandResult {
64+ result, err := cmd.Control(exec, request.Args, command.ControlOptions{JSON: request.JSON})
65+ if err == nil {
66+ return ports.CommandResult{OK: true, Output: result.Output}
67+ }
68+ switch {
69+ case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired), errors.Is(err, domain.ErrInvalidSessionName):
70+ return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)
71+ case errors.Is(err, errSessionNameInUse):
72+ return commandFailure(ports.ErrNameTaken, err.Error())
73+ default:
74+ return commandFailure(ports.ErrInternal, err.Error())
75+ }
76+}
77+
78+func commandFailure(code uint16, text string) ports.CommandResult {
79+ return ports.CommandResult{Code: code, Text: text}
80+}
81+
82+// resolveTargetSession applies explicit-name, stable-ID, then unique-session
83+// resolution. A name paired with IDs is advisory only when no live session has
84+// that name (the VEV value can survive a rename).
85+func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, uint16, string) {
86+ d.mu.Lock()
87+ defer d.mu.Unlock()
88+
89+ named := d.findByNameLocked(request.TargetSession)
90+ if request.Self && (request.TargetTab == "" || request.TargetPane == "") {
91+ return nil, ports.ErrNoSuchTarget, "--self requires target tab and pane IDs"
92+ }
93+ if request.TargetSession != "" && request.TargetTab == "" && request.TargetPane == "" {
94+ if named == nil {
95+ return nil, ports.ErrNoSuchTarget, "no such session: " + request.TargetSession
96+ }
97+ return named, 0, ""
98+ }
99+ if request.TargetTab != "" || request.TargetPane != "" {
100+ if request.TargetTab == "" || request.TargetPane == "" {
101+ return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
102+ }
103+ for _, sess := range d.sessions {
104+ if sess.containsStableIDs(request.TargetTab, request.TargetPane) {
105+ if named != nil && named != sess {
106+ return nil, ports.ErrNoSuchTarget, "tab/pane IDs belong to another session"
107+ }
108+ return sess, 0, ""
109+ }
110+ }
111+ return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"
112+ }
113+ if len(d.sessions) == 0 {
114+ return nil, ports.ErrNoSuchTarget, "no live sessions"
115+ }
116+ if len(d.sessions) != 1 {
117+ return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane"
118+ }
119+ for _, sess := range d.sessions {
120+ return sess, 0, ""
121+ }
122+ panic("unreachable")
123+}
124+
125+func (s *session) containsStableIDs(tabID, paneID string) bool {
126+ s.mu.Lock()
127+ tabs := append([]*tab(nil), s.tabs...)
128+ s.mu.Unlock()
129+ for _, tb := range tabs {
130+ tb.mu.Lock()
131+ pane := paneByStableIDLocked(tb, paneID)
132+ match := tb.stableID == tabID && pane != nil
133+ tb.mu.Unlock()
134+ if match {
135+ return true
136+ }
137+ }
138+ return false
139+}
140+
141+func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) {
142+ sess.mu.Lock()
143+ tabs := append([]*tab(nil), sess.tabs...)
144+ active := sess.active
145+ sess.mu.Unlock()
146+ if request.Self {
147+ for _, tb := range tabs {
148+ tb.mu.Lock()
149+ if tb.stableID != request.TargetTab {
150+ tb.mu.Unlock()
151+ continue
152+ }
153+ target := paneByStableIDLocked(tb, request.TargetPane)
154+ tb.mu.Unlock()
155+ if target == nil {
156+ return nil, nil, ports.ErrNoSuchTarget, "target pane does not belong to target tab"
157+ }
158+ return tb, target, 0, ""
159+ }
160+ return nil, nil, ports.ErrNoSuchTarget, "no such target tab"
161+ }
162+ if len(tabs) == 0 || active < 0 || active >= len(tabs) {
163+ return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab"
164+ }
165+ tb := tabs[active]
166+ if kind != command.TargetPane {
167+ return tb, nil, 0, ""
168+ }
169+ tb.mu.Lock()
170+ target := tb.focusedPane()
171+ tb.mu.Unlock()
172+ if target == nil {
173+ return nil, nil, ports.ErrNoSuchTarget, "no such target pane"
174+ }
175+ return tb, target, 0, ""
176+}
177+
178+func paneByStableIDLocked(tb *tab, stableID string) *pane {
179+ for _, pane := range tb.panes {
180+ if pane.stableID == stableID {
181+ return pane
182+ }
183+ }
184+ return nil
185+}
186+
187+type daemonActionKind uint8
188+
189+const (
190+ daemonActionCreateTab daemonActionKind = iota
191+ daemonActionCloseTab
192+ daemonActionSplitPane
193+ daemonActionStackPane
194+ daemonActionToggleStack
195+ daemonActionClosePane
196+ daemonActionFocusPane
197+ daemonActionNextTab
198+ daemonActionPreviousTab
199+ daemonActionRenameSession
200+ daemonActionRenameTab
201+)
202+
203+type daemonActionTarget struct {
204+ session *session
205+ tab *tab
206+ pane *pane
207+}
208+
209+type daemonActionRequest struct {
210+ kind daemonActionKind
211+ target daemonActionTarget
212+ direction layout.Direction
213+ viewport domain.Size
214+ name string
215+}
216+
217+type daemonActionRunner interface {
218+ Run(daemonActionRequest) error
219+}
220+
221+func resolveDaemonActionTarget(sess *session) daemonActionTarget {
222+ if sess == nil {
223+ return daemonActionTarget{}
224+ }
225+ tb := sess.activeTab()
226+ if tb == nil {
227+ return daemonActionTarget{session: sess}
228+ }
229+ tb.mu.Lock()
230+ pane := tb.focusedPane()
231+ tb.mu.Unlock()
232+ return daemonActionTarget{session: sess, tab: tb, pane: pane}
233+}
234+
235+// daemonActions is the daemon-owned mutation seam shared by palette and
236+// control. Requests contain already-resolved targets and no attachment state;
237+// adapters remain responsible for their attached-client UI lifecycle.
238+type daemonActions struct{ d *Daemon }
239+
240+func (a daemonActions) Run(request daemonActionRequest) error {
241+ target := request.target
242+ switch request.kind {
243+ case daemonActionCreateTab:
244+ return a.d.createTab(target.session, request.viewport)
245+ case daemonActionCloseTab:
246+ return a.d.closeTab(target.session, target.tab, true)
247+ case daemonActionSplitPane:
248+ return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction)
249+ case daemonActionStackPane:
250+ return a.d.stackPaneAt(target.session, target.tab, target.pane)
251+ case daemonActionToggleStack:
252+ return a.d.toggleStackAt(target.session, target.tab, target.pane)
253+ case daemonActionClosePane:
254+ if a.d.ptys == nil {
255+ return nil
256+ }
257+ if !a.d.hasDaemonActionPaneTarget(target) {
258+ return layout.ErrNotFound
259+ }
260+ if err := a.d.closePane(target.session, target.tab, target.pane.id, nil, true); err != nil {
261+ return err
262+ }
263+ if a.d.hasDaemonActionPaneTarget(target) {
264+ return errors.New("pane close did not complete")
265+ }
266+ return nil
267+ case daemonActionFocusPane:
268+ return a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
269+ case daemonActionNextTab:
270+ return a.switchRelative(target.session, 1)
271+ case daemonActionPreviousTab:
272+ return a.switchRelative(target.session, -1)
273+ case daemonActionRenameSession:
274+ return a.d.renameSession(target.session, request.name)
275+ case daemonActionRenameTab:
276+ return a.d.renameTab(target.session, target.tab, request.name)
277+ default:
278+ return errors.New("daemon: unknown action")
279+ }
280+}
281+
282+// hasDaemonActionPaneTarget verifies that an already-resolved pane still
283+// belongs to the live tab and session. Close-pane uses it both before mutation
284+// and as a postcondition because closing a final pane delegates to close-tab.
285+func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool {
286+ if target.session == nil || target.tab == nil || target.pane == nil {
287+ return false
288+ }
289+ d.mu.Lock()
290+ defer d.mu.Unlock()
291+ if d.sessions[target.session.id] != target.session {
292+ return false
293+ }
294+ target.session.mu.Lock()
295+ defer target.session.mu.Unlock()
296+ foundTab := false
297+ for _, tb := range target.session.tabs {
298+ if tb == target.tab {
299+ foundTab = true
300+ break
301+ }
302+ }
303+ if !foundTab {
304+ return false
305+ }
306+ target.tab.mu.Lock()
307+ defer target.tab.mu.Unlock()
308+ return target.tab.panes[target.pane.id] == target.pane &&
309+ target.tab.tree != nil && layout.ContainsLeaf(target.tab.tree.Root, target.pane.id)
310+}
311+
312+func (a daemonActions) switchRelative(sess *session, delta int) error {
313+ if sess.switchRelative(delta) {
314+ a.d.activateTab(sess, sess.activeTab())
315+ }
316+ return nil
317+}
318+
319+// controlExec implements command.ControlContext against resolved daemon-owned
320+// targets. It deliberately contains no attached-client state.
321+type controlExec struct {
322+ d *Daemon
323+ sess *session
324+ tab *tab
325+ target daemonActionTarget
326+ actions daemonActionRunner
327+}
328+
329+func (e controlExec) runAction(request daemonActionRequest) error {
330+ if request.target.session == nil {
331+ request.target = e.target
332+ }
333+ runner := e.actions
334+ if runner == nil {
335+ runner = daemonActions{d: e.d}
336+ }
337+ err := runner.Run(request)
338+ if err == nil && e.actions == nil {
339+ e.sess.mu.Lock()
340+ ac := e.sess.client
341+ e.sess.mu.Unlock()
342+ finishDaemonActionForClient(e.d, request, ac, "control.go")
343+ }
344+ return err
345+}
346+
347+func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
348+ if ac == nil {
349+ return
350+ }
351+ if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {
352+ tb := request.target.tab
353+ tb.mu.Lock()
354+ newFocus := tb.tree.Focus
355+ pl, hasPlacement := focusedPlacementLocked(tb)
356+ tb.mu.Unlock()
357+ if newFocus != request.target.pane.id {
358+ d.exitCopyMode(ac)
359+ if hasPlacement && pl.TitleBar.Height > 0 {
360+ d.refreshPaneTitleOnFocus(request.target.session, newFocus)
361+ }
362+ }
363+ }
364+ d.invalidateRender(request.target.session, ac, true, producer)
365+}
366+
367+func (e controlExec) CreateTab() error {
368+ return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()})
369+}
370+func (e controlExec) CreateSessionNamed(name string) error {
371+ if err := domain.ValidateSessionName(name); err != nil {
372+ return command.ErrInvalidArguments
373+ }
374+ e.sess.mu.Lock()
375+ cwd, term, env := e.sess.cwd, e.sess.terminal, copyEnvironment(e.sess.env)
376+ e.sess.mu.Unlock()
377+ size := e.sess.fullViewportSize()
378+ e.d.mu.Lock()
379+ defer e.d.mu.Unlock()
380+ if e.d.closing {
381+ return errors.New("daemon is shutting down")
382+ }
383+ if e.d.nameLiveOrStoppedLocked(name) {
384+ return errSessionNameInUse
385+ }
386+ _, err := e.d.createSessionLocked(name, false, cwd, size, term, env)
387+ return err
388+}
389+func (e controlExec) CloseTab() error {
390+ return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
391+}
392+func (e controlExec) ClosePane() error {
393+ return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
394+}
395+func (e controlExec) split(direction layout.Direction) error {
396+ return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
397+}
398+func (e controlExec) SplitRight() error { return e.split(layout.Right) }
399+func (e controlExec) SplitLeft() error { return e.split(layout.Left) }
400+func (e controlExec) SplitUp() error { return e.split(layout.Up) }
401+func (e controlExec) SplitDown() error { return e.split(layout.Down) }
402+func (e controlExec) StackPane() error {
403+ return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
404+}
405+func (e controlExec) ToggleStack() error {
406+ return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
407+}
408+func (e controlExec) focus(direction layout.Direction) error {
409+ return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
410+}
411+func (e controlExec) FocusPaneLeft() error { return e.focus(layout.Left) }
412+func (e controlExec) FocusPaneRight() error { return e.focus(layout.Right) }
413+func (e controlExec) FocusPaneUp() error { return e.focus(layout.Up) }
414+func (e controlExec) FocusPaneDown() error { return e.focus(layout.Down) }
415+func (e controlExec) NextTab() error {
416+ return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
417+}
418+func (e controlExec) PrevTab() error {
419+ return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
420+}
421+func (e controlExec) RenameSessionTo(name string) error {
422+ return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
423+}
424+func (e controlExec) RenameTabTo(name string) error {
425+ return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, name: name})
426+}
427+func (e controlExec) Toast(severity, message string) error {
428+ var level domain.NoticeSeverity
429+ switch severity {
430+ case "info":
431+ level = domain.NoticeInfo
432+ case "warn":
433+ level = domain.NoticeWarn
434+ case "error":
435+ level = domain.NoticeError
436+ default:
437+ return command.ErrInvalidArguments
438+ }
439+ if strings.TrimSpace(message) == "" {
440+ return command.ErrInvalidArguments
441+ }
442+ e.d.notify(e.sess, level, domain.NoticeUser, message, nil)
443+ return nil
444+}
445+
446+func (e controlExec) ListSessions(asJSON bool) (string, error) {
447+ type row struct {
448+ Name string `json:"name"`
449+ Ephemeral bool `json:"ephemeral"`
450+ Tabs int `json:"tabs"`
451+ Attached bool `json:"attached"`
452+ Active bool `json:"active"`
453+ }
454+ e.d.mu.Lock()
455+ sessions := make([]*session, 0, len(e.d.sessions))
456+ for _, sess := range e.d.sessions {
457+ sessions = append(sessions, sess)
458+ }
459+ e.d.mu.Unlock()
460+ var active *session
461+ for _, sess := range sessions {
462+ if active == nil || sess.mruAt.Load() > active.mruAt.Load() {
463+ active = sess
464+ }
465+ }
466+ rows := make([]row, 0, len(sessions))
467+ for _, sess := range sessions {
468+ sess.mu.Lock()
469+ rows = append(rows, row{Name: sess.name, Ephemeral: sess.ephemeral, Tabs: len(sess.tabs), Attached: sess.client != nil, Active: sess == active})
470+ sess.mu.Unlock()
471+ }
472+ sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
473+ if asJSON {
474+ return marshalListing(rows)
475+ }
476+ var out strings.Builder
477+ out.WriteString("NAME\tSTATE\tTABS\tATTACHED\tACTIVE\n")
478+ for _, row := range rows {
479+ state := "named"
480+ if row.Ephemeral {
481+ state = "ephemeral"
482+ }
483+ fmt.Fprintf(&out, "%s\t%s\t%d\t%t\t%t\n", row.Name, state, row.Tabs, row.Attached, row.Active)
484+ }
485+ return out.String(), nil
486+}
487+
488+func (e controlExec) ListTabs(asJSON bool) (string, error) {
489+ type row struct {
490+ Index int `json:"index"`
491+ ID string `json:"id"`
492+ Name string `json:"name"`
493+ Panes int `json:"panes"`
494+ Active bool `json:"active"`
495+ }
496+ e.sess.mu.Lock()
497+ tabs := append([]*tab(nil), e.sess.tabs...)
498+ active := e.sess.active
499+ e.sess.mu.Unlock()
500+ rows := make([]row, 0, len(tabs))
501+ for i, tb := range tabs {
502+ tb.mu.Lock()
503+ rows = append(rows, row{Index: i, ID: tb.stableID, Name: tb.name, Panes: len(tb.panes), Active: i == active})
504+ tb.mu.Unlock()
505+ }
506+ if asJSON {
507+ return marshalListing(rows)
508+ }
509+ var out strings.Builder
510+ out.WriteString("INDEX\tID\tNAME\tPANES\tACTIVE\n")
511+ for _, row := range rows {
512+ fmt.Fprintf(&out, "%d\t%s\t%s\t%d\t%t\n", row.Index, row.ID, row.Name, row.Panes, row.Active)
513+ }
514+ return out.String(), nil
515+}
516+
517+func (e controlExec) ListPanes(asJSON bool) (string, error) {
518+ type row struct {
519+ ID string `json:"id"`
520+ Pane string `json:"pane"`
521+ Size string `json:"size"`
522+ CWD string `json:"cwd"`
523+ Focused bool `json:"focused"`
524+ }
525+ tb := e.tab
526+ if tb == nil {
527+ tb = e.sess.activeTab()
528+ }
529+ if tb == nil {
530+ return "", errors.New("target session has no active tab")
531+ }
532+ e.sess.mu.Lock()
533+ fallbackCWD := e.sess.cwd
534+ e.sess.mu.Unlock()
535+ tb.mu.Lock()
536+ focus := tb.tree.Focus
537+ panes := tb.panesSnapshot()
538+ tb.mu.Unlock()
539+ rows := make([]row, 0, len(panes))
540+ for _, pane := range panes {
541+ pane.mu.Lock()
542+ size := domain.Size{Cols: pane.rect.Width, Rows: pane.rect.Height}
543+ pid := 0
544+ if pane.pty != nil {
545+ pid = pane.pty.Pid()
546+ }
547+ pane.mu.Unlock()
548+ cwd := fallbackCWD
549+ if e.d.procCwd != nil && pid > 0 {
550+ if live, err := e.d.procCwd(pid); err == nil && live != "" {
551+ cwd = live
552+ }
553+ }
554+ rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: pane.id == focus})
555+ }
556+ sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane })
557+ if asJSON {
558+ return marshalListing(rows)
559+ }
560+ var out strings.Builder
561+ out.WriteString("ID\tPANE\tSIZE\tCWD\tFOCUSED\n")
562+ for _, row := range rows {
563+ fmt.Fprintf(&out, "%s\t%s\t%s\t%s\t%t\n", row.ID, row.Pane, row.Size, row.CWD, row.Focused)
564+ }
565+ return out.String(), nil
566+}
567+
568+func marshalListing(value any) (string, error) {
569+ encoded, err := json.Marshal(value)
570+ if err != nil {
571+ return "", err
572+ }
573+ return string(encoded) + "\n", nil
574+}
A internal/usecase/daemon/control_test.go +589 -0
@@ -0,0 +1,589 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+ "github.com/bnema/vev/internal/usecase/command"
14+ "github.com/bnema/vev/internal/usecase/layout"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
19+ d := newTestDaemon(t, nil, stubClock{})
20+ sess := addControlSession(d, "work", "t_work", "p_work")
21+ tb := sess.activeTab()
22+ tb.mu.Lock()
23+ pane := tb.focusedPane()
24+ tb.mu.Unlock()
25+ spy := &actionRunnerSpy{}
26+
27+ require.NoError(t, (paletteExec{d: d, sess: sess, actions: spy}).SplitRight())
28+ require.NoError(t, (controlExec{
29+ d: d, sess: sess, tab: tb, actions: spy,
30+ target: daemonActionTarget{session: sess, tab: tb, pane: pane},
31+ }).SplitRight())
32+
33+ require.Len(t, spy.requests, 2)
34+ for _, request := range spy.requests {
35+ require.Equal(t, daemonActionSplitPane, request.kind)
36+ require.Same(t, sess, request.target.session)
37+ require.Same(t, tb, request.target.tab)
38+ require.Same(t, pane, request.target.pane)
39+ require.Equal(t, layout.Right, request.direction)
40+ }
41+}
42+
43+type actionRunnerSpy struct {
44+ requests []daemonActionRequest
45+}
46+
47+func (s *actionRunnerSpy) Run(request daemonActionRequest) error {
48+ s.requests = append(s.requests, request)
49+ return nil
50+}
51+
52+func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
53+ // The version prefix is valid and mismatched, while the remainder is not a
54+ // decodable request. The daemon must still return ErrVersionMismatch.
55+ frame := ports.Frame{Type: ports.MsgCommand, Payload: []byte{0, byte(ports.ProtocolVersion + 1)}}
56+ tr, sends, _ := newConn(t, frame)
57+
58+ d := newTestDaemon(t, nil, stubClock{})
59+ d.handleCommand(tr, frame)
60+
61+ result := awaitCommandResult(t, sends)
62+ require.False(t, result.OK)
63+ require.Equal(t, ports.ErrVersionMismatch, result.Code)
64+}
65+
66+func TestHandleConnRoutesCommand(t *testing.T) {
67+ d := newTestDaemon(t, nil, stubClock{})
68+ request := ports.CommandRequest{Version: ports.ProtocolVersion, Slug: "list-sessions"}
69+ payload, err := ports.MarshalCommandRequest(request)
70+ require.NoError(t, err)
71+ frame := ports.Frame{Type: ports.MsgCommand, Payload: payload}
72+ tr, sends, _ := newConn(t, frame)
73+
74+ d.handleConn(tr)
75+
76+ result := awaitCommandResult(t, sends)
77+ require.True(t, result.OK, result.Text)
78+}
79+
80+func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ arrange func(*Daemon)
84+ request ports.CommandRequest
85+ code uint16
86+ }{
87+ {name: "unknown slug", request: ports.CommandRequest{Slug: "no-such"}, code: ports.ErrUnknownCommand},
88+ {name: "non-scriptable command", request: ports.CommandRequest{Slug: "session-picker"}, code: ports.ErrNotScriptable},
89+ {name: "no sessions", request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrNoSuchTarget},
90+ {name: "ambiguous sessions", arrange: func(d *Daemon) {
91+ addControlSession(d, "one", "t_one", "p_one")
92+ addControlSession(d, "two", "t_two", "p_two")
93+ }, request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrAmbiguousTarget},
94+ {name: "missing explicit session", arrange: func(d *Daemon) {
95+ addControlSession(d, "work", "t_work", "p_work")
96+ }, request: ports.CommandRequest{Slug: "new-tab", TargetSession: "missing"}, code: ports.ErrNoSuchTarget},
97+ {name: "missing stable IDs", arrange: func(d *Daemon) {
98+ addControlSession(d, "work", "t_work", "p_work")
99+ }, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget},
100+ {name: "cross-session IDs rejected", arrange: func(d *Daemon) {
101+ addControlSession(d, "one", "t_one", "p_one")
102+ addControlSession(d, "two", "t_two", "p_two")
103+ }, request: ports.CommandRequest{Slug: "split-right", TargetSession: "one", TargetTab: "t_two", TargetPane: "p_two"}, code: ports.ErrNoSuchTarget},
104+ {name: "self requires both IDs", arrange: func(d *Daemon) {
105+ addControlSession(d, "work", "t_work", "p_work")
106+ }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget},
107+ {name: "self rejects pane from another tab", arrange: func(d *Daemon) {
108+ sess := addControlSession(d, "work", "t_work", "p_work")
109+ other := newTabWithStableID("t_other", "p_other", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
110+ other.ctx, other.cancel = context.WithCancel(d.serveCtx)
111+ sess.tabs = append(sess.tabs, other)
112+ }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work", TargetPane: "p_other"}, code: ports.ErrNoSuchTarget},
113+ }
114+
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ d := newTestDaemon(t, nil, stubClock{})
118+ if tt.arrange != nil {
119+ tt.arrange(d)
120+ }
121+ result := sendCommand(t, d, tt.request)
122+ require.False(t, result.OK)
123+ require.Equal(t, tt.code, result.Code)
124+ })
125+ }
126+}
127+
128+func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) {
129+ d := newTestDaemon(t, nil, stubClock{})
130+ sess := addControlSession(d, "renamed", "t_work", "p_work")
131+
132+ result := sendCommand(t, d, ports.CommandRequest{
133+ Slug: "toast", Args: []string{"hello"},
134+ TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
135+ })
136+
137+ require.True(t, result.OK, result.Text)
138+ require.Equal(t, uint64(1), sess.mruAt.Load())
139+}
140+
141+func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
142+ d := newTestDaemon(t, nil, stubClock{})
143+ sess := addControlSession(d, "work", "t_first", "p_first")
144+ first := sess.tabs[0]
145+ second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
146+ second.ctx, second.cancel = context.WithCancel(d.serveCtx)
147+ sess.mu.Lock()
148+ sess.tabs = append(sess.tabs, second)
149+ sess.active = 0
150+ sess.mu.Unlock()
151+
152+ result := sendCommand(t, d, ports.CommandRequest{
153+ Slug: "rename-tab", Args: []string{"targeted"}, TargetSession: "work",
154+ TargetTab: "t_second", TargetPane: "p_second",
155+ })
156+
157+ require.True(t, result.OK, result.Text)
158+ first.mu.Lock()
159+ require.Equal(t, "targeted", first.name)
160+ first.mu.Unlock()
161+ second.mu.Lock()
162+ require.Empty(t, second.name)
163+ second.mu.Unlock()
164+ sess.mu.Lock()
165+ require.Zero(t, sess.active)
166+ sess.mu.Unlock()
167+}
168+
169+func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) {
170+ factory := &controlPTYFactory{}
171+ d := newTestDaemon(t, factory, stubClock{})
172+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
173+ sess := addControlSession(d, "work", "t_active", "p_active")
174+ active := sess.tabs[0]
175+ originFocus := active.tree.Focus
176+ origin := active.focusedPane()
177+ second := newTabWithStableID("t_origin", "p_origin", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
178+ second.ctx, second.cancel = context.WithCancel(d.serveCtx)
179+ sess.mu.Lock()
180+ sess.tabs = append(sess.tabs, second)
181+ sess.active = 0
182+ sess.mu.Unlock()
183+
184+ result := sendCommand(t, d, ports.CommandRequest{
185+ Slug: "split-right", TargetSession: "work",
186+ TargetTab: "t_origin", TargetPane: "p_origin",
187+ })
188+
189+ require.True(t, result.OK, result.Text)
190+ sess.mu.Lock()
191+ activeIndex := sess.active
192+ sess.mu.Unlock()
193+ require.Zero(t, activeIndex)
194+ active.mu.Lock()
195+ activePaneCount := len(active.panes)
196+ activeFocus := active.tree.Focus
197+ originRetained := active.panes[origin.id] == origin
198+ active.mu.Unlock()
199+ require.Equal(t, 2, activePaneCount, "split must mutate the daemon-focused tab")
200+ require.NotEqual(t, originFocus, activeFocus, "split must focus the new pane beside the daemon-focused pane")
201+ require.True(t, originRetained)
202+ second.mu.Lock()
203+ secondPaneCount := len(second.panes)
204+ second.mu.Unlock()
205+ require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator")
206+}
207+
208+func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
209+ factory := &controlPTYFactory{}
210+ d := newTestDaemon(t, factory, stubClock{})
211+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
212+ sess := addControlSession(d, "work", "t_active", "p_active")
213+ active := sess.tabs[0]
214+ invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
215+ invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
216+ sess.mu.Lock()
217+ sess.tabs = append(sess.tabs, invoking)
218+ sess.active = 0
219+ sess.mu.Unlock()
220+
221+ rename := sendCommand(t, d, ports.CommandRequest{
222+ Slug: "rename-tab", Args: []string{"invoking"}, Self: true,
223+ TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
224+ })
225+ require.True(t, rename.OK, rename.Text)
226+ active.mu.Lock()
227+ require.Empty(t, active.name)
228+ active.mu.Unlock()
229+ invoking.mu.Lock()
230+ require.Equal(t, "invoking", invoking.name)
231+ invoking.mu.Unlock()
232+
233+ split := sendCommand(t, d, ports.CommandRequest{
234+ Slug: "split-right", Self: true,
235+ TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
236+ })
237+ require.True(t, split.OK, split.Text)
238+ active.mu.Lock()
239+ require.Len(t, active.panes, 1)
240+ active.mu.Unlock()
241+ invoking.mu.Lock()
242+ require.Len(t, invoking.panes, 2)
243+ invoking.mu.Unlock()
244+}
245+
246+func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {
247+ d := newTestDaemon(t, nil, stubClock{})
248+ sess := addControlSession(d, "work", "t_active", "p_active")
249+ invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
250+ invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
251+ sess.mu.Lock()
252+ sess.tabs = append(sess.tabs, invoking)
253+ sess.active = 0
254+ sess.mu.Unlock()
255+
256+ result := sendCommand(t, d, ports.CommandRequest{
257+ Slug: "list-panes", Self: true, JSON: true,
258+ TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
259+ })
260+ require.True(t, result.OK, result.Text)
261+ require.Contains(t, result.Output, "p_invoking")
262+ require.NotContains(t, result.Output, "p_active")
263+}
264+
265+func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) {
266+ d := newTestDaemon(t, nil, stubClock{})
267+ addControlSession(d, "work", "t_work", "p_work")
268+
269+ result := sendCommand(t, d, ports.CommandRequest{
270+ Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work",
271+ })
272+
273+ require.False(t, result.OK)
274+ require.Equal(t, ports.ErrInvalidCommandArgs, result.Code)
275+}
276+
277+func TestCloseCommandsReportMutationOutcome(t *testing.T) {
278+ t.Run("stale close tab target fails", func(t *testing.T) {
279+ d := newTestDaemon(t, nil, stubClock{})
280+ sess := addControlSession(d, "work", "t_work", "p_work")
281+ stale := newTabWithStableID("t_stale", "p_stale", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
282+ cmd, ok := command.BySlug("close-tab")
283+ require.True(t, ok)
284+
285+ result := d.runControl(cmd, controlExec{
286+ d: d, sess: sess, tab: stale,
287+ target: daemonActionTarget{session: sess, tab: stale},
288+ }, ports.CommandRequest{Slug: "close-tab"})
289+
290+ require.False(t, result.OK)
291+ require.Equal(t, ports.ErrInternal, result.Code)
292+ require.Len(t, sess.tabs, 1, "a stale close must retain the live tab")
293+ })
294+
295+ t.Run("failed final pane close fails and retains session", func(t *testing.T) {
296+ d := newTestDaemon(t, &controlPTYFactory{}, stubClock{})
297+ d.snapsEnabled = true
298+ d.snapshotRepository = portsmocks.NewMockSnapshotRepository(t)
299+ sess := addControlSession(d, "work", "t_work", "p_work")
300+ sess.mu.Lock()
301+ sess.ephemeral = false
302+ sess.mu.Unlock()
303+ sess.snapEligible.Store(true)
304+
305+ result := sendCommand(t, d, ports.CommandRequest{Slug: "close-pane", TargetSession: "work"})
306+
307+ require.False(t, result.OK)
308+ require.Equal(t, ports.ErrInternal, result.Code)
309+ d.mu.Lock()
310+ require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session")
311+ d.mu.Unlock()
312+ sess.mu.Lock()
313+ require.Len(t, sess.tabs, 1)
314+ sess.mu.Unlock()
315+ })
316+
317+ t.Run("successful close tab remains successful", func(t *testing.T) {
318+ d := newTestDaemon(t, nil, stubClock{})
319+ sess := addControlSession(d, "work", "t_first", "p_first")
320+ second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
321+ second.ctx, second.cancel = context.WithCancel(d.serveCtx)
322+ sess.mu.Lock()
323+ sess.tabs = append(sess.tabs, second)
324+ sess.active = 1
325+ sess.mu.Unlock()
326+
327+ result := sendCommand(t, d, ports.CommandRequest{Slug: "close-tab", TargetSession: "work"})
328+
329+ require.True(t, result.OK, result.Text)
330+ sess.mu.Lock()
331+ require.Len(t, sess.tabs, 1)
332+ require.Equal(t, "t_first", sess.tabs[0].stableID)
333+ sess.mu.Unlock()
334+ })
335+}
336+
337+func TestHandleCommandHeadlessMutations(t *testing.T) {
338+ tests := []struct {
339+ name string
340+ slug string
341+ args []string
342+ verify func(*testing.T, *Daemon, *session)
343+ }{
344+ {name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) {
345+ tb := sess.activeTab()
346+ tb.mu.Lock()
347+ defer tb.mu.Unlock()
348+ require.Len(t, tb.panes, 2)
349+ }},
350+ {name: "create tab from retained viewport", slug: "new-tab", verify: func(t *testing.T, _ *Daemon, sess *session) {
351+ sess.mu.Lock()
352+ defer sess.mu.Unlock()
353+ require.Len(t, sess.tabs, 2)
354+ require.Equal(t, 1, sess.active)
355+ }},
356+ {name: "rename tab", slug: "rename-tab", args: []string{"logs"}, verify: func(t *testing.T, _ *Daemon, sess *session) {
357+ tb := sess.activeTab()
358+ tb.mu.Lock()
359+ defer tb.mu.Unlock()
360+ require.Equal(t, "logs", tb.name)
361+ }},
362+ }
363+ for _, tt := range tests {
364+ t.Run(tt.name, func(t *testing.T) {
365+ factory := &controlPTYFactory{}
366+ d := newTestDaemon(t, factory, stubClock{})
367+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
368+ sess := addControlSession(d, "work", "t_work", "p_work")
369+
370+ result := sendCommand(t, d, ports.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"})
371+
372+ require.True(t, result.OK, result.Text)
373+ tt.verify(t, d, sess)
374+ })
375+ }
376+}
377+
378+func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T) {
379+ factory := &controlPTYFactory{}
380+ d := newTestDaemon(t, factory, stubClock{})
381+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
382+ source := addControlSession(d, "work", "t_work", "p_work")
383+ source.mu.Lock()
384+ source.cwd = "/tmp/work"
385+ source.env = []string{"INHERITED=yes"}
386+ source.terminal = terminalEnv{TrueColor: true}
387+ source.tabs[0].size = domain.Size{Cols: 118, Rows: 38}
388+ source.mu.Unlock()
389+
390+ result := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
391+
392+ require.True(t, result.OK, result.Text)
393+ d.mu.Lock()
394+ created := d.findByNameLocked("scripted")
395+ d.mu.Unlock()
396+ require.NotNil(t, created)
397+ created.mu.Lock()
398+ require.Nil(t, created.client)
399+ require.Equal(t, "/tmp/work", created.cwd)
400+ require.Equal(t, []string{"INHERITED=yes"}, created.env)
401+ require.True(t, created.terminal.TrueColor)
402+ tb := created.tabs[0]
403+ created.mu.Unlock()
404+ tb.mu.Lock()
405+ require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size)
406+ tb.mu.Unlock()
407+ require.Nil(t, source.client)
408+
409+ taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
410+ require.False(t, taken.OK)
411+ require.Equal(t, ports.ErrNameTaken, taken.Code)
412+}
413+
414+func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) {
415+ d := newTestDaemon(t, nil, stubClock{})
416+ sess := addControlSession(d, "work", "t_work", "p_work")
417+
418+ bad := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"})
419+ require.False(t, bad.OK)
420+ require.Equal(t, ports.ErrInvalidCommandArgs, bad.Code)
421+
422+ good := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"})
423+ require.True(t, good.OK, good.Text)
424+ d.notices.mu.Lock()
425+ require.Len(t, d.notices.pending, 1)
426+ require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code)
427+ require.Equal(t, sess.id, d.notices.pending[0].SessionID)
428+ d.notices.mu.Unlock()
429+}
430+
431+func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
432+ d := newTestDaemon(t, nil, stubClock{})
433+ sess := addControlSession(d, "work", "t_work", "p_work")
434+ sess.tabs[0].name = "shell"
435+
436+ sessions := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
437+ require.True(t, sessions.OK, sessions.Text)
438+ require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE")
439+ require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue")
440+
441+ tabs := sendCommand(t, d, ports.CommandRequest{Slug: "list-tabs", TargetSession: "work"})
442+ require.True(t, tabs.OK, tabs.Text)
443+ require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue")
444+
445+ panes := sendCommand(t, d, ports.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true})
446+ require.True(t, panes.OK, panes.Text)
447+ var decoded []map[string]any
448+ require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded))
449+ require.Len(t, decoded, 1)
450+ require.Equal(t, "p_work", decoded[0]["id"])
451+ require.Equal(t, "/tmp/work", decoded[0]["cwd"])
452+ require.Equal(t, true, decoded[0]["focused"])
453+}
454+
455+func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
456+ factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
457+ d := newTestDaemon(t, factory, stubClock{})
458+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
459+ sess := addControlSession(d, "work", "t_active", "p_active")
460+ target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
461+ target.ctx, target.cancel = context.WithCancel(d.serveCtx)
462+ sess.mu.Lock()
463+ sess.tabs = append(sess.tabs, target)
464+ sess.active = 0
465+ sess.mu.Unlock()
466+ original := target.tree.Focus
467+
468+ firstFrame := commandFrame(t, ports.CommandRequest{
469+ Slug: "split-right", Self: true, TargetSession: "work",
470+ TargetTab: "t_invoking", TargetPane: "p_invoking",
471+ })
472+ firstTransport, firstSends, _ := newConn(t, firstFrame)
473+ firstDone := make(chan struct{})
474+ go func() {
475+ d.handleCommand(firstTransport, firstFrame)
476+ close(firstDone)
477+ }()
478+ select {
479+ case <-factory.entered:
480+ case <-time.After(2 * time.Second):
481+ t.Fatal("first command did not enter its blocked action")
482+ }
483+
484+ secondFrame := commandFrame(t, ports.CommandRequest{
485+ Slug: "focus-pane-right", Self: true, TargetSession: "work",
486+ TargetTab: "t_invoking", TargetPane: "p_invoking",
487+ })
488+ secondTransport, secondSends, _ := newConn(t, secondFrame)
489+ secondDone := make(chan struct{})
490+ go func() {
491+ d.handleCommand(secondTransport, secondFrame)
492+ close(secondDone)
493+ }()
494+ select {
495+ case <-secondDone:
496+ t.Fatal("second command bypassed serialization")
497+ case <-time.After(100 * time.Millisecond):
498+ }
499+ close(factory.release)
500+ <-firstDone
501+ <-secondDone
502+ require.True(t, awaitCommandResult(t, firstSends).OK)
503+ require.True(t, awaitCommandResult(t, secondSends).OK)
504+
505+ sess.mu.Lock()
506+ require.Zero(t, sess.active, "self commands must not select the invoking tab")
507+ sess.mu.Unlock()
508+ target.mu.Lock()
509+ defer target.mu.Unlock()
510+ require.Len(t, target.panes, 2)
511+ require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
512+}
513+
514+func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame {
515+ t.Helper()
516+ if request.Version == 0 {
517+ request.Version = ports.ProtocolVersion
518+ }
519+ payload, err := ports.MarshalCommandRequest(request)
520+ require.NoError(t, err)
521+ return ports.Frame{Type: ports.MsgCommand, Payload: payload}
522+}
523+
524+func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.CommandResult {
525+ t.Helper()
526+ frame := commandFrame(t, request)
527+ tr, sends, _ := newConn(t, frame)
528+ d.handleCommand(tr, frame)
529+ return awaitCommandResult(t, sends)
530+}
531+
532+func awaitCommandResult(t *testing.T, sends chan ports.Frame) ports.CommandResult {
533+ t.Helper()
534+ reply := awaitFrame(t, sends, ports.MsgCommandResult)
535+ result, err := ports.UnmarshalCommandResult(reply.Payload)
536+ require.NoError(t, err)
537+ return result
538+}
539+
540+type controlPTYFactory struct {
541+ mu sync.Mutex
542+ ptys []*quietPTY
543+ entered chan struct{}
544+ release chan struct{}
545+ once sync.Once
546+}
547+
548+func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
549+ f.once.Do(func() {
550+ if f.entered != nil {
551+ close(f.entered)
552+ <-f.release
553+ }
554+ })
555+ pty := newQuietPTY()
556+ f.mu.Lock()
557+ f.ptys = append(f.ptys, pty)
558+ f.mu.Unlock()
559+ return pty, nil
560+}
561+
562+func (f *controlPTYFactory) close() {
563+ if f.release != nil {
564+ select {
565+ case <-f.release:
566+ default:
567+ close(f.release)
568+ }
569+ }
570+ f.mu.Lock()
571+ defer f.mu.Unlock()
572+ for _, pty := range f.ptys {
573+ _ = pty.Close()
574+ }
575+}
576+
577+func addControlSession(d *Daemon, name, tabID, paneID string) *session {
578+ tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
579+ tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
580+ sess := &session{
581+ id: domain.SessionID("sess-" + name), name: name, ephemeral: true, cwd: "/tmp/" + name,
582+ ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
583+ }
584+ sess.mruAt.Store(1)
585+ d.mu.Lock()
586+ d.sessions[sess.id] = sess
587+ d.mu.Unlock()
588+ return sess
589+}
@@ -0,0 +1,589 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+ "github.com/bnema/vev/internal/usecase/command"
14+ "github.com/bnema/vev/internal/usecase/layout"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
19+ d := newTestDaemon(t, nil, stubClock{})
20+ sess := addControlSession(d, "work", "t_work", "p_work")
21+ tb := sess.activeTab()
22+ tb.mu.Lock()
23+ pane := tb.focusedPane()
24+ tb.mu.Unlock()
25+ spy := &actionRunnerSpy{}
26+
27+ require.NoError(t, (paletteExec{d: d, sess: sess, actions: spy}).SplitRight())
28+ require.NoError(t, (controlExec{
29+ d: d, sess: sess, tab: tb, actions: spy,
30+ target: daemonActionTarget{session: sess, tab: tb, pane: pane},
31+ }).SplitRight())
32+
33+ require.Len(t, spy.requests, 2)
34+ for _, request := range spy.requests {
35+ require.Equal(t, daemonActionSplitPane, request.kind)
36+ require.Same(t, sess, request.target.session)
37+ require.Same(t, tb, request.target.tab)
38+ require.Same(t, pane, request.target.pane)
39+ require.Equal(t, layout.Right, request.direction)
40+ }
41+}
42+
43+type actionRunnerSpy struct {
44+ requests []daemonActionRequest
45+}
46+
47+func (s *actionRunnerSpy) Run(request daemonActionRequest) error {
48+ s.requests = append(s.requests, request)
49+ return nil
50+}
51+
52+func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
53+ // The version prefix is valid and mismatched, while the remainder is not a
54+ // decodable request. The daemon must still return ErrVersionMismatch.
55+ frame := ports.Frame{Type: ports.MsgCommand, Payload: []byte{0, byte(ports.ProtocolVersion + 1)}}
56+ tr, sends, _ := newConn(t, frame)
57+
58+ d := newTestDaemon(t, nil, stubClock{})
59+ d.handleCommand(tr, frame)
60+
61+ result := awaitCommandResult(t, sends)
62+ require.False(t, result.OK)
63+ require.Equal(t, ports.ErrVersionMismatch, result.Code)
64+}
65+
66+func TestHandleConnRoutesCommand(t *testing.T) {
67+ d := newTestDaemon(t, nil, stubClock{})
68+ request := ports.CommandRequest{Version: ports.ProtocolVersion, Slug: "list-sessions"}
69+ payload, err := ports.MarshalCommandRequest(request)
70+ require.NoError(t, err)
71+ frame := ports.Frame{Type: ports.MsgCommand, Payload: payload}
72+ tr, sends, _ := newConn(t, frame)
73+
74+ d.handleConn(tr)
75+
76+ result := awaitCommandResult(t, sends)
77+ require.True(t, result.OK, result.Text)
78+}
79+
80+func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ arrange func(*Daemon)
84+ request ports.CommandRequest
85+ code uint16
86+ }{
87+ {name: "unknown slug", request: ports.CommandRequest{Slug: "no-such"}, code: ports.ErrUnknownCommand},
88+ {name: "non-scriptable command", request: ports.CommandRequest{Slug: "session-picker"}, code: ports.ErrNotScriptable},
89+ {name: "no sessions", request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrNoSuchTarget},
90+ {name: "ambiguous sessions", arrange: func(d *Daemon) {
91+ addControlSession(d, "one", "t_one", "p_one")
92+ addControlSession(d, "two", "t_two", "p_two")
93+ }, request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrAmbiguousTarget},
94+ {name: "missing explicit session", arrange: func(d *Daemon) {
95+ addControlSession(d, "work", "t_work", "p_work")
96+ }, request: ports.CommandRequest{Slug: "new-tab", TargetSession: "missing"}, code: ports.ErrNoSuchTarget},
97+ {name: "missing stable IDs", arrange: func(d *Daemon) {
98+ addControlSession(d, "work", "t_work", "p_work")
99+ }, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget},
100+ {name: "cross-session IDs rejected", arrange: func(d *Daemon) {
101+ addControlSession(d, "one", "t_one", "p_one")
102+ addControlSession(d, "two", "t_two", "p_two")
103+ }, request: ports.CommandRequest{Slug: "split-right", TargetSession: "one", TargetTab: "t_two", TargetPane: "p_two"}, code: ports.ErrNoSuchTarget},
104+ {name: "self requires both IDs", arrange: func(d *Daemon) {
105+ addControlSession(d, "work", "t_work", "p_work")
106+ }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget},
107+ {name: "self rejects pane from another tab", arrange: func(d *Daemon) {
108+ sess := addControlSession(d, "work", "t_work", "p_work")
109+ other := newTabWithStableID("t_other", "p_other", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
110+ other.ctx, other.cancel = context.WithCancel(d.serveCtx)
111+ sess.tabs = append(sess.tabs, other)
112+ }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work", TargetPane: "p_other"}, code: ports.ErrNoSuchTarget},
113+ }
114+
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ d := newTestDaemon(t, nil, stubClock{})
118+ if tt.arrange != nil {
119+ tt.arrange(d)
120+ }
121+ result := sendCommand(t, d, tt.request)
122+ require.False(t, result.OK)
123+ require.Equal(t, tt.code, result.Code)
124+ })
125+ }
126+}
127+
128+func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) {
129+ d := newTestDaemon(t, nil, stubClock{})
130+ sess := addControlSession(d, "renamed", "t_work", "p_work")
131+
132+ result := sendCommand(t, d, ports.CommandRequest{
133+ Slug: "toast", Args: []string{"hello"},
134+ TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
135+ })
136+
137+ require.True(t, result.OK, result.Text)
138+ require.Equal(t, uint64(1), sess.mruAt.Load())
139+}
140+
141+func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
142+ d := newTestDaemon(t, nil, stubClock{})
143+ sess := addControlSession(d, "work", "t_first", "p_first")
144+ first := sess.tabs[0]
145+ second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
146+ second.ctx, second.cancel = context.WithCancel(d.serveCtx)
147+ sess.mu.Lock()
148+ sess.tabs = append(sess.tabs, second)
149+ sess.active = 0
150+ sess.mu.Unlock()
151+
152+ result := sendCommand(t, d, ports.CommandRequest{
153+ Slug: "rename-tab", Args: []string{"targeted"}, TargetSession: "work",
154+ TargetTab: "t_second", TargetPane: "p_second",
155+ })
156+
157+ require.True(t, result.OK, result.Text)
158+ first.mu.Lock()
159+ require.Equal(t, "targeted", first.name)
160+ first.mu.Unlock()
161+ second.mu.Lock()
162+ require.Empty(t, second.name)
163+ second.mu.Unlock()
164+ sess.mu.Lock()
165+ require.Zero(t, sess.active)
166+ sess.mu.Unlock()
167+}
168+
169+func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) {
170+ factory := &controlPTYFactory{}
171+ d := newTestDaemon(t, factory, stubClock{})
172+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
173+ sess := addControlSession(d, "work", "t_active", "p_active")
174+ active := sess.tabs[0]
175+ originFocus := active.tree.Focus
176+ origin := active.focusedPane()
177+ second := newTabWithStableID("t_origin", "p_origin", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
178+ second.ctx, second.cancel = context.WithCancel(d.serveCtx)
179+ sess.mu.Lock()
180+ sess.tabs = append(sess.tabs, second)
181+ sess.active = 0
182+ sess.mu.Unlock()
183+
184+ result := sendCommand(t, d, ports.CommandRequest{
185+ Slug: "split-right", TargetSession: "work",
186+ TargetTab: "t_origin", TargetPane: "p_origin",
187+ })
188+
189+ require.True(t, result.OK, result.Text)
190+ sess.mu.Lock()
191+ activeIndex := sess.active
192+ sess.mu.Unlock()
193+ require.Zero(t, activeIndex)
194+ active.mu.Lock()
195+ activePaneCount := len(active.panes)
196+ activeFocus := active.tree.Focus
197+ originRetained := active.panes[origin.id] == origin
198+ active.mu.Unlock()
199+ require.Equal(t, 2, activePaneCount, "split must mutate the daemon-focused tab")
200+ require.NotEqual(t, originFocus, activeFocus, "split must focus the new pane beside the daemon-focused pane")
201+ require.True(t, originRetained)
202+ second.mu.Lock()
203+ secondPaneCount := len(second.panes)
204+ second.mu.Unlock()
205+ require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator")
206+}
207+
208+func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
209+ factory := &controlPTYFactory{}
210+ d := newTestDaemon(t, factory, stubClock{})
211+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
212+ sess := addControlSession(d, "work", "t_active", "p_active")
213+ active := sess.tabs[0]
214+ invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
215+ invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
216+ sess.mu.Lock()
217+ sess.tabs = append(sess.tabs, invoking)
218+ sess.active = 0
219+ sess.mu.Unlock()
220+
221+ rename := sendCommand(t, d, ports.CommandRequest{
222+ Slug: "rename-tab", Args: []string{"invoking"}, Self: true,
223+ TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
224+ })
225+ require.True(t, rename.OK, rename.Text)
226+ active.mu.Lock()
227+ require.Empty(t, active.name)
228+ active.mu.Unlock()
229+ invoking.mu.Lock()
230+ require.Equal(t, "invoking", invoking.name)
231+ invoking.mu.Unlock()
232+
233+ split := sendCommand(t, d, ports.CommandRequest{
234+ Slug: "split-right", Self: true,
235+ TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
236+ })
237+ require.True(t, split.OK, split.Text)
238+ active.mu.Lock()
239+ require.Len(t, active.panes, 1)
240+ active.mu.Unlock()
241+ invoking.mu.Lock()
242+ require.Len(t, invoking.panes, 2)
243+ invoking.mu.Unlock()
244+}
245+
246+func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {
247+ d := newTestDaemon(t, nil, stubClock{})
248+ sess := addControlSession(d, "work", "t_active", "p_active")
249+ invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
250+ invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
251+ sess.mu.Lock()
252+ sess.tabs = append(sess.tabs, invoking)
253+ sess.active = 0
254+ sess.mu.Unlock()
255+
256+ result := sendCommand(t, d, ports.CommandRequest{
257+ Slug: "list-panes", Self: true, JSON: true,
258+ TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
259+ })
260+ require.True(t, result.OK, result.Text)
261+ require.Contains(t, result.Output, "p_invoking")
262+ require.NotContains(t, result.Output, "p_active")
263+}
264+
265+func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) {
266+ d := newTestDaemon(t, nil, stubClock{})
267+ addControlSession(d, "work", "t_work", "p_work")
268+
269+ result := sendCommand(t, d, ports.CommandRequest{
270+ Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work",
271+ })
272+
273+ require.False(t, result.OK)
274+ require.Equal(t, ports.ErrInvalidCommandArgs, result.Code)
275+}
276+
277+func TestCloseCommandsReportMutationOutcome(t *testing.T) {
278+ t.Run("stale close tab target fails", func(t *testing.T) {
279+ d := newTestDaemon(t, nil, stubClock{})
280+ sess := addControlSession(d, "work", "t_work", "p_work")
281+ stale := newTabWithStableID("t_stale", "p_stale", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
282+ cmd, ok := command.BySlug("close-tab")
283+ require.True(t, ok)
284+
285+ result := d.runControl(cmd, controlExec{
286+ d: d, sess: sess, tab: stale,
287+ target: daemonActionTarget{session: sess, tab: stale},
288+ }, ports.CommandRequest{Slug: "close-tab"})
289+
290+ require.False(t, result.OK)
291+ require.Equal(t, ports.ErrInternal, result.Code)
292+ require.Len(t, sess.tabs, 1, "a stale close must retain the live tab")
293+ })
294+
295+ t.Run("failed final pane close fails and retains session", func(t *testing.T) {
296+ d := newTestDaemon(t, &controlPTYFactory{}, stubClock{})
297+ d.snapsEnabled = true
298+ d.snapshotRepository = portsmocks.NewMockSnapshotRepository(t)
299+ sess := addControlSession(d, "work", "t_work", "p_work")
300+ sess.mu.Lock()
301+ sess.ephemeral = false
302+ sess.mu.Unlock()
303+ sess.snapEligible.Store(true)
304+
305+ result := sendCommand(t, d, ports.CommandRequest{Slug: "close-pane", TargetSession: "work"})
306+
307+ require.False(t, result.OK)
308+ require.Equal(t, ports.ErrInternal, result.Code)
309+ d.mu.Lock()
310+ require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session")
311+ d.mu.Unlock()
312+ sess.mu.Lock()
313+ require.Len(t, sess.tabs, 1)
314+ sess.mu.Unlock()
315+ })
316+
317+ t.Run("successful close tab remains successful", func(t *testing.T) {
318+ d := newTestDaemon(t, nil, stubClock{})
319+ sess := addControlSession(d, "work", "t_first", "p_first")
320+ second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
321+ second.ctx, second.cancel = context.WithCancel(d.serveCtx)
322+ sess.mu.Lock()
323+ sess.tabs = append(sess.tabs, second)
324+ sess.active = 1
325+ sess.mu.Unlock()
326+
327+ result := sendCommand(t, d, ports.CommandRequest{Slug: "close-tab", TargetSession: "work"})
328+
329+ require.True(t, result.OK, result.Text)
330+ sess.mu.Lock()
331+ require.Len(t, sess.tabs, 1)
332+ require.Equal(t, "t_first", sess.tabs[0].stableID)
333+ sess.mu.Unlock()
334+ })
335+}
336+
337+func TestHandleCommandHeadlessMutations(t *testing.T) {
338+ tests := []struct {
339+ name string
340+ slug string
341+ args []string
342+ verify func(*testing.T, *Daemon, *session)
343+ }{
344+ {name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) {
345+ tb := sess.activeTab()
346+ tb.mu.Lock()
347+ defer tb.mu.Unlock()
348+ require.Len(t, tb.panes, 2)
349+ }},
350+ {name: "create tab from retained viewport", slug: "new-tab", verify: func(t *testing.T, _ *Daemon, sess *session) {
351+ sess.mu.Lock()
352+ defer sess.mu.Unlock()
353+ require.Len(t, sess.tabs, 2)
354+ require.Equal(t, 1, sess.active)
355+ }},
356+ {name: "rename tab", slug: "rename-tab", args: []string{"logs"}, verify: func(t *testing.T, _ *Daemon, sess *session) {
357+ tb := sess.activeTab()
358+ tb.mu.Lock()
359+ defer tb.mu.Unlock()
360+ require.Equal(t, "logs", tb.name)
361+ }},
362+ }
363+ for _, tt := range tests {
364+ t.Run(tt.name, func(t *testing.T) {
365+ factory := &controlPTYFactory{}
366+ d := newTestDaemon(t, factory, stubClock{})
367+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
368+ sess := addControlSession(d, "work", "t_work", "p_work")
369+
370+ result := sendCommand(t, d, ports.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"})
371+
372+ require.True(t, result.OK, result.Text)
373+ tt.verify(t, d, sess)
374+ })
375+ }
376+}
377+
378+func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T) {
379+ factory := &controlPTYFactory{}
380+ d := newTestDaemon(t, factory, stubClock{})
381+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
382+ source := addControlSession(d, "work", "t_work", "p_work")
383+ source.mu.Lock()
384+ source.cwd = "/tmp/work"
385+ source.env = []string{"INHERITED=yes"}
386+ source.terminal = terminalEnv{TrueColor: true}
387+ source.tabs[0].size = domain.Size{Cols: 118, Rows: 38}
388+ source.mu.Unlock()
389+
390+ result := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
391+
392+ require.True(t, result.OK, result.Text)
393+ d.mu.Lock()
394+ created := d.findByNameLocked("scripted")
395+ d.mu.Unlock()
396+ require.NotNil(t, created)
397+ created.mu.Lock()
398+ require.Nil(t, created.client)
399+ require.Equal(t, "/tmp/work", created.cwd)
400+ require.Equal(t, []string{"INHERITED=yes"}, created.env)
401+ require.True(t, created.terminal.TrueColor)
402+ tb := created.tabs[0]
403+ created.mu.Unlock()
404+ tb.mu.Lock()
405+ require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size)
406+ tb.mu.Unlock()
407+ require.Nil(t, source.client)
408+
409+ taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
410+ require.False(t, taken.OK)
411+ require.Equal(t, ports.ErrNameTaken, taken.Code)
412+}
413+
414+func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) {
415+ d := newTestDaemon(t, nil, stubClock{})
416+ sess := addControlSession(d, "work", "t_work", "p_work")
417+
418+ bad := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"})
419+ require.False(t, bad.OK)
420+ require.Equal(t, ports.ErrInvalidCommandArgs, bad.Code)
421+
422+ good := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"})
423+ require.True(t, good.OK, good.Text)
424+ d.notices.mu.Lock()
425+ require.Len(t, d.notices.pending, 1)
426+ require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code)
427+ require.Equal(t, sess.id, d.notices.pending[0].SessionID)
428+ d.notices.mu.Unlock()
429+}
430+
431+func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
432+ d := newTestDaemon(t, nil, stubClock{})
433+ sess := addControlSession(d, "work", "t_work", "p_work")
434+ sess.tabs[0].name = "shell"
435+
436+ sessions := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
437+ require.True(t, sessions.OK, sessions.Text)
438+ require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE")
439+ require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue")
440+
441+ tabs := sendCommand(t, d, ports.CommandRequest{Slug: "list-tabs", TargetSession: "work"})
442+ require.True(t, tabs.OK, tabs.Text)
443+ require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue")
444+
445+ panes := sendCommand(t, d, ports.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true})
446+ require.True(t, panes.OK, panes.Text)
447+ var decoded []map[string]any
448+ require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded))
449+ require.Len(t, decoded, 1)
450+ require.Equal(t, "p_work", decoded[0]["id"])
451+ require.Equal(t, "/tmp/work", decoded[0]["cwd"])
452+ require.Equal(t, true, decoded[0]["focused"])
453+}
454+
455+func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
456+ factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
457+ d := newTestDaemon(t, factory, stubClock{})
458+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
459+ sess := addControlSession(d, "work", "t_active", "p_active")
460+ target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
461+ target.ctx, target.cancel = context.WithCancel(d.serveCtx)
462+ sess.mu.Lock()
463+ sess.tabs = append(sess.tabs, target)
464+ sess.active = 0
465+ sess.mu.Unlock()
466+ original := target.tree.Focus
467+
468+ firstFrame := commandFrame(t, ports.CommandRequest{
469+ Slug: "split-right", Self: true, TargetSession: "work",
470+ TargetTab: "t_invoking", TargetPane: "p_invoking",
471+ })
472+ firstTransport, firstSends, _ := newConn(t, firstFrame)
473+ firstDone := make(chan struct{})
474+ go func() {
475+ d.handleCommand(firstTransport, firstFrame)
476+ close(firstDone)
477+ }()
478+ select {
479+ case <-factory.entered:
480+ case <-time.After(2 * time.Second):
481+ t.Fatal("first command did not enter its blocked action")
482+ }
483+
484+ secondFrame := commandFrame(t, ports.CommandRequest{
485+ Slug: "focus-pane-right", Self: true, TargetSession: "work",
486+ TargetTab: "t_invoking", TargetPane: "p_invoking",
487+ })
488+ secondTransport, secondSends, _ := newConn(t, secondFrame)
489+ secondDone := make(chan struct{})
490+ go func() {
491+ d.handleCommand(secondTransport, secondFrame)
492+ close(secondDone)
493+ }()
494+ select {
495+ case <-secondDone:
496+ t.Fatal("second command bypassed serialization")
497+ case <-time.After(100 * time.Millisecond):
498+ }
499+ close(factory.release)
500+ <-firstDone
501+ <-secondDone
502+ require.True(t, awaitCommandResult(t, firstSends).OK)
503+ require.True(t, awaitCommandResult(t, secondSends).OK)
504+
505+ sess.mu.Lock()
506+ require.Zero(t, sess.active, "self commands must not select the invoking tab")
507+ sess.mu.Unlock()
508+ target.mu.Lock()
509+ defer target.mu.Unlock()
510+ require.Len(t, target.panes, 2)
511+ require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
512+}
513+
514+func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame {
515+ t.Helper()
516+ if request.Version == 0 {
517+ request.Version = ports.ProtocolVersion
518+ }
519+ payload, err := ports.MarshalCommandRequest(request)
520+ require.NoError(t, err)
521+ return ports.Frame{Type: ports.MsgCommand, Payload: payload}
522+}
523+
524+func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.CommandResult {
525+ t.Helper()
526+ frame := commandFrame(t, request)
527+ tr, sends, _ := newConn(t, frame)
528+ d.handleCommand(tr, frame)
529+ return awaitCommandResult(t, sends)
530+}
531+
532+func awaitCommandResult(t *testing.T, sends chan ports.Frame) ports.CommandResult {
533+ t.Helper()
534+ reply := awaitFrame(t, sends, ports.MsgCommandResult)
535+ result, err := ports.UnmarshalCommandResult(reply.Payload)
536+ require.NoError(t, err)
537+ return result
538+}
539+
540+type controlPTYFactory struct {
541+ mu sync.Mutex
542+ ptys []*quietPTY
543+ entered chan struct{}
544+ release chan struct{}
545+ once sync.Once
546+}
547+
548+func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
549+ f.once.Do(func() {
550+ if f.entered != nil {
551+ close(f.entered)
552+ <-f.release
553+ }
554+ })
555+ pty := newQuietPTY()
556+ f.mu.Lock()
557+ f.ptys = append(f.ptys, pty)
558+ f.mu.Unlock()
559+ return pty, nil
560+}
561+
562+func (f *controlPTYFactory) close() {
563+ if f.release != nil {
564+ select {
565+ case <-f.release:
566+ default:
567+ close(f.release)
568+ }
569+ }
570+ f.mu.Lock()
571+ defer f.mu.Unlock()
572+ for _, pty := range f.ptys {
573+ _ = pty.Close()
574+ }
575+}
576+
577+func addControlSession(d *Daemon, name, tabID, paneID string) *session {
578+ tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
579+ tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
580+ sess := &session{
581+ id: domain.SessionID("sess-" + name), name: name, ephemeral: true, cwd: "/tmp/" + name,
582+ ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
583+ }
584+ sess.mruAt.Store(1)
585+ d.mu.Lock()
586+ d.sessions[sess.id] = sess
587+ d.mu.Unlock()
588+ return sess
589+}
M internal/usecase/daemon/daemon.go +2 -0
@@ -624,6 +624,8 @@ func (d *Daemon) handleConn(tr ports.Transport) {
624624 switch first.Type {
625625 case ports.MsgList:
626626 d.handleList(tr)
627+ case ports.MsgCommand:
628+ d.handleCommand(tr, first)
627629 case ports.MsgKill:
628630 d.handleKill(tr, first)
629631 case ports.MsgHello:
@@ -624,6 +624,8 @@ func (d *Daemon) handleConn(tr ports.Transport) {
624 switch first.Type {624 switch first.Type {
625 case ports.MsgList:625 case ports.MsgList:
626 d.handleList(tr)626 d.handleList(tr)
627+ case ports.MsgCommand:
628+ d.handleCommand(tr, first)
627 case ports.MsgKill:629 case ports.MsgKill:
628 d.handleKill(tr, first)630 d.handleKill(tr, first)
629 case ports.MsgHello:631 case ports.MsgHello:
M internal/usecase/daemon/input_test.go +1 -1
@@ -157,7 +157,7 @@ func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) {
157157 survivor: cachedSurvivor,
158158 }
159159
160- d.closeTab(sess, closedTab, false)
160+ require.NoError(t, d.closeTab(sess, closedTab, false))
161161 require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment")
162162 require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture")
163163 }
@@ -157,7 +157,7 @@ func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) {
157 survivor: cachedSurvivor,157 survivor: cachedSurvivor,
158 }158 }
159 159
160- d.closeTab(sess, closedTab, false)160+ require.NoError(t, d.closeTab(sess, closedTab, false))
161 require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment")161 require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment")
162 require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture")162 require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture")
163 }163 }
M internal/usecase/daemon/nil_pty_test.go +1 -1
@@ -36,7 +36,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
3636 })
3737 require.NotContains(t, first.panes, layout.PaneID("pane-2"))
3838
39- require.NotPanics(t, func() { d.closeTab(sess, second, false) })
39+ require.NotPanics(t, func() { require.NoError(t, d.closeTab(sess, second, false)) })
4040 require.Len(t, sess.tabs, 1)
4141 }
4242
@@ -36,7 +36,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
36 })36 })
37 require.NotContains(t, first.panes, layout.PaneID("pane-2"))37 require.NotContains(t, first.panes, layout.PaneID("pane-2"))
38 38
39- require.NotPanics(t, func() { d.closeTab(sess, second, false) })39+ require.NotPanics(t, func() { require.NoError(t, d.closeTab(sess, second, false)) })
40 require.Len(t, sess.tabs, 1)40 require.Len(t, sess.tabs, 1)
41 }41 }
42 42
M internal/usecase/daemon/notify.go +103 -39
@@ -25,13 +25,13 @@ const (
2525 var errNoNeighbor = errors.New("no pane in that direction")
2626
2727 // noticeCenter owns the daemon-wide notification history and the queue of
28-// global notices awaiting a first attached client. Its mu is leaf-level: no
29-// other lock is ever taken while holding it.
28+// global or session-scoped notices awaiting an eligible attached client. Its
29+// mu is leaf-level: no other lock is ever taken while holding it.
3030 type noticeCenter struct {
3131 mu sync.Mutex
32- // routingMu serializes global deliver-or-queue with the first-paint drain.
33- // Attachment removal also takes it, so a client selected for a global toast
34- // remains current until that toast is published.
32+ // routingMu serializes notice deliver-or-queue with the first-paint drain.
33+ // Attachment removal also takes it, so a client selected for a toast remains
34+ // current until that toast is published.
3535 routingMu sync.Mutex
3636 ring []domain.Notification
3737 pending []domain.Notification
@@ -41,6 +41,9 @@ type noticeCenter struct {
4141 // beforeGlobalDelivery is a deterministic test seam after targets have been
4242 // selected and before their toast stacks are touched.
4343 beforeGlobalDelivery func()
44+ // beforeSessionDelivery is a deterministic test seam after a session client
45+ // has been selected and before its toast stack is touched.
46+ beforeSessionDelivery func()
4447 // beforeClientNoticeMutation is a deterministic test seam after a client
4548 // notice's ownership validation and before its toast state is changed.
4649 beforeClientNoticeMutation func()
@@ -112,7 +115,7 @@ func (nc *noticeCenter) queueGlobal(n domain.Notification) {
112115 nc.mu.Lock()
113116 defer nc.mu.Unlock()
114117 for i := range nc.pending {
115- if nc.pending[i].Code == n.Code {
118+ if sameToastNotice(nc.pending[i], n) {
116119 nc.pending[i].Count += n.Count
117120 nc.pending[i].Time = n.Time
118121 return
@@ -223,12 +226,13 @@ func (d *Daemon) reportError(sess *session, err error) {
223226
224227 // notify records a notice and routes it to whoever can see it. A nil sess means
225228 // daemon-global: every attached client gets it, and if none is attached the
226-// notice waits in the pending queue for the next attach.
229+// notice waits in the pending queue for the next attach. A detached session's
230+// notice waits for that session's next attach.
227231 //
228232 // Locking: sess.mu and d.mu are only held to snapshot; showToast is always
229-// called with no daemon, session, or pane lock held. Global routing holds the
230-// notice center's routingMu across selection and publication, which serializes
231-// it with attachment detach and pending-queue drains.
233+// called with no daemon, session, or pane lock held. Routing holds the notice
234+// center's routingMu across selection and publication, which serializes it with
235+// attachment detach and pending-queue drains. The order is routingMu -> sess.mu.
232236 func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
233237 n := domain.Notification{
234238 Code: code,
@@ -244,11 +248,24 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
244248 d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
245249
246250 if sess != nil {
251+ d.notices.routingMu.Lock()
247252 sess.mu.Lock()
248253 ac := sess.client
249254 sess.mu.Unlock()
250- if ac != nil {
251- d.showToast(ac, n)
255+ if ac == nil {
256+ d.notices.queueGlobal(n)
257+ d.notices.routingMu.Unlock()
258+ return
259+ }
260+ if d.notices.beforeSessionDelivery != nil {
261+ d.notices.beforeSessionDelivery()
262+ }
263+ // Publish the toast while detach is excluded, then release routing before
264+ // repainting. Rendering can report another notice and re-enter routing.
265+ published := d.publishToast(ac, n)
266+ d.notices.routingMu.Unlock()
267+ if published {
268+ d.repaintForNotice(ac)
252269 }
253270 return
254271 }
@@ -257,10 +274,9 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
257274 }
258275
259276 // deliverGlobal atomically selects all current attachments and either publishes
260-// to all of them or queues the notice. routingMu is deliberately held through
261-// showToast, but never with d.mu or sess.mu held: detach cannot turn a selected
262-// target stale between selection and publication, and firstPaint cannot drain
263-// before an unattached route has queued its notice.
277+// to all of them or queues the notice. routingMu is held through toast-state
278+// publication, but never through repaint: detach cannot turn a selected target
279+// stale before publication, while rendering remains free to re-enter routing.
264280 func (d *Daemon) deliverGlobal(n domain.Notification) {
265281 d.mu.Lock()
266282 d.notices.routingMu.Lock()
@@ -269,45 +285,62 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
269285 sessions = append(sessions, s)
270286 }
271287 d.mu.Unlock()
272- defer d.notices.routingMu.Unlock()
273288
274289 if d.notices.beforeGlobalDelivery != nil {
275290 d.notices.beforeGlobalDelivery()
276291 }
277- delivered := false
292+ targets := make([]*attachedClient, 0, len(sessions))
278293 for _, s := range sessions {
279294 s.mu.Lock()
280295 ac := s.client
281296 s.mu.Unlock()
282- if ac == nil {
297+ if ac == nil || !d.publishToast(ac, n) {
283298 continue
284299 }
285- d.showToast(ac, n)
286- delivered = true
300+ targets = append(targets, ac)
287301 }
288- if !delivered {
302+ if len(targets) == 0 {
289303 if d.notices.beforeQueueGlobal != nil {
290304 d.notices.beforeQueueGlobal()
291305 }
292306 d.notices.queueGlobal(n)
293307 }
308+ d.notices.routingMu.Unlock()
309+
310+ for _, ac := range targets {
311+ d.repaintForNotice(ac)
312+ }
294313 }
295314
296-// drainPendingForFirstPaint publishes pending globals only while ac remains the
297-// session's current attachment. It shares routingMu with deliverGlobal, so an
298-// unattached global route cannot queue after this drain has already happened.
315+// drainPendingForFirstPaint publishes pending globals and notices scoped to
316+// sess only while ac remains the session's current attachment. It shares
317+// routingMu with notice routing, so an unattached route cannot queue after this
318+// drain has already happened.
299319 func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
300320 d.notices.routingMu.Lock()
301- defer d.notices.routingMu.Unlock()
302321
303322 sess.mu.Lock()
304323 current := sess.client == ac
305324 sess.mu.Unlock()
306325 if !current {
326+ d.notices.routingMu.Unlock()
307327 return
308328 }
329+ published := false
330+ var keep []domain.Notification
309331 for _, n := range d.notices.drainPending() {
310- d.showToast(ac, n)
332+ if n.SessionID == "" || n.SessionID == sess.id {
333+ published = d.publishToast(ac, n) || published
334+ continue
335+ }
336+ keep = append(keep, n)
337+ }
338+ for _, n := range keep {
339+ d.notices.queueGlobal(n)
340+ }
341+ d.notices.routingMu.Unlock()
342+ if published {
343+ d.repaintForNotice(ac)
311344 }
312345 }
313346
@@ -316,27 +349,32 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
316349 d.notify(nil, sev, code, msg, cause)
317350 }
318351
319-// showToast publishes a notice into one client's toast stack. An identical
320-// code within the same scope coalesces into the existing entry and restarts its
321-// TTL; otherwise the notice is prepended and the stack trimmed. The repaint is
322-// issued after noticeMu is released.
352+// showToast publishes a notice into one client's toast stack and repaints only
353+// after the state mutation is complete.
323354 func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
355+ if d.publishToast(ac, n) {
356+ d.repaintForNotice(ac)
357+ }
358+}
359+
360+// publishToast durably mutates one client's toast state without repainting.
361+// Routing paths call it while routingMu excludes detach, then release routingMu
362+// before rendering so a render failure can safely route another notice.
363+func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool {
324364 if ac == nil {
325- return
365+ return false
326366 }
327367 ac.initOverlays()
328368 rt := ac.overlays
329369
330370 rt.noticeMu.Lock()
331- if i := rt.indexOfToastLocked(n.Code, n.SessionID); i >= 0 {
371+ defer rt.noticeMu.Unlock()
372+ if i := rt.indexOfMatchingToastLocked(n); i >= 0 {
332373 rt.noticeToasts[i].n.Count += n.Count
333374 rt.noticeToasts[i].n.Time = n.Time
334- rt.noticeToasts[i].n.Details = n.Details
335375 rt.noticeToasts[i].timer.stop()
336376 d.retainToastTimerLocked(ac, &rt.noticeToasts[i])
337- rt.noticeMu.Unlock()
338- d.repaintForNotice(ac)
339- return
377+ return true
340378 }
341379
342380 toast := noticeToast{n: n}
@@ -349,8 +387,34 @@ func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
349387 rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts]
350388 }
351389 d.retainToastTimerLocked(ac, &rt.noticeToasts[0])
352- rt.noticeMu.Unlock()
353- d.repaintForNotice(ac)
390+ return true
391+}
392+
393+func sameToastNotice(a, b domain.Notification) bool {
394+ if a.Code != b.Code || a.SessionID != b.SessionID {
395+ return false
396+ }
397+ // NoticeUser is intentionally generic: its message and selected severity
398+ // are the notice identity. Typed daemon notices retain their established
399+ // code-and-scope coalescing behavior.
400+ if a.Code != domain.NoticeUser {
401+ return true
402+ }
403+ return a.Severity == b.Severity &&
404+ a.Message == b.Message &&
405+ a.Details == b.Details
406+}
407+
408+// indexOfMatchingToastLocked finds a visible toast with the code-specific
409+// coalescing identity. Count and time are occurrence metadata, not identity.
410+// Callers must hold noticeMu.
411+func (rt *overlayRuntime) indexOfMatchingToastLocked(n domain.Notification) int {
412+ for i := range rt.noticeToasts {
413+ if sameToastNotice(rt.noticeToasts[i].n, n) {
414+ return i
415+ }
416+ }
417+ return -1
354418 }
355419
356420 // indexOfToastLocked finds a visible toast with the same code and scope.
@@ -25,13 +25,13 @@ const (
25 var errNoNeighbor = errors.New("no pane in that direction")25 var errNoNeighbor = errors.New("no pane in that direction")
26 26
27 // noticeCenter owns the daemon-wide notification history and the queue of27 // noticeCenter owns the daemon-wide notification history and the queue of
28-// global notices awaiting a first attached client. Its mu is leaf-level: no28+// global or session-scoped notices awaiting an eligible attached client. Its
29-// other lock is ever taken while holding it.29+// mu is leaf-level: no other lock is ever taken while holding it.
30 type noticeCenter struct {30 type noticeCenter struct {
31 mu sync.Mutex31 mu sync.Mutex
32- // routingMu serializes global deliver-or-queue with the first-paint drain.32+ // routingMu serializes notice deliver-or-queue with the first-paint drain.
33- // Attachment removal also takes it, so a client selected for a global toast33+ // Attachment removal also takes it, so a client selected for a toast remains
34- // remains current until that toast is published.34+ // current until that toast is published.
35 routingMu sync.Mutex35 routingMu sync.Mutex
36 ring []domain.Notification36 ring []domain.Notification
37 pending []domain.Notification37 pending []domain.Notification
@@ -41,6 +41,9 @@ type noticeCenter struct {
41 // beforeGlobalDelivery is a deterministic test seam after targets have been41 // beforeGlobalDelivery is a deterministic test seam after targets have been
42 // selected and before their toast stacks are touched.42 // selected and before their toast stacks are touched.
43 beforeGlobalDelivery func()43 beforeGlobalDelivery func()
44+ // beforeSessionDelivery is a deterministic test seam after a session client
45+ // has been selected and before its toast stack is touched.
46+ beforeSessionDelivery func()
44 // beforeClientNoticeMutation is a deterministic test seam after a client47 // beforeClientNoticeMutation is a deterministic test seam after a client
45 // notice's ownership validation and before its toast state is changed.48 // notice's ownership validation and before its toast state is changed.
46 beforeClientNoticeMutation func()49 beforeClientNoticeMutation func()
@@ -112,7 +115,7 @@ func (nc *noticeCenter) queueGlobal(n domain.Notification) {
112 nc.mu.Lock()115 nc.mu.Lock()
113 defer nc.mu.Unlock()116 defer nc.mu.Unlock()
114 for i := range nc.pending {117 for i := range nc.pending {
115- if nc.pending[i].Code == n.Code {118+ if sameToastNotice(nc.pending[i], n) {
116 nc.pending[i].Count += n.Count119 nc.pending[i].Count += n.Count
117 nc.pending[i].Time = n.Time120 nc.pending[i].Time = n.Time
118 return121 return
@@ -223,12 +226,13 @@ func (d *Daemon) reportError(sess *session, err error) {
223 226
224 // notify records a notice and routes it to whoever can see it. A nil sess means227 // notify records a notice and routes it to whoever can see it. A nil sess means
225 // daemon-global: every attached client gets it, and if none is attached the228 // daemon-global: every attached client gets it, and if none is attached the
226-// notice waits in the pending queue for the next attach.229+// notice waits in the pending queue for the next attach. A detached session's
230+// notice waits for that session's next attach.
227 //231 //
228 // Locking: sess.mu and d.mu are only held to snapshot; showToast is always232 // Locking: sess.mu and d.mu are only held to snapshot; showToast is always
229-// called with no daemon, session, or pane lock held. Global routing holds the233+// called with no daemon, session, or pane lock held. Routing holds the notice
230-// notice center's routingMu across selection and publication, which serializes234+// center's routingMu across selection and publication, which serializes it with
231-// it with attachment detach and pending-queue drains.235+// attachment detach and pending-queue drains. The order is routingMu -> sess.mu.
232 func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {236 func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
233 n := domain.Notification{237 n := domain.Notification{
234 Code: code,238 Code: code,
@@ -244,11 +248,24 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
244 d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)248 d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
245 249
246 if sess != nil {250 if sess != nil {
251+ d.notices.routingMu.Lock()
247 sess.mu.Lock()252 sess.mu.Lock()
248 ac := sess.client253 ac := sess.client
249 sess.mu.Unlock()254 sess.mu.Unlock()
250- if ac != nil {255+ if ac == nil {
251- d.showToast(ac, n)256+ d.notices.queueGlobal(n)
257+ d.notices.routingMu.Unlock()
258+ return
259+ }
260+ if d.notices.beforeSessionDelivery != nil {
261+ d.notices.beforeSessionDelivery()
262+ }
263+ // Publish the toast while detach is excluded, then release routing before
264+ // repainting. Rendering can report another notice and re-enter routing.
265+ published := d.publishToast(ac, n)
266+ d.notices.routingMu.Unlock()
267+ if published {
268+ d.repaintForNotice(ac)
252 }269 }
253 return270 return
254 }271 }
@@ -257,10 +274,9 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
257 }274 }
258 275
259 // deliverGlobal atomically selects all current attachments and either publishes276 // deliverGlobal atomically selects all current attachments and either publishes
260-// to all of them or queues the notice. routingMu is deliberately held through277+// to all of them or queues the notice. routingMu is held through toast-state
261-// showToast, but never with d.mu or sess.mu held: detach cannot turn a selected278+// publication, but never through repaint: detach cannot turn a selected target
262-// target stale between selection and publication, and firstPaint cannot drain279+// stale before publication, while rendering remains free to re-enter routing.
263-// before an unattached route has queued its notice.
264 func (d *Daemon) deliverGlobal(n domain.Notification) {280 func (d *Daemon) deliverGlobal(n domain.Notification) {
265 d.mu.Lock()281 d.mu.Lock()
266 d.notices.routingMu.Lock()282 d.notices.routingMu.Lock()
@@ -269,45 +285,62 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
269 sessions = append(sessions, s)285 sessions = append(sessions, s)
270 }286 }
271 d.mu.Unlock()287 d.mu.Unlock()
272- defer d.notices.routingMu.Unlock()
273 288
274 if d.notices.beforeGlobalDelivery != nil {289 if d.notices.beforeGlobalDelivery != nil {
275 d.notices.beforeGlobalDelivery()290 d.notices.beforeGlobalDelivery()
276 }291 }
277- delivered := false292+ targets := make([]*attachedClient, 0, len(sessions))
278 for _, s := range sessions {293 for _, s := range sessions {
279 s.mu.Lock()294 s.mu.Lock()
280 ac := s.client295 ac := s.client
281 s.mu.Unlock()296 s.mu.Unlock()
282- if ac == nil {297+ if ac == nil || !d.publishToast(ac, n) {
283 continue298 continue
284 }299 }
285- d.showToast(ac, n)300+ targets = append(targets, ac)
286- delivered = true
287 }301 }
288- if !delivered {302+ if len(targets) == 0 {
289 if d.notices.beforeQueueGlobal != nil {303 if d.notices.beforeQueueGlobal != nil {
290 d.notices.beforeQueueGlobal()304 d.notices.beforeQueueGlobal()
291 }305 }
292 d.notices.queueGlobal(n)306 d.notices.queueGlobal(n)
293 }307 }
308+ d.notices.routingMu.Unlock()
309+
310+ for _, ac := range targets {
311+ d.repaintForNotice(ac)
312+ }
294 }313 }
295 314
296-// drainPendingForFirstPaint publishes pending globals only while ac remains the315+// drainPendingForFirstPaint publishes pending globals and notices scoped to
297-// session's current attachment. It shares routingMu with deliverGlobal, so an316+// sess only while ac remains the session's current attachment. It shares
298-// unattached global route cannot queue after this drain has already happened.317+// routingMu with notice routing, so an unattached route cannot queue after this
318+// drain has already happened.
299 func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {319 func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
300 d.notices.routingMu.Lock()320 d.notices.routingMu.Lock()
301- defer d.notices.routingMu.Unlock()
302 321
303 sess.mu.Lock()322 sess.mu.Lock()
304 current := sess.client == ac323 current := sess.client == ac
305 sess.mu.Unlock()324 sess.mu.Unlock()
306 if !current {325 if !current {
326+ d.notices.routingMu.Unlock()
307 return327 return
308 }328 }
329+ published := false
330+ var keep []domain.Notification
309 for _, n := range d.notices.drainPending() {331 for _, n := range d.notices.drainPending() {
310- d.showToast(ac, n)332+ if n.SessionID == "" || n.SessionID == sess.id {
333+ published = d.publishToast(ac, n) || published
334+ continue
335+ }
336+ keep = append(keep, n)
337+ }
338+ for _, n := range keep {
339+ d.notices.queueGlobal(n)
340+ }
341+ d.notices.routingMu.Unlock()
342+ if published {
343+ d.repaintForNotice(ac)
311 }344 }
312 }345 }
313 346
@@ -316,27 +349,32 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
316 d.notify(nil, sev, code, msg, cause)349 d.notify(nil, sev, code, msg, cause)
317 }350 }
318 351
319-// showToast publishes a notice into one client's toast stack. An identical352+// showToast publishes a notice into one client's toast stack and repaints only
320-// code within the same scope coalesces into the existing entry and restarts its353+// after the state mutation is complete.
321-// TTL; otherwise the notice is prepended and the stack trimmed. The repaint is
322-// issued after noticeMu is released.
323 func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {354 func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
355+ if d.publishToast(ac, n) {
356+ d.repaintForNotice(ac)
357+ }
358+}
359+
360+// publishToast durably mutates one client's toast state without repainting.
361+// Routing paths call it while routingMu excludes detach, then release routingMu
362+// before rendering so a render failure can safely route another notice.
363+func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool {
324 if ac == nil {364 if ac == nil {
325- return365+ return false
326 }366 }
327 ac.initOverlays()367 ac.initOverlays()
328 rt := ac.overlays368 rt := ac.overlays
329 369
330 rt.noticeMu.Lock()370 rt.noticeMu.Lock()
331- if i := rt.indexOfToastLocked(n.Code, n.SessionID); i >= 0 {371+ defer rt.noticeMu.Unlock()
372+ if i := rt.indexOfMatchingToastLocked(n); i >= 0 {
332 rt.noticeToasts[i].n.Count += n.Count373 rt.noticeToasts[i].n.Count += n.Count
333 rt.noticeToasts[i].n.Time = n.Time374 rt.noticeToasts[i].n.Time = n.Time
334- rt.noticeToasts[i].n.Details = n.Details
335 rt.noticeToasts[i].timer.stop()375 rt.noticeToasts[i].timer.stop()
336 d.retainToastTimerLocked(ac, &rt.noticeToasts[i])376 d.retainToastTimerLocked(ac, &rt.noticeToasts[i])
337- rt.noticeMu.Unlock()377+ return true
338- d.repaintForNotice(ac)
339- return
340 }378 }
341 379
342 toast := noticeToast{n: n}380 toast := noticeToast{n: n}
@@ -349,8 +387,34 @@ func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
349 rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts]387 rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts]
350 }388 }
351 d.retainToastTimerLocked(ac, &rt.noticeToasts[0])389 d.retainToastTimerLocked(ac, &rt.noticeToasts[0])
352- rt.noticeMu.Unlock()390+ return true
353- d.repaintForNotice(ac)391+}
392+
393+func sameToastNotice(a, b domain.Notification) bool {
394+ if a.Code != b.Code || a.SessionID != b.SessionID {
395+ return false
396+ }
397+ // NoticeUser is intentionally generic: its message and selected severity
398+ // are the notice identity. Typed daemon notices retain their established
399+ // code-and-scope coalescing behavior.
400+ if a.Code != domain.NoticeUser {
401+ return true
402+ }
403+ return a.Severity == b.Severity &&
404+ a.Message == b.Message &&
405+ a.Details == b.Details
406+}
407+
408+// indexOfMatchingToastLocked finds a visible toast with the code-specific
409+// coalescing identity. Count and time are occurrence metadata, not identity.
410+// Callers must hold noticeMu.
411+func (rt *overlayRuntime) indexOfMatchingToastLocked(n domain.Notification) int {
412+ for i := range rt.noticeToasts {
413+ if sameToastNotice(rt.noticeToasts[i].n, n) {
414+ return i
415+ }
416+ }
417+ return -1
354 }418 }
355 419
356 // indexOfToastLocked finds a visible toast with the same code and scope.420 // indexOfToastLocked finds a visible toast with the same code and scope.
M internal/usecase/daemon/notify_test.go +166 -15
@@ -254,10 +254,10 @@ func TestNoticeCenterPendingQueue(t *testing.T) {
254254 for i := 0; i < 32; i++ {
255255 nc.queueGlobal(notice(domain.NoticeCode(i), fmt.Sprintf("code %d", i), ""))
256256 }
257- // The queue is now full. Re-queuing an existing code must coalesce
257+ // The queue is now full. Re-queuing an identical notice must coalesce
258258 // in place, not hit the overflow-drop branch.
259- nc.queueGlobal(notice(domain.NoticeCode(0), "code 0 again", ""))
260- nc.queueGlobal(notice(domain.NoticeCode(0), "code 0 again", ""))
259+ nc.queueGlobal(notice(domain.NoticeCode(0), "code 0", ""))
260+ nc.queueGlobal(notice(domain.NoticeCode(0), "code 0", ""))
261261 pending := nc.drainPending()
262262 if len(pending) != 32 {
263263 t.Fatalf("pending len = %d, want 32 (dedup must not grow past cap)", len(pending))
@@ -525,18 +525,128 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
525525 require.Empty(t, otherToasts, "another session's client must be untouched by session-scoped notices")
526526 }
527527
528-func TestNotifySessionWithoutClientRecordsHistoryOnly(t *testing.T) {
528+func TestNotifySessionScopedSerializesDetachThroughToastPublication(t *testing.T) {
529529 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
530- sess.mu.Lock()
531- sess.client = nil
532- sess.mu.Unlock()
533530
534- d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
531+ selected := make(chan bool, 1)
532+ releasePublication := make(chan struct{})
533+ d.notices.beforeSessionDelivery = func() {
534+ unlocked := d.notices.routingMu.TryLock()
535+ if unlocked {
536+ d.notices.routingMu.Unlock()
537+ }
538+ selected <- !unlocked
539+ <-releasePublication
540+ }
541+ t.Cleanup(func() {
542+ select {
543+ case <-releasePublication:
544+ default:
545+ close(releasePublication)
546+ }
547+ })
548+
549+ notifyDone := make(chan struct{})
550+ go func() {
551+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil)
552+ close(notifyDone)
553+ }()
554+ require.True(t, <-selected, "routingMu must remain held from attachment selection through toast publication")
555+
556+ detachStarted := make(chan struct{})
557+ detachDone := make(chan bool, 1)
558+ go func() {
559+ close(detachStarted)
560+ detachDone <- d.detachIfCurrent(sess, ac)
561+ }()
562+ <-detachStarted
563+ select {
564+ case <-detachDone:
565+ t.Fatal("detach completed before the selected toast was durably published")
566+ default:
567+ }
568+
569+ close(releasePublication)
570+ <-notifyDone
571+ require.True(t, <-detachDone)
535572
536- require.Len(t, d.notices.history(), 1)
537573 toasts, _ := visibleToasts(ac)
538- require.Empty(t, toasts, "a detached session must not paint a toast")
539- require.Empty(t, d.notices.drainPending(), "session-scoped notices are never queued as pending globals")
574+ require.Len(t, toasts, 1)
575+ require.Equal(t, "hello", toasts[0].Message)
576+ require.Empty(t, d.notices.drainPending(), "a toast published before detach must not also be queued")
577+}
578+
579+func TestNotifySessionScopedReleasesRoutingBeforeRepaint(t *testing.T) {
580+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
581+
582+ repaintObserved := make(chan bool, 1)
583+ ac.renderStages.capture = func() {
584+ unlocked := d.notices.routingMu.TryLock()
585+ if unlocked {
586+ d.notices.routingMu.Unlock()
587+ }
588+ repaintObserved <- unlocked
589+ }
590+
591+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil)
592+
593+ require.True(t, <-repaintObserved, "notice repaint must run without routingMu held")
594+}
595+
596+func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
597+ d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock())
598+ sessA.mu.Lock()
599+ sessA.client = nil
600+ sessA.mu.Unlock()
601+
602+ clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
603+ clientB.initOverlays()
604+ sessB := &session{id: "manual-2", name: "other", ctx: sessA.ctx, cancel: func() {}}
605+ clientB.setSession(sessB)
606+ d.mu.Lock()
607+ d.sessions[sessB.id] = sessB
608+ d.mu.Unlock()
609+
610+ d.notify(sessA, domain.NoticeInfo, domain.NoticeUser, "hello from a script", nil)
611+ // Use the same code globally to prove pending coalescing includes scope.
612+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeUser, "global announcement", nil)
613+
614+ history := d.notices.history()
615+ require.Len(t, history, 2)
616+ require.Equal(t, domain.NoticeUser, history[1].Code)
617+ oldToasts, _ := visibleToasts(oldA)
618+ require.Empty(t, oldToasts, "a detached session must not paint a toast")
619+
620+ sessB.mu.Lock()
621+ sessB.client = clientB
622+ sessB.mu.Unlock()
623+ d.drainPendingForFirstPaint(sessB, clientB)
624+ bToasts, _ := visibleToasts(clientB)
625+ require.Len(t, bToasts, 1, "another session receives only the global notice")
626+ require.Equal(t, domain.NoticeUser, bToasts[0].Code)
627+ require.Empty(t, bToasts[0].SessionID)
628+ require.Equal(t, "global announcement", bToasts[0].Message)
629+
630+ sessA.mu.Lock()
631+ sessA.client = oldA
632+ sessA.mu.Unlock()
633+ d.drainPendingForFirstPaint(sessA, oldA)
634+ aToasts, _ := visibleToasts(oldA)
635+ require.Len(t, aToasts, 1)
636+ require.Equal(t, domain.NoticeUser, aToasts[0].Code)
637+ require.Equal(t, sessA.id, aToasts[0].SessionID)
638+ require.Equal(t, "hello from a script", aToasts[0].Message)
639+ require.Equal(t, 1, aToasts[0].Count)
640+
641+ secondA := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
642+ secondA.initOverlays()
643+ secondA.setSession(sessA)
644+ sessA.mu.Lock()
645+ sessA.client = secondA
646+ sessA.mu.Unlock()
647+ d.drainPendingForFirstPaint(sessA, secondA)
648+ secondToasts, _ := visibleToasts(secondA)
649+ require.Empty(t, secondToasts, "a drained session notice must not be delivered twice")
540650 }
541651
542652 func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
@@ -665,6 +775,30 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) {
665775 sess.mu.Unlock()
666776 }
667777
778+func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.T) {
779+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
780+ sess.mu.Lock()
781+ sess.client = nil
782+ sess.mu.Unlock()
783+
784+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil)
785+ d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil)
786+ d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil)
787+
788+ sess.mu.Lock()
789+ sess.client = ac
790+ sess.mu.Unlock()
791+ d.drainPendingForFirstPaint(sess, ac)
792+
793+ toasts := awaitToastCount(t, ac, 2)
794+ require.Equal(t, "build delayed", toasts[0].Message)
795+ require.Equal(t, domain.NoticeWarn, toasts[0].Severity)
796+ require.Equal(t, 2, toasts[0].Count, "identical detached notices coalesce")
797+ require.Equal(t, "build started", toasts[1].Message)
798+ require.Equal(t, domain.NoticeInfo, toasts[1].Severity)
799+ require.Equal(t, 1, toasts[1].Count)
800+}
801+
668802 func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
669803 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
670804 sess.mu.Lock()
@@ -763,19 +897,36 @@ func TestNotifyLogsAtSeverityLevel(t *testing.T) {
763897 }
764898
765899 func TestShowToastCoalesceAndTrim(t *testing.T) {
766- t.Run("same code and scope coalesces", func(t *testing.T) {
767- d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
900+ t.Run("identical notices coalesce and renew their ttl", func(t *testing.T) {
901+ clk := newNoticeClock()
902+ d, sess, ac, _ := newNoticeFixture(t, clk)
768903
769- d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
770- d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
904+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build finished", nil)
905+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build finished", nil)
771906
772907 toasts := awaitToastCount(t, ac, 1)
773908 require.Equal(t, 2, toasts[0].Count)
909+ require.Equal(t, []time.Duration{4 * time.Second, 4 * time.Second}, clk.durations(), "coalescing renews the identical notice's ttl")
774910 _, overflow := visibleToasts(ac)
775911 require.Zero(t, overflow)
776912 require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both")
777913 })
778914
915+ t.Run("same code and scope preserve distinct message and severity", func(t *testing.T) {
916+ clk := newNoticeClock()
917+ d, sess, ac, _ := newNoticeFixture(t, clk)
918+
919+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil)
920+ d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil)
921+
922+ toasts := awaitToastCount(t, ac, 2)
923+ require.Equal(t, "build delayed", toasts[0].Message)
924+ require.Equal(t, domain.NoticeWarn, toasts[0].Severity)
925+ require.Equal(t, "build started", toasts[1].Message)
926+ require.Equal(t, domain.NoticeInfo, toasts[1].Severity)
927+ require.Equal(t, []time.Duration{4 * time.Second, 6 * time.Second}, clk.durations(), "each distinct notice keeps its severity ttl")
928+ })
929+
779930 t.Run("same code different scope does not coalesce", func(t *testing.T) {
780931 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
781932
@@ -254,10 +254,10 @@ func TestNoticeCenterPendingQueue(t *testing.T) {
254 for i := 0; i < 32; i++ {254 for i := 0; i < 32; i++ {
255 nc.queueGlobal(notice(domain.NoticeCode(i), fmt.Sprintf("code %d", i), ""))255 nc.queueGlobal(notice(domain.NoticeCode(i), fmt.Sprintf("code %d", i), ""))
256 }256 }
257- // The queue is now full. Re-queuing an existing code must coalesce257+ // The queue is now full. Re-queuing an identical notice must coalesce
258 // in place, not hit the overflow-drop branch.258 // in place, not hit the overflow-drop branch.
259- nc.queueGlobal(notice(domain.NoticeCode(0), "code 0 again", ""))259+ nc.queueGlobal(notice(domain.NoticeCode(0), "code 0", ""))
260- nc.queueGlobal(notice(domain.NoticeCode(0), "code 0 again", ""))260+ nc.queueGlobal(notice(domain.NoticeCode(0), "code 0", ""))
261 pending := nc.drainPending()261 pending := nc.drainPending()
262 if len(pending) != 32 {262 if len(pending) != 32 {
263 t.Fatalf("pending len = %d, want 32 (dedup must not grow past cap)", len(pending))263 t.Fatalf("pending len = %d, want 32 (dedup must not grow past cap)", len(pending))
@@ -525,18 +525,128 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
525 require.Empty(t, otherToasts, "another session's client must be untouched by session-scoped notices")525 require.Empty(t, otherToasts, "another session's client must be untouched by session-scoped notices")
526 }526 }
527 527
528-func TestNotifySessionWithoutClientRecordsHistoryOnly(t *testing.T) {528+func TestNotifySessionScopedSerializesDetachThroughToastPublication(t *testing.T) {
529 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())529 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
530- sess.mu.Lock()
531- sess.client = nil
532- sess.mu.Unlock()
533 530
534- d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)531+ selected := make(chan bool, 1)
532+ releasePublication := make(chan struct{})
533+ d.notices.beforeSessionDelivery = func() {
534+ unlocked := d.notices.routingMu.TryLock()
535+ if unlocked {
536+ d.notices.routingMu.Unlock()
537+ }
538+ selected <- !unlocked
539+ <-releasePublication
540+ }
541+ t.Cleanup(func() {
542+ select {
543+ case <-releasePublication:
544+ default:
545+ close(releasePublication)
546+ }
547+ })
548+
549+ notifyDone := make(chan struct{})
550+ go func() {
551+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil)
552+ close(notifyDone)
553+ }()
554+ require.True(t, <-selected, "routingMu must remain held from attachment selection through toast publication")
555+
556+ detachStarted := make(chan struct{})
557+ detachDone := make(chan bool, 1)
558+ go func() {
559+ close(detachStarted)
560+ detachDone <- d.detachIfCurrent(sess, ac)
561+ }()
562+ <-detachStarted
563+ select {
564+ case <-detachDone:
565+ t.Fatal("detach completed before the selected toast was durably published")
566+ default:
567+ }
568+
569+ close(releasePublication)
570+ <-notifyDone
571+ require.True(t, <-detachDone)
535 572
536- require.Len(t, d.notices.history(), 1)
537 toasts, _ := visibleToasts(ac)573 toasts, _ := visibleToasts(ac)
538- require.Empty(t, toasts, "a detached session must not paint a toast")574+ require.Len(t, toasts, 1)
539- require.Empty(t, d.notices.drainPending(), "session-scoped notices are never queued as pending globals")575+ require.Equal(t, "hello", toasts[0].Message)
576+ require.Empty(t, d.notices.drainPending(), "a toast published before detach must not also be queued")
577+}
578+
579+func TestNotifySessionScopedReleasesRoutingBeforeRepaint(t *testing.T) {
580+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
581+
582+ repaintObserved := make(chan bool, 1)
583+ ac.renderStages.capture = func() {
584+ unlocked := d.notices.routingMu.TryLock()
585+ if unlocked {
586+ d.notices.routingMu.Unlock()
587+ }
588+ repaintObserved <- unlocked
589+ }
590+
591+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil)
592+
593+ require.True(t, <-repaintObserved, "notice repaint must run without routingMu held")
594+}
595+
596+func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
597+ d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock())
598+ sessA.mu.Lock()
599+ sessA.client = nil
600+ sessA.mu.Unlock()
601+
602+ clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
603+ clientB.initOverlays()
604+ sessB := &session{id: "manual-2", name: "other", ctx: sessA.ctx, cancel: func() {}}
605+ clientB.setSession(sessB)
606+ d.mu.Lock()
607+ d.sessions[sessB.id] = sessB
608+ d.mu.Unlock()
609+
610+ d.notify(sessA, domain.NoticeInfo, domain.NoticeUser, "hello from a script", nil)
611+ // Use the same code globally to prove pending coalescing includes scope.
612+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeUser, "global announcement", nil)
613+
614+ history := d.notices.history()
615+ require.Len(t, history, 2)
616+ require.Equal(t, domain.NoticeUser, history[1].Code)
617+ oldToasts, _ := visibleToasts(oldA)
618+ require.Empty(t, oldToasts, "a detached session must not paint a toast")
619+
620+ sessB.mu.Lock()
621+ sessB.client = clientB
622+ sessB.mu.Unlock()
623+ d.drainPendingForFirstPaint(sessB, clientB)
624+ bToasts, _ := visibleToasts(clientB)
625+ require.Len(t, bToasts, 1, "another session receives only the global notice")
626+ require.Equal(t, domain.NoticeUser, bToasts[0].Code)
627+ require.Empty(t, bToasts[0].SessionID)
628+ require.Equal(t, "global announcement", bToasts[0].Message)
629+
630+ sessA.mu.Lock()
631+ sessA.client = oldA
632+ sessA.mu.Unlock()
633+ d.drainPendingForFirstPaint(sessA, oldA)
634+ aToasts, _ := visibleToasts(oldA)
635+ require.Len(t, aToasts, 1)
636+ require.Equal(t, domain.NoticeUser, aToasts[0].Code)
637+ require.Equal(t, sessA.id, aToasts[0].SessionID)
638+ require.Equal(t, "hello from a script", aToasts[0].Message)
639+ require.Equal(t, 1, aToasts[0].Count)
640+
641+ secondA := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
642+ secondA.initOverlays()
643+ secondA.setSession(sessA)
644+ sessA.mu.Lock()
645+ sessA.client = secondA
646+ sessA.mu.Unlock()
647+ d.drainPendingForFirstPaint(sessA, secondA)
648+ secondToasts, _ := visibleToasts(secondA)
649+ require.Empty(t, secondToasts, "a drained session notice must not be delivered twice")
540 }650 }
541 651
542 func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {652 func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
@@ -665,6 +775,30 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) {
665 sess.mu.Unlock()775 sess.mu.Unlock()
666 }776 }
667 777
778+func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.T) {
779+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
780+ sess.mu.Lock()
781+ sess.client = nil
782+ sess.mu.Unlock()
783+
784+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil)
785+ d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil)
786+ d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil)
787+
788+ sess.mu.Lock()
789+ sess.client = ac
790+ sess.mu.Unlock()
791+ d.drainPendingForFirstPaint(sess, ac)
792+
793+ toasts := awaitToastCount(t, ac, 2)
794+ require.Equal(t, "build delayed", toasts[0].Message)
795+ require.Equal(t, domain.NoticeWarn, toasts[0].Severity)
796+ require.Equal(t, 2, toasts[0].Count, "identical detached notices coalesce")
797+ require.Equal(t, "build started", toasts[1].Message)
798+ require.Equal(t, domain.NoticeInfo, toasts[1].Severity)
799+ require.Equal(t, 1, toasts[1].Count)
800+}
801+
668 func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {802 func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
669 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())803 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
670 sess.mu.Lock()804 sess.mu.Lock()
@@ -763,19 +897,36 @@ func TestNotifyLogsAtSeverityLevel(t *testing.T) {
763 }897 }
764 898
765 func TestShowToastCoalesceAndTrim(t *testing.T) {899 func TestShowToastCoalesceAndTrim(t *testing.T) {
766- t.Run("same code and scope coalesces", func(t *testing.T) {900+ t.Run("identical notices coalesce and renew their ttl", func(t *testing.T) {
767- d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())901+ clk := newNoticeClock()
902+ d, sess, ac, _ := newNoticeFixture(t, clk)
768 903
769- d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)904+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build finished", nil)
770- d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)905+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build finished", nil)
771 906
772 toasts := awaitToastCount(t, ac, 1)907 toasts := awaitToastCount(t, ac, 1)
773 require.Equal(t, 2, toasts[0].Count)908 require.Equal(t, 2, toasts[0].Count)
909+ require.Equal(t, []time.Duration{4 * time.Second, 4 * time.Second}, clk.durations(), "coalescing renews the identical notice's ttl")
774 _, overflow := visibleToasts(ac)910 _, overflow := visibleToasts(ac)
775 require.Zero(t, overflow)911 require.Zero(t, overflow)
776 require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both")912 require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both")
777 })913 })
778 914
915+ t.Run("same code and scope preserve distinct message and severity", func(t *testing.T) {
916+ clk := newNoticeClock()
917+ d, sess, ac, _ := newNoticeFixture(t, clk)
918+
919+ d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil)
920+ d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil)
921+
922+ toasts := awaitToastCount(t, ac, 2)
923+ require.Equal(t, "build delayed", toasts[0].Message)
924+ require.Equal(t, domain.NoticeWarn, toasts[0].Severity)
925+ require.Equal(t, "build started", toasts[1].Message)
926+ require.Equal(t, domain.NoticeInfo, toasts[1].Severity)
927+ require.Equal(t, []time.Duration{4 * time.Second, 6 * time.Second}, clk.durations(), "each distinct notice keeps its severity ttl")
928+ })
929+
779 t.Run("same code different scope does not coalesce", func(t *testing.T) {930 t.Run("same code different scope does not coalesce", func(t *testing.T) {
780 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())931 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
781 932
M internal/usecase/daemon/palette.go +46 -53
@@ -103,7 +103,7 @@ func recentSessionHints(recent []recentSession, args []string) palette.Contextua
103103 }
104104
105105 func (d *Daemon) paletteCommands() []command.Command {
106- commands := command.Registry()
106+ commands := command.PaletteRegistry()
107107 d.paletteRecentMu.Lock()
108108 recent := append([]string(nil), d.paletteRecent...)
109109 d.paletteRecentMu.Unlock()
@@ -303,7 +303,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
303303 if !ac.closeExecutedPalette(generation, rawQuery) {
304304 return
305305 }
306- if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil {
306+ sess.dispatchMu.Lock()
307+ err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args)
308+ sess.dispatchMu.Unlock()
309+ if err != nil {
307310 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
308311 d.reportError(sess, err)
309312 } else {
@@ -348,15 +351,30 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
348351 }
349352
350353 type paletteExec struct {
351- d *Daemon
352- sess *session
353- ac *attachedClient
354- recent []recentSession
354+ d *Daemon
355+ sess *session
356+ ac *attachedClient
357+ recent []recentSession
358+ actions daemonActionRunner
359+}
360+
361+func (e paletteExec) runAction(request daemonActionRequest) error {
362+ if request.target.session == nil {
363+ request.target = resolveDaemonActionTarget(e.sess)
364+ }
365+ runner := e.actions
366+ if runner == nil {
367+ runner = daemonActions{d: e.d}
368+ }
369+ err := runner.Run(request)
370+ if err == nil && e.actions == nil {
371+ finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
372+ }
373+ return err
355374 }
356375
357376 func (e paletteExec) CreateTab() error {
358- defer e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
359- return e.d.createTab(e.sess, e.ac.size)
377+ return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.ac.size})
360378 }
361379
362380 func (e paletteExec) CreateSession() error {
@@ -367,70 +385,45 @@ func (e paletteExec) CreateSession() error {
367385 }
368386
369387 func (e paletteExec) CloseTab() error {
370- if tb := e.sess.activeTab(); tb != nil {
371- e.d.closeTab(e.sess, tb, true)
372- }
373- return nil
388+ return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
374389 }
375390
376-func (e paletteExec) SplitRight() error {
377- return e.d.splitPane(e.sess, e.ac, layout.Right)
391+func (e paletteExec) split(direction layout.Direction) error {
392+ return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
378393 }
379394
380-func (e paletteExec) SplitLeft() error {
381- return e.d.splitPane(e.sess, e.ac, layout.Left)
382-}
383-
384-func (e paletteExec) SplitUp() error {
385- return e.d.splitPane(e.sess, e.ac, layout.Up)
386-}
387-
388-func (e paletteExec) SplitDown() error {
389- return e.d.splitPane(e.sess, e.ac, layout.Down)
390-}
395+func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
396+func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }
397+func (e paletteExec) SplitUp() error { return e.split(layout.Up) }
398+func (e paletteExec) SplitDown() error { return e.split(layout.Down) }
391399
392400 func (e paletteExec) StackPane() error {
393- return e.d.stackPane(e.sess, e.ac)
401+ return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
394402 }
395403
396404 func (e paletteExec) ToggleStack() error {
397- return e.d.toggleStack(e.sess, e.ac)
405+ return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
398406 }
399407
400408 func (e paletteExec) ClosePane() error {
401- return e.d.closeFocusedPane(e.sess, e.ac)
402-}
403-
404-func (e paletteExec) FocusPaneLeft() error {
405- return e.d.focusDir(e.sess, e.ac, layout.Left)
406-}
407-
408-func (e paletteExec) FocusPaneRight() error {
409- return e.d.focusDir(e.sess, e.ac, layout.Right)
409+ return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
410410 }
411411
412-func (e paletteExec) FocusPaneUp() error {
413- return e.d.focusDir(e.sess, e.ac, layout.Up)
412+func (e paletteExec) focus(direction layout.Direction) error {
413+ return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
414414 }
415415
416-func (e paletteExec) FocusPaneDown() error {
417- return e.d.focusDir(e.sess, e.ac, layout.Down)
418-}
416+func (e paletteExec) FocusPaneLeft() error { return e.focus(layout.Left) }
417+func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) }
418+func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) }
419+func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) }
419420
420421 func (e paletteExec) NextTab() error {
421- if e.sess.switchRelative(1) {
422- e.d.activateTab(e.sess, e.sess.activeTab())
423- }
424- e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
425- return nil
422+ return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
426423 }
427424
428425 func (e paletteExec) PrevTab() error {
429- if e.sess.switchRelative(-1) {
430- e.d.activateTab(e.sess, e.sess.activeTab())
431- }
432- e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
433- return nil
426+ return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
434427 }
435428
436429 func (e paletteExec) ToggleFloatingPane() error {
@@ -457,7 +450,7 @@ func (e paletteExec) RenameSession() error {
457450 currentName := e.sess.name
458451 e.sess.mu.Unlock()
459452 e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {
460- return e.d.renameSession(e.sess, name)
453+ return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
461454 })
462455 return nil
463456 }
@@ -471,7 +464,7 @@ func (e paletteExec) RenameTab() error {
471464 currentName := tabDisplayName(tb, e.sess.active)
472465 e.sess.mu.Unlock()
473466 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
474- return e.d.renameTab(e.sess, tb, name)
467+ return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
475468 })
476469 return nil
477470 }
@@ -103,7 +103,7 @@ func recentSessionHints(recent []recentSession, args []string) palette.Contextua
103 }103 }
104 104
105 func (d *Daemon) paletteCommands() []command.Command {105 func (d *Daemon) paletteCommands() []command.Command {
106- commands := command.Registry()106+ commands := command.PaletteRegistry()
107 d.paletteRecentMu.Lock()107 d.paletteRecentMu.Lock()
108 recent := append([]string(nil), d.paletteRecent...)108 recent := append([]string(nil), d.paletteRecent...)
109 d.paletteRecentMu.Unlock()109 d.paletteRecentMu.Unlock()
@@ -303,7 +303,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
303 if !ac.closeExecutedPalette(generation, rawQuery) {303 if !ac.closeExecutedPalette(generation, rawQuery) {
304 return304 return
305 }305 }
306- if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil {306+ sess.dispatchMu.Lock()
307+ err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args)
308+ sess.dispatchMu.Unlock()
309+ if err != nil {
307 d.log.Error("palette command failed", "err", err, "command", cmd.Code)310 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
308 d.reportError(sess, err)311 d.reportError(sess, err)
309 } else {312 } else {
@@ -348,15 +351,30 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
348 }351 }
349 352
350 type paletteExec struct {353 type paletteExec struct {
351- d *Daemon354+ d *Daemon
352- sess *session355+ sess *session
353- ac *attachedClient356+ ac *attachedClient
354- recent []recentSession357+ recent []recentSession
358+ actions daemonActionRunner
359+}
360+
361+func (e paletteExec) runAction(request daemonActionRequest) error {
362+ if request.target.session == nil {
363+ request.target = resolveDaemonActionTarget(e.sess)
364+ }
365+ runner := e.actions
366+ if runner == nil {
367+ runner = daemonActions{d: e.d}
368+ }
369+ err := runner.Run(request)
370+ if err == nil && e.actions == nil {
371+ finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
372+ }
373+ return err
355 }374 }
356 375
357 func (e paletteExec) CreateTab() error {376 func (e paletteExec) CreateTab() error {
358- defer e.d.invalidateRender(e.sess, e.ac, true, "palette.go")377+ return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.ac.size})
359- return e.d.createTab(e.sess, e.ac.size)
360 }378 }
361 379
362 func (e paletteExec) CreateSession() error {380 func (e paletteExec) CreateSession() error {
@@ -367,70 +385,45 @@ func (e paletteExec) CreateSession() error {
367 }385 }
368 386
369 func (e paletteExec) CloseTab() error {387 func (e paletteExec) CloseTab() error {
370- if tb := e.sess.activeTab(); tb != nil {388+ return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
371- e.d.closeTab(e.sess, tb, true)
372- }
373- return nil
374 }389 }
375 390
376-func (e paletteExec) SplitRight() error {391+func (e paletteExec) split(direction layout.Direction) error {
377- return e.d.splitPane(e.sess, e.ac, layout.Right)392+ return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
378 }393 }
379 394
380-func (e paletteExec) SplitLeft() error {395+func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
381- return e.d.splitPane(e.sess, e.ac, layout.Left)396+func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }
382-}397+func (e paletteExec) SplitUp() error { return e.split(layout.Up) }
383-398+func (e paletteExec) SplitDown() error { return e.split(layout.Down) }
384-func (e paletteExec) SplitUp() error {
385- return e.d.splitPane(e.sess, e.ac, layout.Up)
386-}
387-
388-func (e paletteExec) SplitDown() error {
389- return e.d.splitPane(e.sess, e.ac, layout.Down)
390-}
391 399
392 func (e paletteExec) StackPane() error {400 func (e paletteExec) StackPane() error {
393- return e.d.stackPane(e.sess, e.ac)401+ return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
394 }402 }
395 403
396 func (e paletteExec) ToggleStack() error {404 func (e paletteExec) ToggleStack() error {
397- return e.d.toggleStack(e.sess, e.ac)405+ return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
398 }406 }
399 407
400 func (e paletteExec) ClosePane() error {408 func (e paletteExec) ClosePane() error {
401- return e.d.closeFocusedPane(e.sess, e.ac)409+ return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
402-}
403-
404-func (e paletteExec) FocusPaneLeft() error {
405- return e.d.focusDir(e.sess, e.ac, layout.Left)
406-}
407-
408-func (e paletteExec) FocusPaneRight() error {
409- return e.d.focusDir(e.sess, e.ac, layout.Right)
410 }410 }
411 411
412-func (e paletteExec) FocusPaneUp() error {412+func (e paletteExec) focus(direction layout.Direction) error {
413- return e.d.focusDir(e.sess, e.ac, layout.Up)413+ return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
414 }414 }
415 415
416-func (e paletteExec) FocusPaneDown() error {416+func (e paletteExec) FocusPaneLeft() error { return e.focus(layout.Left) }
417- return e.d.focusDir(e.sess, e.ac, layout.Down)417+func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) }
418-}418+func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) }
419+func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) }
419 420
420 func (e paletteExec) NextTab() error {421 func (e paletteExec) NextTab() error {
421- if e.sess.switchRelative(1) {422+ return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
422- e.d.activateTab(e.sess, e.sess.activeTab())
423- }
424- e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
425- return nil
426 }423 }
427 424
428 func (e paletteExec) PrevTab() error {425 func (e paletteExec) PrevTab() error {
429- if e.sess.switchRelative(-1) {426+ return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
430- e.d.activateTab(e.sess, e.sess.activeTab())
431- }
432- e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
433- return nil
434 }427 }
435 428
436 func (e paletteExec) ToggleFloatingPane() error {429 func (e paletteExec) ToggleFloatingPane() error {
@@ -457,7 +450,7 @@ func (e paletteExec) RenameSession() error {
457 currentName := e.sess.name450 currentName := e.sess.name
458 e.sess.mu.Unlock()451 e.sess.mu.Unlock()
459 e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {452 e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {
460- return e.d.renameSession(e.sess, name)453+ return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
461 })454 })
462 return nil455 return nil
463 }456 }
@@ -471,7 +464,7 @@ func (e paletteExec) RenameTab() error {
471 currentName := tabDisplayName(tb, e.sess.active)464 currentName := tabDisplayName(tb, e.sess.active)
472 e.sess.mu.Unlock()465 e.sess.mu.Unlock()
473 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {466 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
474- return e.d.renameTab(e.sess, tb, name)467+ return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
475 })468 })
476 return nil469 return nil
477 }470 }
M internal/usecase/daemon/palette_test.go +31 -1
@@ -574,6 +574,36 @@ func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
574574 require.Equal(t, "NXT", cmd.Code)
575575 }
576576
577+func TestPaletteCodeOverridesRejectAPIOnlyCommands(t *testing.T) {
578+ d := newTestDaemon(t, nil, stubClock{})
579+
580+ overrides, warnings := d.buildCodeOverrides(map[string]string{
581+ "new-tab": "TAB",
582+ "toast": "API",
583+ })
584+
585+ require.Equal(t, map[string]string{"new-tab": "TAB"}, overrides)
586+ require.Len(t, warnings, 1)
587+ require.Contains(t, warnings[0].Msg, `unknown command code slug "toast"`)
588+}
589+
590+func TestCommandByEffectiveCodeExcludesAPIOnlyCommands(t *testing.T) {
591+ d := newTestDaemon(t, nil, stubClock{})
592+ overrides := map[string]string{
593+ "new-tab": "TAB",
594+ "toast": "API",
595+ }
596+ d.codeOverrides.Store(&overrides)
597+
598+ cmd, ok := d.commandByEffectiveCode("tab")
599+ require.True(t, ok)
600+ require.Equal(t, "new-tab", cmd.Slug)
601+ require.Equal(t, "TAB", cmd.Code)
602+
603+ _, ok = d.commandByEffectiveCode("API")
604+ require.False(t, ok)
605+}
606+
577607 func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
578608 d := &Daemon{}
579609 d.recordPaletteUse("SSP")
@@ -608,7 +638,7 @@ func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
608638 wg.Wait()
609639
610640 commands := d.paletteCommands()
611- require.Len(t, commands, len(command.Registry()))
641+ require.Len(t, commands, len(command.PaletteRegistry()))
612642 seen := map[string]bool{}
613643 for _, cmd := range commands {
614644 require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)
@@ -574,6 +574,36 @@ func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
574 require.Equal(t, "NXT", cmd.Code)574 require.Equal(t, "NXT", cmd.Code)
575 }575 }
576 576
577+func TestPaletteCodeOverridesRejectAPIOnlyCommands(t *testing.T) {
578+ d := newTestDaemon(t, nil, stubClock{})
579+
580+ overrides, warnings := d.buildCodeOverrides(map[string]string{
581+ "new-tab": "TAB",
582+ "toast": "API",
583+ })
584+
585+ require.Equal(t, map[string]string{"new-tab": "TAB"}, overrides)
586+ require.Len(t, warnings, 1)
587+ require.Contains(t, warnings[0].Msg, `unknown command code slug "toast"`)
588+}
589+
590+func TestCommandByEffectiveCodeExcludesAPIOnlyCommands(t *testing.T) {
591+ d := newTestDaemon(t, nil, stubClock{})
592+ overrides := map[string]string{
593+ "new-tab": "TAB",
594+ "toast": "API",
595+ }
596+ d.codeOverrides.Store(&overrides)
597+
598+ cmd, ok := d.commandByEffectiveCode("tab")
599+ require.True(t, ok)
600+ require.Equal(t, "new-tab", cmd.Slug)
601+ require.Equal(t, "TAB", cmd.Code)
602+
603+ _, ok = d.commandByEffectiveCode("API")
604+ require.False(t, ok)
605+}
606+
577 func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {607 func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
578 d := &Daemon{}608 d := &Daemon{}
579 d.recordPaletteUse("SSP")609 d.recordPaletteUse("SSP")
@@ -608,7 +638,7 @@ func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
608 wg.Wait()638 wg.Wait()
609 639
610 commands := d.paletteCommands()640 commands := d.paletteCommands()
611- require.Len(t, commands, len(command.Registry()))641+ require.Len(t, commands, len(command.PaletteRegistry()))
612 seen := map[string]bool{}642 seen := map[string]bool{}
613 for _, cmd := range commands {643 for _, cmd := range commands {
614 require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)644 require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)
M internal/usecase/daemon/pane_actions.go +74 -44
@@ -10,25 +10,31 @@ import (
1010 )
1111
1212 func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {
13+ target := resolveDaemonActionTarget(sess)
14+ err := d.splitPaneAt(sess, target.tab, target.pane, dir)
15+ if err == nil && ac != nil {
16+ d.invalidateRender(sess, ac, true, "pane_actions.go")
17+ }
18+ return err
19+}
20+
21+func (d *Daemon) splitPaneAt(sess *session, tb *tab, target *pane, dir layout.Direction) error {
1322 after := dir == layout.Right || dir == layout.Down
14- return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
23+ return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
1524 return tree.Split(oldFocus, dir, after, newID, area)
1625 })
1726 }
1827
19-func (d *Daemon) spawnPaneOp(
28+func (d *Daemon) spawnPaneOpAt(
2029 sess *session,
21- ac *attachedClient,
30+ tb *tab,
31+ target *pane,
2232 mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error,
2333 ) error {
2434 if d.ptys == nil {
25- if ac != nil {
26- d.invalidateRender(sess, ac, true, "pane_actions.go")
27- }
2835 return nil
2936 }
30- tb := sess.activeTab()
31- if tb == nil {
37+ if tb == nil || target == nil {
3238 return layout.ErrNotFound
3339 }
3440
@@ -43,7 +49,11 @@ func (d *Daemon) spawnPaneOp(
4349 tb.mu.Unlock()
4450 return layout.ErrNotFound
4551 }
46- oldFocus := tb.tree.Focus
52+ oldFocus := target.id
53+ if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) {
54+ tb.mu.Unlock()
55+ return layout.ErrNotFound
56+ }
4757 newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))
4858 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
4959 if err := mutate(tb.tree, oldFocus, newID, area); err != nil {
@@ -103,9 +113,6 @@ func (d *Daemon) spawnPaneOp(
103113
104114 d.startPaneGoroutines(sess, tb, p)
105115 markSnapshotDirty(sess)
106- if ac != nil {
107- d.invalidateRender(sess, ac, true, "pane_actions.go")
108- }
109116 return nil
110117 }
111118
@@ -164,20 +171,34 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re
164171 func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
165172
166173 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
167- return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
174+ target := resolveDaemonActionTarget(sess)
175+ err := d.stackPaneAt(sess, target.tab, target.pane)
176+ if err == nil && ac != nil {
177+ d.invalidateRender(sess, ac, true, "pane_actions.go")
178+ }
179+ return err
180+}
181+
182+func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error {
183+ return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
168184 return tree.StackNew(oldFocus, newID, area)
169185 })
170186 }
171187
172188 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
189+ target := resolveDaemonActionTarget(sess)
190+ err := d.toggleStackAt(sess, target.tab, target.pane)
191+ if err == nil && ac != nil {
192+ d.invalidateRender(sess, ac, true, "pane_actions.go")
193+ }
194+ return err
195+}
196+
197+func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error {
173198 if d.ptys == nil {
174- if ac != nil {
175- d.invalidateRender(sess, ac, true, "pane_actions.go")
176- }
177199 return nil
178200 }
179- tb := sess.activeTab()
180- if tb == nil {
201+ if tb == nil || target == nil {
181202 return layout.ErrNotFound
182203 }
183204 tb.mu.Lock()
@@ -185,16 +206,17 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
185206 tb.mu.Unlock()
186207 return layout.ErrNotFound
187208 }
188- err := tb.tree.ToggleStack(tb.tree.Focus, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
209+ if tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
210+ tb.mu.Unlock()
211+ return layout.ErrNotFound
212+ }
213+ err := tb.tree.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
189214 if err == nil {
190215 d.applyLayoutLocked(tb)
191216 }
192217 tb.mu.Unlock()
193218 if err == nil {
194219 markSnapshotDirty(sess)
195- if ac != nil {
196- d.invalidateRender(sess, ac, true, "pane_actions.go")
197- }
198220 }
199221 return err
200222 }
@@ -247,8 +269,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
247269 if ac != nil {
248270 ac.overlays.clearCopyModeForPane(p)
249271 }
250- d.closeTab(sess, tb, repaint)
251- return nil
272+ return d.closeTab(sess, tb, repaint)
252273 }
253274 if err := tb.tree.Close(id); err != nil {
254275 tb.mu.Unlock()
@@ -283,38 +304,47 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
283304 }
284305
285306 func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error {
286- tb := sess.activeTab()
287- if tb == nil {
307+ target := resolveDaemonActionTarget(sess)
308+ oldFocus := layout.PaneID("")
309+ if target.pane != nil {
310+ oldFocus = target.pane.id
311+ }
312+ err := d.focusDirAt(sess, target.tab, target.pane, dir)
313+ if err == nil && ac != nil {
314+ target.tab.mu.Lock()
315+ newFocus := target.tab.tree.Focus
316+ pl, hasPlacement := focusedPlacementLocked(target.tab)
317+ target.tab.mu.Unlock()
318+ if newFocus != oldFocus {
319+ d.exitCopyMode(ac)
320+ if hasPlacement && pl.TitleBar.Height > 0 {
321+ d.refreshPaneTitleOnFocus(sess, newFocus)
322+ }
323+ }
324+ d.invalidateRender(sess, ac, true, "pane_actions.go")
325+ }
326+ return err
327+}
328+
329+func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) error {
330+ if tb == nil || target == nil {
288331 return layout.ErrNotFound
289332 }
290333 tb.mu.Lock()
291- if tb.tree == nil {
334+ if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
292335 tb.mu.Unlock()
293336 return layout.ErrNotFound
294337 }
295- oldFocus := tb.tree.Focus
338+ oldFocus := target.id
339+ tb.tree.Focus = oldFocus
296340 err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
297341 newFocus := tb.tree.Focus
298342 if err == nil {
299343 d.applyLayoutLocked(tb)
300344 }
301345 tb.mu.Unlock()
302- if err == nil {
303- if newFocus != oldFocus {
304- markSnapshotDirty(sess)
305- }
306- }
307- if err == nil && ac != nil {
308- if newFocus != oldFocus {
309- d.exitCopyMode(ac)
310- tb.mu.Lock()
311- pl, ok := focusedPlacementLocked(tb)
312- tb.mu.Unlock()
313- if ok && pl.TitleBar.Height > 0 {
314- d.refreshPaneTitleOnFocus(sess, newFocus)
315- }
316- }
317- d.invalidateRender(sess, ac, true, "pane_actions.go")
346+ if err == nil && newFocus != oldFocus {
347+ markSnapshotDirty(sess)
318348 }
319349 if errors.Is(err, layout.ErrNoPane) {
320350 return errNoNeighbor
@@ -10,25 +10,31 @@ import (
10 )10 )
11 11
12 func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {12 func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {
13+ target := resolveDaemonActionTarget(sess)
14+ err := d.splitPaneAt(sess, target.tab, target.pane, dir)
15+ if err == nil && ac != nil {
16+ d.invalidateRender(sess, ac, true, "pane_actions.go")
17+ }
18+ return err
19+}
20+
21+func (d *Daemon) splitPaneAt(sess *session, tb *tab, target *pane, dir layout.Direction) error {
13 after := dir == layout.Right || dir == layout.Down22 after := dir == layout.Right || dir == layout.Down
14- return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {23+ return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
15 return tree.Split(oldFocus, dir, after, newID, area)24 return tree.Split(oldFocus, dir, after, newID, area)
16 })25 })
17 }26 }
18 27
19-func (d *Daemon) spawnPaneOp(28+func (d *Daemon) spawnPaneOpAt(
20 sess *session,29 sess *session,
21- ac *attachedClient,30+ tb *tab,
31+ target *pane,
22 mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error,32 mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error,
23 ) error {33 ) error {
24 if d.ptys == nil {34 if d.ptys == nil {
25- if ac != nil {
26- d.invalidateRender(sess, ac, true, "pane_actions.go")
27- }
28 return nil35 return nil
29 }36 }
30- tb := sess.activeTab()37+ if tb == nil || target == nil {
31- if tb == nil {
32 return layout.ErrNotFound38 return layout.ErrNotFound
33 }39 }
34 40
@@ -43,7 +49,11 @@ func (d *Daemon) spawnPaneOp(
43 tb.mu.Unlock()49 tb.mu.Unlock()
44 return layout.ErrNotFound50 return layout.ErrNotFound
45 }51 }
46- oldFocus := tb.tree.Focus52+ oldFocus := target.id
53+ if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) {
54+ tb.mu.Unlock()
55+ return layout.ErrNotFound
56+ }
47 newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))57 newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))
48 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}58 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
49 if err := mutate(tb.tree, oldFocus, newID, area); err != nil {59 if err := mutate(tb.tree, oldFocus, newID, area); err != nil {
@@ -103,9 +113,6 @@ func (d *Daemon) spawnPaneOp(
103 113
104 d.startPaneGoroutines(sess, tb, p)114 d.startPaneGoroutines(sess, tb, p)
105 markSnapshotDirty(sess)115 markSnapshotDirty(sess)
106- if ac != nil {
107- d.invalidateRender(sess, ac, true, "pane_actions.go")
108- }
109 return nil116 return nil
110 }117 }
111 118
@@ -164,20 +171,34 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re
164 func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }171 func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
165 172
166 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {173 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
167- return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {174+ target := resolveDaemonActionTarget(sess)
175+ err := d.stackPaneAt(sess, target.tab, target.pane)
176+ if err == nil && ac != nil {
177+ d.invalidateRender(sess, ac, true, "pane_actions.go")
178+ }
179+ return err
180+}
181+
182+func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error {
183+ return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
168 return tree.StackNew(oldFocus, newID, area)184 return tree.StackNew(oldFocus, newID, area)
169 })185 })
170 }186 }
171 187
172 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {188 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
189+ target := resolveDaemonActionTarget(sess)
190+ err := d.toggleStackAt(sess, target.tab, target.pane)
191+ if err == nil && ac != nil {
192+ d.invalidateRender(sess, ac, true, "pane_actions.go")
193+ }
194+ return err
195+}
196+
197+func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error {
173 if d.ptys == nil {198 if d.ptys == nil {
174- if ac != nil {
175- d.invalidateRender(sess, ac, true, "pane_actions.go")
176- }
177 return nil199 return nil
178 }200 }
179- tb := sess.activeTab()201+ if tb == nil || target == nil {
180- if tb == nil {
181 return layout.ErrNotFound202 return layout.ErrNotFound
182 }203 }
183 tb.mu.Lock()204 tb.mu.Lock()
@@ -185,16 +206,17 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
185 tb.mu.Unlock()206 tb.mu.Unlock()
186 return layout.ErrNotFound207 return layout.ErrNotFound
187 }208 }
188- err := tb.tree.ToggleStack(tb.tree.Focus, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})209+ if tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
210+ tb.mu.Unlock()
211+ return layout.ErrNotFound
212+ }
213+ err := tb.tree.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
189 if err == nil {214 if err == nil {
190 d.applyLayoutLocked(tb)215 d.applyLayoutLocked(tb)
191 }216 }
192 tb.mu.Unlock()217 tb.mu.Unlock()
193 if err == nil {218 if err == nil {
194 markSnapshotDirty(sess)219 markSnapshotDirty(sess)
195- if ac != nil {
196- d.invalidateRender(sess, ac, true, "pane_actions.go")
197- }
198 }220 }
199 return err221 return err
200 }222 }
@@ -247,8 +269,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
247 if ac != nil {269 if ac != nil {
248 ac.overlays.clearCopyModeForPane(p)270 ac.overlays.clearCopyModeForPane(p)
249 }271 }
250- d.closeTab(sess, tb, repaint)272+ return d.closeTab(sess, tb, repaint)
251- return nil
252 }273 }
253 if err := tb.tree.Close(id); err != nil {274 if err := tb.tree.Close(id); err != nil {
254 tb.mu.Unlock()275 tb.mu.Unlock()
@@ -283,38 +304,47 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
283 }304 }
284 305
285 func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error {306 func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error {
286- tb := sess.activeTab()307+ target := resolveDaemonActionTarget(sess)
287- if tb == nil {308+ oldFocus := layout.PaneID("")
309+ if target.pane != nil {
310+ oldFocus = target.pane.id
311+ }
312+ err := d.focusDirAt(sess, target.tab, target.pane, dir)
313+ if err == nil && ac != nil {
314+ target.tab.mu.Lock()
315+ newFocus := target.tab.tree.Focus
316+ pl, hasPlacement := focusedPlacementLocked(target.tab)
317+ target.tab.mu.Unlock()
318+ if newFocus != oldFocus {
319+ d.exitCopyMode(ac)
320+ if hasPlacement && pl.TitleBar.Height > 0 {
321+ d.refreshPaneTitleOnFocus(sess, newFocus)
322+ }
323+ }
324+ d.invalidateRender(sess, ac, true, "pane_actions.go")
325+ }
326+ return err
327+}
328+
329+func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) error {
330+ if tb == nil || target == nil {
288 return layout.ErrNotFound331 return layout.ErrNotFound
289 }332 }
290 tb.mu.Lock()333 tb.mu.Lock()
291- if tb.tree == nil {334+ if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
292 tb.mu.Unlock()335 tb.mu.Unlock()
293 return layout.ErrNotFound336 return layout.ErrNotFound
294 }337 }
295- oldFocus := tb.tree.Focus338+ oldFocus := target.id
339+ tb.tree.Focus = oldFocus
296 err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})340 err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
297 newFocus := tb.tree.Focus341 newFocus := tb.tree.Focus
298 if err == nil {342 if err == nil {
299 d.applyLayoutLocked(tb)343 d.applyLayoutLocked(tb)
300 }344 }
301 tb.mu.Unlock()345 tb.mu.Unlock()
302- if err == nil {346+ if err == nil && newFocus != oldFocus {
303- if newFocus != oldFocus {347+ markSnapshotDirty(sess)
304- markSnapshotDirty(sess)
305- }
306- }
307- if err == nil && ac != nil {
308- if newFocus != oldFocus {
309- d.exitCopyMode(ac)
310- tb.mu.Lock()
311- pl, ok := focusedPlacementLocked(tb)
312- tb.mu.Unlock()
313- if ok && pl.TitleBar.Height > 0 {
314- d.refreshPaneTitleOnFocus(sess, newFocus)
315- }
316- }
317- d.invalidateRender(sess, ac, true, "pane_actions.go")
318 }348 }
319 if errors.Is(err, layout.ErrNoPane) {349 if errors.Is(err, layout.ErrNoPane) {
320 return errNoNeighbor350 return errNoNeighbor
M internal/usecase/daemon/render_coordinator_test.go +1 -1
@@ -1185,7 +1185,7 @@ func TestProducerInvalidations(t *testing.T) {
11851185 sess.mu.Lock()
11861186 tb := sess.tabs[1]
11871187 sess.mu.Unlock()
1188- d.closeTab(sess, tb, true)
1188+ require.NoError(t, d.closeTab(sess, tb, true))
11891189 },
11901190 },
11911191 {
@@ -1185,7 +1185,7 @@ func TestProducerInvalidations(t *testing.T) {
1185 sess.mu.Lock()1185 sess.mu.Lock()
1186 tb := sess.tabs[1]1186 tb := sess.tabs[1]
1187 sess.mu.Unlock()1187 sess.mu.Unlock()
1188- d.closeTab(sess, tb, true)1188+ require.NoError(t, d.closeTab(sess, tb, true))
1189 },1189 },
1190 },1190 },
1191 {1191 {
M internal/usecase/daemon/session.go +42 -6
@@ -36,6 +36,11 @@ type session struct {
3636 ctx context.Context
3737 cancel context.CancelFunc
3838
39+ // dispatchMu serializes user-initiated mutating command dispatch (palette
40+ // and vev-cmd control) for this session, so a one-shot command cannot
41+ // resolve focus against one state and mutate a later one. Lock order:
42+ // dispatchMu strictly before d.mu, sess.mu, or any tab mu.
43+ dispatchMu sync.Mutex
3944 mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
4045 themeMu sync.Mutex
4146 tabs []*tab
@@ -457,14 +462,41 @@ func (tb *tab) closeAllPanes() {
457462 }
458463 }
459464
465+// tabChromeRows is the vertical chrome (tab bar + status bar) between a
466+// client viewport and a tab's content area.
467+const tabChromeRows = 2
468+
460469 func tabSize(clientSize domain.Size) domain.Size {
461470 if !clientSize.Valid() {
462471 clientSize = defaultSize
463472 }
464- rows := max(clientSize.Rows-2, 1)
473+ rows := max(clientSize.Rows-tabChromeRows, 1)
465474 return domain.Size{Cols: clientSize.Cols, Rows: rows}
466475 }
467476
477+// fullViewportSize derives a full client-equivalent viewport from the active
478+// tab's retained content size, for actions that need a viewport while no
479+// client is attached. It falls back to defaultSize when no valid size exists.
480+func (s *session) fullViewportSize() domain.Size {
481+ s.mu.Lock()
482+ var tb *tab
483+ if s.active >= 0 && s.active < len(s.tabs) {
484+ tb = s.tabs[s.active]
485+ }
486+ s.mu.Unlock()
487+
488+ var content domain.Size
489+ if tb != nil {
490+ tb.mu.Lock()
491+ content = tb.size
492+ tb.mu.Unlock()
493+ }
494+ if !content.Valid() {
495+ return defaultSize
496+ }
497+ return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}
498+}
499+
468500 func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
469501 tb.mu.Lock()
470502 panes := tb.panesSnapshot()
@@ -705,11 +737,14 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
705737 return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
706738 }
707739
708-func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
740+func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
741+ if sess == nil || tb == nil {
742+ return layout.ErrNotFound
743+ }
709744 d.mu.Lock()
710745 if d.sessions[sess.id] != sess {
711746 d.mu.Unlock()
712- return
747+ return errors.New("daemon: session closed")
713748 }
714749 sess.mu.Lock()
715750 idx := -1
@@ -722,7 +757,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
722757 if idx == -1 {
723758 sess.mu.Unlock()
724759 d.mu.Unlock()
725- return
760+ return errors.New("tab not found")
726761 }
727762 if len(sess.tabs) == 1 {
728763 name := sess.name
@@ -732,10 +767,10 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
732767 d.log.Warn("closing last tab failed", "session", name, "err", err)
733768 d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated,
734769 "couldn't close tab: session state not yet saved; try again", err))
735- return
770+ return err
736771 }
737772 d.log.Info("tab closed", "session", name, "last", true)
738- return
773+ return nil
739774 }
740775 ringing := tb.attention
741776 wasActive := idx == sess.active
@@ -786,6 +821,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
786821 if ringing {
787822 d.repaintAllAttachedClients()
788823 }
824+ return nil
789825 }
790826
791827 // ptyReader drains child output into the VT screen and pokes the dirty channel
@@ -36,6 +36,11 @@ type session struct {
36 ctx context.Context36 ctx context.Context
37 cancel context.CancelFunc37 cancel context.CancelFunc
38 38
39+ // dispatchMu serializes user-initiated mutating command dispatch (palette
40+ // and vev-cmd control) for this session, so a one-shot command cannot
41+ // resolve focus against one state and mutate a later one. Lock order:
42+ // dispatchMu strictly before d.mu, sess.mu, or any tab mu.
43+ dispatchMu sync.Mutex
39 mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state44 mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
40 themeMu sync.Mutex45 themeMu sync.Mutex
41 tabs []*tab46 tabs []*tab
@@ -457,14 +462,41 @@ func (tb *tab) closeAllPanes() {
457 }462 }
458 }463 }
459 464
465+// tabChromeRows is the vertical chrome (tab bar + status bar) between a
466+// client viewport and a tab's content area.
467+const tabChromeRows = 2
468+
460 func tabSize(clientSize domain.Size) domain.Size {469 func tabSize(clientSize domain.Size) domain.Size {
461 if !clientSize.Valid() {470 if !clientSize.Valid() {
462 clientSize = defaultSize471 clientSize = defaultSize
463 }472 }
464- rows := max(clientSize.Rows-2, 1)473+ rows := max(clientSize.Rows-tabChromeRows, 1)
465 return domain.Size{Cols: clientSize.Cols, Rows: rows}474 return domain.Size{Cols: clientSize.Cols, Rows: rows}
466 }475 }
467 476
477+// fullViewportSize derives a full client-equivalent viewport from the active
478+// tab's retained content size, for actions that need a viewport while no
479+// client is attached. It falls back to defaultSize when no valid size exists.
480+func (s *session) fullViewportSize() domain.Size {
481+ s.mu.Lock()
482+ var tb *tab
483+ if s.active >= 0 && s.active < len(s.tabs) {
484+ tb = s.tabs[s.active]
485+ }
486+ s.mu.Unlock()
487+
488+ var content domain.Size
489+ if tb != nil {
490+ tb.mu.Lock()
491+ content = tb.size
492+ tb.mu.Unlock()
493+ }
494+ if !content.Valid() {
495+ return defaultSize
496+ }
497+ return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}
498+}
499+
468 func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {500 func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
469 tb.mu.Lock()501 tb.mu.Lock()
470 panes := tb.panesSnapshot()502 panes := tb.panesSnapshot()
@@ -705,11 +737,14 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
705 return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}737 return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
706 }738 }
707 739
708-func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {740+func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
741+ if sess == nil || tb == nil {
742+ return layout.ErrNotFound
743+ }
709 d.mu.Lock()744 d.mu.Lock()
710 if d.sessions[sess.id] != sess {745 if d.sessions[sess.id] != sess {
711 d.mu.Unlock()746 d.mu.Unlock()
712- return747+ return errors.New("daemon: session closed")
713 }748 }
714 sess.mu.Lock()749 sess.mu.Lock()
715 idx := -1750 idx := -1
@@ -722,7 +757,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
722 if idx == -1 {757 if idx == -1 {
723 sess.mu.Unlock()758 sess.mu.Unlock()
724 d.mu.Unlock()759 d.mu.Unlock()
725- return760+ return errors.New("tab not found")
726 }761 }
727 if len(sess.tabs) == 1 {762 if len(sess.tabs) == 1 {
728 name := sess.name763 name := sess.name
@@ -732,10 +767,10 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
732 d.log.Warn("closing last tab failed", "session", name, "err", err)767 d.log.Warn("closing last tab failed", "session", name, "err", err)
733 d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated,768 d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated,
734 "couldn't close tab: session state not yet saved; try again", err))769 "couldn't close tab: session state not yet saved; try again", err))
735- return770+ return err
736 }771 }
737 d.log.Info("tab closed", "session", name, "last", true)772 d.log.Info("tab closed", "session", name, "last", true)
738- return773+ return nil
739 }774 }
740 ringing := tb.attention775 ringing := tb.attention
741 wasActive := idx == sess.active776 wasActive := idx == sess.active
@@ -786,6 +821,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
786 if ringing {821 if ringing {
787 d.repaintAllAttachedClients()822 d.repaintAllAttachedClients()
788 }823 }
824+ return nil
789 }825 }
790 826
791 // ptyReader drains child output into the VT screen and pokes the dirty channel827 // ptyReader drains child output into the VT screen and pokes the dirty channel
A internal/usecase/daemon/session_size_test.go +43 -0
@@ -0,0 +1,43 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+func TestFullViewportSize(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ sess *session
13+ want domain.Size
14+ }{
15+ {
16+ name: "derives from active tab content size",
17+ sess: &session{tabs: []*tab{{size: domain.Size{Cols: 120, Rows: 38}}}, active: 0},
18+ want: domain.Size{Cols: 120, Rows: 40},
19+ },
20+ {
21+ name: "round-trips through tabSize",
22+ sess: &session{tabs: []*tab{{size: tabSize(domain.Size{Cols: 80, Rows: 24})}}, active: 0},
23+ want: domain.Size{Cols: 80, Rows: 24},
24+ },
25+ {
26+ name: "falls back to defaultSize with no tabs",
27+ sess: &session{},
28+ want: defaultSize,
29+ },
30+ {
31+ name: "falls back to defaultSize with invalid tab size",
32+ sess: &session{tabs: []*tab{{}}, active: 0},
33+ want: defaultSize,
34+ },
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ if got := tt.sess.fullViewportSize(); got != tt.want {
39+ t.Fatalf("fullViewportSize() = %+v, want %+v", got, tt.want)
40+ }
41+ })
42+ }
43+}
@@ -0,0 +1,43 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+func TestFullViewportSize(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ sess *session
13+ want domain.Size
14+ }{
15+ {
16+ name: "derives from active tab content size",
17+ sess: &session{tabs: []*tab{{size: domain.Size{Cols: 120, Rows: 38}}}, active: 0},
18+ want: domain.Size{Cols: 120, Rows: 40},
19+ },
20+ {
21+ name: "round-trips through tabSize",
22+ sess: &session{tabs: []*tab{{size: tabSize(domain.Size{Cols: 80, Rows: 24})}}, active: 0},
23+ want: domain.Size{Cols: 80, Rows: 24},
24+ },
25+ {
26+ name: "falls back to defaultSize with no tabs",
27+ sess: &session{},
28+ want: defaultSize,
29+ },
30+ {
31+ name: "falls back to defaultSize with invalid tab size",
32+ sess: &session{tabs: []*tab{{}}, active: 0},
33+ want: defaultSize,
34+ },
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ if got := tt.sess.fullViewportSize(); got != tt.want {
39+ t.Fatalf("fullViewportSize() = %+v, want %+v", got, tt.want)
40+ }
41+ })
42+ }
43+}
M internal/usecase/daemon/session_test.go +2 -2
@@ -954,7 +954,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
954954 require.NoError(t, d.renameTab(sess, sess.tabs[i], name))
955955 }
956956
957- d.closeTab(sess, sess.tabs[tt.closeIndex], false)
957+ require.NoError(t, d.closeTab(sess, sess.tabs[tt.closeIndex], false))
958958 records, err := d.persist.LoadAll()
959959 require.NoError(t, err)
960960 require.Len(t, records, 1)
@@ -980,7 +980,7 @@ func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) {
980980 first.mu.Unlock()
981981 closeFloatingPane(stale)
982982
983- d.closeTab(sess, closing, false)
983+ require.NoError(t, d.closeTab(sess, closing, false))
984984
985985 require.Same(t, first, sess.activeTab())
986986 requireFloatingInitialized(t, first)
@@ -954,7 +954,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
954 require.NoError(t, d.renameTab(sess, sess.tabs[i], name))954 require.NoError(t, d.renameTab(sess, sess.tabs[i], name))
955 }955 }
956 956
957- d.closeTab(sess, sess.tabs[tt.closeIndex], false)957+ require.NoError(t, d.closeTab(sess, sess.tabs[tt.closeIndex], false))
958 records, err := d.persist.LoadAll()958 records, err := d.persist.LoadAll()
959 require.NoError(t, err)959 require.NoError(t, err)
960 require.Len(t, records, 1)960 require.Len(t, records, 1)
@@ -980,7 +980,7 @@ func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) {
980 first.mu.Unlock()980 first.mu.Unlock()
981 closeFloatingPane(stale)981 closeFloatingPane(stale)
982 982
983- d.closeTab(sess, closing, false)983+ require.NoError(t, d.closeTab(sess, closing, false))
984 984
985 require.Same(t, first, sess.activeTab())985 require.Same(t, first, sess.activeTab())
986 requireFloatingInitialized(t, first)986 requireFloatingInitialized(t, first)
M internal/usecase/palette/model.go +1 -1
@@ -51,7 +51,7 @@ func New(results []Result) *Model {
5151 return m
5252 }
5353
54-func NewRegistry() *Model { return New(CommandResults(command.Registry())) }
54+func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) }
5555
5656 func DefaultRenderStyles() RenderStyles {
5757 selection := renderer.DefaultStyle()
@@ -51,7 +51,7 @@ func New(results []Result) *Model {
51 return m51 return m
52 }52 }
53 53
54-func NewRegistry() *Model { return New(CommandResults(command.Registry())) }54+func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) }
55 55
56 func DefaultRenderStyles() RenderStyles {56 func DefaultRenderStyles() RenderStyles {
57 selection := renderer.DefaultStyle()57 selection := renderer.DefaultStyle()
M main.go +1 -1
@@ -11,7 +11,7 @@ import (
1111 func main() {
1212 if err := app.Run(os.Args[1:]); err != nil {
1313 fmt.Fprintln(os.Stderr, cliError(err))
14- os.Exit(1)
14+ os.Exit(app.ExitCode(err))
1515 }
1616 }
1717
@@ -11,7 +11,7 @@ import (
11 func main() {11 func main() {
12 if err := app.Run(os.Args[1:]); err != nil {12 if err := app.Run(os.Args[1:]); err != nil {
13 fmt.Fprintln(os.Stderr, cliError(err))13 fmt.Fprintln(os.Stderr, cliError(err))
14- os.Exit(1)14+ os.Exit(app.ExitCode(err))
15 }15 }
16 }16 }
17 17

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