soothe-cli 0.7.5__tar.gz → 0.7.7__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.5 → soothe_cli-0.7.7}/PKG-INFO +1 -1
  2. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/execution/daemon.py +1 -1
  3. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/policy/display_policy.py +3 -3
  4. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/presentation/renderer_protocol.py +1 -1
  5. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/presentation/subagent_task_display.py +10 -7
  6. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/state/file_tracker.py +62 -12
  7. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/transport/session.py +1 -1
  8. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/app/_history.py +2 -2
  9. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/app/_messages_mixin.py +27 -3
  10. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/app/app.tcss +0 -27
  11. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/binding.py +1 -1
  12. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/command_registry.py +7 -2
  13. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/commands/slash_commands.py +8 -2
  14. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/commands/subagent_routing.py +5 -4
  15. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/file_change_notify.py +13 -5
  16. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/file_change_renderers.py +72 -15
  17. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/input.py +8 -5
  18. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/textual_adapter.py +125 -51
  19. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/theme.py +3 -0
  20. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/chat_input.py +46 -3
  21. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/file_change_preview.py +17 -35
  22. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +18 -2
  23. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/cognition_step.py +1 -2
  24. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +21 -14
  25. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +1 -1
  26. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/diff_message.py +1 -20
  27. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/status.py +1 -1
  28. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/welcome.py +1 -1
  29. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/.gitignore +0 -0
  30. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/README.md +0 -0
  31. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/pyproject.toml +0 -0
  32. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/__init__.py +0 -0
  33. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/__init__.py +0 -0
  34. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/commands/__init__.py +0 -0
  35. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  36. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  37. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  38. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  39. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  40. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  41. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/execution/__init__.py +0 -0
  42. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  43. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/execution/headless.py +0 -0
  44. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  45. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/execution/launcher.py +0 -0
  46. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/cli/main.py +0 -0
  47. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/config/__init__.py +0 -0
  48. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/config/cli_config.py +0 -0
  49. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/config/loader.py +0 -0
  50. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/config/logging_setup.py +0 -0
  51. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/__init__.py +0 -0
  52. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/headless/processor.py +0 -0
  53. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  54. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  55. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  56. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  57. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  58. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  59. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  60. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  61. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  62. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  63. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/state/step_router.py +0 -0
  64. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  65. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/state/transcript.py +0 -0
  66. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  67. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  68. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  69. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/wire/display_text.py +0 -0
  70. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  71. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/runtime/wire/messages.py +0 -0
  72. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/__init__.py +0 -0
  73. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/_cli_context.py +0 -0
  74. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/_env_vars.py +0 -0
  75. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/_version.py +0 -0
  76. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/app/__init__.py +0 -0
  77. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/app/_app.py +0 -0
  78. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/app/_commands.py +0 -0
  79. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/app/_execution.py +0 -0
  80. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/app/_model.py +0 -0
  81. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/app/_module_init.py +0 -0
  82. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/app/_startup.py +0 -0
  83. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/app/_ui.py +0 -0
  84. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/commands/__init__.py +0 -0
  85. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/commands/command_router.py +0 -0
  86. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/config.py +0 -0
  87. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/hooks.py +0 -0
  88. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/markdown_theme.py +0 -0
  89. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/media_utils.py +0 -0
  90. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/model_config.py +0 -0
  91. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/path_utils.py +0 -0
  92. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/preview_limits.py +0 -0
  93. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/project_utils.py +0 -0
  94. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/sessions.py +0 -0
  95. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/shell_color.py +0 -0
  96. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/skills/__init__.py +0 -0
  97. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/skills/invocation.py +0 -0
  98. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/skills/load.py +0 -0
  99. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/spinner_labels.py +0 -0
  100. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/tips.py +0 -0
  101. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/tool_display.py +0 -0
  102. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/unicode_security.py +0 -0
  103. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/update_check.py +0 -0
  104. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  105. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/_links.py +0 -0
  106. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  107. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -0
  108. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  109. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/context_viewer.py +0 -0
  110. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/diff.py +0 -0
  111. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/editor.py +0 -0
  112. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
  113. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/history.py +0 -0
  114. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/loading.py +0 -0
  115. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  116. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  117. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  118. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  119. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
  120. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  121. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
  122. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  123. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  124. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  125. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  126. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  127. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  128. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  129. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +0 -0
  130. {soothe_cli-0.7.5 → soothe_cli-0.7.7}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.7.5
