soothe-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,302 @@
1
+ /* Deep Agents CLI Textual Stylesheet */
2
+
3
+ /* Define layers for z-ordering */
4
+ Screen {
5
+ layout: vertical;
6
+ layers: base autocomplete;
7
+ }
8
+
9
+ /* Thin scrollbars app-wide */
10
+ * {
11
+ scrollbar-size-vertical: 1;
12
+ }
13
+
14
+ /* Main content goes on base layer by default */
15
+
16
+ /* Chat area - main scrollable messages area */
17
+ #chat {
18
+ height: 1fr;
19
+ padding: 1 2;
20
+ background: $background;
21
+ }
22
+
23
+ /* Welcome banner */
24
+ #welcome-banner {
25
+ height: auto;
26
+ margin-bottom: 1;
27
+ }
28
+
29
+ /* Messages area — uses undocumented "stream" layout (Textual ≥5.2.0) for
30
+ O(1) append performance via incremental placement caching. */
31
+ #messages {
32
+ layout: stream;
33
+ height: auto;
34
+ }
35
+
36
+ /* Bottom app container - holds ChatInput (now inside scroll) */
37
+ #bottom-app-container {
38
+ height: auto;
39
+ margin-top: 1;
40
+ padding: 0 1;
41
+ }
42
+
43
+ /* Sticky thinking row directly above input box */
44
+ #thinking-status {
45
+ height: auto;
46
+ min-height: 1;
47
+ margin-bottom: 0;
48
+ }
49
+
50
+ /* Input area */
51
+ #input-area {
52
+ height: auto;
53
+ min-height: 3;
54
+ max-height: 25;
55
+ }
56
+
57
+ /* Approval Menu - inline in messages area */
58
+ .approval-menu {
59
+ height: auto;
60
+ margin: 1 0;
61
+ padding: 0 1;
62
+ background: $surface;
63
+ border: solid $warning;
64
+ }
65
+
66
+ /* Placeholder shown while the user is actively typing (approval deferred) */
67
+ .approval-placeholder {
68
+ height: auto;
69
+ margin: 1 0;
70
+ padding: 0 1;
71
+ border: solid $panel;
72
+ color: $text-muted;
73
+ text-style: italic;
74
+ }
75
+
76
+ .approval-menu .approval-title {
77
+ text-style: bold;
78
+ color: $warning;
79
+ margin-bottom: 0;
80
+ }
81
+
82
+ .approval-menu .approval-info {
83
+ height: auto;
84
+ color: $text-muted;
85
+ margin-bottom: 1;
86
+ }
87
+
88
+ .approval-menu .approval-option {
89
+ height: 1;
90
+ padding: 0 1;
91
+ }
92
+
93
+ .approval-menu .approval-option-selected {
94
+ background: $primary;
95
+ text-style: bold;
96
+ }
97
+
98
+ .approval-menu .approval-help {
99
+ color: $text-muted;
100
+ text-style: italic;
101
+ margin-top: 0;
102
+ margin-bottom: 0;
103
+ }
104
+
105
+ /* Status bar */
106
+ #status-bar {
107
+ height: 1;
108
+ dock: bottom;
109
+ margin-bottom: 1;
110
+ }
111
+
112
+ /* Tool approval widgets */
113
+ .tool-approval-widget {
114
+ height: auto;
115
+ }
116
+
117
+ .approval-description {
118
+ color: $text-muted;
119
+ }
120
+
121
+ /* Diff styling — used by EditFileApprovalWidget (fg + bg + padding) */
122
+ .diff-removed {
123
+ height: auto;
124
+ color: $text-error;
125
+ background: $error-muted;
126
+ padding: 0 1;
127
+ }
128
+
129
+ .diff-added {
130
+ height: auto;
131
+ color: $text-success;
132
+ background: $success-muted;
133
+ padding: 0 1;
134
+ }
135
+
136
+ .diff-context {
137
+ height: auto;
138
+ color: $text-muted;
139
+ padding: 0 1;
140
+ }
141
+
142
+ /* Diff line backgrounds — used by compose_diff_lines (bg only, fg via Content) */
143
+ .diff-line-removed {
144
+ background: $error-muted;
145
+ }
146
+
147
+ .diff-line-added {
148
+ background: $success-muted;
149
+ }
150
+
151
+ /* Mode-specific borders for UserMessage */
152
+ UserMessage.-mode-shell {
153
+ border-left: wide $mode-bash;
154
+ }
155
+
156
+ UserMessage.-mode-command {
157
+ border-left: wide $mode-command;
158
+ }
159
+
160
+ /* ASCII border overrides */
161
+ UserMessage.-ascii {
162
+ border-left: ascii $primary;
163
+ }
164
+
165
+ UserMessage.-ascii.-mode-shell {
166
+ border-left: ascii $mode-bash;
167
+ }
168
+
169
+ UserMessage.-ascii.-mode-command {
170
+ border-left: ascii $mode-command;
171
+ }
172
+
173
+ QueuedUserMessage.-ascii {
174
+ border-left: ascii $panel;
175
+ }
176
+
177
+ ToolCallMessage.-ascii {
178
+ border-left: ascii $panel;
179
+ }
180
+
181
+ ToolCallMessage.-ascii:hover {
182
+ border-left: ascii $secondary;
183
+ }
184
+
185
+ /* Approval command — warning color for shell commands */
186
+ .approval-menu .approval-command {
187
+ height: auto;
188
+ margin: 0 0 1 0;
189
+ padding: 0 1;
190
+ color: $warning;
191
+ }
192
+
193
+ /* Separator line between tool details and options */
194
+ .approval-menu .approval-separator {
195
+ height: 1;
196
+ color: $warning;
197
+ margin: 0;
198
+ }
199
+
200
+ /* Scrollable tool info area in approval menu */
201
+ .approval-menu .tool-info-scroll {
202
+ height: auto;
203
+ max-height: 10;
204
+ margin-top: 0;
205
+ }
206
+
207
+ /* Inner container for tool info - allows content to expand for scrolling */
208
+ .approval-menu .tool-info-container {
209
+ height: auto;
210
+ }
211
+
212
+ /* Options container with background */
213
+ .approval-menu .approval-options-container {
214
+ height: auto;
215
+ background: $surface-darken-1;
216
+ padding: 0 1;
217
+ margin-top: 0;
218
+ }
219
+
220
+ /* Ask user widget */
221
+ .ask-user-menu {
222
+ height: auto;
223
+ margin: 1 0;
224
+ padding: 0 1;
225
+ background: $surface;
226
+ border: solid $success;
227
+ }
228
+
229
+ .ask-user-menu .ask-user-title {
230
+ text-style: bold;
231
+ color: $success;
232
+ margin-bottom: 0;
233
+ }
234
+
235
+ .ask-user-menu .ask-user-help {
236
+ color: $text-muted;
237
+ text-style: italic;
238
+ margin-top: 0;
239
+ margin-bottom: 0;
240
+ }
241
+
242
+ .ask-user-menu .ask-user-questions {
243
+ height: auto;
244
+ padding: 0 1;
245
+ }
246
+
247
+ .ask-user-menu .ask-user-question {
248
+ height: auto;
249
+ margin-bottom: 1;
250
+ }
251
+
252
+ .ask-user-menu .ask-user-question-active {
253
+ border-left: thick $success;
254
+ padding-left: 1;
255
+ }
256
+
257
+ .ask-user-menu .ask-user-question-inactive {
258
+ opacity: 0.5;
259
+ padding-left: 2;
260
+ }
261
+
262
+ .ask-user-menu .ask-user-question-text {
263
+ margin: 0 0 0 2;
264
+ padding: 0;
265
+ }
266
+
267
+ .ask-user-menu .ask-user-choice {
268
+ height: 1;
269
+ padding: 0 1;
270
+ }
271
+
272
+ .ask-user-menu .ask-user-text-input {
273
+ margin: 1 1 0 1;
274
+ }
275
+
276
+ .ask-user-menu .ask-user-other-input {
277
+ margin: 1 1 0 1;
278
+ }
279
+
280
+ /* ChatTextArea: override TextArea's built-in $surface background */
281
+ ChatTextArea {
282
+ background: transparent;
283
+
284
+ & .text-area--cursor-line {
285
+ background: transparent;
286
+ }
287
+
288
+ & .text-area--cursor-gutter {
289
+ background: transparent;
290
+ }
291
+ }
292
+
293
+ /* Completion popup styling (used by ChatInput) - appears BELOW input */
294
+ #completion-popup {
295
+ height: auto;
296
+ max-height: 12;
297
+ width: 100%;
298
+ margin-left: 3;
299
+ margin-top: 0;
300
+ padding: 0;
301
+ color: $text;
302
+ }
@@ -0,0 +1,310 @@
1
+ """Unified slash-command registry.
2
+
3
+ Every slash command is declared once as a `SlashCommand` entry in `COMMANDS`.
4
+ Bypass-tier frozensets and autocomplete tuples are derived automatically — no
5
+ other file should hard-code command metadata.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from enum import StrEnum
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ if TYPE_CHECKING:
15
+ from soothe_cli.tui.skills.load import ExtendedSkillMetadata
16
+
17
+
18
+ class BypassTier(StrEnum):
19
+ """Classification that controls whether a command can skip the message queue."""
20
+
21
+ ALWAYS = "always"
22
+ """Execute regardless of any busy state, including mid-thread-switch."""
23
+
24
+ CONNECTING = "connecting"
25
+ """Bypass only during initial server connection, not during agent/shell."""
26
+
27
+ IMMEDIATE_UI = "immediate_ui"
28
+ """Open modal UI immediately; real work deferred via `_defer_action` callback."""
29
+
30
+ SIDE_EFFECT_FREE = "side_effect_free"
31
+ """Execute the side effect immediately; defer chat output until idle."""
32
+
33
+ QUEUED = "queued"
34
+ """Must wait in the queue when the app is busy."""
35
+
36
+
37
+ @dataclass(frozen=True, slots=True, kw_only=True)
38
+ class SlashCommand:
39
+ """A single slash-command definition."""
40
+
41
+ name: str
42
+ """Canonical command name (e.g. `/quit`)."""
43
+
44
+ description: str
45
+ """Short user-facing description."""
46
+
47
+ bypass_tier: BypassTier
48
+ """Queue-bypass classification."""
49
+
50
+ hidden_keywords: str = ""
51
+ """Space-separated terms for fuzzy matching (never displayed)."""
52
+
53
+ aliases: tuple[str, ...] = ()
54
+ """Alternative names (e.g. `("/q",)` for `/quit`)."""
55
+
56
+
57
+ COMMANDS: tuple[SlashCommand, ...] = (
58
+ SlashCommand(
59
+ name="/autopilot",
60
+ description="Open autopilot dashboard",
61
+ bypass_tier=BypassTier.IMMEDIATE_UI,
62
+ hidden_keywords="goals autonomous",
63
+ ),
64
+ SlashCommand(
65
+ name="/clear",
66
+ description="Clear chat and start new thread",
67
+ bypass_tier=BypassTier.QUEUED,
68
+ hidden_keywords="reset",
69
+ ),
70
+ SlashCommand(
71
+ name="/editor",
72
+ description="Open prompt in external editor ($EDITOR)",
73
+ bypass_tier=BypassTier.QUEUED,
74
+ ),
75
+ SlashCommand(
76
+ name="/mcp",
77
+ description="Show active MCP servers and tools",
78
+ bypass_tier=BypassTier.SIDE_EFFECT_FREE,
79
+ hidden_keywords="servers",
80
+ ),
81
+ SlashCommand(
82
+ name="/model",
83
+ description="Switch or configure model (--model-params, --default)",
84
+ bypass_tier=BypassTier.IMMEDIATE_UI,
85
+ ),
86
+ SlashCommand(
87
+ name="/notifications",
88
+ description="Configure startup warning preferences",
89
+ bypass_tier=BypassTier.IMMEDIATE_UI,
90
+ hidden_keywords="warnings alerts suppress",
91
+ ),
92
+ SlashCommand( # Static alias; not auto-generated from skill discovery
93
+ name="/remember",
94
+ description="Update memory and skills from conversation",
95
+ bypass_tier=BypassTier.QUEUED,
96
+ ),
97
+ SlashCommand(
98
+ name="/threads",
99
+ description="Browse and resume previous threads",
100
+ bypass_tier=BypassTier.IMMEDIATE_UI,
101
+ hidden_keywords="continue history sessions",
102
+ ),
103
+ SlashCommand(
104
+ name="/trace",
105
+ description="Open current thread in LangSmith",
106
+ bypass_tier=BypassTier.SIDE_EFFECT_FREE,
107
+ ),
108
+ SlashCommand(
109
+ name="/tokens",
110
+ description="Token usage",
111
+ bypass_tier=BypassTier.QUEUED,
112
+ hidden_keywords="cost",
113
+ ),
114
+ SlashCommand(
115
+ name="/reload",
116
+ description="Reload config from environment variables and .env",
117
+ bypass_tier=BypassTier.QUEUED,
118
+ hidden_keywords="refresh",
119
+ ),
120
+ SlashCommand(
121
+ name="/theme",
122
+ description="Switch color theme",
123
+ bypass_tier=BypassTier.IMMEDIATE_UI,
124
+ hidden_keywords="dark light color appearance",
125
+ ),
126
+ SlashCommand(
127
+ name="/update",
128
+ description="Check for and install updates",
129
+ bypass_tier=BypassTier.QUEUED,
130
+ hidden_keywords="upgrade",
131
+ ),
132
+ SlashCommand(
133
+ name="/auto-update",
134
+ description="Toggle automatic updates on or off",
135
+ bypass_tier=BypassTier.SIDE_EFFECT_FREE,
136
+ ),
137
+ SlashCommand(
138
+ name="/changelog",
139
+ description="Open changelog in browser",
140
+ bypass_tier=BypassTier.SIDE_EFFECT_FREE,
141
+ ),
142
+ SlashCommand(
143
+ name="/version",
144
+ description="Show version",
145
+ bypass_tier=BypassTier.CONNECTING,
146
+ ),
147
+ SlashCommand(
148
+ name="/feedback",
149
+ description="Submit a bug report or feature request",
150
+ bypass_tier=BypassTier.SIDE_EFFECT_FREE,
151
+ ),
152
+ SlashCommand(
153
+ name="/docs",
154
+ description="Open documentation in browser",
155
+ bypass_tier=BypassTier.SIDE_EFFECT_FREE,
156
+ ),
157
+ SlashCommand(
158
+ name="/help",
159
+ description="Show help",
160
+ bypass_tier=BypassTier.QUEUED,
161
+ ),
162
+ SlashCommand(
163
+ name="/quit",
164
+ description="Exit app",
165
+ bypass_tier=BypassTier.ALWAYS,
166
+ hidden_keywords="close leave",
167
+ aliases=("/q",),
168
+ ),
169
+ )
170
+ """All slash commands."""
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Derived bypass-tier frozensets
175
+ # ---------------------------------------------------------------------------
176
+
177
+
178
+ def _build_bypass_set(tier: BypassTier) -> frozenset[str]:
179
+ """Build a frozenset of command names (including aliases) for a tier.
180
+
181
+ Args:
182
+ tier: The bypass tier to collect.
183
+
184
+ Returns:
185
+ Frozenset of all names and aliases that belong to `tier`.
186
+ """
187
+ names: set[str] = set()
188
+ for cmd in COMMANDS:
189
+ if cmd.bypass_tier == tier:
190
+ names.add(cmd.name)
191
+ names.update(cmd.aliases)
192
+ return frozenset(names)
193
+
194
+
195
+ ALWAYS_IMMEDIATE: frozenset[str] = _build_bypass_set(BypassTier.ALWAYS)
196
+ """Commands that execute regardless of any busy state."""
197
+
198
+ BYPASS_WHEN_CONNECTING: frozenset[str] = _build_bypass_set(BypassTier.CONNECTING)
199
+ """Commands that bypass only during initial server connection."""
200
+
201
+ IMMEDIATE_UI: frozenset[str] = _build_bypass_set(BypassTier.IMMEDIATE_UI)
202
+ """Commands that open modal UI immediately, deferring real work."""
203
+
204
+ SIDE_EFFECT_FREE: frozenset[str] = _build_bypass_set(BypassTier.SIDE_EFFECT_FREE)
205
+ """Commands whose side effect fires immediately; chat output deferred until idle."""
206
+
207
+ QUEUE_BOUND: frozenset[str] = _build_bypass_set(BypassTier.QUEUED)
208
+ """Commands that must wait in the queue when the app is busy."""
209
+
210
+ HIDDEN_DEBUG: frozenset[str] = frozenset({"/debug-error"})
211
+ """Hidden debug commands not exposed in autocomplete or help."""
212
+
213
+ ALL_CLASSIFIED: frozenset[str] = (
214
+ ALWAYS_IMMEDIATE
215
+ | BYPASS_WHEN_CONNECTING
216
+ | IMMEDIATE_UI
217
+ | SIDE_EFFECT_FREE
218
+ | QUEUE_BOUND
219
+ | HIDDEN_DEBUG
220
+ )
221
+ """Union of all tiers plus hidden debug commands — used by drift tests."""
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Autocomplete tuples
226
+ # ---------------------------------------------------------------------------
227
+
228
+ SLASH_COMMANDS: list[tuple[str, str, str]] = [
229
+ (cmd.name, cmd.description, cmd.hidden_keywords) for cmd in COMMANDS
230
+ ]
231
+ """`(name, description, hidden_keywords)` tuples for `SlashCommandController`."""
232
+
233
+
234
+ def parse_skill_command(command: str) -> tuple[str, str]:
235
+ """Extract skill name and args from a `/skill:<name>` command.
236
+
237
+ Args:
238
+ command: The full command string (e.g., `/skill:web-research find X`).
239
+
240
+ Returns:
241
+ Tuple of `(skill_name, args)`.
242
+
243
+ The skill name is normalized to lowercase. Both are empty strings
244
+ when the command has no skill name after the prefix.
245
+ """
246
+ after_prefix = command[len("/skill:") :].strip()
247
+ parts = after_prefix.split(maxsplit=1)
248
+ if not parts or not parts[0]:
249
+ return "", ""
250
+ skill_name = parts[0].lower()
251
+ args = parts[1] if len(parts) > 1 else ""
252
+ return skill_name, args
253
+
254
+
255
+ _STATIC_SKILL_ALIASES: frozenset[str] = frozenset({"remember"})
256
+ """Built-in skill names that have a dedicated top-level slash command.
257
+
258
+ Only list skills whose `/skill:<name>` form is redundant because a `/<name>`
259
+ convenience alias exists in `COMMANDS`. Do **not** add every command name
260
+ here — that would silently suppress unrelated user skills that happen to share a
261
+ name with a slash command (e.g., a user skill called `model` should still
262
+ appear as `/skill:model`).
263
+ """
264
+
265
+
266
+ def build_skill_commands_from_wire(
267
+ rows: list[dict[str, Any]],
268
+ ) -> list[tuple[str, str, str]]:
269
+ """Build autocomplete tuples from daemon ``skills_list_response`` rows.
270
+
271
+ Args:
272
+ rows: Wire-safe dicts with at least ``name`` and optional ``description``.
273
+
274
+ Returns:
275
+ Sorted list of ``(name, description, hidden_keywords)`` tuples.
276
+ """
277
+ tuples: list[tuple[str, str, str]] = []
278
+ for row in rows:
279
+ name = str(row.get("name", "")).strip().lower()
280
+ if not name or name in _STATIC_SKILL_ALIASES:
281
+ continue
282
+ desc = str(row.get("description", "")).strip()
283
+ tuples.append((f"/skill:{name}", desc, name))
284
+ tuples.sort(key=lambda t: t[0].lower())
285
+ return tuples
286
+
287
+
288
+ def build_skill_commands(
289
+ skills: list[ExtendedSkillMetadata],
290
+ ) -> list[tuple[str, str, str]]:
291
+ """Build autocomplete tuples for discovered skills.
292
+
293
+ Each skill becomes a `/skill:<name>` entry with its description
294
+ and the skill name as a hidden keyword for fuzzy matching.
295
+
296
+ Skills that already have a dedicated slash command in `COMMANDS`
297
+ (e.g., `remember` → `/remember`) are excluded to avoid duplicate
298
+ autocomplete entries.
299
+
300
+ Args:
301
+ skills: List of discovered skill metadata.
302
+
303
+ Returns:
304
+ List of `(name, description, hidden_keywords)` tuples.
305
+ """
306
+ return [
307
+ (f"/skill:{skill['name']}", skill["description"], skill["name"])
308
+ for skill in skills
309
+ if skill["name"] not in _STATIC_SKILL_ALIASES
310
+ ]