soothe-cli 0.7.0__tar.gz → 0.7.2__tar.gz

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 (127) hide show
  1. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/.gitignore +9 -3
  2. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/PKG-INFO +1 -1
  3. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/execution/daemon.py +1 -1
  4. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/execution/daemon_errors.py +18 -0
  5. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/main.py +27 -0
  6. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/config/cli_config.py +3 -0
  7. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/policy/essential_events.py +3 -1
  8. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/state/session_stats.py +60 -0
  9. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/transport/session.py +30 -9
  10. soothe_cli-0.7.2/src/soothe_cli/runtime/turn/pipeline.py +404 -0
  11. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/turn/prepare.py +16 -1
  12. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_app.py +11 -2
  13. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_execution.py +37 -1
  14. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_messages_mixin.py +20 -12
  15. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_model.py +23 -39
  16. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_startup.py +137 -60
  17. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_ui.py +40 -5
  18. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/app.tcss +6 -6
  19. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/binding.py +0 -2
  20. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/commands/slash_commands.py +0 -7
  21. soothe_cli-0.7.2/src/soothe_cli/tui/markdown_theme.py +358 -0
  22. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/model_config.py +78 -24
  23. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/sessions.py +8 -0
  24. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/textual_adapter.py +304 -38
  25. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/tool_display.py +54 -1
  26. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +3 -8
  27. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/context_viewer.py +1 -1
  28. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/loading.py +60 -15
  29. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/loop_selector.py +22 -131
  30. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -2
  31. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -42
  32. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/assistant.py +4 -5
  33. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +3 -10
  34. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/cognition_step.py +5 -4
  35. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +1 -0
  36. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/skill.py +2 -4
  37. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/user.py +20 -12
  38. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/welcome.py +1 -15
  39. soothe_cli-0.7.0/src/soothe_cli/runtime/turn/pipeline.py +0 -166
  40. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/README.md +0 -0
  41. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/pyproject.toml +0 -0
  42. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/__init__.py +0 -0
  43. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/__init__.py +0 -0
  44. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/commands/__init__.py +0 -0
  45. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  46. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  47. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  48. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  49. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  50. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/execution/__init__.py +0 -0
  51. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/execution/headless.py +0 -0
  52. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  53. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/cli/execution/launcher.py +0 -0
  54. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/config/__init__.py +0 -0
  55. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/config/loader.py +0 -0
  56. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/config/logging_setup.py +0 -0
  57. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/__init__.py +0 -0
  58. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/headless/processor.py +0 -0
  59. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  60. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  61. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  62. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  63. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  64. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  65. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  66. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  67. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
  68. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  69. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  70. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  71. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/state/step_router.py +0 -0
  72. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  73. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/state/transcript.py +0 -0
  74. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  75. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/wire/display_text.py +0 -0
  76. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  77. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/runtime/wire/messages.py +0 -0
  78. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/__init__.py +0 -0
  79. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/_cli_context.py +0 -0
  80. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/_env_vars.py +0 -0
  81. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/_version.py +0 -0
  82. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/__init__.py +0 -0
  83. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_commands.py +0 -0
  84. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_history.py +0 -0
  85. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_module_init.py +0 -0
  86. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/command_registry.py +0 -0
  87. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/commands/__init__.py +0 -0
  88. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/commands/command_router.py +0 -0
  89. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  90. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/config.py +0 -0
  91. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/file_change_notify.py +0 -0
  92. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  93. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/hooks.py +0 -0
  94. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/input.py +0 -0
  95. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/media_utils.py +0 -0
  96. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/path_utils.py +0 -0
  97. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/preview_limits.py +0 -0
  98. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/project_utils.py +0 -0
  99. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/skills/__init__.py +0 -0
  100. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/skills/invocation.py +0 -0
  101. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/skills/load.py +0 -0
  102. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/theme.py +0 -0
  103. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/tips.py +0 -0
  104. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/unicode_security.py +0 -0
  105. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/update_check.py +0 -0
  106. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  107. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/_links.py +0 -0
  108. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  109. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
  110. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  111. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  112. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/diff.py +0 -0
  113. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/editor.py +0 -0
  114. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  115. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/history.py +0 -0
  116. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  117. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  118. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  119. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  120. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
  121. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
  122. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  123. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  124. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  125. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  126. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/status.py +0 -0
  127. {soothe_cli-0.7.0 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
@@ -191,6 +191,15 @@ cython_debug/
191
191
  # PyPI configuration file
192
192
  .pypirc
193
193
 
194
+ # Soothe local runtime data
195
+ .soothe/
196
+
197
+ # Tool backup files (created by file editing tools)
198
+ .backups/
199
+
200
+ # Large tool result cache
201
+ large_tool_results/
202
+
194
203
  # Cursor
195
204
  # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
196
205
  # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
@@ -206,7 +215,6 @@ __marimo__/
206
215
  .python-version
207
216
  thirdparty/
208
217
  .cursor/
209
- large_tool_results/
210
218
  plot_*
211
219
  .claude/
212
220
  .DS_Store
@@ -216,5 +224,3 @@ manifest.json
216
224
  _bmad
217
225
  __MACOSX
218
226
  .qoder
219
- .soothe
220
- .backups/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Soothe CLI client - communicates with daemon via WebSocket
5
5
  Project-URL: Homepage, https://github.com/mirasoth/soothe
6
6
  Project-URL: Documentation, https://soothe.readthedocs.io
@@ -280,7 +280,7 @@ async def _run_headless_session_once(
280
280
  query_started = True
281
281
  elif (state == "idle" and query_started) or state == "stopped":
282
282
  loop_clock = asyncio.get_event_loop()
283
- drain_deadline = loop_clock.time() + 2.5
283
+ drain_deadline = loop_clock.time() + 30.0
284
284
  while loop_clock.time() < drain_deadline:
285
285
  try:
286
286
  nxt = await asyncio.wait_for(client.read_event(), timeout=0.25)
@@ -5,11 +5,19 @@ from __future__ import annotations
5
5
  # Partial match for pool_runner RuntimeError when an OS worker exits mid-turn.
6
6
  DAEMON_WORKER_SUBPROCESS_LOST = "Worker subprocess exited unexpectedly during query execution"
7
7
 
8
+ # Partial match for thread_runner RuntimeError when a worker thread dies mid-turn.
9
+ DAEMON_WORKER_THREAD_LOST = "Worker thread exited unexpectedly during query execution"
10
+
8
11
  _FRIENDLY_WORKER_SUBPROCESS_LOST = (
9
12
  "The daemon execution worker stopped unexpectedly (for example after the pool "
10
13
  "recycled an idle subprocess). Send your message again."
11
14
  )
12
15
 
16
+ _FRIENDLY_WORKER_THREAD_LOST = (
17
+ "The daemon execution worker stopped unexpectedly during your request. "
18
+ "Send your message again, or use /resume to recover the loop."
19
+ )
20
+
13
21
  _FRIENDLY_DAEMON_CONNECTION_LOST = (
14
22
  "Daemon connection lost (the daemon may have restarted). "
15
23
  "Send your message again to reconnect and continue this loop."
@@ -45,9 +53,13 @@ def friendly_daemon_execution_error(exc: BaseException | str) -> str:
45
53
  return _FRIENDLY_DAEMON_CONNECTION_LOST
46
54
  if isinstance(exc, RuntimeError) and DAEMON_WORKER_SUBPROCESS_LOST in str(exc):
47
55
  return _FRIENDLY_WORKER_SUBPROCESS_LOST
56
+ if isinstance(exc, RuntimeError) and DAEMON_WORKER_THREAD_LOST in str(exc):
57
+ return _FRIENDLY_WORKER_THREAD_LOST
48
58
  text = str(exc)
49
59
  if DAEMON_WORKER_SUBPROCESS_LOST in text:
50
60
  return _FRIENDLY_WORKER_SUBPROCESS_LOST
61
+ if DAEMON_WORKER_THREAD_LOST in text:
62
+ return _FRIENDLY_WORKER_THREAD_LOST
51
63
  return text if isinstance(exc, str) else str(exc)
52
64
 
53
65
 
@@ -55,3 +67,9 @@ def is_daemon_worker_subprocess_lost(exc: BaseException | str) -> bool:
55
67
  """Return whether an error indicates a pool worker process exited mid-query."""
56
68
  text = str(exc)
57
69
  return DAEMON_WORKER_SUBPROCESS_LOST in text
70
+
71
+
72
+ def is_daemon_worker_thread_lost(exc: BaseException | str) -> bool:
73
+ """Return whether an error indicates a pool worker thread exited mid-query."""
74
+ text = str(exc)
75
+ return DAEMON_WORKER_THREAD_LOST in text
@@ -15,6 +15,12 @@ from soothe_sdk.client.config import SOOTHE_HOME # noqa: E402
15
15
 
16
16
  from soothe_cli.config.cli_config import CLIConfig # noqa: E402
17
17
  from soothe_cli.config.loader import set_runtime_config # noqa: E402
18
+ from soothe_cli.tui.markdown_theme import ( # noqa: E402
19
+ DEFAULT_MARKDOWN_THEME,
20
+ REGISTRY,
21
+ load_markdown_theme_preference,
22
+ markdown_theme_help,
23
+ )
18
24
 
19
25
  app = typer.Typer(
20
26
  name="soothe",
@@ -101,6 +107,16 @@ def main(
101
107
  help="Render assistant messages as Markdown in the TUI.",
102
108
  ),
103
109
  ] = True,
110
+ markdown_theme: Annotated[
111
+ str | None,
112
+ typer.Option(
113
+ "--markdown-theme",
114
+ help=(
115
+ "Markdown appearance preset for TUI cards "
116
+ f"({markdown_theme_help()}). Default: {DEFAULT_MARKDOWN_THEME}."
117
+ ),
118
+ ),
119
+ ] = None,
104
120
  soothe_home: Annotated[
105
121
  str | None,
106
122
  typer.Option("--soothe-home", help="Soothe home directory (default: ~/.soothe)."),
@@ -168,11 +184,22 @@ def main(
168
184
  if mode is not None and mode not in ("manual", "auto"):
169
185
  typer.echo(f"Invalid --mode {mode!r}; expected 'manual' or 'auto'.", err=True)
170
186
  raise typer.Exit(code=2)
187
+ if markdown_theme is not None and markdown_theme not in REGISTRY:
188
+ typer.echo(
189
+ f"Invalid --markdown-theme {markdown_theme!r}; "
190
+ f"expected one of: {markdown_theme_help()}.",
191
+ err=True,
192
+ )
193
+ raise typer.Exit(code=2)
194
+ resolved_markdown_theme = (
195
+ markdown_theme if markdown_theme is not None else load_markdown_theme_preference()
196
+ )
171
197
  cli_cfg = CLIConfig(
172
198
  daemon_host=daemon_host,
173
199
  daemon_port=daemon_port,
174
200
  logging_level=log_level,
175
201
  render_markdown=render_markdown,
202
+ markdown_theme=resolved_markdown_theme,
176
203
  output_streaming_enabled=streaming,
177
204
  output_streaming_mode=streaming_mode,
178
205
  clarification_mode=mode,
@@ -29,6 +29,9 @@ class CLIConfig:
29
29
  render_markdown: bool = True
30
30
  """Render assistant messages as Markdown in TUI (default True)."""
31
31
 
32
+ markdown_theme: str = "match-app"
33
+ """Markdown appearance preset (``match-app``, ``langchain``, ``standard``, …)."""
34
+
32
35
  # Output streaming overrides (RFC-614)
33
36
  output_streaming_enabled: bool | None = None
34
37
  """Override daemon streaming enabled setting."""
@@ -24,12 +24,13 @@ STEP_COMPLETE_EVENT_TYPES: Final[frozenset[str]] = frozenset(
24
24
  )
25
25
 
26
26
  LOOP_REASON_EVENT_TYPE: Final[str] = "soothe.cognition.strange_loop.reasoned"
27
+ INTENT_CLASSIFIED_EVENT_TYPE: Final[str] = "soothe.cognition.intent.classified"
27
28
 
28
29
  ESSENTIAL_PROGRESS_EVENT_TYPES: Final[frozenset[str]] = frozenset(
29
30
  set(GOAL_START_EVENT_TYPES)
30
31
  | set(STEP_START_EVENT_TYPES)
31
32
  | set(STEP_COMPLETE_EVENT_TYPES)
32
- | {LOOP_REASON_EVENT_TYPE}
33
+ | {LOOP_REASON_EVENT_TYPE, INTENT_CLASSIFIED_EVENT_TYPE}
33
34
  )
34
35
 
35
36
 
@@ -56,6 +57,7 @@ def is_step_complete_event_type(event_type: str) -> bool:
56
57
  __all__ = [
57
58
  "ESSENTIAL_PROGRESS_EVENT_TYPES",
58
59
  "GOAL_START_EVENT_TYPES",
60
+ "INTENT_CLASSIFIED_EVENT_TYPE",
59
61
  "LOOP_REASON_EVENT_TYPE",
60
62
  "STEP_COMPLETE_EVENT_TYPES",
61
63
  "STEP_START_EVENT_TYPES",
@@ -8,6 +8,7 @@ config, no widget imports) so that `app.py` can import `SessionStats` and
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import time
11
12
  from dataclasses import dataclass, field
12
13
 
13
14
  SpinnerStatus = str | None
@@ -106,6 +107,52 @@ class SessionStats:
106
107
  self.event_stats.merge(other.event_stats)
107
108
 
108
109
 
110
+ @dataclass
111
+ class TurnLatencyStats:
112
+ """IG-534 Phase 3: end-to-end turn latency observability.
113
+
114
+ Attributes:
115
+ turn_start_monotonic: Monotonic timestamp when the turn began.
116
+ time_to_first_chunk_ms: Milliseconds from turn start to first applied chunk.
117
+ synthesis_visible_ms: Milliseconds from turn start to first goal_completion apply.
118
+ goal_completion_applied: Whether a goal_completion chunk was applied.
119
+ """
120
+
121
+ turn_start_monotonic: float = 0.0
122
+ time_to_first_chunk_ms: float | None = None
123
+ synthesis_visible_ms: float | None = None
124
+ goal_completion_applied: bool = False
125
+
126
+ def record_first_chunk(self) -> None:
127
+ """Record time-to-first-chunk once per turn."""
128
+ if self.time_to_first_chunk_ms is not None or self.turn_start_monotonic <= 0:
129
+ return
130
+ self.time_to_first_chunk_ms = (time.monotonic() - self.turn_start_monotonic) * 1000.0
131
+
132
+ def record_goal_completion(self) -> None:
133
+ """Record synthesis-visible latency once per turn."""
134
+ if self.goal_completion_applied or self.turn_start_monotonic <= 0:
135
+ return
136
+ self.goal_completion_applied = True
137
+ self.synthesis_visible_ms = (time.monotonic() - self.turn_start_monotonic) * 1000.0
138
+
139
+ def merge(self, other: TurnLatencyStats) -> None:
140
+ """Merge another stats object (keeps earliest first-chunk / synthesis times)."""
141
+ if other.time_to_first_chunk_ms is not None:
142
+ if (
143
+ self.time_to_first_chunk_ms is None
144
+ or other.time_to_first_chunk_ms < self.time_to_first_chunk_ms
145
+ ):
146
+ self.time_to_first_chunk_ms = other.time_to_first_chunk_ms
147
+ if other.synthesis_visible_ms is not None:
148
+ if (
149
+ self.synthesis_visible_ms is None
150
+ or other.synthesis_visible_ms < self.synthesis_visible_ms
151
+ ):
152
+ self.synthesis_visible_ms = other.synthesis_visible_ms
153
+ self.goal_completion_applied = self.goal_completion_applied or other.goal_completion_applied
154
+
155
+
109
156
  @dataclass
110
157
  class TurnEventStats:
111
158
  """Event counts accumulated over a single daemon turn.
@@ -139,6 +186,8 @@ class TurnEventStats:
139
186
  text_chunks: int = 0
140
187
  heartbeats_dropped: int = 0
141
188
  post_idle_drained: int = 0
189
+ inbound_dropped: int = 0
190
+ latency: TurnLatencyStats | None = None
142
191
 
143
192
  def record(
144
193
  self,
@@ -187,6 +236,11 @@ class TurnEventStats:
187
236
  self.heartbeats_dropped += other.heartbeats_dropped
188
237
  self.post_idle_drained += other.post_idle_drained
189
238
  self.filtered_early += other.filtered_early
239
+ self.inbound_dropped += other.inbound_dropped
240
+ if other.latency is not None:
241
+ if self.latency is None:
242
+ self.latency = TurnLatencyStats()
243
+ self.latency.merge(other.latency)
190
244
 
191
245
  def summary_line(self) -> str:
192
246
  """Return a one-line summary suitable for structured logging.
@@ -220,6 +274,12 @@ class TurnEventStats:
220
274
  detail_parts.append(f"{self.heartbeats_dropped} hb-drop")
221
275
  if self.post_idle_drained:
222
276
  detail_parts.append(f"{self.post_idle_drained} post-idle")
277
+ if self.inbound_dropped:
278
+ detail_parts.append(f"{self.inbound_dropped} inbound-drop")
279
+ if self.latency is not None and self.latency.time_to_first_chunk_ms is not None:
280
+ detail_parts.append(f"ttfc={self.latency.time_to_first_chunk_ms:.0f}ms")
281
+ if self.latency is not None and self.latency.synthesis_visible_ms is not None:
282
+ detail_parts.append(f"synth={self.latency.synthesis_visible_ms:.0f}ms")
223
283
  if detail_parts:
224
284
  parts.append("; ".join(detail_parts))
225
285
 
@@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
26
26
 
27
27
  # Match headless daemon client: brief read window after ``idle`` so stream events
28
28
  # that arrive slightly after status are not dropped (``cli/execution/daemon.py``).
29
- _POST_IDLE_DRAIN_DEADLINE_S = 2.5
29
+ _POST_IDLE_DRAIN_DEADLINE_S = 30.0
30
30
 
31
31
  # Align with ``bootstrap_loop_session`` daemon-ready wait (RFC-450 §8.2).
32
32
  _RPC_HANDSHAKE_TIMEOUT_S = 20.0
@@ -81,7 +81,6 @@ class TuiDaemonSession:
81
81
  self._client = WebSocketClient(url=ws_url)
82
82
  self._rpc_client = WebSocketClient(url=ws_url)
83
83
  self._loop_id: str | None = None
84
- self._autopilot_mode: str | None = None
85
84
  self._read_lock = asyncio.Lock()
86
85
  self._rpc_lock = asyncio.Lock()
87
86
  self._rpc_connected = False
@@ -91,17 +90,13 @@ class TuiDaemonSession:
91
90
  self.turn_event_stats = TurnEventStats()
92
91
  self.last_turn_end_state: str | None = None
93
92
  self.last_turn_cancellation_seen: bool = False
93
+ self.last_turn_error_message: str | None = None
94
94
 
95
95
  @property
96
96
  def loop_id(self) -> str | None:
97
97
  """Active StrangeLoop id for this WebSocket session."""
98
98
  return self._loop_id
99
99
 
100
- @property
101
- def autopilot_mode(self) -> str | None:
102
- """Active loop Solo/Autopilot mode when known."""
103
- return self._autopilot_mode
104
-
105
100
  async def connect(self, *, resume_loop_id: str | None = None) -> dict[str, Any]:
106
101
  """Connect and bootstrap a daemon loop session."""
107
102
  await connect_websocket_with_retries(self._client)
@@ -122,8 +117,6 @@ class TuiDaemonSession:
122
117
  if status_event.get("type") == "error":
123
118
  raise RuntimeError(str(status_event.get("message", "daemon bootstrap failed")))
124
119
  self._loop_id = status_event.get("loop_id")
125
- mode = status_event.get("autopilot_mode")
126
- self._autopilot_mode = str(mode) if mode in ("solo", "autopilot") else None
127
120
  return status_event
128
121
 
129
122
  def _resolve_stream_delivery_mode(self) -> str:
@@ -248,6 +241,14 @@ class TuiDaemonSession:
248
241
  """Ask the daemon to cancel the in-flight query (same wire path as ``/cancel``)."""
249
242
  await self._client.notify("slash_command", {"cmd": "/cancel"})
250
243
 
244
+ async def cancel_active_turn(self) -> None:
245
+ """Cancel the in-flight query on the active loop (IG-533 ordering contract).
246
+
247
+ Call before switching ``loop_id`` (e.g. ``/clear``) so synthesis on the
248
+ prior loop is torn down server-side instead of filtered client-side.
249
+ """
250
+ await self.cancel_remote_query()
251
+
251
252
  async def _drain_stream_events_after_idle(
252
253
  self,
253
254
  *,
@@ -316,6 +317,8 @@ class TuiDaemonSession:
316
317
  self.turn_event_stats = TurnEventStats()
317
318
  self.last_turn_end_state = None
318
319
  self.last_turn_cancellation_seen = False
320
+ self.last_turn_error_message = None
321
+ inbound_dropped_baseline = getattr(self._client, "inbound_dropped", 0)
319
322
  query_started = False
320
323
  expected_loop_id = self._loop_id
321
324
  self._streaming = True
@@ -431,8 +434,15 @@ class TuiDaemonSession:
431
434
  # Graph auto-resumes LangGraph interrupts server-side; keep consuming events.
432
435
  if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
433
436
  continue
437
+ except Exception as exc:
438
+ self.last_turn_error_message = str(exc)
439
+ raise
434
440
  finally:
435
441
  self._streaming = False
442
+ self.turn_event_stats.inbound_dropped = max(
443
+ 0,
444
+ getattr(self._client, "inbound_dropped", 0) - inbound_dropped_baseline,
445
+ )
436
446
 
437
447
  async def list_skills(self) -> list[dict[str, Any]]:
438
448
  """Return skill rows from the daemon catalog (no filesystem paths)."""
@@ -651,5 +661,16 @@ class TuiDaemonSession:
651
661
  return []
652
662
  return [m for m in raw if isinstance(m, dict)]
653
663
 
664
+ async def fetch_goal_completion_text(self, loop_id: str) -> str | None:
665
+ """Return the latest persisted ``goal_completion`` body for a loop, if any."""
666
+ rows = await self.fetch_conversation_log(loop_id, limit=200, include_events=False)
667
+ for row in reversed(rows):
668
+ if row.get("phase") != "goal_completion":
669
+ continue
670
+ text = row.get("text") or row.get("content") or ""
671
+ if isinstance(text, str) and text.strip():
672
+ return text.strip()
673
+ return None
674
+
654
675
 
655
676
  DaemonSession = TuiDaemonSession