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,1248 @@
1
+ """Thread management using LangGraph's built-in checkpoint persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import sqlite3
8
+ from contextlib import asynccontextmanager
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, NamedTuple, NotRequired, cast
12
+
13
+ from soothe_sdk import SOOTHE_HOME
14
+ from typing_extensions import TypedDict
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import AsyncIterator
18
+
19
+ import aiosqlite
20
+ from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
21
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
22
+
23
+ from soothe_cli.tui.output import OutputFormat
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _aiosqlite_patched = False
28
+ _jsonplus_serializer: JsonPlusSerializer | None = None
29
+ _message_count_cache: dict[str, tuple[str | None, int]] = {}
30
+ _MAX_MESSAGE_COUNT_CACHE = 4096
31
+ _initial_prompt_cache: dict[str, tuple[str | None, str | None]] = {}
32
+ _MAX_INITIAL_PROMPT_CACHE = 4096
33
+ _recent_threads_cache: dict[tuple[str | None, int], list[ThreadInfo]] = {}
34
+ _MAX_RECENT_THREADS_CACHE_KEYS = 16
35
+
36
+
37
+ def _patch_aiosqlite() -> None:
38
+ """Patch aiosqlite.Connection with `is_alive()` if missing.
39
+
40
+ Required by langgraph-checkpoint>=2.1.0.
41
+ See: https://github.com/langchain-ai/langgraph/issues/6583
42
+ """
43
+ global _aiosqlite_patched # noqa: PLW0603 # Module-level flag requires global statement
44
+ if _aiosqlite_patched:
45
+ return
46
+
47
+ import aiosqlite as _aiosqlite
48
+
49
+ if not hasattr(_aiosqlite.Connection, "is_alive"):
50
+
51
+ def _is_alive(self: _aiosqlite.Connection) -> bool:
52
+ """Check if the connection is still alive.
53
+
54
+ Returns:
55
+ True if connection is alive, False otherwise.
56
+ """
57
+ return bool(self._running and self._connection is not None)
58
+
59
+ # Dynamically adding a method to aiosqlite.Connection at runtime.
60
+ # Type checkers can't understand this monkey-patch, so we suppress the
61
+ # "attr-defined" error that would otherwise be raised.
62
+ _aiosqlite.Connection.is_alive = _is_alive # type: ignore[attr-defined]
63
+
64
+ _aiosqlite_patched = True
65
+
66
+
67
+ @asynccontextmanager
68
+ async def _connect() -> AsyncIterator[aiosqlite.Connection]:
69
+ """Import aiosqlite, apply the compatibility patch, and connect.
70
+
71
+ Centralizes the deferred import + patch + connect sequence used by every
72
+ database function in this module.
73
+
74
+ Yields:
75
+ An open aiosqlite connection to the sessions database.
76
+ """
77
+ import aiosqlite as _aiosqlite
78
+
79
+ _patch_aiosqlite()
80
+
81
+ async with _aiosqlite.connect(str(get_db_path()), timeout=30.0) as conn:
82
+ yield conn
83
+
84
+
85
+ class ThreadInfo(TypedDict):
86
+ """Thread metadata returned by `list_threads`."""
87
+
88
+ thread_id: str
89
+ """Unique identifier for the thread."""
90
+
91
+ agent_name: str | None
92
+ """Name of the agent that owns the thread."""
93
+
94
+ updated_at: str | None
95
+ """ISO timestamp of the last update."""
96
+
97
+ created_at: NotRequired[str | None]
98
+ """ISO timestamp of thread creation (earliest checkpoint)."""
99
+
100
+ git_branch: NotRequired[str | None]
101
+ """Git branch active when the thread was created."""
102
+
103
+ initial_prompt: NotRequired[str | None]
104
+ """First human message in the thread."""
105
+
106
+ message_count: NotRequired[int]
107
+ """Number of messages in the thread."""
108
+
109
+ latest_checkpoint_id: NotRequired[str | None]
110
+ """Most recent checkpoint ID for cache invalidation."""
111
+
112
+ cwd: NotRequired[str | None]
113
+ """Working directory where the thread was last used."""
114
+
115
+
116
+ class _CheckpointSummary(NamedTuple):
117
+ """Structured data extracted from a thread's latest checkpoint."""
118
+
119
+ message_count: int
120
+ """Number of messages in the latest checkpoint."""
121
+
122
+ initial_prompt: str | None
123
+ """First human prompt recovered from the latest checkpoint."""
124
+
125
+
126
+ def format_timestamp(iso_timestamp: str | None) -> str:
127
+ """Format ISO timestamp for display (e.g., 'Dec 30, 6:10pm').
128
+
129
+ Args:
130
+ iso_timestamp: ISO 8601 timestamp string, or `None`.
131
+
132
+ Returns:
133
+ Formatted timestamp string or empty string if invalid.
134
+ """
135
+ if not iso_timestamp:
136
+ return ""
137
+ try:
138
+ dt = datetime.fromisoformat(iso_timestamp).astimezone()
139
+ return dt.strftime("%b %d, %-I:%M%p").lower().replace("am", "am").replace("pm", "pm")
140
+ except (ValueError, TypeError):
141
+ logger.debug(
142
+ "Failed to parse timestamp %r; displaying as blank",
143
+ iso_timestamp,
144
+ exc_info=True,
145
+ )
146
+ return ""
147
+
148
+
149
+ def format_relative_timestamp(iso_timestamp: str | None) -> str:
150
+ """Format ISO timestamp as relative time (e.g., '5m ago', '2h ago').
151
+
152
+ Args:
153
+ iso_timestamp: ISO 8601 timestamp string, or `None`.
154
+
155
+ Returns:
156
+ Relative time string or empty string if invalid.
157
+ """
158
+ if not iso_timestamp:
159
+ return ""
160
+ try:
161
+ dt = datetime.fromisoformat(iso_timestamp).astimezone()
162
+ except (ValueError, TypeError):
163
+ logger.debug(
164
+ "Failed to parse timestamp %r; displaying as blank",
165
+ iso_timestamp,
166
+ exc_info=True,
167
+ )
168
+ return ""
169
+
170
+ delta = datetime.now(tz=dt.tzinfo) - dt
171
+ seconds = int(delta.total_seconds())
172
+ if seconds < 0:
173
+ return "just now"
174
+ if seconds < 60: # noqa: PLR2004
175
+ return f"{seconds}s ago"
176
+ minutes = seconds // 60
177
+ if minutes < 60: # noqa: PLR2004
178
+ return f"{minutes}m ago"
179
+ hours = minutes // 60
180
+ if hours < 24: # noqa: PLR2004
181
+ return f"{hours}h ago"
182
+ days = hours // 24
183
+ if days < 30: # noqa: PLR2004
184
+ return f"{days}d ago"
185
+ months = days // 30
186
+ if months < 12: # noqa: PLR2004
187
+ return f"{months}mo ago"
188
+ years = days // 365
189
+ return f"{years}y ago"
190
+
191
+
192
+ def format_path(path: str | None) -> str:
193
+ """Format a filesystem path for display.
194
+
195
+ Paths under the user's home directory are shown relative to `~`.
196
+ All other paths are returned as-is.
197
+
198
+ Args:
199
+ path: Absolute filesystem path, or `None`.
200
+
201
+ Returns:
202
+ Formatted path string, or empty string if path is falsy.
203
+ """
204
+ if not path:
205
+ return ""
206
+ try:
207
+ home = str(Path.home())
208
+ if path == home:
209
+ return "~"
210
+ prefix = home + "/"
211
+ if path.startswith(prefix):
212
+ return "~/" + path[len(prefix) :]
213
+ except (RuntimeError, KeyError, OSError):
214
+ logger.debug("Could not resolve home directory for path formatting", exc_info=True)
215
+ return path
216
+ else:
217
+ return path
218
+
219
+
220
+ _db_path: Path | None = None
221
+
222
+
223
+ def get_db_path() -> Path:
224
+ """Get path to global database.
225
+
226
+ The result is cached after the first successful call to avoid repeated
227
+ filesystem operations.
228
+
229
+ Returns:
230
+ Path to the SQLite database file.
231
+ """
232
+ global _db_path # noqa: PLW0603 # Module-level cache requires global statement
233
+ if _db_path is not None:
234
+ return _db_path
235
+ db_dir = Path(SOOTHE_HOME)
236
+ db_dir.mkdir(parents=True, exist_ok=True)
237
+ _db_path = db_dir / "sessions.db"
238
+ return _db_path
239
+
240
+
241
+ def generate_thread_id() -> str:
242
+ """Generate a new thread ID as a full UUID7 string.
243
+
244
+ Returns:
245
+ UUID7 string (time-ordered for natural sort by creation time).
246
+ """
247
+ from uuid_utils import uuid7
248
+
249
+ return str(uuid7())
250
+
251
+
252
+ async def _table_exists(conn: aiosqlite.Connection, table: str) -> bool:
253
+ """Check if a table exists in the database.
254
+
255
+ Returns:
256
+ True if table exists, False otherwise.
257
+ """
258
+ query = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?"
259
+ async with conn.execute(query, (table,)) as cursor:
260
+ return await cursor.fetchone() is not None
261
+
262
+
263
+ async def list_threads(
264
+ agent_name: str | None = None,
265
+ limit: int = 20,
266
+ include_message_count: bool = False,
267
+ sort_by: str = "updated",
268
+ branch: str | None = None,
269
+ ) -> list[ThreadInfo]:
270
+ """List threads from checkpoints table.
271
+
272
+ Args:
273
+ agent_name: Optional filter by agent name.
274
+ limit: Maximum number of threads to return.
275
+ include_message_count: Whether to include message counts.
276
+ sort_by: Sort field — `"updated"` or `"created"`.
277
+ branch: Optional filter by git branch name.
278
+
279
+ Returns:
280
+ List of `ThreadInfo` dicts with `thread_id`, `agent_name`,
281
+ `updated_at`, `created_at`, `latest_checkpoint_id`, `git_branch`,
282
+ `cwd`, and optionally `message_count`.
283
+
284
+ Raises:
285
+ ValueError: If `sort_by` is not `"updated"` or `"created"`.
286
+ """
287
+ async with _connect() as conn:
288
+ if not await _table_exists(conn, "checkpoints"):
289
+ return []
290
+
291
+ if sort_by not in {"updated", "created"}:
292
+ msg = f"Invalid sort_by {sort_by!r}; expected 'updated' or 'created'"
293
+ raise ValueError(msg)
294
+ order_col = "created_at" if sort_by == "created" else "updated_at"
295
+
296
+ where_clauses: list[str] = []
297
+ params_list: list[str | int] = []
298
+
299
+ if agent_name:
300
+ where_clauses.append("json_extract(metadata, '$.agent_name') = ?")
301
+ params_list.append(agent_name)
302
+ if branch:
303
+ where_clauses.append("json_extract(metadata, '$.git_branch') = ?")
304
+ params_list.append(branch)
305
+
306
+ where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
307
+
308
+ query = f"""
309
+ SELECT thread_id,
310
+ json_extract(metadata, '$.agent_name') as agent_name,
311
+ MAX(json_extract(metadata, '$.updated_at')) as updated_at,
312
+ MAX(checkpoint_id) as latest_checkpoint_id,
313
+ MIN(json_extract(metadata, '$.updated_at')) as created_at,
314
+ MAX(json_extract(metadata, '$.git_branch')) as git_branch,
315
+ MAX(json_extract(metadata, '$.cwd')) as cwd
316
+ FROM checkpoints
317
+ {where_sql}
318
+ GROUP BY thread_id
319
+ ORDER BY {order_col} DESC
320
+ LIMIT ?
321
+ """ # noqa: S608 # where_sql/order_col derived from controlled internal values; user values use ? placeholders
322
+ params: tuple = (*params_list, limit)
323
+
324
+ async with conn.execute(query, params) as cursor:
325
+ rows = await cursor.fetchall()
326
+ threads: list[ThreadInfo] = [
327
+ ThreadInfo(
328
+ thread_id=r[0],
329
+ agent_name=r[1],
330
+ updated_at=r[2],
331
+ latest_checkpoint_id=r[3],
332
+ created_at=r[4],
333
+ git_branch=r[5],
334
+ cwd=r[6],
335
+ )
336
+ for r in rows
337
+ ]
338
+
339
+ # Fetch message counts if requested
340
+ if include_message_count and threads:
341
+ await _populate_message_counts(conn, threads)
342
+
343
+ # Only cache unfiltered results so the thread selector modal
344
+ # doesn't receive branch-filtered or differently-sorted data.
345
+ if sort_by == "updated" and branch is None:
346
+ _cache_recent_threads(agent_name, limit, threads)
347
+ return threads
348
+
349
+
350
+ async def populate_thread_message_counts(threads: list[ThreadInfo]) -> list[ThreadInfo]:
351
+ """Populate `message_count` for an existing thread list.
352
+
353
+ This is used by the `/threads` modal to render rows quickly, then backfill
354
+ counts in the background without issuing a second thread-list query.
355
+
356
+ Args:
357
+ threads: Thread rows to enrich in place.
358
+
359
+ Returns:
360
+ The same list object with `message_count` values populated.
361
+ """
362
+ if not threads:
363
+ return threads
364
+
365
+ async with _connect() as conn:
366
+ await _populate_message_counts(conn, threads)
367
+ return threads
368
+
369
+
370
+ async def populate_thread_checkpoint_details(
371
+ threads: list[ThreadInfo],
372
+ *,
373
+ include_message_count: bool = True,
374
+ include_initial_prompt: bool = True,
375
+ ) -> list[ThreadInfo]:
376
+ """Populate checkpoint-derived fields for an existing thread list.
377
+
378
+ This is used by the `/threads` modal to enrich rows in one background pass,
379
+ so the latest checkpoint is fetched and deserialized at most once per row.
380
+
381
+ Args:
382
+ threads: Thread rows to enrich in place.
383
+ include_message_count: Whether to populate `message_count`.
384
+ include_initial_prompt: Whether to populate `initial_prompt`.
385
+
386
+ Returns:
387
+ The same list object with missing checkpoint-derived fields populated.
388
+ """
389
+ if not threads or (not include_message_count and not include_initial_prompt):
390
+ return threads
391
+
392
+ async with _connect() as conn:
393
+ await _populate_checkpoint_fields(
394
+ conn,
395
+ threads,
396
+ include_message_count=include_message_count,
397
+ include_initial_prompt=include_initial_prompt,
398
+ )
399
+ return threads
400
+
401
+
402
+ async def prewarm_thread_message_counts(limit: int | None = None) -> None:
403
+ """Prewarm thread selector cache for faster `/threads` open.
404
+
405
+ Fetches a bounded list of recent threads and populates checkpoint-derived
406
+ fields for currently visible columns into the in-memory cache. Intended to
407
+ run in a background worker during app startup.
408
+
409
+ Args:
410
+ limit: Maximum threads to prewarm. Uses `get_thread_limit()` when `None`.
411
+ """
412
+ thread_limit = limit if limit is not None else get_thread_limit()
413
+ if thread_limit < 1:
414
+ return
415
+
416
+ try:
417
+ from soothe_cli.tui.model_config import load_thread_config
418
+
419
+ cfg = load_thread_config()
420
+ threads = await list_threads(limit=thread_limit, include_message_count=False)
421
+ if threads:
422
+ await populate_thread_checkpoint_details(
423
+ threads,
424
+ include_message_count=cfg.columns.get("messages", False),
425
+ include_initial_prompt=cfg.columns.get("initial_prompt", False),
426
+ )
427
+ _cache_recent_threads(None, thread_limit, threads)
428
+ except (OSError, sqlite3.Error):
429
+ logger.debug("Could not prewarm thread selector cache", exc_info=True)
430
+ except Exception:
431
+ logger.warning(
432
+ "Unexpected error while prewarming thread selector cache",
433
+ exc_info=True,
434
+ )
435
+
436
+
437
+ def get_cached_threads(
438
+ agent_name: str | None = None,
439
+ limit: int | None = None,
440
+ ) -> list[ThreadInfo] | None:
441
+ """Get cached recent threads, if available.
442
+
443
+ Args:
444
+ agent_name: Optional agent-name filter key.
445
+ limit: Maximum rows requested. Uses `get_thread_limit()` when `None`.
446
+
447
+ Returns:
448
+ Copy of cached rows when available, otherwise `None`.
449
+ """
450
+
451
+ def _copy_with_cached_counts(rows: list[ThreadInfo]) -> list[ThreadInfo]:
452
+ copied_rows = _copy_threads(rows)
453
+ apply_cached_thread_message_counts(copied_rows)
454
+ apply_cached_thread_initial_prompts(copied_rows)
455
+ return copied_rows
456
+
457
+ thread_limit = limit if limit is not None else get_thread_limit()
458
+ if thread_limit < 1:
459
+ return None
460
+
461
+ exact = _recent_threads_cache.get((agent_name, thread_limit))
462
+ if exact is not None:
463
+ return _copy_with_cached_counts(exact)
464
+
465
+ best_key: tuple[str | None, int] | None = None
466
+ for key in _recent_threads_cache:
467
+ cache_agent, cache_limit = key
468
+ if cache_agent != agent_name or cache_limit < thread_limit:
469
+ continue
470
+ if best_key is None or cache_limit < best_key[1]:
471
+ best_key = key
472
+
473
+ if best_key is None:
474
+ return None
475
+
476
+ return _copy_with_cached_counts(_recent_threads_cache[best_key][:thread_limit])
477
+
478
+
479
+ def apply_cached_thread_message_counts(threads: list[ThreadInfo]) -> int:
480
+ """Apply cached message counts onto thread rows when freshness matches.
481
+
482
+ Args:
483
+ threads: Thread rows to mutate in place.
484
+
485
+ Returns:
486
+ Number of rows that were populated from cache.
487
+ """
488
+ populated = 0
489
+ for thread in threads:
490
+ if "message_count" in thread:
491
+ continue
492
+ thread_id = thread["thread_id"]
493
+ freshness = _thread_freshness(thread)
494
+ cached = _message_count_cache.get(thread_id)
495
+ if cached is None or cached[0] != freshness:
496
+ continue
497
+ thread["message_count"] = cached[1]
498
+ populated += 1
499
+ return populated
500
+
501
+
502
+ def apply_cached_thread_initial_prompts(threads: list[ThreadInfo]) -> int:
503
+ """Apply cached initial prompts onto thread rows when freshness matches.
504
+
505
+ Args:
506
+ threads: Thread rows to mutate in place.
507
+
508
+ Returns:
509
+ Number of rows that were populated from cache.
510
+ """
511
+ populated = 0
512
+ for thread in threads:
513
+ if "initial_prompt" in thread:
514
+ continue
515
+ thread_id = thread["thread_id"]
516
+ freshness = _thread_freshness(thread)
517
+ cached = _initial_prompt_cache.get(thread_id)
518
+ if cached is None or cached[0] != freshness:
519
+ continue
520
+ thread["initial_prompt"] = cached[1]
521
+ populated += 1
522
+ return populated
523
+
524
+
525
+ async def _populate_message_counts(
526
+ conn: aiosqlite.Connection,
527
+ threads: list[ThreadInfo],
528
+ ) -> None:
529
+ """Fill `message_count` on thread rows with cache-aware lookup."""
530
+ await _populate_checkpoint_fields(
531
+ conn,
532
+ threads,
533
+ include_message_count=True,
534
+ include_initial_prompt=False,
535
+ )
536
+
537
+
538
+ async def _get_jsonplus_serializer() -> JsonPlusSerializer:
539
+ """Return a cached JsonPlus serializer, loading it off the UI loop."""
540
+ global _jsonplus_serializer # noqa: PLW0603 # Module-level cache requires global statement
541
+ if _jsonplus_serializer is not None:
542
+ return _jsonplus_serializer
543
+
544
+ loop = asyncio.get_running_loop()
545
+ _jsonplus_serializer = await loop.run_in_executor(None, _create_jsonplus_serializer)
546
+ return _jsonplus_serializer
547
+
548
+
549
+ def _create_jsonplus_serializer() -> JsonPlusSerializer:
550
+ """Import and create a JsonPlus serializer.
551
+
552
+ Returns:
553
+ A ready `JsonPlusSerializer` instance.
554
+ """
555
+ from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
556
+
557
+ return JsonPlusSerializer()
558
+
559
+
560
+ def _cache_message_count(thread_id: str, freshness: str | None, count: int) -> None:
561
+ """Cache a thread's message count with a freshness token."""
562
+ if len(_message_count_cache) >= _MAX_MESSAGE_COUNT_CACHE and (
563
+ thread_id not in _message_count_cache
564
+ ):
565
+ oldest = next(iter(_message_count_cache))
566
+ _message_count_cache.pop(oldest, None)
567
+ _message_count_cache[thread_id] = (freshness, count)
568
+
569
+
570
+ def _cache_initial_prompt(
571
+ thread_id: str,
572
+ freshness: str | None,
573
+ initial_prompt: str | None,
574
+ ) -> None:
575
+ """Cache a thread's initial prompt with a freshness token."""
576
+ if len(_initial_prompt_cache) >= _MAX_INITIAL_PROMPT_CACHE and (
577
+ thread_id not in _initial_prompt_cache
578
+ ):
579
+ oldest = next(iter(_initial_prompt_cache))
580
+ _initial_prompt_cache.pop(oldest, None)
581
+ _initial_prompt_cache[thread_id] = (freshness, initial_prompt)
582
+
583
+
584
+ def _thread_freshness(thread: ThreadInfo) -> str | None:
585
+ """Return a cache freshness token for a thread row."""
586
+ return thread.get("latest_checkpoint_id") or thread.get("updated_at")
587
+
588
+
589
+ def _cache_recent_threads(
590
+ agent_name: str | None,
591
+ limit: int,
592
+ threads: list[ThreadInfo],
593
+ ) -> None:
594
+ """Store a copy of recent thread rows for fast selector startup."""
595
+ key = (agent_name, max(1, limit))
596
+ if len(_recent_threads_cache) >= _MAX_RECENT_THREADS_CACHE_KEYS and (
597
+ key not in _recent_threads_cache
598
+ ):
599
+ _recent_threads_cache.clear()
600
+ _recent_threads_cache[key] = _copy_threads(threads)
601
+
602
+
603
+ def _copy_threads(threads: list[ThreadInfo]) -> list[ThreadInfo]:
604
+ """Return shallow-copied thread rows."""
605
+ return [ThreadInfo(**thread) for thread in threads]
606
+
607
+
608
+ async def _count_messages_from_checkpoint(
609
+ conn: aiosqlite.Connection,
610
+ thread_id: str,
611
+ serde: JsonPlusSerializer,
612
+ ) -> int:
613
+ """Count messages from the most recent checkpoint blob.
614
+
615
+ With `durability='exit'`, messages are stored in the checkpoint blob, not in
616
+ the writes table. This function deserializes the checkpoint and counts the
617
+ messages in channel_values.
618
+
619
+ Args:
620
+ conn: Database connection.
621
+ thread_id: The thread ID to count messages for.
622
+ serde: Serializer for decoding checkpoint data.
623
+
624
+ Returns:
625
+ Number of messages in the checkpoint, or 0 if not found.
626
+ """
627
+ return (await _load_latest_checkpoint_summary(conn, thread_id, serde)).message_count
628
+
629
+
630
+ async def _extract_initial_prompt(
631
+ conn: aiosqlite.Connection,
632
+ thread_id: str,
633
+ serde: JsonPlusSerializer,
634
+ ) -> str | None:
635
+ """Extract the first human message from the latest checkpoint.
636
+
637
+ Args:
638
+ conn: Database connection.
639
+ thread_id: The thread ID to extract from.
640
+ serde: Serializer for decoding checkpoint data.
641
+
642
+ Returns:
643
+ First human message content, or None if not found.
644
+ """
645
+ summary = await _load_latest_checkpoint_summary(conn, thread_id, serde)
646
+ return summary.initial_prompt
647
+
648
+
649
+ async def populate_thread_initial_prompts(threads: list[ThreadInfo]) -> None:
650
+ """Populate `initial_prompt` for thread rows in the background.
651
+
652
+ Args:
653
+ threads: Thread rows to enrich in place.
654
+ """
655
+ if not threads:
656
+ return
657
+
658
+ async with _connect() as conn:
659
+ await _populate_checkpoint_fields(
660
+ conn,
661
+ threads,
662
+ include_message_count=False,
663
+ include_initial_prompt=True,
664
+ )
665
+
666
+
667
+ async def _populate_checkpoint_fields(
668
+ conn: aiosqlite.Connection,
669
+ threads: list[ThreadInfo],
670
+ *,
671
+ include_message_count: bool,
672
+ include_initial_prompt: bool,
673
+ ) -> None:
674
+ """Populate checkpoint-derived thread fields with a batched latest-row pass."""
675
+ serde = await _get_jsonplus_serializer()
676
+
677
+ # Phase 1: apply cache hits, collect threads that need DB fetch.
678
+ uncached: list[ThreadInfo] = []
679
+ for thread in threads:
680
+ thread_id = thread["thread_id"]
681
+ freshness = _thread_freshness(thread)
682
+ needs_count = False
683
+ needs_prompt = False
684
+
685
+ if include_message_count:
686
+ cached = _message_count_cache.get(thread_id)
687
+ if cached is not None and cached[0] == freshness:
688
+ thread["message_count"] = cached[1]
689
+ else:
690
+ needs_count = True
691
+
692
+ if include_initial_prompt and "initial_prompt" not in thread:
693
+ cached_prompt = _initial_prompt_cache.get(thread_id)
694
+ if cached_prompt is not None and cached_prompt[0] == freshness:
695
+ thread["initial_prompt"] = cached_prompt[1]
696
+ else:
697
+ needs_prompt = True
698
+
699
+ if needs_count or needs_prompt:
700
+ uncached.append(thread)
701
+
702
+ if not uncached:
703
+ return
704
+
705
+ # Phase 2: batch-fetch all uncached threads.
706
+ uncached_ids = [t["thread_id"] for t in uncached]
707
+ batch_results = await _load_latest_checkpoint_summaries_batch(conn, uncached_ids, serde)
708
+
709
+ # Phase 3: apply results and update caches.
710
+ for thread in uncached:
711
+ thread_id = thread["thread_id"]
712
+ freshness = _thread_freshness(thread)
713
+ summary = batch_results.get(thread_id, _CheckpointSummary(0, None))
714
+
715
+ if include_message_count and "message_count" not in thread:
716
+ thread["message_count"] = summary.message_count
717
+ _cache_message_count(thread_id, freshness, summary.message_count)
718
+ if include_initial_prompt and "initial_prompt" not in thread:
719
+ thread["initial_prompt"] = summary.initial_prompt
720
+ _cache_initial_prompt(thread_id, freshness, summary.initial_prompt)
721
+
722
+
723
+ _SQLITE_MAX_VARIABLE_NUMBER = 500
724
+ """Max `?` placeholders per SQL query.
725
+
726
+ SQLite limits how many `?` parameters a single query can have (default 999,
727
+ lower on some builds). If a user accumulates hundreds of threads and the
728
+ `/threads` modal fetches them all at once, the `IN (?, ?, ...)` clause could
729
+ exceed that limit. We chunk to this size to stay safe.
730
+ """
731
+
732
+
733
+ async def _load_latest_checkpoint_summaries_batch(
734
+ conn: aiosqlite.Connection,
735
+ thread_ids: list[str],
736
+ serde: JsonPlusSerializer,
737
+ ) -> dict[str, _CheckpointSummary]:
738
+ """Batch-load the latest checkpoint summary for multiple threads.
739
+
740
+ Uses a window function to fetch the latest checkpoint per thread, issuing
741
+ one query per chunk for SQLite variable-limit safety.
742
+
743
+ Args:
744
+ conn: Database connection.
745
+ thread_ids: Thread IDs to look up.
746
+ serde: Serializer for decoding checkpoint blobs.
747
+
748
+ Returns:
749
+ Dict mapping thread IDs to their checkpoint summaries.
750
+ """
751
+ if not thread_ids:
752
+ return {}
753
+
754
+ results: dict[str, _CheckpointSummary] = {}
755
+
756
+ for start in range(0, len(thread_ids), _SQLITE_MAX_VARIABLE_NUMBER):
757
+ chunk = thread_ids[start : start + _SQLITE_MAX_VARIABLE_NUMBER]
758
+ placeholders = ",".join("?" * len(chunk))
759
+ query = f"""
760
+ SELECT thread_id, type, checkpoint FROM (
761
+ SELECT thread_id, type, checkpoint,
762
+ ROW_NUMBER() OVER (
763
+ PARTITION BY thread_id ORDER BY checkpoint_id DESC
764
+ ) AS rn
765
+ FROM checkpoints
766
+ WHERE thread_id IN ({placeholders})
767
+ ) WHERE rn = 1
768
+ """ # noqa: S608 # placeholders built from len(chunk); user values use ? params
769
+ async with conn.execute(query, chunk) as cursor:
770
+ rows = await cursor.fetchall()
771
+
772
+ loop = asyncio.get_running_loop()
773
+ for row in rows:
774
+ tid, type_str, checkpoint_blob = row
775
+ if not type_str or not checkpoint_blob:
776
+ results[tid] = _CheckpointSummary(message_count=0, initial_prompt=None)
777
+ continue
778
+ try:
779
+ data = await loop.run_in_executor(
780
+ None, serde.loads_typed, (type_str, checkpoint_blob)
781
+ )
782
+ results[tid] = _summarize_checkpoint(data)
783
+ except Exception:
784
+ logger.warning(
785
+ "Failed to deserialize checkpoint for thread %s; "
786
+ "message count and initial prompt may be incomplete",
787
+ tid,
788
+ exc_info=True,
789
+ )
790
+ results[tid] = _CheckpointSummary(message_count=0, initial_prompt=None)
791
+
792
+ return results
793
+
794
+
795
+ async def _load_latest_checkpoint_summary(
796
+ conn: aiosqlite.Connection,
797
+ thread_id: str,
798
+ serde: JsonPlusSerializer,
799
+ ) -> _CheckpointSummary:
800
+ """Load checkpoint-derived summary data from the latest checkpoint row.
801
+
802
+ Returns:
803
+ Message-count and prompt data extracted from the latest checkpoint row.
804
+ """
805
+ query = """
806
+ SELECT type, checkpoint
807
+ FROM checkpoints
808
+ WHERE thread_id = ?
809
+ ORDER BY checkpoint_id DESC
810
+ LIMIT 1
811
+ """
812
+ async with conn.execute(query, (thread_id,)) as cursor:
813
+ row = await cursor.fetchone()
814
+ if not row or not row[0] or not row[1]:
815
+ return _CheckpointSummary(message_count=0, initial_prompt=None)
816
+
817
+ type_str, checkpoint_blob = row
818
+ try:
819
+ data = serde.loads_typed((type_str, checkpoint_blob))
820
+ except (ValueError, TypeError, KeyError, AttributeError):
821
+ logger.warning(
822
+ "Failed to deserialize checkpoint for thread %s; message count and initial prompt may be incomplete",
823
+ thread_id,
824
+ exc_info=True,
825
+ )
826
+ return _CheckpointSummary(message_count=0, initial_prompt=None)
827
+
828
+ return _summarize_checkpoint(data)
829
+
830
+
831
+ def _summarize_checkpoint(data: object) -> _CheckpointSummary:
832
+ """Extract message count and initial human prompt from checkpoint data.
833
+
834
+ Returns:
835
+ Structured summary for the decoded checkpoint payload.
836
+ """
837
+ messages = _checkpoint_messages(data)
838
+ return _CheckpointSummary(
839
+ message_count=len(messages),
840
+ initial_prompt=_initial_prompt_from_messages(messages),
841
+ )
842
+
843
+
844
+ def _checkpoint_messages(data: object) -> list[object]:
845
+ """Return checkpoint messages when the decoded payload has the expected shape."""
846
+ if not isinstance(data, dict):
847
+ return []
848
+
849
+ payload = cast("dict[str, object]", data)
850
+ channel_values = payload.get("channel_values")
851
+ if not isinstance(channel_values, dict):
852
+ return []
853
+
854
+ channel_values_dict = cast("dict[str, object]", channel_values)
855
+ messages = channel_values_dict.get("messages")
856
+ if not isinstance(messages, list):
857
+ return []
858
+
859
+ return cast("list[object]", messages)
860
+
861
+
862
+ def _initial_prompt_from_messages(messages: list[object]) -> str | None:
863
+ """Return the first human message content from a checkpoint message list."""
864
+ for msg in messages:
865
+ if getattr(msg, "type", None) == "human":
866
+ return _coerce_prompt_text(getattr(msg, "content", None))
867
+ return None
868
+
869
+
870
+ def _coerce_prompt_text(content: object) -> str | None:
871
+ """Normalize checkpoint message content into displayable text.
872
+
873
+ Returns:
874
+ Displayable prompt text, or `None` when the content is empty.
875
+ """
876
+ if isinstance(content, str):
877
+ return content
878
+ if isinstance(content, list):
879
+ parts: list[str] = []
880
+ for part in content:
881
+ if isinstance(part, dict):
882
+ part_dict = cast("dict[str, object]", part)
883
+ text = part_dict.get("text")
884
+ parts.append(text if isinstance(text, str) else "")
885
+ else:
886
+ parts.append(str(part))
887
+ joined = " ".join(parts).strip()
888
+ return joined or None
889
+ if content is None:
890
+ return None
891
+ return str(content)
892
+
893
+
894
+ async def get_most_recent(agent_name: str | None = None) -> str | None:
895
+ """Get most recent thread_id, optionally filtered by agent.
896
+
897
+ Returns:
898
+ Most recent thread_id or None if no threads exist.
899
+ """
900
+ async with _connect() as conn:
901
+ if not await _table_exists(conn, "checkpoints"):
902
+ return None
903
+
904
+ if agent_name:
905
+ query = """
906
+ SELECT thread_id FROM checkpoints
907
+ WHERE json_extract(metadata, '$.agent_name') = ?
908
+ ORDER BY checkpoint_id DESC
909
+ LIMIT 1
910
+ """
911
+ params: tuple = (agent_name,)
912
+ else:
913
+ query = "SELECT thread_id FROM checkpoints ORDER BY checkpoint_id DESC LIMIT 1"
914
+ params = ()
915
+
916
+ async with conn.execute(query, params) as cursor:
917
+ row = await cursor.fetchone()
918
+ return row[0] if row else None
919
+
920
+
921
+ async def get_thread_agent(thread_id: str) -> str | None:
922
+ """Get agent_name for a thread.
923
+
924
+ Returns:
925
+ Agent name associated with the thread, or None if not found.
926
+ """
927
+ async with _connect() as conn:
928
+ if not await _table_exists(conn, "checkpoints"):
929
+ return None
930
+
931
+ query = """
932
+ SELECT json_extract(metadata, '$.agent_name')
933
+ FROM checkpoints
934
+ WHERE thread_id = ?
935
+ LIMIT 1
936
+ """
937
+ async with conn.execute(query, (thread_id,)) as cursor:
938
+ row = await cursor.fetchone()
939
+ return row[0] if row else None
940
+
941
+
942
+ async def thread_exists(thread_id: str) -> bool:
943
+ """Check if a thread exists in checkpoints.
944
+
945
+ Returns:
946
+ True if thread exists, False otherwise.
947
+ """
948
+ async with _connect() as conn:
949
+ if not await _table_exists(conn, "checkpoints"):
950
+ return False
951
+
952
+ query = "SELECT 1 FROM checkpoints WHERE thread_id = ? LIMIT 1"
953
+ async with conn.execute(query, (thread_id,)) as cursor:
954
+ row = await cursor.fetchone()
955
+ return row is not None
956
+
957
+
958
+ async def find_similar_threads(thread_id: str, limit: int = 3) -> list[str]:
959
+ """Find threads whose IDs start with the given prefix.
960
+
961
+ Args:
962
+ thread_id: Prefix to match against thread IDs.
963
+ limit: Maximum number of matching threads to return.
964
+
965
+ Returns:
966
+ List of thread IDs that begin with the given prefix.
967
+ """
968
+ async with _connect() as conn:
969
+ if not await _table_exists(conn, "checkpoints"):
970
+ return []
971
+
972
+ query = """
973
+ SELECT DISTINCT thread_id
974
+ FROM checkpoints
975
+ WHERE thread_id LIKE ?
976
+ ORDER BY thread_id
977
+ LIMIT ?
978
+ """
979
+ prefix = thread_id + "%"
980
+ async with conn.execute(query, (prefix, limit)) as cursor:
981
+ rows = await cursor.fetchall()
982
+ return [r[0] for r in rows]
983
+
984
+
985
+ async def delete_thread(thread_id: str) -> bool:
986
+ """Delete thread checkpoints.
987
+
988
+ Returns:
989
+ True if thread was deleted, False if not found.
990
+ """
991
+ async with _connect() as conn:
992
+ if not await _table_exists(conn, "checkpoints"):
993
+ return False
994
+
995
+ cursor = await conn.execute("DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,))
996
+ deleted = cursor.rowcount > 0
997
+ if await _table_exists(conn, "writes"):
998
+ await conn.execute("DELETE FROM writes WHERE thread_id = ?", (thread_id,))
999
+ await conn.commit()
1000
+ if deleted:
1001
+ _message_count_cache.pop(thread_id, None)
1002
+ for key, rows in list(_recent_threads_cache.items()):
1003
+ filtered = [row for row in rows if row["thread_id"] != thread_id]
1004
+ _recent_threads_cache[key] = filtered
1005
+ return deleted
1006
+
1007
+
1008
+ @asynccontextmanager
1009
+ async def get_checkpointer() -> AsyncIterator[AsyncSqliteSaver]:
1010
+ """Get AsyncSqliteSaver for the global database.
1011
+
1012
+ Yields:
1013
+ AsyncSqliteSaver instance for checkpoint persistence.
1014
+ """
1015
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
1016
+
1017
+ _patch_aiosqlite()
1018
+
1019
+ async with AsyncSqliteSaver.from_conn_string(str(get_db_path())) as checkpointer:
1020
+ yield checkpointer
1021
+
1022
+
1023
+ _DEFAULT_THREAD_LIMIT = 20
1024
+
1025
+
1026
+ def get_thread_limit() -> int:
1027
+ """Read the thread listing limit from `DA_CLI_RECENT_THREADS`.
1028
+
1029
+ Falls back to `_DEFAULT_THREAD_LIMIT` when the variable is unset or contains
1030
+ a non-integer value. The result is clamped to a minimum of 1.
1031
+
1032
+ Returns:
1033
+ Number of threads to display.
1034
+ """
1035
+ import os
1036
+
1037
+ raw = os.environ.get("DA_CLI_RECENT_THREADS")
1038
+ if raw is None:
1039
+ return _DEFAULT_THREAD_LIMIT
1040
+ try:
1041
+ return max(1, int(raw))
1042
+ except ValueError:
1043
+ logger.warning(
1044
+ "Invalid DA_CLI_RECENT_THREADS value %r, using default %d",
1045
+ raw,
1046
+ _DEFAULT_THREAD_LIMIT,
1047
+ )
1048
+ return _DEFAULT_THREAD_LIMIT
1049
+
1050
+
1051
+ async def list_threads_command(
1052
+ agent_name: str | None = None,
1053
+ limit: int | None = None,
1054
+ sort_by: str | None = None,
1055
+ branch: str | None = None,
1056
+ verbose: bool = False,
1057
+ relative: bool | None = None,
1058
+ *,
1059
+ output_format: OutputFormat = "text",
1060
+ ) -> None:
1061
+ """CLI handler for `Soothe threads list`.
1062
+
1063
+ Fetches and displays a table of recent conversation threads, optionally
1064
+ filtered by agent name or git branch.
1065
+
1066
+ Args:
1067
+ agent_name: Only show threads belonging to this agent.
1068
+
1069
+ When `None`, threads for all agents are shown.
1070
+ limit: Maximum number of threads to display.
1071
+
1072
+ When `None`, reads from `DA_CLI_RECENT_THREADS` or falls back to
1073
+ the default.
1074
+ sort_by: Sort field — `"updated"` or `"created"`.
1075
+
1076
+ When `None`, reads from config (`~/SOOTHE_HOME/config/config.yml`).
1077
+ branch: Only show threads from this git branch.
1078
+ verbose: When `True`, show all columns (branch, created, prompt).
1079
+ relative: Show timestamps as relative time (e.g., '5m ago').
1080
+
1081
+ When `None`, reads from config (`~/SOOTHE_HOME/config/config.yml`).
1082
+ output_format: Output format — `'text'` (Rich) or `'json'`.
1083
+ """
1084
+ from soothe_cli.tui.model_config import (
1085
+ load_thread_relative_time,
1086
+ load_thread_sort_order,
1087
+ )
1088
+
1089
+ if sort_by is None:
1090
+ raw = load_thread_sort_order()
1091
+ sort_by = "created" if raw == "created_at" else "updated"
1092
+ if relative is None:
1093
+ relative = load_thread_relative_time()
1094
+
1095
+ fmt_ts = format_relative_timestamp if relative else format_timestamp
1096
+
1097
+ limit = get_thread_limit() if limit is None else max(1, limit)
1098
+
1099
+ threads = await list_threads(
1100
+ agent_name,
1101
+ limit=limit,
1102
+ include_message_count=True,
1103
+ sort_by=sort_by,
1104
+ branch=branch,
1105
+ )
1106
+
1107
+ if verbose and threads:
1108
+ await populate_thread_checkpoint_details(
1109
+ threads, include_message_count=False, include_initial_prompt=True
1110
+ )
1111
+
1112
+ if output_format == "json":
1113
+ from soothe_cli.tui.output import write_json
1114
+
1115
+ write_json("threads list", list(threads))
1116
+ return
1117
+
1118
+ from rich.markup import escape as escape_markup
1119
+ from rich.table import Table
1120
+
1121
+ from soothe_cli.tui import theme
1122
+ from soothe_cli.tui.config import console
1123
+
1124
+ if not threads:
1125
+ filters = []
1126
+ if agent_name:
1127
+ filters.append(f"agent '{escape_markup(agent_name)}'")
1128
+ if branch:
1129
+ filters.append(f"branch '{escape_markup(branch)}'")
1130
+ if filters:
1131
+ console.print(f"[yellow]No threads found for {' and '.join(filters)}.[/yellow]")
1132
+ else:
1133
+ console.print("[yellow]No threads found.[/yellow]")
1134
+ console.print("[dim]Start a conversation with: Soothe[/dim]")
1135
+ return
1136
+
1137
+ title_parts = []
1138
+ if agent_name:
1139
+ title_parts.append(f"agent '{escape_markup(agent_name)}'")
1140
+ if branch:
1141
+ title_parts.append(f"branch '{escape_markup(branch)}'")
1142
+
1143
+ title_filter = f" for {' and '.join(title_parts)}" if title_parts else ""
1144
+ sort_label = "created" if sort_by == "created" else "updated"
1145
+ title = f"Recent Threads{title_filter} (last {limit}, by {sort_label})"
1146
+
1147
+ table = Table(title=title, show_header=True, header_style=f"bold {theme.PRIMARY}")
1148
+ table.add_column("Thread ID", style="bold")
1149
+ table.add_column("Agent")
1150
+ table.add_column("Messages", justify="right")
1151
+ if verbose:
1152
+ table.add_column("Created")
1153
+ table.add_column("Updated" if sort_by == "updated" else "Last Used")
1154
+ if verbose:
1155
+ table.add_column("Branch")
1156
+ table.add_column("Location")
1157
+ table.add_column("Prompt", max_width=40, no_wrap=True)
1158
+
1159
+ prompt_max = 40
1160
+
1161
+ for t in threads:
1162
+ row: list[str] = [
1163
+ t["thread_id"],
1164
+ t["agent_name"] or "unknown",
1165
+ str(t.get("message_count", 0)),
1166
+ ]
1167
+ if verbose:
1168
+ row.append(fmt_ts(t.get("created_at")))
1169
+ row.append(fmt_ts(t.get("updated_at")))
1170
+ if verbose:
1171
+ prompt = " ".join((t.get("initial_prompt") or "").split())
1172
+ if len(prompt) > prompt_max:
1173
+ prompt = prompt[: prompt_max - 3] + "..."
1174
+ row.extend(
1175
+ [
1176
+ t.get("git_branch") or "",
1177
+ format_path(t.get("cwd")),
1178
+ prompt,
1179
+ ]
1180
+ )
1181
+ table.add_row(*row)
1182
+
1183
+ console.print()
1184
+ console.print(table)
1185
+ if len(threads) >= limit:
1186
+ console.print(
1187
+ f"[dim]Showing last {limit} threads. Override with -n/--limit or DA_CLI_RECENT_THREADS.[/dim]"
1188
+ )
1189
+ console.print()
1190
+
1191
+
1192
+ async def delete_thread_command(
1193
+ thread_id: str,
1194
+ *,
1195
+ dry_run: bool = False,
1196
+ output_format: OutputFormat = "text",
1197
+ ) -> None:
1198
+ """CLI handler for: Soothe threads delete.
1199
+
1200
+ Args:
1201
+ thread_id: ID of the thread to delete.
1202
+ dry_run: If `True`, print what would happen without making changes.
1203
+ output_format: Output format — `'text'` (Rich) or `'json'`.
1204
+ """
1205
+ if dry_run:
1206
+ exists = await thread_exists(thread_id)
1207
+ if output_format == "json":
1208
+ from soothe_cli.tui.output import write_json
1209
+
1210
+ write_json(
1211
+ "threads delete",
1212
+ {"thread_id": thread_id, "exists": exists, "dry_run": True},
1213
+ )
1214
+ return
1215
+
1216
+ from rich.markup import escape as escape_markup
1217
+
1218
+ from soothe_cli.tui.config import console
1219
+
1220
+ escaped_id = escape_markup(thread_id)
1221
+ if exists:
1222
+ console.print(f"Would delete thread '{escaped_id}'.")
1223
+ else:
1224
+ console.print(f"Thread '{escaped_id}' not found. Nothing to delete.")
1225
+ console.print("No changes made.", style="dim")
1226
+ return
1227
+
1228
+ deleted = await delete_thread(thread_id)
1229
+
1230
+ if output_format == "json":
1231
+ from soothe_cli.tui.output import write_json
1232
+
1233
+ write_json("threads delete", {"thread_id": thread_id, "deleted": deleted})
1234
+ return
1235
+
1236
+ from rich.markup import escape as escape_markup
1237
+
1238
+ from soothe_cli.tui import theme
1239
+ from soothe_cli.tui.config import console
1240
+
1241
+ escaped_id = escape_markup(thread_id)
1242
+ if deleted:
1243
+ console.print(f"[green]Thread '{escaped_id}' deleted.[/green]")
1244
+ else:
1245
+ console.print(
1246
+ f"Thread '{escaped_id}' not found or already deleted.",
1247
+ style=theme.MUTED,
1248
+ )