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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +296 -33
M internal/usecase/command/command.go +51 -0
| @@ -72,10 +72,61 @@ const ( | |||
| 72 | 72 | 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 | 121 | // Command describes an executable command. | |
| 76 | 122 | type Command struct { | |
| 77 | 123 | Slug, Code, Name, Desc string | |
| 124 | + | Usage string | |
| 78 | 125 | Arguments Arguments | |
| 79 | 126 | ContextHint ContextHint | |
| 127 | + | Scriptable bool | |
| 128 | + | PaletteVisible bool | |
| 129 | + | Target TargetKind | |
| 80 | 130 | Run func(Context, []string) error | |
| 131 | + | Control func(ControlContext, []string, ControlOptions) (ControlResult, error) | |
| 81 | 132 | } | |
| @@ -72,10 +72,61 @@ const ( | |||||
| 72 | ContextHintRecentSessions | 72 | 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 string | 123 | Slug, Code, Name, Desc string | ||
| 124 | + | Usage string | |||
| 78 | Arguments Arguments | 125 | Arguments Arguments | ||
| 79 | ContextHint ContextHint | 126 | ContextHint ContextHint | ||
| 127 | + | Scriptable bool | |||
| 128 | + | PaletteVisible bool | |||
| 129 | + | Target TargetKind | |||
| 80 | Run func(Context, []string) error | 130 | 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 | |||
| 2 | 2 | ||
| 3 | 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 | 6 | func Registry() []Command { | |
| 7 | 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() }}, | |
| 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 { | |
| 27 | 27 | rank, err := ParsePositiveDecimal(args) | |
| 28 | 28 | if err != nil { | |
| 29 | 29 | return err | |
| 30 | 30 | } | |
| 31 | 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() }}, | |
| 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) }), | |
| 40 | 44 | } | |
| 41 | - | ||
| 42 | 45 | 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 | 96 | // ByCode returns the command matching code, case-insensitively. | |
| 46 | 97 | func ByCode(code string) (Command, bool) { | |
| 47 | 98 | code = strings.ToUpper(code) | |
| 48 | - | for _, cmd := range Registry() { | |
| 99 | + | for _, cmd := range PaletteRegistry() { | |
| 49 | 100 | if cmd.Code == code { | |
| 50 | 101 | 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 | 114 | 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 | + | } | |
| @@ -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 err | 29 | 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 commands | 45 | 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, true | 101 | 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{}, false | 114 | 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 @@ | |||
| 1 | 1 | package command | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "regexp" | |
| 6 | + | "strconv" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 9 | - | commands := Registry() | |
| 11 | + | commands := PaletteRegistry() | |
| 10 | 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 | 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 | 180 | func TestRegistryIncludesFloatingPaneToggle(t *testing.T) { | |
| 41 | 181 | cmd, ok := ByCode("FLT") | |
| 42 | 182 | if !ok { | |
| @@ -1,12 +1,14 @@ | |||||
| 1 | package command | 1 | 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 | |||
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 105 | func (d *Daemon) paletteCommands() []command.Command { | |
| 106 | - | commands := command.Registry() | |
| 106 | + | commands := command.PaletteRegistry() | |
| 107 | 107 | d.paletteRecentMu.Lock() | |
| 108 | 108 | recent := append([]string(nil), d.paletteRecent...) | |
| 109 | 109 | 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) { | |||
| 608 | 608 | wg.Wait() | |
| 609 | 609 | ||
| 610 | 610 | commands := d.paletteCommands() | |
| 611 | - | require.Len(t, commands, len(command.Registry())) | |
| 611 | + | require.Len(t, commands, len(command.PaletteRegistry())) | |
| 612 | 612 | seen := map[string]bool{} | |
| 613 | 613 | for _, cmd := range commands { | |
| 614 | 614 | 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 { | |||
| 51 | 51 | 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 | 56 | func DefaultRenderStyles() RenderStyles { | |
| 57 | 57 | selection := renderer.DefaultStyle() | |
| @@ -51,7 +51,7 @@ func New(results []Result) *Model { | |||||
| 51 | return m | 51 | 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() | ||