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,657 @@
1
+ """Thread commands for Soothe CLI.
2
+
3
+ All thread operations communicate exclusively via daemon WebSocket RPC.
4
+ The daemon must be running for thread commands to work.
5
+ """
6
+
7
+ import asyncio
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Annotated, Any
11
+
12
+ import typer
13
+ from soothe_sdk import SOOTHE_HOME, VERBOSITY_TO_LOG_LEVEL
14
+ from soothe_sdk.client import WebSocketClient, is_daemon_live, websocket_url_from_config
15
+
16
+ from soothe_cli.shared import load_config
17
+
18
+ # Display limits for thread list
19
+ _TOPIC_DISPLAY_LIMIT = 30 # Max chars for last human message
20
+ _TOPIC_TRUNCATE_KEEP = 27 # Leave room for "..."
21
+ _THREAD_ID_DISPLAY_WIDTH = 20 # Max width for thread IDs
22
+ _THREAD_ID_TRUNCATE_KEEP = 17 # Leave room for "..."
23
+
24
+
25
+ def _require_daemon(ws_url: str) -> None:
26
+ """Check daemon is running, exit with error if not."""
27
+ live = asyncio.run(_check_daemon(ws_url))
28
+ if not live:
29
+ typer.echo(
30
+ "Error: Daemon not running. Start with 'soothe daemon start'.",
31
+ err=True,
32
+ )
33
+ sys.exit(1)
34
+
35
+
36
+ async def _check_daemon(ws_url: str) -> bool:
37
+ return await is_daemon_live(ws_url, timeout=5.0)
38
+
39
+
40
+ async def _rpc(
41
+ ws_url: str,
42
+ send_fn: str,
43
+ send_args: dict[str, Any],
44
+ response_type: str,
45
+ timeout: float = 30.0,
46
+ ) -> dict[str, Any]:
47
+ """Send an RPC request and wait for a matching response.
48
+
49
+ Args:
50
+ ws_url: WebSocket URL.
51
+ send_fn: Name of the WebSocketClient method to call.
52
+ send_args: Keyword arguments for the send method.
53
+ response_type: Expected response message type.
54
+ timeout: Maximum seconds to wait.
55
+
56
+ Returns:
57
+ Response dict from daemon.
58
+ """
59
+ client = WebSocketClient(url=ws_url)
60
+ try:
61
+ await client.connect()
62
+ method = getattr(client, send_fn)
63
+ await method(**send_args)
64
+ async with asyncio.timeout(timeout):
65
+ while True:
66
+ event = await client.read_event()
67
+ if not event:
68
+ return {"error": "Connection closed"}
69
+ if event.get("type") == response_type:
70
+ return event
71
+ except TimeoutError:
72
+ return {"error": "Timed out waiting for daemon response"}
73
+ finally:
74
+ await client.close()
75
+
76
+
77
+ def _thread_status_matches_cli_filter(thread_status: str | None, status_filter: str | None) -> bool:
78
+ """Match CLI ``--status`` against persisted thread status strings."""
79
+ if not status_filter:
80
+ return True
81
+ s = (thread_status or "").lower()
82
+ f = status_filter.lower()
83
+ if f == "active":
84
+ return s in ("idle", "running", "active")
85
+ return s == f
86
+
87
+
88
+ def _echo_thread_table(rows: list[dict[str, object]]) -> None:
89
+ """Print thread table rows (from ``model_dump`` JSON or API dicts)."""
90
+ if not rows:
91
+ typer.echo("No threads.")
92
+ return
93
+ typer.echo(f"{'ID':<20} {'Status':<10} {'Created':<19} {'Last Message':<19} {'Topic':<30}")
94
+ typer.echo("\u2500" * 104)
95
+ for raw in rows:
96
+ tid_raw = str(raw.get("thread_id", ""))
97
+ tid = (
98
+ tid_raw
99
+ if len(tid_raw) <= _THREAD_ID_DISPLAY_WIDTH
100
+ else tid_raw[:_THREAD_ID_TRUNCATE_KEEP] + "..."
101
+ )
102
+ t_status = str(raw.get("status", ""))
103
+ created = str(raw.get("created_at", ""))[:19]
104
+ last_msg = str(raw.get("updated_at", ""))[:19]
105
+ last_human = raw.get("last_human_message")
106
+ topic_raw = str(last_human) if last_human is not None else ""
107
+ topic = (
108
+ topic_raw[:_TOPIC_TRUNCATE_KEEP] + "..."
109
+ if len(topic_raw) > _TOPIC_DISPLAY_LIMIT
110
+ else topic_raw
111
+ )
112
+ typer.echo(f"{tid:<20} {t_status:<10} {created:<19} {last_msg:<19} {topic:<30}")
113
+
114
+
115
+ def thread_list(
116
+ config: Annotated[
117
+ str | None,
118
+ typer.Option("--config", "-c", help="Path to configuration file."),
119
+ ] = None,
120
+ status: Annotated[
121
+ str | None,
122
+ typer.Option("--status", "-s", help="Filter by status (active, archived)."),
123
+ ] = None,
124
+ limit: Annotated[
125
+ int | None,
126
+ typer.Option("--limit", "-l", help="Limit number of threads shown."),
127
+ ] = None,
128
+ ) -> None:
129
+ """List all agent threads.
130
+
131
+ Examples:
132
+ soothe thread list
133
+ soothe thread list --status active
134
+ soothe thread list --limit 10
135
+ soothe thread list --limit 20 --status idle
136
+ """
137
+ cfg = load_config(config)
138
+ ws_url = websocket_url_from_config(cfg)
139
+ _require_daemon(ws_url)
140
+
141
+ async def _list() -> None:
142
+ client = WebSocketClient(url=ws_url)
143
+ try:
144
+ await client.connect()
145
+ filter_payload: dict[str, str] | None = None
146
+ if status and status.lower() != "active":
147
+ sf = status.lower()
148
+ if sf in ("archived", "suspended", "idle", "running", "error"):
149
+ filter_payload = {"status": sf}
150
+
151
+ await client.send_thread_list(filter_payload, include_last_message=True)
152
+
153
+ async with asyncio.timeout(60.0):
154
+ while True:
155
+ event = await client.read_event()
156
+ if not event:
157
+ typer.echo("No response from daemon.", err=True)
158
+ return
159
+ if event.get("type") != "thread_list_response":
160
+ continue
161
+ threads = event.get("threads", [])
162
+ if not isinstance(threads, list):
163
+ threads = []
164
+ filtered = [
165
+ t
166
+ for t in threads
167
+ if isinstance(t, dict)
168
+ and _thread_status_matches_cli_filter(t.get("status"), status)
169
+ ]
170
+ filtered.sort(key=lambda x: str(x.get("updated_at", "")), reverse=True)
171
+ if limit is not None and limit > 0:
172
+ filtered = filtered[:limit]
173
+ _echo_thread_table(filtered)
174
+ return
175
+ except TimeoutError:
176
+ typer.echo("Timed out waiting for thread list from daemon.", err=True)
177
+ finally:
178
+ await client.close()
179
+
180
+ asyncio.run(_list())
181
+
182
+
183
+ def thread_continue(
184
+ thread_id: Annotated[
185
+ str | None,
186
+ typer.Argument(help="Thread ID to continue. Omit to continue last active thread."),
187
+ ] = None,
188
+ config: Annotated[
189
+ str | None,
190
+ typer.Option("--config", "-c", help="Path to configuration file."),
191
+ ] = None,
192
+ *,
193
+ new: Annotated[
194
+ bool,
195
+ typer.Option("--new", help="Create a new thread instead of continuing."),
196
+ ] = False,
197
+ ) -> None:
198
+ """Continue a conversation thread in the TUI.
199
+
200
+ Requires a running daemon. Start daemon with 'soothe daemon start' first.
201
+
202
+ Examples:
203
+ soothe thread continue abc123
204
+ soothe thread continue --new
205
+ soothe thread continue
206
+ """
207
+ from soothe_cli.cli.execution import run_tui
208
+ from soothe_cli.shared import setup_logging
209
+
210
+ cfg = load_config(config)
211
+ log_level = VERBOSITY_TO_LOG_LEVEL.get(cfg.logging.verbosity, "INFO")
212
+ log_file = Path(SOOTHE_HOME) / "logs" / "soothe-cli.log"
213
+ setup_logging(log_level, log_file=log_file)
214
+ ws_url = websocket_url_from_config(cfg)
215
+ _require_daemon(ws_url)
216
+
217
+ # Handle --new flag
218
+ if new:
219
+ thread_id = None
220
+ elif not thread_id:
221
+ # Find the most recently updated active thread through the daemon
222
+ async def get_last_thread_via_daemon() -> str | None:
223
+ client = WebSocketClient(url=ws_url)
224
+ try:
225
+ await client.connect()
226
+ await client.send_thread_list()
227
+ while True:
228
+ event = await client.read_event()
229
+ if not event:
230
+ break
231
+ if event.get("type") != "thread_list_response":
232
+ continue
233
+ threads = event.get("threads", [])
234
+ active_threads = [t for t in threads if t.get("status") in ("active", "idle")]
235
+ if not active_threads:
236
+ typer.echo("No active threads found.", err=True)
237
+ sys.exit(1)
238
+ active_threads.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
239
+ return active_threads[0].get("thread_id")
240
+ finally:
241
+ await client.close()
242
+
243
+ typer.echo("No active threads found.", err=True)
244
+ sys.exit(1)
245
+
246
+ thread_id = asyncio.run(get_last_thread_via_daemon())
247
+
248
+ run_tui(cfg, thread_id=thread_id, config_path=config)
249
+
250
+
251
+ def thread_archive(
252
+ thread_id: Annotated[str, typer.Argument(help="Thread ID to archive.")],
253
+ config: Annotated[
254
+ str | None,
255
+ typer.Option("--config", "-c", help="Path to configuration file."),
256
+ ] = None,
257
+ ) -> None:
258
+ """Archive a thread.
259
+
260
+ Example:
261
+ soothe thread archive abc123
262
+ """
263
+ cfg = load_config(config)
264
+ ws_url = websocket_url_from_config(cfg)
265
+ _require_daemon(ws_url)
266
+
267
+ resp = asyncio.run(
268
+ _rpc(ws_url, "send_thread_archive", {"thread_id": thread_id}, "thread_operation_ack")
269
+ )
270
+ if resp.get("success"):
271
+ typer.echo(f"Archived thread {thread_id}.")
272
+ else:
273
+ typer.echo(
274
+ f"Failed to archive thread: {resp.get('message', resp.get('error', 'unknown'))}",
275
+ err=True,
276
+ )
277
+
278
+
279
+ def thread_show(
280
+ thread_id: Annotated[str, typer.Argument(help="Thread ID to show.")],
281
+ config: Annotated[
282
+ str | None,
283
+ typer.Option("--config", "-c", help="Path to configuration file."),
284
+ ] = None,
285
+ ) -> None:
286
+ """Show thread details.
287
+
288
+ Example:
289
+ soothe thread show abc123
290
+ """
291
+ cfg = load_config(config)
292
+ ws_url = websocket_url_from_config(cfg)
293
+ _require_daemon(ws_url)
294
+
295
+ async def _show() -> None:
296
+ client = WebSocketClient(url=ws_url)
297
+ try:
298
+ await client.connect()
299
+
300
+ # Get thread metadata
301
+ await client.send_thread_get(thread_id)
302
+ async with asyncio.timeout(30.0):
303
+ while True:
304
+ event = await client.read_event()
305
+ if not event:
306
+ typer.echo("No response from daemon.", err=True)
307
+ return
308
+ etype = event.get("type", "")
309
+ if etype == "thread_get_response":
310
+ thread = event.get("thread", {})
311
+ typer.echo(f"Thread ID: {thread.get('thread_id', thread_id)}")
312
+ typer.echo(f"Status: {thread.get('status', 'unknown')}")
313
+ typer.echo(f"Created: {thread.get('created_at', 'unknown')}")
314
+ typer.echo(f"Updated: {thread.get('updated_at', 'unknown')}")
315
+ metadata = thread.get("metadata", {})
316
+ if metadata.get("tags"):
317
+ typer.echo(f"Tags: {', '.join(metadata['tags'])}")
318
+ return
319
+ if etype == "error":
320
+ typer.echo(f"Error: {event.get('message', 'unknown')}", err=True)
321
+ return
322
+ except TimeoutError:
323
+ typer.echo("Timed out waiting for response.", err=True)
324
+ finally:
325
+ await client.close()
326
+
327
+ asyncio.run(_show())
328
+
329
+
330
+ def thread_delete(
331
+ thread_id: Annotated[str, typer.Argument(help="Thread ID to delete.")],
332
+ config: Annotated[
333
+ str | None,
334
+ typer.Option("--config", "-c", help="Path to configuration file."),
335
+ ] = None,
336
+ *,
337
+ yes: Annotated[
338
+ bool,
339
+ typer.Option("--yes", "-y", help="Skip confirmation."),
340
+ ] = False,
341
+ ) -> None:
342
+ """Permanently delete a thread.
343
+
344
+ Example:
345
+ soothe thread delete abc123
346
+ """
347
+ if not yes:
348
+ confirm = typer.confirm(f"Permanently delete thread {thread_id}?")
349
+ if not confirm:
350
+ typer.echo("Cancelled.")
351
+ return
352
+
353
+ cfg = load_config(config)
354
+ ws_url = websocket_url_from_config(cfg)
355
+ _require_daemon(ws_url)
356
+
357
+ resp = asyncio.run(
358
+ _rpc(ws_url, "send_thread_delete", {"thread_id": thread_id}, "thread_operation_ack")
359
+ )
360
+ if resp.get("success"):
361
+ typer.echo(f"Deleted thread {thread_id}.")
362
+ else:
363
+ typer.echo(
364
+ f"Failed to delete thread: {resp.get('message', resp.get('error', 'unknown'))}",
365
+ err=True,
366
+ )
367
+
368
+
369
+ def thread_export(
370
+ thread_id: Annotated[str, typer.Argument(help="Thread ID to export.")],
371
+ output: Annotated[
372
+ str | None,
373
+ typer.Option("--output", "-o", help="Output file path."),
374
+ ] = None,
375
+ export_format: Annotated[
376
+ str,
377
+ typer.Option("--format", "-f", help="Export format: jsonl or md."),
378
+ ] = "jsonl",
379
+ ) -> None:
380
+ """Export thread conversation to a file.
381
+
382
+ Example:
383
+ soothe thread export abc123 --output out.jsonl
384
+ soothe thread export abc123 --format md --output out.md
385
+ """
386
+ import json
387
+ from pathlib import Path
388
+
389
+ cfg = load_config(config=None)
390
+ ws_url = websocket_url_from_config(cfg)
391
+ _require_daemon(ws_url)
392
+
393
+ async def _export() -> None:
394
+ client = WebSocketClient(url=ws_url)
395
+ try:
396
+ await client.connect()
397
+ await client.send_thread_messages(thread_id, limit=10000)
398
+ async with asyncio.timeout(60.0):
399
+ while True:
400
+ event = await client.read_event()
401
+ if not event:
402
+ typer.echo("No response from daemon.", err=True)
403
+ return
404
+ if event.get("type") != "thread_messages_response":
405
+ continue
406
+ messages = event.get("messages", [])
407
+ if not messages:
408
+ typer.echo(f"No messages found for thread {thread_id}.")
409
+ return
410
+
411
+ if export_format == "md":
412
+ lines = [f"# Thread {thread_id}\n"]
413
+ for msg in messages:
414
+ role = msg.get("type", msg.get("role", "unknown"))
415
+ content = msg.get("content", "")
416
+ if isinstance(content, list):
417
+ content = "\n".join(
418
+ str(c.get("text", c)) if isinstance(c, dict) else str(c)
419
+ for c in content
420
+ )
421
+ lines.append(f"\n## {role}\n\n{content}\n")
422
+ text = "\n".join(lines)
423
+ else:
424
+ text = "\n".join(json.dumps(msg) for msg in messages) + "\n"
425
+
426
+ if output:
427
+ Path(output).write_text(text, encoding="utf-8")
428
+ typer.echo(f"Exported {len(messages)} messages to {output}")
429
+ else:
430
+ typer.echo(text)
431
+ return
432
+ except TimeoutError:
433
+ typer.echo("Timed out waiting for messages.", err=True)
434
+ finally:
435
+ await client.close()
436
+
437
+ asyncio.run(_export())
438
+
439
+
440
+ def thread_stats(
441
+ thread_id: Annotated[str, typer.Argument(help="Thread ID.")],
442
+ config: Annotated[
443
+ str | None,
444
+ typer.Option("--config", "-c", help="Path to configuration file."),
445
+ ] = None,
446
+ ) -> None:
447
+ """Show thread execution statistics.
448
+
449
+ Example:
450
+ soothe thread stats abc123
451
+ """
452
+ cfg = load_config(config)
453
+ ws_url = websocket_url_from_config(cfg)
454
+ _require_daemon(ws_url)
455
+
456
+ async def _stats() -> None:
457
+ client = WebSocketClient(url=ws_url)
458
+ try:
459
+ await client.connect()
460
+ # Use thread_get with stats included via thread_list
461
+ await client.send_thread_list(
462
+ {"thread_id": thread_id}, include_stats=True, include_last_message=True
463
+ )
464
+ async with asyncio.timeout(30.0):
465
+ while True:
466
+ event = await client.read_event()
467
+ if not event:
468
+ typer.echo("No response from daemon.", err=True)
469
+ return
470
+ if event.get("type") != "thread_list_response":
471
+ continue
472
+ threads = event.get("threads", [])
473
+ match = [t for t in threads if t.get("thread_id") == thread_id]
474
+ if not match:
475
+ typer.echo(f"Thread {thread_id} not found.")
476
+ return
477
+ t = match[0]
478
+ typer.echo(f"Thread: {thread_id}")
479
+ typer.echo(f"Status: {t.get('status', 'unknown')}")
480
+ typer.echo(f"Created: {t.get('created_at', 'unknown')}")
481
+ typer.echo(f"Updated: {t.get('updated_at', 'unknown')}")
482
+ stats = t.get("stats", {})
483
+ if stats:
484
+ typer.echo(f"Messages: {stats.get('message_count', 'N/A')}")
485
+ typer.echo(f"Events: {stats.get('event_count', 'N/A')}")
486
+ typer.echo(f"Artifacts: {stats.get('artifact_count', 'N/A')}")
487
+ typer.echo(f"Errors: {stats.get('error_count', 'N/A')}")
488
+ return
489
+ except TimeoutError:
490
+ typer.echo("Timed out waiting for response.", err=True)
491
+ finally:
492
+ await client.close()
493
+
494
+ asyncio.run(_stats())
495
+
496
+
497
+ def thread_tag(
498
+ thread_id: Annotated[str, typer.Argument(help="Thread ID.")],
499
+ tags: Annotated[
500
+ list[str],
501
+ typer.Argument(help="Tags to add/remove."),
502
+ ],
503
+ config: Annotated[
504
+ str | None,
505
+ typer.Option("--config", "-c", help="Path to configuration file."),
506
+ ] = None,
507
+ *,
508
+ remove: Annotated[
509
+ bool,
510
+ typer.Option("--remove", help="Remove tags instead of adding."),
511
+ ] = False,
512
+ ) -> None:
513
+ """Add or remove tags from a thread.
514
+
515
+ Examples:
516
+ soothe thread tag abc123 research analysis
517
+ soothe thread tag abc123 research --remove
518
+ """
519
+ cfg = load_config(config)
520
+ ws_url = websocket_url_from_config(cfg)
521
+ _require_daemon(ws_url)
522
+
523
+ async def _tag() -> None:
524
+ client = WebSocketClient(url=ws_url)
525
+ try:
526
+ await client.connect()
527
+
528
+ # Get current thread state to read existing tags
529
+ await client.send_thread_get(thread_id)
530
+ thread_data: dict[str, Any] = {}
531
+ async with asyncio.timeout(30.0):
532
+ while True:
533
+ event = await client.read_event()
534
+ if not event:
535
+ typer.echo("No response from daemon.", err=True)
536
+ return
537
+ etype = event.get("type", "")
538
+ if etype == "thread_get_response":
539
+ thread_data = event.get("thread", {})
540
+ break
541
+ if etype == "error":
542
+ typer.echo(f"Error: {event.get('message', 'unknown')}", err=True)
543
+ return
544
+
545
+ # Update tags
546
+ metadata = dict(thread_data.get("metadata", {}))
547
+ current_tags = set(metadata.get("tags", []))
548
+
549
+ if remove:
550
+ current_tags -= set(tags)
551
+ else:
552
+ current_tags |= set(tags)
553
+
554
+ metadata["tags"] = sorted(current_tags)
555
+
556
+ # Update state via thread_update_state
557
+ await client.send_thread_update_state(thread_id, {"metadata": metadata})
558
+
559
+ # Wait for ack
560
+ async with asyncio.timeout(10.0):
561
+ while True:
562
+ event = await client.read_event()
563
+ if not event:
564
+ break
565
+ if event.get("type") in (
566
+ "thread_update_state_response",
567
+ "thread_operation_ack",
568
+ ):
569
+ break
570
+
571
+ tag_list = ", ".join(metadata["tags"]) if metadata["tags"] else "(none)"
572
+ typer.echo(f"Tags: {tag_list}")
573
+ except TimeoutError:
574
+ typer.echo("Timed out waiting for response.", err=True)
575
+ finally:
576
+ await client.close()
577
+
578
+ asyncio.run(_tag())
579
+
580
+
581
+ def thread_create(
582
+ config: Annotated[
583
+ str | None,
584
+ typer.Option("--config", "-c", help="Path to configuration file."),
585
+ ] = None,
586
+ *,
587
+ message: Annotated[
588
+ str | None,
589
+ typer.Option("--message", "-m", help="Initial message to seed the thread."),
590
+ ] = None,
591
+ tag: Annotated[
592
+ list[str] | None,
593
+ typer.Option("--tag", "-t", help="Tags for the thread (repeatable)."),
594
+ ] = None,
595
+ ) -> None:
596
+ """Create a new persisted thread.
597
+
598
+ Examples:
599
+ soothe thread create
600
+ soothe thread create --message "Hello world"
601
+ soothe thread create --tag research --tag analysis
602
+ """
603
+ cfg = load_config(config)
604
+ ws_url = websocket_url_from_config(cfg)
605
+ _require_daemon(ws_url)
606
+
607
+ metadata: dict[str, Any] | None = None
608
+ if tag:
609
+ metadata = {"tags": sorted(tag)}
610
+
611
+ resp = asyncio.run(
612
+ _rpc(
613
+ ws_url,
614
+ "send_thread_create",
615
+ {"initial_message": message, "metadata": metadata},
616
+ "thread_created",
617
+ )
618
+ )
619
+ if resp.get("thread_id"):
620
+ typer.echo(f"Created thread {resp['thread_id']}")
621
+ else:
622
+ typer.echo(
623
+ f"Failed to create thread: {resp.get('message', resp.get('error', 'unknown'))}",
624
+ err=True,
625
+ )
626
+
627
+
628
+ def thread_artifacts(
629
+ thread_id: Annotated[str, typer.Argument(help="Thread ID to list artifacts for.")],
630
+ config: Annotated[
631
+ str | None,
632
+ typer.Option("--config", "-c", help="Path to configuration file."),
633
+ ] = None,
634
+ ) -> None:
635
+ """List artifacts for a thread.
636
+
637
+ Example:
638
+ soothe thread artifacts abc123
639
+ """
640
+ cfg = load_config(config)
641
+ ws_url = websocket_url_from_config(cfg)
642
+ _require_daemon(ws_url)
643
+
644
+ resp = asyncio.run(
645
+ _rpc(ws_url, "send_thread_artifacts", {"thread_id": thread_id}, "thread_artifacts_response")
646
+ )
647
+ artifacts = resp.get("artifacts", [])
648
+ if not artifacts:
649
+ typer.echo("No artifacts found.")
650
+ return
651
+ typer.echo(f"{'Name':<30} {'Type':<15} {'Summary':<40}")
652
+ typer.echo("\u2500" * 90)
653
+ for a in artifacts:
654
+ name = str(a.get("name", ""))[:30]
655
+ a_type = str(a.get("type", ""))[:15]
656
+ summary = str(a.get("summary", ""))[:40]
657
+ typer.echo(f"{name:<30} {a_type:<15} {summary:<40}")
@@ -0,0 +1,6 @@
1
+ """Module initialization for UX components."""
2
+
3
+ from soothe_cli.cli.execution.headless import run_headless
4
+ from soothe_cli.cli.execution.launcher import run_tui
5
+
6
+ __all__ = ["run_headless", "run_tui"]