detached 2301cb6

feat(command): add control metadata, handlers, and BySlug to the registry

commit
2301cb651e28521a7413651cb066777eab1d0716
author
brice · · 2026-07-23 21:31 UTC
parent
8be4601
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +296 -33
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/palette.go +1 -1
@@ -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()
@@ -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()
M internal/usecase/daemon/palette_test.go +1 -1
@@ -608,7 +608,7 @@ func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
608608 wg.Wait()
609609
610610 commands := d.paletteCommands()
611- require.Len(t, commands, len(command.Registry()))
611+ require.Len(t, commands, len(command.PaletteRegistry()))
612612 seen := map[string]bool{}
613613 for _, cmd := range commands {
614614 require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)
@@ -608,7 +608,7 @@ func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
608 wg.Wait()608 wg.Wait()
609 609
610 commands := d.paletteCommands()610 commands := d.paletteCommands()
611- require.Len(t, commands, len(command.Registry()))611+ require.Len(t, commands, len(command.PaletteRegistry()))
612 seen := map[string]bool{}612 seen := map[string]bool{}
613 for _, cmd := range commands {613 for _, cmd := range commands {
614 require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)614 require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)
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()

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close