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,158 @@
1
+ """Abstract callback interface for CLI/TUI event rendering.
2
+
3
+ This module defines the RendererProtocol that CLI and TUI renderers implement.
4
+ The EventProcessor calls these callbacks; implementations handle mode-specific display.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any, Protocol
10
+
11
+ if TYPE_CHECKING:
12
+ from soothe_sdk import Plan
13
+
14
+
15
+ class RendererProtocol(Protocol):
16
+ """Abstract callback interface for CLI/TUI event rendering.
17
+
18
+ Implementations handle mode-specific display while EventProcessor
19
+ handles unified event routing and state management.
20
+
21
+ Core callbacks are required for basic functionality.
22
+ Optional fine-grained hooks can be implemented for specific event handling.
23
+ """
24
+
25
+ # === Core Callbacks (Required) ===
26
+
27
+ def on_assistant_text(
28
+ self,
29
+ text: str,
30
+ *,
31
+ is_main: bool,
32
+ is_streaming: bool,
33
+ ) -> None:
34
+ """Assistant text chunk or complete message.
35
+
36
+ Args:
37
+ text: Text content to display.
38
+ is_main: True if from main agent, False if from subagent.
39
+ is_streaming: True if partial chunk, False if complete.
40
+ """
41
+ ...
42
+
43
+ def on_tool_call(
44
+ self,
45
+ name: str,
46
+ args: dict[str, Any],
47
+ tool_call_id: str,
48
+ *,
49
+ is_main: bool,
50
+ ) -> None:
51
+ """Tool invocation started.
52
+
53
+ Args:
54
+ name: Tool name (snake_case internal name).
55
+ args: Parsed argument dictionary.
56
+ tool_call_id: Unique identifier for correlation with result.
57
+ is_main: True if from main agent.
58
+ """
59
+ ...
60
+
61
+ def on_tool_result(
62
+ self,
63
+ name: str,
64
+ result: str,
65
+ tool_call_id: str,
66
+ *,
67
+ is_error: bool,
68
+ is_main: bool,
69
+ ) -> None:
70
+ """Tool returned a result.
71
+
72
+ Args:
73
+ name: Tool name.
74
+ result: Result content (may be truncated).
75
+ tool_call_id: Correlates with on_tool_call.
76
+ is_error: True if result indicates failure.
77
+ is_main: True if from main agent.
78
+ """
79
+ ...
80
+
81
+ def on_status_change(self, state: str) -> None:
82
+ """Daemon state changed.
83
+
84
+ Args:
85
+ state: One of "idle", "running", "stopped".
86
+ """
87
+ ...
88
+
89
+ def on_error(self, error: str, *, context: str | None = None) -> None:
90
+ """Error occurred.
91
+
92
+ Args:
93
+ error: Error message.
94
+ context: Optional context (e.g., "tool_execution", "daemon").
95
+ """
96
+ ...
97
+
98
+ def on_progress_event(
99
+ self,
100
+ event_type: str,
101
+ data: dict[str, Any],
102
+ *,
103
+ namespace: tuple[str, ...],
104
+ ) -> None:
105
+ """Protocol/subagent progress event.
106
+
107
+ Catch-all for events not covered by specific callbacks.
108
+
109
+ Args:
110
+ event_type: Full event type string (e.g., "soothe.capability.browser.step.running").
111
+ data: Event payload.
112
+ namespace: Subagent namespace tuple (empty for main agent).
113
+ """
114
+ ...
115
+
116
+ # === Optional Fine-Grained Hooks ===
117
+ # These have default no-op implementations in base renderers
118
+
119
+ def on_plan_created(self, plan: Plan) -> None:
120
+ """Plan was created.
121
+
122
+ Default implementation may delegate to on_progress_event.
123
+
124
+ Args:
125
+ plan: The created plan object.
126
+ """
127
+ ...
128
+
129
+ def on_plan_step_started(self, step_id: str, description: str) -> None:
130
+ """Plan step began execution.
131
+
132
+ Args:
133
+ step_id: Unique step identifier.
134
+ description: Step description.
135
+ """
136
+ ...
137
+
138
+ def on_plan_step_completed(
139
+ self,
140
+ step_id: str,
141
+ success: bool, # noqa: FBT001
142
+ duration_ms: int,
143
+ ) -> None:
144
+ """Plan step finished.
145
+
146
+ Args:
147
+ step_id: Unique step identifier.
148
+ success: True if step succeeded.
149
+ duration_ms: Execution duration in milliseconds.
150
+ """
151
+ ...
152
+
153
+ def on_turn_end(self) -> None:
154
+ """Current turn completed.
155
+
156
+ Use for finalizing streaming buffers and cleanup.
157
+ """
158
+ ...
@@ -0,0 +1,43 @@
1
+ """Shared TUI utilities for state management and rendering.
2
+
3
+ This module contains reusable display helpers used by both TUI and headless modes.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from soothe_sdk import _TASK_NAME_RE
9
+
10
+ __all__ = [
11
+ "update_name_map_from_tool_calls",
12
+ ]
13
+
14
+
15
+ def _display_subagent_name(name: str) -> str:
16
+ """Return friendly display name for a subagent id."""
17
+ from soothe_cli.shared.subagent_routing import SUBAGENT_DISPLAY_NAMES
18
+
19
+ return SUBAGENT_DISPLAY_NAMES.get(name.lower(), name.replace("_", " ").title())
20
+
21
+
22
+ def update_name_map_from_tool_calls(message_obj: object, name_map: dict[str, str]) -> None:
23
+ """Update tool-call-id -> display name mapping from AIMessage/tool calls.
24
+
25
+ This is the shared implementation used by both TUI and headless modes.
26
+ """
27
+ tool_calls = getattr(message_obj, "tool_calls", None) or []
28
+ for tc in tool_calls:
29
+ if not isinstance(tc, dict):
30
+ continue
31
+ if tc.get("name") != "task":
32
+ continue
33
+ call_id = str(tc.get("id", ""))
34
+ args = tc.get("args", {})
35
+ raw_name = ""
36
+ if isinstance(args, dict):
37
+ raw_name = str(args.get("agent", "") or args.get("name", ""))
38
+ elif args:
39
+ match = _TASK_NAME_RE.search(str(args))
40
+ if match:
41
+ raw_name = match.group(1)
42
+ if call_id and raw_name:
43
+ name_map[call_id] = _display_subagent_name(raw_name)
@@ -0,0 +1,354 @@
1
+ """Slash command handlers for CLI and TUI (RFC-404).
2
+
3
+ Unified command registry with metadata-based routing:
4
+ - CLI-only commands: handled locally
5
+ - Daemon RPC commands: structured data rendering
6
+ - Daemon routing commands: behavior indicators
7
+
8
+ This module provides the COMMANDS registry and rendering functions.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from rich.panel import Panel
17
+ from rich.table import Table
18
+
19
+ if TYPE_CHECKING:
20
+ from rich.console import Console
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Rendering Functions (must be defined before COMMANDS registry)
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ def show_commands(console: Console) -> None:
28
+ """Show available slash commands (CLI-only)."""
29
+ table = Table(title="Available Commands", show_lines=False)
30
+ table.add_column("Command", style="bold cyan")
31
+ table.add_column("Description")
32
+
33
+ # Import COMMANDS here to avoid circular reference at module load
34
+ from soothe_cli.shared.slash_commands import COMMANDS
35
+
36
+ for cmd, entry in COMMANDS.items():
37
+ table.add_row(cmd, entry.get("description", ""))
38
+
39
+ console.print(table)
40
+
41
+
42
+ def show_keymaps(console: Console) -> None:
43
+ """Show keyboard shortcuts (CLI-only)."""
44
+ table = Table(title="Keyboard Shortcuts", show_lines=False)
45
+ table.add_column("Shortcut", style="bold cyan")
46
+ table.add_column("Action")
47
+
48
+ for k, v in KEYBOARD_SHORTCUTS.items():
49
+ table.add_row(k, v)
50
+
51
+ console.print(table)
52
+
53
+
54
+ def show_memory(console: Console, data: dict[str, Any]) -> None:
55
+ """Render memory stats from daemon RPC response."""
56
+ stats = data.get("memory_stats", {})
57
+ console.print(
58
+ Panel(
59
+ json.dumps(stats, indent=2, default=str),
60
+ title="Memory Stats",
61
+ border_style="cyan",
62
+ )
63
+ )
64
+
65
+
66
+ def show_policy(console: Console, data: dict[str, Any]) -> None:
67
+ """Render policy profile from daemon RPC response."""
68
+ policy = data.get("policy", {})
69
+ console.print(f"[dim]Policy profile: {policy.get('profile', 'unknown')}[/dim]")
70
+ console.print(f"[dim]Planner routing: {policy.get('planner_routing', 'unknown')}[/dim]")
71
+ console.print(f"[dim]Memory backend: {policy.get('memory_backend', 'unknown')}[/dim]")
72
+
73
+
74
+ def show_history(console: Console, data: dict[str, Any]) -> None:
75
+ """Render input history from daemon RPC response."""
76
+ history = data.get("history", [])
77
+ if not history:
78
+ console.print("[dim]No recent history.[/dim]")
79
+ return
80
+
81
+ table = Table(title="Recent Input History", show_lines=False)
82
+ table.add_column("Time", style="dim")
83
+ table.add_column("Input", style="cyan")
84
+
85
+ for item in history[:10]: # Show last 10
86
+ timestamp = item.get("timestamp", "")
87
+ text = item.get("text", "")
88
+ if len(text) > 50:
89
+ text = text[:47] + "..."
90
+ table.add_row(timestamp, text)
91
+
92
+ console.print(table)
93
+
94
+
95
+ def show_config(console: Console, data: dict[str, Any]) -> None:
96
+ """Render configuration summary from daemon RPC response."""
97
+ config = data.get("config", {})
98
+ console.print(
99
+ Panel(
100
+ json.dumps(config, indent=2, default=str),
101
+ title="Configuration Summary",
102
+ border_style="cyan",
103
+ )
104
+ )
105
+
106
+
107
+ def show_review(console: Console, data: dict[str, Any]) -> None:
108
+ """Render conversation/action history from daemon RPC response."""
109
+ history = data.get("review", [])
110
+ if not history:
111
+ console.print("[dim]No conversation history.[/dim]")
112
+ return
113
+
114
+ table = Table(title="Conversation Review", show_lines=False)
115
+ table.add_column("Time", style="dim")
116
+ table.add_column("Type", style="cyan")
117
+ table.add_column("Content", style="white")
118
+
119
+ for item in history[:20]:
120
+ timestamp = item.get("timestamp", "")
121
+ item_type = item.get("type", "unknown")
122
+ content = item.get("content", "")
123
+ if len(content) > 60:
124
+ content = content[:57] + "..."
125
+ table.add_row(timestamp, item_type, content)
126
+
127
+ console.print(table)
128
+
129
+
130
+ def show_autopilot_dashboard(console: Console, data: dict[str, Any]) -> None:
131
+ """Render autopilot dashboard from daemon RPC response."""
132
+ dashboard = data.get("autopilot_dashboard", {})
133
+
134
+ table = Table(title="Autopilot Dashboard", show_lines=False)
135
+ table.add_column("Metric", style="bold cyan")
136
+ table.add_column("Value", style="white")
137
+
138
+ # Display key metrics
139
+ table.add_row("Status", dashboard.get("status", "idle"))
140
+ table.add_row("Iterations", str(dashboard.get("iterations", 0)))
141
+ table.add_row("Goals Completed", str(dashboard.get("goals_completed", 0)))
142
+ table.add_row("Goals Active", str(dashboard.get("goals_active", 0)))
143
+
144
+ console.print(table)
145
+
146
+ # Display active goals if present
147
+ active_goals = dashboard.get("active_goals", [])
148
+ if active_goals:
149
+ console.print("\n[bold cyan]Active Goals:[/bold cyan]")
150
+ for goal in active_goals:
151
+ console.print(f" • {goal.get('description', 'unknown')}")
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Keyboard Shortcuts
156
+ # ---------------------------------------------------------------------------
157
+
158
+ KEYBOARD_SHORTCUTS: dict[str, str] = {
159
+ "Ctrl+Q": "Quit TUI: Stop thread (confirm) and exit client",
160
+ "Ctrl+D": "Detach TUI: Leave thread running (confirm) and exit client",
161
+ "Ctrl+C": "Cancel running job, press twice within 1s to quit",
162
+ "Ctrl+E": "Focus chat input",
163
+ "Ctrl+Y": "Copy last message to clipboard",
164
+ }
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Unified Command Registry (RFC-404)
169
+ # ---------------------------------------------------------------------------
170
+
171
+ COMMANDS: dict[str, dict[str, Any]] = {
172
+ # CLI-only commands (2)
173
+ "/help": {
174
+ "location": "cli",
175
+ "handler": show_commands,
176
+ "description": "Show available commands",
177
+ },
178
+ "/keymaps": {
179
+ "location": "cli",
180
+ "handler": show_keymaps,
181
+ "description": "Show keyboard shortcuts",
182
+ },
183
+ # Daemon RPC commands (12)
184
+ "/clear": {
185
+ "location": "daemon",
186
+ "type": "rpc",
187
+ "daemon_command": "clear",
188
+ "description": "Clear thread history",
189
+ "requires_thread": True,
190
+ },
191
+ "/exit": {
192
+ "location": "daemon",
193
+ "type": "rpc",
194
+ "daemon_command": "exit",
195
+ "description": "Stop thread and exit client",
196
+ },
197
+ "/quit": {
198
+ "location": "daemon",
199
+ "type": "rpc",
200
+ "daemon_command": "quit",
201
+ "description": "Stop thread and exit client",
202
+ },
203
+ "/detach": {
204
+ "location": "daemon",
205
+ "type": "rpc",
206
+ "daemon_command": "detach",
207
+ "description": "Leave thread running and exit client",
208
+ },
209
+ "/cancel": {
210
+ "location": "daemon",
211
+ "type": "rpc",
212
+ "daemon_command": "cancel",
213
+ "description": "Cancel the current running job",
214
+ "requires_thread": True,
215
+ },
216
+ "/memory": {
217
+ "location": "daemon",
218
+ "type": "rpc",
219
+ "daemon_command": "memory",
220
+ "description": "Show memory stats",
221
+ "requires_thread": True,
222
+ "handler": show_memory,
223
+ },
224
+ "/policy": {
225
+ "location": "daemon",
226
+ "type": "rpc",
227
+ "daemon_command": "policy",
228
+ "description": "Show active policy profile",
229
+ "handler": show_policy,
230
+ },
231
+ "/history": {
232
+ "location": "daemon",
233
+ "type": "rpc",
234
+ "daemon_command": "history",
235
+ "description": "Show recent prompt history",
236
+ "requires_thread": True,
237
+ "handler": show_history,
238
+ },
239
+ "/config": {
240
+ "location": "daemon",
241
+ "type": "rpc",
242
+ "daemon_command": "config",
243
+ "description": "Show active configuration summary",
244
+ "handler": show_config,
245
+ },
246
+ "/review": {
247
+ "location": "daemon",
248
+ "type": "rpc",
249
+ "daemon_command": "review",
250
+ "description": "Review recent conversation and action history",
251
+ "requires_thread": True,
252
+ "handler": show_review,
253
+ },
254
+ "/thread": {
255
+ "location": "daemon",
256
+ "type": "rpc",
257
+ "daemon_command": "thread",
258
+ "description": "Thread operations (archive <id>)",
259
+ "params_schema": {
260
+ "action": {"type": "string", "required": True},
261
+ "id": {"type": "string", "required": False},
262
+ },
263
+ },
264
+ "/resume": {
265
+ "location": "daemon",
266
+ "type": "rpc",
267
+ "daemon_command": "resume",
268
+ "description": "Resume a recent thread",
269
+ "params_schema": {"thread_id": {"type": "string", "required": True}},
270
+ },
271
+ "/autopilot": {
272
+ "location": "daemon",
273
+ "type": "rpc",
274
+ "daemon_command": "autopilot_dashboard",
275
+ "description": "Show autopilot dashboard",
276
+ "requires_thread": True,
277
+ "handler": show_autopilot_dashboard,
278
+ },
279
+ # Daemon routing commands (5)
280
+ "/plan": {"location": "daemon", "type": "routing", "description": "Trigger plan mode"},
281
+ "/browser": {
282
+ "location": "daemon",
283
+ "type": "routing",
284
+ "description": "Route query to Browser subagent",
285
+ "requires_query": True,
286
+ },
287
+ "/claude": {
288
+ "location": "daemon",
289
+ "type": "routing",
290
+ "description": "Route query to Claude subagent",
291
+ "requires_query": True,
292
+ },
293
+ "/research": {
294
+ "location": "daemon",
295
+ "type": "routing",
296
+ "description": "Route query to Research subagent",
297
+ "requires_query": True,
298
+ },
299
+ }
300
+
301
+
302
+ # Legacy compatibility (used by tests/old code)
303
+ SLASH_COMMANDS: dict[str, str] = {
304
+ cmd: entry.get("description", "") for cmd, entry in COMMANDS.items()
305
+ }
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Legacy helper (used by tests/old code)
310
+ # ---------------------------------------------------------------------------
311
+
312
+
313
+ def parse_autonomous_command(cmd: str) -> tuple[int | None, str] | None:
314
+ """Parse `/autopilot` command payload (legacy helper)."""
315
+ stripped = cmd.strip()
316
+ if not stripped.startswith("/autopilot"):
317
+ return None
318
+
319
+ parts = stripped.split(maxsplit=2)
320
+ if len(parts) == 1:
321
+ return None
322
+
323
+ if len(parts) == 2:
324
+ single = parts[1].strip()
325
+ if not single or single.isdigit():
326
+ return None
327
+ return (None, single)
328
+
329
+ maybe_num = parts[1].strip()
330
+ if maybe_num.isdigit():
331
+ prompt = parts[2].strip()
332
+ if not prompt:
333
+ return None
334
+ max_iterations = int(maybe_num)
335
+ return (max_iterations if max_iterations > 0 else None, prompt)
336
+
337
+ prompt = f"{parts[1]} {parts[2]}".strip()
338
+ return (None, prompt) if prompt else None
339
+
340
+
341
+ __all__ = [
342
+ "COMMANDS",
343
+ "SLASH_COMMANDS",
344
+ "KEYBOARD_SHORTCUTS",
345
+ "parse_autonomous_command",
346
+ "show_commands",
347
+ "show_keymaps",
348
+ "show_memory",
349
+ "show_policy",
350
+ "show_history",
351
+ "show_config",
352
+ "show_review",
353
+ "show_autopilot_dashboard",
354
+ ]
@@ -0,0 +1,63 @@
1
+ """Subagent display names and input routing (shared by CLI and TUI)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ SUBAGENT_DISPLAY_NAMES: dict[str, str] = {
6
+ "browser": "Browser",
7
+ "claude": "Claude",
8
+ "research": "Research",
9
+ }
10
+
11
+ BUILTIN_SUBAGENT_NAMES: list[str] = list(SUBAGENT_DISPLAY_NAMES.keys())
12
+
13
+
14
+ def get_subagent_display_name(technical_name: str) -> str:
15
+ """Get display name for a subagent.
16
+
17
+ Args:
18
+ technical_name: Internal subagent name.
19
+
20
+ Returns:
21
+ PascalCase display name.
22
+ """
23
+ return SUBAGENT_DISPLAY_NAMES.get(
24
+ technical_name,
25
+ technical_name.replace("_", " ").title().replace(" ", ""),
26
+ )
27
+
28
+
29
+ def parse_subagent_from_input(user_input: str) -> tuple[str | None, str]:
30
+ """Parse subagent subcommand from user input.
31
+
32
+ Detects subagent subcommands (e.g., /browser, /claude) anywhere in the text
33
+ and extracts the subagent name along with the cleaned input text.
34
+
35
+ Args:
36
+ user_input: Raw user input string.
37
+
38
+ Returns:
39
+ Tuple of ``(subagent_name, cleaned_text)``.
40
+ ``subagent_name`` is ``None`` if no valid subcommand found.
41
+ The subcommand is removed from ``cleaned_text``.
42
+
43
+ Examples:
44
+ ``"/browser check this"`` -> ``("browser", "check this")``
45
+ ``"Can you /claude analyze this"`` -> ``("claude", "Can you analyze this")``
46
+ ``"hello world"`` -> ``(None, "hello world")``
47
+ """
48
+ first_match: tuple[int, str] | None = None
49
+
50
+ for subagent_name in BUILTIN_SUBAGENT_NAMES:
51
+ subcommand = f"/{subagent_name}"
52
+ idx = user_input.lower().find(subcommand)
53
+ if idx != -1 and (first_match is None or idx < first_match[0]):
54
+ first_match = (idx, subagent_name)
55
+
56
+ if first_match:
57
+ idx, subagent_name = first_match
58
+ subcommand = f"/{subagent_name}"
59
+ cleaned = user_input[:idx] + user_input[idx + len(subcommand) :]
60
+ cleaned = " ".join(cleaned.split())
61
+ return (subagent_name, cleaned)
62
+
63
+ return (None, user_input)