3
+ Version: 0.7.7
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,9 +134,9 @@ 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."""
@@ -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
 
@@ -8,15 +8,15 @@ from dataclasses import dataclass, field
8
8
  from pathlib import Path
9
9
  from typing import Any, Literal
10
10
 
11
+ from soothe_sdk.tools.metadata import get_file_write_tool_names
12
+
11
13
  from soothe_cli.tui.preview_limits import APPROVAL_DIFF_MAX_LINES
12
14
 
13
15
  logger = logging.getLogger(__name__)
14
16
 
15
17
  FileOpStatus = Literal["pending", "success", "error"]
16
18
 
17
- FILE_CHANGE_TOOLS: frozenset[str] = frozenset(
18
- {"write_file", "edit_file", "edit_file_lines", "delete_file"}
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
22
 
@@ -150,6 +150,22 @@ def resolve_physical_path(path_str: str | None, assistant_id: str | None) -> Pat
150
150
  return None
151
151
 
152
152
 
153
+ def parse_insert_line_arg(args: dict[str, Any]) -> int | None:
154
+ """Parse ``line`` from insert_lines tool args (1-indexed).
155
+
156
+ Returns:
157
+ Line number or None when missing or not an integer.
158
+ """
159
+ line = args.get("line")
160
+ if isinstance(line, bool):
161
+ return None
162
+ if isinstance(line, int):
163
+ return line
164
+ if isinstance(line, float) and line.is_integer():
165
+ return int(line)
166
+ return None
167
+
168
+
153
169
  def parse_line_range_args(args: dict[str, Any]) -> tuple[int, int] | None:
154
170
  """Parse ``start_line`` and ``end_line`` from tool args (1-indexed inclusive).
155
171
 
@@ -188,6 +204,26 @@ def extract_line_range_text(content: str, start_line: int, end_line: int) -> str
188
204
  return "".join(lines[start_line - 1 : end_line])
189
205
 
190
206
 
207
+ def apply_insert_lines_to_content(content: str, line: int, insert_content: str) -> str | None:
208
+ """Insert ``insert_content`` before line ``line`` (matches middleware semantics).
209
+
210
+ Returns:
211
+ Modified file text, or None when ``line`` is out of range for ``content``.
212
+ """
213
+ lines = content.splitlines(keepends=True)
214
+ total = len(lines)
215
+ if total == 0 and content:
216
+ lines = [content]
217
+ total = 1
218
+ if line < 1 or line > total + 1:
219
+ return None
220
+ new_lines = insert_content.splitlines(keepends=True)
221
+ if new_lines and not new_lines[-1].endswith("\n"):
222
+ new_lines[-1] += "\n"
223
+ lines[line - 1 : line - 1] = new_lines
224
+ return "".join(lines)
225
+
226
+
191
227
  def apply_edit_file_lines_to_content(
192
228
  content: str,
193
229
  start_line: int,
@@ -393,14 +429,28 @@ def track_file_operation(
393
429
  tracker.start_operation(tool_name, args, tcid)
394
430
 
395
431
 
396
- def file_change_action_label(record: FileOperationRecord) -> str:
397
- """Human-readable label for a completed file operation (chat diff header)."""
398
- if record.tool_name == "delete_file":
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"):
399
435
  return "Deleted"
400
- if record.tool_name == "write_file" and not (record.before_content or ""):
401
- return "New file"
402
- if record.tool_name == "write_file":
403
- return "Written"
404
- if record.tool_name in ("edit_file", "edit_file_lines"):
405
- return "Updated"
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"
406
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)
451
+
452
+
453
+ def file_change_action_label(record: FileOperationRecord) -> str:
454
+ """Human-readable label for a completed file operation (chat diff header)."""
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)
@@ -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
@@ -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
@@ -97,22 +97,11 @@ Screen {
97
97
  margin: 0;
98
98
  }
99
99
 
100
- .file-change-preview-label {
101
- text-style: bold;
102
- margin: 0;
103
- }
104
-
105
100
  .file-change-preview-body {
106
101
  color: $text-muted;
107
102
  margin: 0;
108
103
  }
109
104
 
