soothe-cli 0.7.6__tar.gz → 0.7.8__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 (130) hide show
  1. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/PKG-INFO +1 -1
  2. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/execution/daemon.py +1 -1
  3. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/policy/display_policy.py +3 -18
  4. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/presentation/renderer_protocol.py +1 -1
  5. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/presentation/subagent_task_display.py +10 -7
  6. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/state/file_tracker.py +21 -24
  7. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -3
  8. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/transport/session.py +1 -1
  9. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_app.py +0 -2
  10. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_execution.py +31 -0
  11. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_history.py +2 -2
  12. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_messages_mixin.py +27 -3
  13. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/app.tcss +0 -22
  14. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/command_registry.py +7 -2
  15. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/commands/slash_commands.py +8 -2
  16. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/commands/subagent_routing.py +5 -4
  17. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/config.py +0 -45
  18. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/file_change_notify.py +2 -2
  19. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/file_change_renderers.py +4 -5
  20. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/input.py +8 -5
  21. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/preview_limits.py +2 -5
  22. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/textual_adapter.py +106 -51
  23. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/chat_input.py +46 -3
  24. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/context_viewer.py +1 -1
  25. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -22
  26. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +181 -28
  27. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +1 -1
  28. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/diff_message.py +1 -20
  29. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +27 -1
  30. soothe_cli-0.7.6/src/soothe_cli/runtime/wire/display_text.py +0 -15
  31. soothe_cli-0.7.6/src/soothe_cli/tui/app/_commands.py +0 -50
  32. soothe_cli-0.7.6/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -341
  33. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/.gitignore +0 -0
  34. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/README.md +0 -0
  35. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/pyproject.toml +0 -0
  36. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/__init__.py +0 -0
  37. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/__init__.py +0 -0
  38. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/__init__.py +0 -0
  39. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  40. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  41. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  42. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  43. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  44. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  45. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/execution/__init__.py +0 -0
  46. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  47. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/execution/headless.py +0 -0
  48. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  49. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/execution/launcher.py +0 -0
  50. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/main.py +0 -0
  51. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/config/__init__.py +0 -0
  52. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/config/cli_config.py +0 -0
  53. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/config/loader.py +0 -0
  54. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/config/logging_setup.py +0 -0
  55. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/__init__.py +0 -0
  56. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/headless/processor.py +0 -0
  57. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  58. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  59. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  60. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  61. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  62. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  63. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  64. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  65. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  66. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  67. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/state/step_router.py +0 -0
  68. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/state/transcript.py +0 -0
  69. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  70. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  71. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  72. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  73. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/wire/messages.py +0 -0
  74. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/__init__.py +0 -0
  75. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/_cli_context.py +0 -0
  76. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/_env_vars.py +0 -0
  77. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/_version.py +0 -0
  78. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/__init__.py +0 -0
  79. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_model.py +0 -0
  80. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_module_init.py +0 -0
  81. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_startup.py +0 -0
  82. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_ui.py +0 -0
  83. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/binding.py +0 -0
  84. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/commands/__init__.py +0 -0
  85. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/commands/command_router.py +0 -0
  86. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/hooks.py +0 -0
  87. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/markdown_theme.py +0 -0
  88. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/media_utils.py +0 -0
  89. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/model_config.py +0 -0
  90. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/path_utils.py +0 -0
  91. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/project_utils.py +0 -0
  92. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/sessions.py +0 -0
  93. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/shell_color.py +0 -0
  94. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/skills/__init__.py +0 -0
  95. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/skills/invocation.py +0 -0
  96. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/skills/load.py +0 -0
  97. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/spinner_labels.py +0 -0
  98. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/theme.py +0 -0
  99. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/tips.py +0 -0
  100. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/tool_display.py +0 -0
  101. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/unicode_security.py +0 -0
  102. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/update_check.py +0 -0
  103. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  104. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/_links.py +0 -0
  105. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  106. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  107. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/diff.py +0 -0
  108. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/editor.py +0 -0
  109. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
  110. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/history.py +0 -0
  111. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/loading.py +0 -0
  112. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  113. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  114. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  115. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  116. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
  117. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  118. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
  119. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  120. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  121. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/cognition_step.py +0 -0
  122. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
  123. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  124. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  125. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  126. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  127. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  128. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/status.py +0 -0
  129. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  130. {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/welcome.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.7.6
3
+ Version: 0.7.8
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() + 30.0
283
+ drain_deadline = loop_clock.time() + 0.5
284
284
  while loop_clock.time() < drain_deadline:
285
285
  try:
286
286
  nxt = await asyncio.wait_for(client.read_event(), timeout=0.25)
@@ -134,33 +134,18 @@ class DisplayPolicy:
134
134
  """Check if this is a plan-related event."""
135
135
  return event_type.startswith("soothe.cognition.plan.")
136
136
 
137
- def is_tacitus_event(self, event_type: str) -> bool:
138
- """Check if this is a Tacitus subagent event."""
139
- return event_type.startswith("soothe.subagent.tacitus.")
137
+ def is_deep_research_event(self, event_type: str) -> bool:
138
+ """Check if this is a Deep Research subagent event."""
139
+ return event_type.startswith("soothe.subagent.deep_research.")
140
140
 
141
141
  def is_internal_event(self, event_type: str) -> bool:
142
142
  """Check if this is an internal (never-shown) event."""
143
143
  return event_type in INTERNAL_EVENT_TYPES or event_type.startswith("soothe.internal.")
144
144
 
145
145
 
146
- # =============================================================================
147
- # Factory Function
148
- # =============================================================================
149
-
150
-
151
- def create_display_policy() -> DisplayPolicy:
152
- """Create the shared display policy instance (single fixed UX tier)."""
153
- return DisplayPolicy()
154
-
155
-
156
- # =============================================================================
157
- # Exports
158
- # =============================================================================
159
-
160
146
  __all__ = [
161
147
  "INTERNAL_EVENT_TYPES",
162
148
  "SKIP_EVENT_TYPES",
163
149
  "DisplayPolicy",
164
150
  "VerbosityTier",
165
- "create_display_policy",
166
151
  ]
@@ -141,7 +141,7 @@ class RendererProtocol(Protocol):
141
141
  Catch-all for events not covered by specific callbacks.
142
142
 
143
143
  Args:
144
- event_type: Full event type string (e.g., ``soothe.subagent.tacitus.started``).
144
+ event_type: Full event type string (e.g., ``soothe.subagent.deep_research.started``).
145
145
  data: Event payload.
146
146
  namespace: Subagent namespace tuple (empty for main agent).
147
147
  task_scope: When subgraph streams are bound to a Task tool call (IG-334).
@@ -1,6 +1,6 @@
1
1
  """Format subagent Task-scope assistant blobs for CLI/TUI.
2
2
 
3
- Tacitus emits structured JSON in subgraph assistant streams. Clients show
3
+ Deep Research emits structured JSON in subgraph assistant streams. Clients show
4
4
  user-facing summaries only; internal planning JSON is suppressed.
5
5
  """
6
6
 
@@ -11,8 +11,8 @@ from collections.abc import Iterator
11
11
  from typing import Any
12
12
 
13
13
 
14
- def _is_tacitus_internal_json_object(obj: dict[str, Any]) -> bool:
15
- """True when ``obj`` is Tacitus engine scratch (not a user-facing report)."""
14
+ def _is_deep_research_internal_json_object(obj: dict[str, Any]) -> bool:
15
+ """True when ``obj`` is Deep Research engine scratch (not a user-facing report)."""
16
16
  keys = set(obj.keys())
17
17
  if "sub_questions" in keys:
18
18
  return True
@@ -89,21 +89,24 @@ def format_subagent_task_assistant_for_display(
89
89
 
90
90
  Args:
91
91
  raw: Full assistant text from a subgraph namespace.
92
- subagent_type: Built-in subagent id when known (``tacitus``, …).
92
+ subagent_type: Built-in subagent id when known (``deep_research``, …).
93
93
 
94
94
  Returns:
95
95
  Scrubbed one-line text, or ``""`` when only internal payloads remain.
96
96
  """
97
97
  agent = (subagent_type or "").strip().lower()
98
98
  text = raw
99
- if agent == "tacitus":
99
+ if agent in ("deep_research", "academic_research"):
100
100
  stripped = text.strip()
101
101
  internal_present = any(
102
- _is_tacitus_internal_json_object(obj) for obj in _iter_embedded_json_objects(stripped)
102
+ _is_deep_research_internal_json_object(obj)
103
+ for obj in _iter_embedded_json_objects(stripped)
103
104
  )
104
105
  if internal_present:
105
106
  return ""
106
- text = _strip_concatenated_json_objects(text, predicate=_is_tacitus_internal_json_object)
107
+ text = _strip_concatenated_json_objects(
108
+ text, predicate=_is_deep_research_internal_json_object
109
+ )
107
110
  return (text or "").strip()
108
111
 
109
112
 
@@ -19,16 +19,6 @@ FileOpStatus = Literal["pending", "success", "error"]
19
19
  FILE_CHANGE_TOOLS: frozenset[str] = get_file_write_tool_names()
20
20
  """Filesystem tools that produce before/after diffs in the TUI chat."""
21
21
 
22
- _FILE_CHANGE_PREVIEW_LABELS: dict[str, str] = {
23
- "write_file": "Writing",
24
- "edit_file": "Editing",
25
- "edit_file_lines": "Editing",
26
- "insert_lines": "Inserting",
27
- "delete_lines": "Deleting",
28
- "apply_diff": "Patching",
29
- "delete_file": "Deleting",
30
- }
31
-
32
22
 
33
23
  def _safe_read(path: Path) -> str | None:
34
24
  """Read file content, returning None on failure.
@@ -439,21 +429,28 @@ def track_file_operation(
439
429
  tracker.start_operation(tool_name, args, tcid)
440
430
 
441
431
 
442
- def file_change_preview_label(tool_name: str) -> str:
443
- """Short header label for a pending filesystem tool preview."""
444
- return _FILE_CHANGE_PREVIEW_LABELS.get(tool_name, "Change")
432
+ def file_change_label(tool_name: str, *, is_new_file: bool = False) -> str:
433
+ """Single-word past-tense prefix for a file-change card header."""
434
+ if tool_name in ("delete_file", "delete_lines"):
435
+ return "Deleted"
436
+ if tool_name == "write_file":
437
+ return "Created" if is_new_file else "Written"
438
+ if tool_name in ("edit_file", "edit_file_lines"):
439
+ return "Edited"
440
+ if tool_name == "insert_lines":
441
+ return "Inserted"
442
+ if tool_name == "apply_diff":
443
+ return "Patched"
444
+ return "Changed"
445
+
446
+
447
+ def file_change_label_from_preview_data(tool_name: str, data: dict[str, Any]) -> str:
448
+ """Derive a preview header label from renderer-built widget data."""
449
+ is_new = bool(data.get("is_new_file")) if tool_name == "write_file" else False
450
+ return file_change_label(tool_name, is_new_file=is_new)
445
451
 
446
452
 
447
453
  def file_change_action_label(record: FileOperationRecord) -> str:
448
454
  """Human-readable label for a completed file operation (chat diff header)."""
449
- if record.tool_name == "delete_file":
450
- return "Deleted"
451
- if record.tool_name == "write_file" and not (record.before_content or ""):
452
- return "Created"
453
- if record.tool_name == "write_file":
454
- return "Written"
455
- if record.tool_name in ("edit_file", "edit_file_lines", "delete_lines", "apply_diff"):
456
- return "Updated"
457
- if record.tool_name == "insert_lines":
458
- return "Inserted"
459
- return "Changed"
455
+ is_new = record.tool_name == "write_file" and not (record.before_content or "")
456
+ return file_change_label(record.tool_name, is_new_file=is_new)
@@ -139,6 +139,3 @@ class StreamingTextAccumulator:
139
139
  def clear(self) -> None:
140
140
  """Clear all accumulated state (call after finalizing)."""
141
141
  self.streams.clear()
142
-
143
-
144
- StreamAccumulator = StreamingTextAccumulator
@@ -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 = 30.0
29
+ _POST_IDLE_DRAIN_DEADLINE_S = 0.5
30
30
 
31
31
  # Align with ``bootstrap_loop_session`` daemon-ready wait (RFC-450 §8.2).
32
32
  _RPC_HANDSHAKE_TIMEOUT_S = 20.0
@@ -23,7 +23,6 @@ from textual.message import Message
23
23
 
24
24
  from soothe_cli.runtime.state.session_stats import SessionStats
25
25
  from soothe_cli.tui import theme
26
- from soothe_cli.tui.app._commands import _CommandsMixin
27
26
  from soothe_cli.tui.app._execution import _ExecutionMixin
28
27
  from soothe_cli.tui.app._history import _HistoryMixin
29
28
  from soothe_cli.tui.app._messages_mixin import _MessagesMixin
@@ -55,7 +54,6 @@ class SootheApp(
55
54
  App,
56
55
  _StartupMixin,
57
56
  _HistoryMixin,
58
- _CommandsMixin,
59
57
  _ModelMixin,
60
58
  _ExecutionMixin,
61
59
  _UIMixin,
@@ -450,6 +450,37 @@ class _ExecutionMixin:
450
450
  """
451
451
  return f"{prefix}: {loop_id}"
452
452
 
453
+ async def _get_conversation_token_count(self) -> int | None:
454
+ """Return the approximate conversation-only token count.
455
+
456
+ Returns:
457
+ Token count as an integer, or `None` if state is unavailable.
458
+ """
459
+ if not self._lc_loop_id:
460
+ return None
461
+ try:
462
+ from langchain_core.messages import messages_from_dict
463
+ from langchain_core.messages.utils import count_tokens_approximately
464
+
465
+ if self._daemon_session is None:
466
+ return None
467
+ snap = await self._daemon_session.aget_loop_state(self._lc_loop_id)
468
+ vals = getattr(snap, "values", None)
469
+ if not isinstance(vals, dict):
470
+ return None
471
+ raw = vals.get("messages")
472
+ if not isinstance(raw, list) or not raw:
473
+ return None
474
+ if isinstance(raw[0], dict):
475
+ messages = messages_from_dict(raw)
476
+ else:
477
+ messages = raw
478
+
479
+ return count_tokens_approximately(messages)
480
+ except Exception: # best-effort for /tokens display
481
+ logger.debug("Failed to retrieve conversation token count", exc_info=True)
482
+ return None
483
+
453
484
  async def _handle_command(self, command: str) -> None:
454
485
  """Handle a slash command.
455
486
 
@@ -24,7 +24,7 @@ from soothe_sdk.display.text_extract import (
24
24
  normalize_stream_message,
25
25
  )
26
26
  from soothe_sdk.display.transcript_types import MessageData
27
- from soothe_sdk.ux.loop_stream import is_goal_completion_stream_terminal
27
+ from soothe_sdk.ux.loop_stream import is_stream_terminal
28
28
  from textual.content import Content
29
29
 
30
30
  from soothe_cli.tui.app._module_init import _LoopHistoryPayload
@@ -339,7 +339,7 @@ class _HistoryMixin:
339
339
 
340
340
  if isinstance(message, (AIMessage, AIMessageChunk)):
341
341
  extracted = extract_ai_text_for_display(message)
342
- is_terminal = is_goal_completion_stream_terminal(message)
342
+ is_terminal = is_stream_terminal(message)
343
343
  if extracted:
344
344
  # Deduplicate immediate replayed AI chunks after reconnect/resubscribe.
345
345
  if last_ai_chunk_by_ns.get(ns_key) == extracted:
@@ -399,6 +399,23 @@ class _MessagesMixin:
399
399
  )
400
400
  )
401
401
 
402
+ _INTERRUPT_UI_MESSAGE = "Stream cancelled"
403
+
404
+ async def _tear_down_interrupt_ui(self, message: str | None = None) -> None:
405
+ """Immediately stop in-flight step/goal UI when the user interrupts a turn.
406
+
407
+ Daemon cancel and worker teardown can take several seconds; without an
408
+ eager UI pass the thinking spinner and goal-tree running rows stay live.
409
+ """
410
+ label = message or self._INTERRUPT_UI_MESSAGE
411
+ adapter = getattr(self, "_ui_adapter", None)
412
+ if adapter is not None:
413
+ if adapter._tool_to_step or adapter._tool_display_by_call_id:
414
+ adapter.finalize_pending_tools_with_error(label)
415
+ if adapter._current_step_messages or adapter._goal_tree_message is not None:
416
+ adapter.finalize_pending_steps_with_error(label)
417
+ await self._set_spinner(None)
418
+
402
419
  def _cancel_worker(self, worker: Worker[None] | None, *, discard_queue: bool = True) -> None:
403
420
  """Cancel an active worker, optionally discarding the pending message queue.
404
421
 
@@ -414,15 +431,17 @@ class _MessagesMixin:
414
431
  worker.cancel()
415
432
 
416
433
  async def _interrupt_daemon_agent_turn(self, *, discard_queue: bool = True) -> None:
417
- """Send daemon ``/cancel``, then cancel the local streaming worker.
434
+ """Stop in-flight UI, send daemon ``/cancel``, then cancel the local worker.
418
435
 
419
- Awaiting cancel first stops the server-side query; cancelling the worker
420
- alone would only detach the TUI from chunks while the daemon kept running.
436
+ UI teardown runs first so Ctrl+C does not leave the thinking spinner or
437
+ goal-tree running rows active while the daemon winds down (which can take
438
+ several seconds on long execute steps).
421
439
 
422
440
  Args:
423
441
  discard_queue: When ``False``, preserve queued user goals so they
424
442
  run after the cancelled turn finishes cleanup.
425
443
  """
444
+ await self._tear_down_interrupt_ui()
426
445
  session = self._daemon_session
427
446
  worker = self._agent_worker
428
447
  if session is not None:
@@ -483,6 +502,11 @@ class _MessagesMixin:
483
502
  group="daemon-interrupt",
484
503
  )
485
504
  else:
505
+ self.run_worker(
506
+ self._tear_down_interrupt_ui(),
507
+ exclusive=False,
508
+ group="interrupt-ui",
509
+ )
486
510
  self._cancel_worker(self._agent_worker, discard_queue=False)
487
511
  self._quit_pending = False
488
512
  return
@@ -102,12 +102,6 @@ Screen {
102
102
  margin: 0;
103
103
  }
104
104
 
105
- .file-change-preview Markdown {
106
- margin: 0;
107
- padding: 0;
108
- height: auto;
109
- }
110
-
111
105
  .file-change-preview-section-label {
112
106
  height: auto;
113
107
  margin: 0;
@@ -126,22 +120,6 @@ Screen {
126
120
  }
127
121
 
128
122
  /* Diff styling — previews and DiffMessage (fg + bg + padding) */
129
- .diff-removed {
130
- height: auto;
131
- color: $text-error;
132
- background: $error-muted;
133
- padding: 0;
134
- margin: 0;
135
- }
136
-
137
- .diff-added {
138
- height: auto;
139
- color: $text-success;
140
- background: $success-muted;
141
- padding: 0;
142
- margin: 0;
143
- }
144
-
145
123
  .diff-context {
146
124
  height: auto;
147
125
  color: $text-muted;
@@ -114,8 +114,13 @@ COMMANDS: tuple[SlashCommand, ...] = (
114
114
  hidden_keywords="continue history sessions loops",
115
115
  ),
116
116
  SlashCommand(
117
- name="/tacitus",
118
- description="Route prompt to Tacitus subagent (usage: /tacitus <query>)",
117
+ name="/deep_research",
118
+ description="Route prompt to deep_research subagent (usage: /deep_research <query>)",
119
+ bypass_tier=BypassTier.QUEUED,
120
+ ),
121
+ SlashCommand(
122
+ name="/academic_research",
123
+ description="Route prompt to academic_research subagent (usage: /academic_research <query>)",
119
124
  bypass_tier=BypassTier.QUEUED,
120
125
  ),
121
126
  SlashCommand(
@@ -274,10 +274,16 @@ COMMANDS: dict[str, dict[str, Any]] = {
274
274
  },
275
275
  # Daemon routing commands (3)
276
276
  "/plan": {"location": "daemon", "type": "routing", "description": "Trigger plan mode"},
277
- "/tacitus": {
277
+ "/deep_research": {
278
278
  "location": "daemon",
279
279
  "type": "routing",
280
- "description": "Route query to Tacitus subagent",
280
+ "description": "Route query to Deep Research subagent",
281
+ "requires_query": True,
282
+ },
283
+ "/academic_research": {
284
+ "location": "daemon",
285
+ "type": "routing",
286
+ "description": "Route query to Academic Research subagent",
281
287
  "requires_query": True,
282
288
  },
283
289
  }
@@ -5,12 +5,13 @@ from __future__ import annotations
5
5
  # Display names for known soothe core subagents (IG-517).
6
6
  SUBAGENT_DISPLAY_NAMES: dict[str, str] = {
7
7
  "planner": "Planner",
8
- "tacitus": "Tacitus",
8
+ "deep_research": "Deep Research",
9
+ "academic_research": "Academic Research",
9
10
  "browser_use": "Browser",
10
11
  }
11
12
 
12
13
  # Lowercase ids matched after ``/`` for preferred_subagent routing (core only).
13
- SUBAGENT_SLASH_ROUTE_IDS: tuple[str, ...] = ("tacitus",)
14
+ SUBAGENT_SLASH_ROUTE_IDS: tuple[str, ...] = ("deep_research", "academic_research")
14
15
 
15
16
  BUILTIN_SUBAGENT_NAMES: list[str] = list(SUBAGENT_SLASH_ROUTE_IDS)
16
17
 
@@ -33,7 +34,7 @@ def get_subagent_display_name(technical_name: str) -> str:
33
34
  def parse_subagent_from_input(user_input: str) -> tuple[str | None, str]:
34
35
  """Parse subagent subcommand from user input.
35
36
 
36
- Detects subagent routing commands (e.g. ``/tacitus``)
37
+ Detects subagent routing commands (e.g. ``/deep_research``)
37
38
  and extracts the subagent name along with the cleaned input text.
38
39
 
39
40
  Args:
@@ -45,7 +46,7 @@ def parse_subagent_from_input(user_input: str) -> tuple[str | None, str]:
45
46
  The subcommand is removed from ``cleaned_text``.
46
47
 
47
48
  Examples:
48
- ``"/tacitus check this"`` -> ``("tacitus", "check this")``
49
+ ``"/deep_research check this"`` -> ``("deep_research", "check this")``
49
50
  ``"hello world"`` -> ``(None, "hello world")``
50
51
  """
51
52
  first_match: tuple[int, str] | None = None
@@ -1242,51 +1242,6 @@ class Settings:
1242
1242
  return None
1243
1243
  return self.project_root / ".agents" / "skills"
1244
1244
 
1245
- @staticmethod
1246
- def get_user_claude_skills_dir() -> Path:
1247
- """Get user-level `~/.claude/skills/` directory (experimental).
1248
-
1249
- Convenience bridge for cross-tool skill sharing with Claude Code.
1250
- This is experimental and may be removed.
1251
-
1252
- Returns:
1253
- Path to `~/.claude/skills/`
1254
- """
1255
- return Path.home() / ".claude" / "skills"
1256
-
1257
- def get_project_claude_skills_dir(self) -> Path | None:
1258
- """Get project-level `.claude/skills/` directory (experimental).
1259
-
1260
- Convenience bridge for cross-tool skill sharing with Claude Code.
1261
- This is experimental and may be removed.
1262
-
1263
- Returns:
1264
- Path to `{project_root}/.claude/skills/`, or `None` if not in a project.
1265
- """
1266
- if not self.project_root:
1267
- return None
1268
- return self.project_root / ".claude" / "skills"
1269
-
1270
- @staticmethod
1271
- def get_built_in_skills_dir() -> Path:
1272
- """Get the directory containing built-in skills that ship with the CLI.
1273
-
1274
- Returns:
1275
- Path to the `built_in_skills/` directory within the package.
1276
- """
1277
- return Path(__file__).parent / "built_in_skills"
1278
-
1279
- def get_extra_skills_dirs(self) -> list[Path]:
1280
- """Get user-configured extra skill directories.
1281
-
1282
- Set via `SOOTHE_EXTRA_SKILLS_DIRS` (colon-separated paths) or
1283
- `[skills].extra_allowed_dirs` in `SOOTHE_HOME/config/config.yml`.
1284
-
1285
- Returns:
1286
- List of extra skill directory paths, or empty list if not configured.
1287
- """
1288
- return self.extra_skills_dirs or []
1289
-
1290
1245
 
1291
1246
  class SessionState:
1292
1247
  """Mutable session state shared across the app, adapter, and agent."""
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
8
8
 
9
9
  from soothe_cli.runtime.state.file_tracker import (
10
10
  FILE_CHANGE_TOOLS,
11
- file_change_preview_label,
11
+ file_change_label_from_preview_data,
12
12
  parse_insert_line_arg,
13
13
  parse_line_range_args,
14
14
  )
@@ -89,7 +89,7 @@ async def mount_file_change_preview(
89
89
  if built is None:
90
90
  return
91
91
  widget_cls, data = built
92
- label = file_change_preview_label(tool_name)
92
+ label = file_change_label_from_preview_data(tool_name, data)
93
93
  try:
94
94
  widget = widget_cls(data, action_label=label)
95
95
  widget.id = textual_widget_id("file-preview", tcid)
@@ -21,7 +21,6 @@ from soothe_cli.tui.widgets.file_change_preview import (
21
21
  EditFileLinesPreviewWidget,
22
22
  EditFilePreviewWidget,
23
23
  FileChangePreviewWidget,
24
- GenericFilePreviewWidget,
25
24
  InsertLinesPreviewWidget,
26
25
  WriteFilePreviewWidget,
27
26
  unified_diff_body_lines,
@@ -84,7 +83,7 @@ def build_file_change_preview(
84
83
  if tool_name == "edit_file_lines":
85
84
  line_range = parse_line_range_args(args)
86
85
  if line_range is None:
87
- return GenericFilePreviewWidget, dict(args)
86
+ return None
88
87
  start_line, end_line = line_range
89
88
  new_string = str(args.get("new_content") or "")
90
89
  before = ""
@@ -104,7 +103,7 @@ def build_file_change_preview(
104
103
  if tool_name == "insert_lines":
105
104
  line = parse_insert_line_arg(args)
106
105
  if line is None:
107
- return GenericFilePreviewWidget, dict(args)
106
+ return None
108
107
  insert_content = str(args.get("content") or "")
109
108
  before = ""
110
109
  physical = resolve_physical_path(path_str, assistant_id)
@@ -122,7 +121,7 @@ def build_file_change_preview(
122
121
  if tool_name == "delete_lines":
123
122
  line_range = parse_line_range_args(args)
124
123
  if line_range is None:
125
- return GenericFilePreviewWidget, dict(args)
124
+ return None
126
125
  start_line, end_line = line_range
127
126
  before = ""
128
127
  physical = resolve_physical_path(path_str, assistant_id)
@@ -172,7 +171,7 @@ def build_file_change_preview(
172
171
  "total_lines": len(lines),
173
172
  }
174
173
 
175
- return GenericFilePreviewWidget, dict(args)
174
+ return None
176
175
 
177
176
 
178
177
  def update_preview_data_from_record(data: dict[str, Any], record: FileOperationRecord) -> None:
@@ -839,21 +839,24 @@ def compose_paste_into_input(
839
839
  return f"{existing_text[:start]}{pasted_text}{existing_text[end:]}"
840
840
 
841
841
 
842
- def abbreviate_pasted_input_display(text: str) -> str:
843
- """Build a compact one-line preview for a large pasted payload.
842
+ def abbreviate_pasted_input_display(text: str, *, paste_index: int = 1) -> str:
843
+ """Build a compact paste token for large pasted payloads.
844
844
 
845
845
  The full ``text`` is retained separately for submission; this string is
846
846
  only for on-screen display in the input widget.
847
847
 
848
848
  Args:
849
849
  text: Full pasted content.
850
+ paste_index: 1-based counter for multiple pastes in the same input.
850
851
 
851
852
  Returns:
852
- Abbreviated display text containing only a summary header.
853
+ Abbreviated display token (e.g. ``[Pasted text #1 +35 lines]``).
853
854
  """
854
855
  lines = text.splitlines()
855
856
  if not lines and text:
856
857
  lines = [text]
857
858
  n_lines = len(lines) if lines else 1
858
- n_chars = len(text)
859
- return f"[pasted {n_lines} lines, {n_chars} characters]"
859
+ if n_lines > 1:
860
+ extra = n_lines - 1
861
+ return f"[Pasted text #{paste_index} +{extra} lines]"
862
+ return f"[Pasted text #{paste_index}]"
@@ -48,8 +48,5 @@ APPROVAL_WARNING_TEXT_TRUNCATE_CHARS: Final[int] = 220
48
48
  # --- Unified diff snippets in chat (`file_ops`, DiffMessage) ---
49
49
  APPROVAL_DIFF_MAX_LINES: Final[int] = 15
50
50
 
51
- # --- Autopilot dashboard (`autopilot_dashboard`) ---
52
- AUTOPILOT_GOAL_DESCRIPTION_PREVIEW_CHARS: Final[int] = 50
53
- AUTOPILOT_FINDING_LINE_PREVIEW_CHARS: Final[int] = 80
54
- AUTOPILOT_FINDINGS_VISIBLE_COUNT: Final[int] = 20
55
- AUTOPILOT_GRAPH_EDGE_PREVIEW_COUNT: Final[int] = 3
51
+ # --- Plan quick-view overlay (Ctrl+T goal tree step rows) ---
52
+ PLAN_QUICK_VIEW_STEP_LINE_MAX_CHARS: Final[int] = 76