110
- .file-change-preview Markdown {
111
- margin: 0;
112
- padding: 0;
113
- height: auto;
114
- }
115
-
116
105
  .file-change-preview-section-label {
117
106
  height: auto;
118
107
  margin: 0;
@@ -131,22 +120,6 @@ Screen {
131
120
  }
132
121
 
133
122
  /* Diff styling — previews and DiffMessage (fg + bg + padding) */
134
- .diff-removed {
135
- height: auto;
136
- color: $text-error;
137
- background: $error-muted;
138
- padding: 0;
139
- margin: 0;
140
- }
141
-
142
- .diff-added {
143
- height: auto;
144
- color: $text-success;
145
- background: $success-muted;
146
- padding: 0;
147
- margin: 0;
148
- }
149
-
150
123
  .diff-context {
151
124
  height: auto;
152
125
  color: $text-muted;
@@ -274,7 +274,7 @@ def message_from_widget(widget: Widget) -> MessageData:
274
274
  if isinstance(widget, FileChangePreviewWidget):
275
275
  path = str(widget.data.get("file_path") or widget.data.get("path") or "")
276
276
  summary = f"{widget._action_label}: {path}" if path else widget._action_label
277
- return MessageData(type=MessageType.APP, content=summary or "File change", id=widget_id)
277
+ return MessageData(type=MessageType.APP, content=summary or "Change", id=widget_id)
278
278
 
279
279
  if isinstance(widget, SummarizationMessage):
280
280
  return MessageData(
@@ -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
@@ -6,11 +6,13 @@ import asyncio
6
6
  import logging
7
7
  from typing import TYPE_CHECKING, Any
8
8
 
9
- from soothe_cli.runtime.state.file_tracker import FILE_CHANGE_TOOLS, parse_line_range_args
10
- from soothe_cli.tui.file_change_renderers import (
11
- build_file_change_preview,
12
- file_change_preview_label,
9
+ from soothe_cli.runtime.state.file_tracker import (
10
+ FILE_CHANGE_TOOLS,
11
+ file_change_label_from_preview_data,
12
+ parse_insert_line_arg,
13
+ parse_line_range_args,
13
14
  )
15
+ from soothe_cli.tui.file_change_renderers import build_file_change_preview
14
16
 
15
17
  if TYPE_CHECKING:
16
18
  from soothe_cli.runtime.state.file_tracker import FileOperationRecord
@@ -76,12 +78,18 @@ async def mount_file_change_preview(
76
78
  return
77
79
  if tool_name == "edit_file_lines" and parse_line_range_args(args) is None:
78
80
  return
81
+ if tool_name == "insert_lines" and parse_insert_line_arg(args) is None:
82
+ return
83
+ if tool_name == "delete_lines" and parse_line_range_args(args) is None:
84
+ return
85
+ if tool_name == "apply_diff" and not str(args.get("diff") or "").strip():
86
+ return
79
87
 
80
88
  built = build_file_change_preview(tool_name, args, assistant_id=assistant_id)
81
89
  if built is None:
82
90
  return
83
91
  widget_cls, data = built
84
- label = file_change_preview_label(tool_name)
92
+ label = file_change_label_from_preview_data(tool_name, data)
85
93
  try:
86
94
  widget = widget_cls(data, action_label=label)
87
95
  widget.id = textual_widget_id("file-preview", tcid)
@@ -7,7 +7,10 @@ from typing import Any
7
7
  from soothe_cli.runtime.state.file_tracker import (
8
8
  FILE_CHANGE_TOOLS,
9
9
  FileOperationRecord,
10
+ apply_edit_file_lines_to_content,
11
+ apply_insert_lines_to_content,
10
12
  extract_line_range_text,
13
+ parse_insert_line_arg,
11
14
  parse_line_range_args,
12
15
  read_physical_file_text,
13
16
  resolve_physical_path,
@@ -18,23 +21,11 @@ from soothe_cli.tui.widgets.file_change_preview import (
18
21
  EditFileLinesPreviewWidget,
19
22
  EditFilePreviewWidget,
20
23
  FileChangePreviewWidget,
21
- GenericFilePreviewWidget,
24
+ InsertLinesPreviewWidget,
22
25
  WriteFilePreviewWidget,
23
26
  unified_diff_body_lines,
24
27
  )
25
28
 
26
- _PREVIEW_LABELS: dict[str, str] = {
27
- "write_file": "Writing file",
28
- "edit_file": "Editing file",
29
- "edit_file_lines": "Editing lines",
30
- "delete_file": "Deleting file",
31
- }
32
-
33
-
34
- def file_change_preview_label(tool_name: str) -> str:
35
- """Short header label for a pending filesystem tool preview."""
36
- return _PREVIEW_LABELS.get(tool_name, "File change")
37
-
38
29
 
39
30
  def build_file_change_preview(
40
31
  tool_name: str,
@@ -92,7 +83,7 @@ def build_file_change_preview(
92
83
  if tool_name == "edit_file_lines":
93
84
  line_range = parse_line_range_args(args)
94
85
  if line_range is None:
95
- return GenericFilePreviewWidget, dict(args)
86
+ return None
96
87
  start_line, end_line = line_range
97
88
  new_string = str(args.get("new_content") or "")
98
89
  before = ""
@@ -109,6 +100,62 @@ def build_file_change_preview(
109
100
  "new_string": new_string,
110
101
  }
111
102
 
103
+ if tool_name == "insert_lines":
104
+ line = parse_insert_line_arg(args)
105
+ if line is None:
106
+ return None
107
+ insert_content = str(args.get("content") or "")
108
+ before = ""
109
+ physical = resolve_physical_path(path_str, assistant_id)
110
+ if physical and physical.is_file():
111
+ before = read_physical_file_text(physical) or ""
112
+ after_text = apply_insert_lines_to_content(before, line, insert_content) or before
113
+ return InsertLinesPreviewWidget, {
114
+ "file_path": path_str,
115
+ "insert_line": line,
116
+ "diff_lines": unified_diff_body_lines(before, after_text),
117
+ "old_string": before,
118
+ "new_string": after_text,
119
+ }
120
+
121
+ if tool_name == "delete_lines":
122
+ line_range = parse_line_range_args(args)
123
+ if line_range is None:
124
+ return None
125
+ start_line, end_line = line_range
126
+ before = ""
127
+ physical = resolve_physical_path(path_str, assistant_id)
128
+ if physical and physical.is_file():
129
+ before = read_physical_file_text(physical) or ""
130
+ old_segment = extract_line_range_text(before, start_line, end_line) if before else ""
131
+ after_text = (
132
+ apply_edit_file_lines_to_content(before, start_line, end_line, "") if before else ""
133
+ )
134
+ if after_text is None:
135
+ after_text = before
136
+ return EditFileLinesPreviewWidget, {
137
+ "file_path": path_str,
138
+ "start_line": start_line,
139
+ "end_line": end_line,
140
+ "diff_lines": unified_diff_body_lines(before, after_text),
141
+ "old_string": old_segment,
142
+ "new_string": "",
143
+ }
144
+
145
+ if tool_name == "apply_diff":
146
+ diff_text = str(args.get("diff") or "")
147
+ before = ""
148
+ physical = resolve_physical_path(path_str, assistant_id)
149
+ if physical and physical.is_file():
150
+ before = read_physical_file_text(physical) or ""
151
+ diff_lines = [ln for ln in diff_text.splitlines() if ln.strip()]
152
+ return EditFilePreviewWidget, {
153
+ "file_path": path_str,
154
+ "diff_lines": diff_lines,
155
+ "old_string": before,
156
+ "new_string": "",
157
+ }
158
+
112
159
  if tool_name == "delete_file":
113
160
  content = ""
114
161
  physical = resolve_physical_path(path_str, assistant_id)
@@ -124,7 +171,7 @@ def build_file_change_preview(
124
171
  "total_lines": len(lines),
125
172
  }
126
173
 
127
- return GenericFilePreviewWidget, dict(args)
174
+ return None
128
175
 
129
176
 
130
177
  def update_preview_data_from_record(data: dict[str, Any], record: FileOperationRecord) -> None:
@@ -161,6 +208,16 @@ def update_preview_data_from_record(data: dict[str, Any], record: FileOperationR
161
208
  if line_range is not None:
162
209
  data["start_line"], data["end_line"] = line_range
163
210
 
211
+ if record.tool_name == "insert_lines":
212
+ line = parse_insert_line_arg(record.args)
213
+ if line is not None:
214
+ data["insert_line"] = line
215
+
216
+ if record.tool_name == "delete_lines":
217
+ line_range = parse_line_range_args(record.args)
218
+ if line_range is not None:
219
+ data["start_line"], data["end_line"] = line_range
220
+
164
221
  if record.diff:
165
222
  data["diff_lines"] = record.diff.splitlines()
166
223
  data["old_string"] = record.before_content or ""
@@ -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}]"