soothe-cli 0.7.4__tar.gz → 0.7.6__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.4 → soothe_cli-0.7.6}/.gitignore +6 -0
  2. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/PKG-INFO +1 -1
  3. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/state/file_tracker.py +58 -5
  4. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/app/app.tcss +0 -5
  5. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/binding.py +1 -1
  6. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/file_change_notify.py +11 -3
  7. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/file_change_renderers.py +70 -12
  8. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/preview_limits.py +2 -2
  9. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/textual_adapter.py +24 -1
  10. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/theme.py +3 -0
  11. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/file_change_preview.py +23 -19
  12. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/cognition_step.py +1 -2
  13. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +21 -14
  14. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/diff_message.py +1 -1
  15. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/status.py +1 -1
  16. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/welcome.py +1 -1
  17. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/README.md +0 -0
  18. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/pyproject.toml +0 -0
  19. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/__init__.py +0 -0
  20. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/__init__.py +0 -0
  21. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/commands/__init__.py +0 -0
  22. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  23. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  24. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  25. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  26. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  27. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  28. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/execution/__init__.py +0 -0
  29. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/execution/daemon.py +0 -0
  30. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  31. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/execution/headless.py +0 -0
  32. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  33. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/execution/launcher.py +0 -0
  34. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/cli/main.py +0 -0
  35. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/config/__init__.py +0 -0
  36. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/config/cli_config.py +0 -0
  37. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/config/loader.py +0 -0
  38. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/config/logging_setup.py +0 -0
  39. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/__init__.py +0 -0
  40. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/headless/processor.py +0 -0
  41. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  42. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  43. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  44. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  45. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  46. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  47. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  48. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  49. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  50. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  51. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  52. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
  53. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  54. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/state/step_router.py +0 -0
  55. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  56. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/state/transcript.py +0 -0
  57. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/transport/session.py +0 -0
  58. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  59. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  60. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  61. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/wire/display_text.py +0 -0
  62. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  63. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/runtime/wire/messages.py +0 -0
  64. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/__init__.py +0 -0
  65. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/_cli_context.py +0 -0
  66. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/_env_vars.py +0 -0
  67. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/_version.py +0 -0
  68. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/app/__init__.py +0 -0
  69. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/app/_app.py +0 -0
  70. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/app/_commands.py +0 -0
  71. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/app/_execution.py +0 -0
  72. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/app/_history.py +0 -0
  73. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/app/_messages_mixin.py +0 -0
  74. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/app/_model.py +0 -0
  75. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/app/_module_init.py +0 -0
  76. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/app/_startup.py +0 -0
  77. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/app/_ui.py +0 -0
  78. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/command_registry.py +0 -0
  79. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/commands/__init__.py +0 -0
  80. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/commands/command_router.py +0 -0
  81. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
  82. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  83. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/config.py +0 -0
  84. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/hooks.py +0 -0
  85. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/input.py +0 -0
  86. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/markdown_theme.py +0 -0
  87. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/media_utils.py +0 -0
  88. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/model_config.py +0 -0
  89. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/path_utils.py +0 -0
  90. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/project_utils.py +0 -0
  91. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/sessions.py +0 -0
  92. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/shell_color.py +0 -0
  93. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/skills/__init__.py +0 -0
  94. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/skills/invocation.py +0 -0
  95. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/skills/load.py +0 -0
  96. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/spinner_labels.py +0 -0
  97. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/tips.py +0 -0
  98. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/tool_display.py +0 -0
  99. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/unicode_security.py +0 -0
  100. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/update_check.py +0 -0
  101. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  102. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/_links.py +0 -0
  103. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  104. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -0
  105. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  106. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  107. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/context_viewer.py +0 -0
  108. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/diff.py +0 -0
  109. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/editor.py +0 -0
  110. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
  111. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/history.py +0 -0
  112. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/loading.py +0 -0
  113. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  114. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  115. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  116. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  117. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
  118. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  119. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
  120. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  121. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
  122. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  123. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
  124. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  125. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  126. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  127. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  128. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  129. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +0 -0
  130. {soothe_cli-0.7.4 → soothe_cli-0.7.6}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
@@ -1,3 +1,9 @@
1
+ # Jekyll / GitHub Pages local build output
2
+ _site/
3
+ .sass-cache/
4
+ .jekyll-cache/
5
+ .jekyll-metadata
6
+
1
7
  # Byte-compiled / optimized / DLL files
2
8
  __pycache__/
3
9
  *.py[codz]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.7.4
3
+ Version: 0.7.6
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
@@ -8,17 +8,27 @@ 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
+ _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
+
22
32
 
23
33
  def _safe_read(path: Path) -> str | None:
24
34
  """Read file content, returning None on failure.
@@ -150,6 +160,22 @@ def resolve_physical_path(path_str: str | None, assistant_id: str | None) -> Pat
150
160
  return None
151
161
 
152
162
 
163
+ def parse_insert_line_arg(args: dict[str, Any]) -> int | None:
164
+ """Parse ``line`` from insert_lines tool args (1-indexed).
165
+
166
+ Returns:
167
+ Line number or None when missing or not an integer.
168
+ """
169
+ line = args.get("line")
170
+ if isinstance(line, bool):
171
+ return None
172
+ if isinstance(line, int):
173
+ return line
174
+ if isinstance(line, float) and line.is_integer():
175
+ return int(line)
176
+ return None
177
+
178
+
153
179
  def parse_line_range_args(args: dict[str, Any]) -> tuple[int, int] | None:
154
180
  """Parse ``start_line`` and ``end_line`` from tool args (1-indexed inclusive).
155
181
 
@@ -188,6 +214,26 @@ def extract_line_range_text(content: str, start_line: int, end_line: int) -> str
188
214
  return "".join(lines[start_line - 1 : end_line])
189
215
 
190
216
 
217
+ def apply_insert_lines_to_content(content: str, line: int, insert_content: str) -> str | None:
218
+ """Insert ``insert_content`` before line ``line`` (matches middleware semantics).
219
+
220
+ Returns:
221
+ Modified file text, or None when ``line`` is out of range for ``content``.
222
+ """
223
+ lines = content.splitlines(keepends=True)
224
+ total = len(lines)
225
+ if total == 0 and content:
226
+ lines = [content]
227
+ total = 1
228
+ if line < 1 or line > total + 1:
229
+ return None
230
+ new_lines = insert_content.splitlines(keepends=True)
231
+ if new_lines and not new_lines[-1].endswith("\n"):
232
+ new_lines[-1] += "\n"
233
+ lines[line - 1 : line - 1] = new_lines
234
+ return "".join(lines)
235
+
236
+
191
237
  def apply_edit_file_lines_to_content(
192
238
  content: str,
193
239
  start_line: int,
@@ -393,14 +439,21 @@ def track_file_operation(
393
439
  tracker.start_operation(tool_name, args, tcid)
394
440
 
395
441
 
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")
445
+
446
+
396
447
  def file_change_action_label(record: FileOperationRecord) -> str:
397
448
  """Human-readable label for a completed file operation (chat diff header)."""
398
449
  if record.tool_name == "delete_file":
399
450
  return "Deleted"
400
451
  if record.tool_name == "write_file" and not (record.before_content or ""):
401
- return "New file"
452
+ return "Created"
402
453
  if record.tool_name == "write_file":
403
454
  return "Written"
404
- if record.tool_name in ("edit_file", "edit_file_lines"):
455
+ if record.tool_name in ("edit_file", "edit_file_lines", "delete_lines", "apply_diff"):
405
456
  return "Updated"
457
+ if record.tool_name == "insert_lines":
458
+ return "Inserted"
406
459
  return "Changed"
@@ -97,11 +97,6 @@ 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;
@@ -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(
@@ -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,
9
+ from soothe_cli.runtime.state.file_tracker import (
10
+ FILE_CHANGE_TOOLS,
12
11
  file_change_preview_label,
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,6 +78,12 @@ 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:
@@ -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,
@@ -19,22 +22,11 @@ from soothe_cli.tui.widgets.file_change_preview import (
19
22
  EditFilePreviewWidget,
20
23
  FileChangePreviewWidget,
21
24
  GenericFilePreviewWidget,
25
+ InsertLinesPreviewWidget,
22
26
  WriteFilePreviewWidget,
23
27
  unified_diff_body_lines,
24
28
  )
25
29
 
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
30
 
39
31
  def build_file_change_preview(
40
32
  tool_name: str,
@@ -109,6 +101,62 @@ def build_file_change_preview(
109
101
  "new_string": new_string,
110
102
  }
111
103
 
104
+ if tool_name == "insert_lines":
105
+ line = parse_insert_line_arg(args)
106
+ if line is None:
107
+ return GenericFilePreviewWidget, dict(args)
108
+ insert_content = str(args.get("content") or "")
109
+ before = ""
110
+ physical = resolve_physical_path(path_str, assistant_id)
111
+ if physical and physical.is_file():
112
+ before = read_physical_file_text(physical) or ""
113
+ after_text = apply_insert_lines_to_content(before, line, insert_content) or before
114
+ return InsertLinesPreviewWidget, {
115
+ "file_path": path_str,
116
+ "insert_line": line,
117
+ "diff_lines": unified_diff_body_lines(before, after_text),
118
+ "old_string": before,
119
+ "new_string": after_text,
120
+ }
121
+
122
+ if tool_name == "delete_lines":
123
+ line_range = parse_line_range_args(args)
124
+ if line_range is None:
125
+ return GenericFilePreviewWidget, dict(args)
126
+ start_line, end_line = line_range
127
+ before = ""
128
+ physical = resolve_physical_path(path_str, assistant_id)
129
+ if physical and physical.is_file():
130
+ before = read_physical_file_text(physical) or ""
131
+ old_segment = extract_line_range_text(before, start_line, end_line) if before else ""
132
+ after_text = (
133
+ apply_edit_file_lines_to_content(before, start_line, end_line, "") if before else ""
134
+ )
135
+ if after_text is None:
136
+ after_text = before
137
+ return EditFileLinesPreviewWidget, {
138
+ "file_path": path_str,
139
+ "start_line": start_line,
140
+ "end_line": end_line,
141
+ "diff_lines": unified_diff_body_lines(before, after_text),
142
+ "old_string": old_segment,
143
+ "new_string": "",
144
+ }
145
+
146
+ if tool_name == "apply_diff":
147
+ diff_text = str(args.get("diff") or "")
148
+ before = ""
149
+ physical = resolve_physical_path(path_str, assistant_id)
150
+ if physical and physical.is_file():
151
+ before = read_physical_file_text(physical) or ""
152
+ diff_lines = [ln for ln in diff_text.splitlines() if ln.strip()]
153
+ return EditFilePreviewWidget, {
154
+ "file_path": path_str,
155
+ "diff_lines": diff_lines,
156
+ "old_string": before,
157
+ "new_string": "",
158
+ }
159
+
112
160
  if tool_name == "delete_file":
113
161
  content = ""
114
162
  physical = resolve_physical_path(path_str, assistant_id)
@@ -161,6 +209,16 @@ def update_preview_data_from_record(data: dict[str, Any], record: FileOperationR
161
209
  if line_range is not None:
162
210
  data["start_line"], data["end_line"] = line_range
163
211
 
212
+ if record.tool_name == "insert_lines":
213
+ line = parse_insert_line_arg(record.args)
214
+ if line is not None:
215
+ data["insert_line"] = line
216
+
217
+ if record.tool_name == "delete_lines":
218
+ line_range = parse_line_range_args(record.args)
219
+ if line_range is not None:
220
+ data["start_line"], data["end_line"] = line_range
221
+
164
222
  if record.diff:
165
223
  data["diff_lines"] = record.diff.splitlines()
166
224
  data["old_string"] = record.before_content or ""
@@ -14,8 +14,8 @@ from typing import Final
14
14
  # When True, the full nested tool list also renders in `#step-cognition-tools`.
15
15
  STEP_CARD_SHOW_TOOL_ROW_DETAILS: Final[bool] = False
16
16
 
17
- # Latest per-tool invocation lines shown per scope (task branch vs main-agent branch).
18
- STEP_CARD_TOOL_ACTIVITY_PREVIEW_COUNT: Final[int] = 1
17
+ # Latest per-tool invocation lines on step and SubAgent cards (shared activity tree).
18
+ STEP_CARD_TOOL_ACTIVITY_PREVIEW_COUNT: Final[int] = 3
19
19
 
20
20
  # Optional manual full tool-list folding threshold (not auto-collapse).
21
21
  STEP_TASK_CARD_COLLAPSE_LINE_THRESHOLD: Final[int] = 3
@@ -290,6 +290,9 @@ class TextualUIAdapter:
290
290
  self._execute_wave_completed: int = 0
291
291
  """Completed steps in the current execute batch."""
292
292
 
293
+ self._successful_step_ids: set[str] = set()
294
+ """Step ids that finished successfully this loop (survives plan=keep replays)."""
295
+
293
296
  self._clarification_input_by_step: dict[str, ClarificationInputMessage] = {}
294
297
  """Active inline ``ClarificationInputMessage`` widgets keyed by step id.
295
298
 
@@ -1426,6 +1429,8 @@ async def sync_pending_step_cards_from_plan(
1426
1429
  sid = str(row.get("id", "")).strip()
1427
1430
  if not sid or sid in adapter._current_step_messages:
1428
1431
  continue
1432
+ if _is_successful_step_id(adapter, sid):
1433
+ continue
1429
1434
  desc = str(row.get("description", "")).strip() or "(step)"
1430
1435
  step_widget = CognitionStepMessage(
1431
1436
  step_id=sid,
@@ -1451,6 +1456,17 @@ def _step_card_lookup_keys(step_id: str) -> list[str]:
1451
1456
  return keys
1452
1457
 
1453
1458
 
1459
+ def _register_successful_step_id(adapter: TextualUIAdapter, step_id: str) -> None:
1460
+ """Remember a step that completed successfully so plan=keep does not remount it."""
1461
+ for key in _step_card_lookup_keys(step_id):
1462
+ adapter._successful_step_ids.add(key)
1463
+
1464
+
1465
+ def _is_successful_step_id(adapter: TextualUIAdapter, step_id: str) -> bool:
1466
+ """True when this step already succeeded earlier in the current loop."""
1467
+ return any(key in adapter._successful_step_ids for key in _step_card_lookup_keys(step_id))
1468
+
1469
+
1454
1470
  def _lookup_step_card(
1455
1471
  adapter: TextualUIAdapter,
1456
1472
  step_id: str,
@@ -2731,6 +2747,7 @@ async def execute_task_textual(
2731
2747
  router.reset_turn()
2732
2748
  adapter._execute_wave_total = 0
2733
2749
  adapter._execute_wave_completed = 0
2750
+ adapter._successful_step_ids.clear()
2734
2751
  ui_coalesce = TurnToolUiCoalescer()
2735
2752
  adapter._goal_completion_mounted_this_turn = False
2736
2753
  adapter._goal_tree_message = None
@@ -3742,7 +3759,11 @@ async def execute_task_textual(
3742
3759
  execution_mode = str(data.get("execution_mode", "")).strip()
3743
3760
  adapter._last_plan_execution_mode = execution_mode or None
3744
3761
  adapter._execute_wave_total = len(raw_steps)
3745
- adapter._execute_wave_completed = 0
3762
+ done_steps = int(data.get("done_steps", 0) or 0)
3763
+ adapter._execute_wave_completed = min(
3764
+ done_steps,
3765
+ len(raw_steps),
3766
+ )
3746
3767
  await sync_pending_step_cards_from_plan(
3747
3768
  adapter,
3748
3769
  steps=raw_steps,
@@ -3896,6 +3917,8 @@ async def execute_task_textual(
3896
3917
  pending_text_by_namespace[ns_key] = ""
3897
3918
  assistant_message_by_namespace.pop(ns_key, None)
3898
3919
  success = bool(data.get("success", True))
3920
+ if success:
3921
+ _register_successful_step_id(adapter, step_id)
3899
3922
  duration_ms = int(data.get("duration_ms", 0))
3900
3923
  tool_call_count = int(data.get("tool_call_count", 0))
3901
3924
  summary = str(
@@ -38,6 +38,9 @@ if TYPE_CHECKING:
38
38
 
39
39
  logger = logging.getLogger(__name__)
40
40
 
41
+ # Rich/Textual style for de-emphasized secondary labels (loop id, tool stats, activity).
42
+ SECONDARY_TEXT_STYLE: str = "dim"
43
+
41
44
  # ---------------------------------------------------------------------------
42
45
  # Brand palette — dark (originally tokyonight-inspired, LangChain blue primary)
43
46
  # ---------------------------------------------------------------------------
@@ -110,7 +110,7 @@ class FileChangePreviewWidget(Vertical):
110
110
  FileChangePreviewWidget {
111
111
  height: auto;
112
112
  padding: 0;
113
- margin: 0;
113
+ margin: 0 0 1 0;
114
114
  }
115
115
 
116
116
  FileChangePreviewWidget.-collapsed {
@@ -125,7 +125,6 @@ class FileChangePreviewWidget(Vertical):
125
125
 
126
126
  FileChangePreviewWidget.-collapsed .file-change-preview-body,
127
127
  FileChangePreviewWidget.-collapsed .file-change-preview-section-label,
128
- FileChangePreviewWidget.-collapsed .file-change-preview-label,
129
128
  FileChangePreviewWidget.-collapsed .diff-line-added,
130
129
  FileChangePreviewWidget.-collapsed .diff-line-removed,
131
130
  FileChangePreviewWidget.-collapsed .diff-context {
@@ -193,15 +192,6 @@ class FileChangePreviewWidget(Vertical):
193
192
  await self.mount(*self.compose())
194
193
  self._apply_expand_classes()
195
194
 
196
- def compose(self) -> ComposeResult: # noqa: PLR6301
197
- """Default compose — subclasses override."""
198
- if self._action_label:
199
- yield Static(
200
- Content.from_markup("[bold]$label[/bold]", label=self._action_label),
201
- classes="file-change-preview-label",
202
- )
203
- yield Static("Change details not available", classes="file-change-preview-body")
204
-
205
195
  def _yield_compact_header(
206
196
  self,
207
197
  file_path: str,
@@ -270,7 +260,7 @@ class WriteFilePreviewWidget(FileChangePreviewWidget):
270
260
  total_lines = len(lines)
271
261
 
272
262
  if is_new_file:
273
- extra = "new file" if not self._finalized else ""
263
+ extra = "new" if not self._finalized else ""
274
264
  yield from self._yield_compact_header(file_path, extra=extra)
275
265
  else:
276
266
  yield from self._yield_compact_header(
@@ -358,9 +348,14 @@ class EditFilePreviewWidget(FileChangePreviewWidget):
358
348
 
359
349
  def compose(self) -> ComposeResult:
360
350
  """Compose diff preview."""
361
- yield from self._compose_edit_diff_body(show_line_range=False)
351
+ yield from self._compose_edit_diff_body()
362
352
 
363
- def _compose_edit_diff_body(self, *, show_line_range: bool) -> ComposeResult:
353
+ def _compose_edit_diff_body(
354
+ self,
355
+ *,
356
+ show_line_range: bool = False,
357
+ show_insert_line: bool = False,
358
+ ) -> ComposeResult:
364
359
  file_path = self.data.get("file_path", "")
365
360
  diff_lines = self.data.get("diff_lines", [])
366
361
  old_string = self.data.get("old_string", "")
@@ -373,6 +368,10 @@ class EditFilePreviewWidget(FileChangePreviewWidget):
373
368
  end_line = self.data.get("end_line")
374
369
  if isinstance(start_line, int) and isinstance(end_line, int):
375
370
  extra = f"lines {start_line}–{end_line}"
371
+ elif show_insert_line:
372
+ insert_line = self.data.get("insert_line")
373
+ if isinstance(insert_line, int):
374
+ extra = f"at line {insert_line}"
376
375
  yield from self._yield_compact_header(
377
376
  file_path,
378
377
  additions=additions,
@@ -404,13 +403,21 @@ class EditFilePreviewWidget(FileChangePreviewWidget):
404
403
 
405
404
 
406
405
  class EditFileLinesPreviewWidget(EditFilePreviewWidget):
407
- """Preview for edit_file_lines — line range + segment diff."""
406
+ """Preview for edit_file_lines / delete_lines — line range + segment diff."""
408
407
 
409
408
  def compose(self) -> ComposeResult:
410
409
  """Compose line-range edit preview."""
411
410
  yield from self._compose_edit_diff_body(show_line_range=True)
412
411
 
413
412
 
413
+ class InsertLinesPreviewWidget(EditFilePreviewWidget):
414
+ """Preview for insert_lines — insertion point + file diff."""
415
+
416
+ def compose(self) -> ComposeResult:
417
+ """Compose insert-at-line preview."""
418
+ yield from self._compose_edit_diff_body(show_insert_line=True)
419
+
420
+
414
421
  class GenericFilePreviewWidget(FileChangePreviewWidget):
415
422
  """Fallback preview — key/value args."""
416
423
 
@@ -419,10 +426,7 @@ class GenericFilePreviewWidget(FileChangePreviewWidget):
419
426
  if file_path:
420
427
  yield from self._yield_compact_header(file_path)
421
428
  elif self._action_label:
422
- yield Static(
423
- Content.from_markup("[bold]$label[/bold]", label=self._action_label),
424
- classes="file-change-preview-header",
425
- )
429
+ yield from self._yield_compact_header("")
426
430
  for key, value in self.data.items():
427
431
  if value is None:
428
432
  continue
@@ -561,10 +561,9 @@ class CognitionStepMessage(Vertical):
561
561
  colors = theme.DARK_COLORS
562
562
  icon = g.checkmark if success else g.error
563
563
  head_tone = colors.card_success if success else colors.card_error
564
- suffix_tone = colors.cognition
565
564
  parts: list[object] = [Content.styled(f"{gutter}{icon} {head}", head_tone)]
566
565
  if suffix:
567
- parts.append(Content.styled(suffix, suffix_tone))
566
+ parts.append(Content.styled(suffix, theme.SECONDARY_TEXT_STYLE))
568
567
  self._status_widget.update(Content.assemble(*parts))
569
568
  self._status_widget.display = True
570
569
 
@@ -20,6 +20,7 @@ from soothe_sdk.ux.task_namespace import (
20
20
  from textual.content import Content
21
21
 
22
22
  from soothe_cli.runtime.presentation.duration_format import format_duration
23
+ from soothe_cli.tui import theme
23
24
  from soothe_cli.tui.commands.subagent_routing import get_subagent_display_name
24
25
  from soothe_cli.tui.preview_limits import STEP_CARD_TOOL_ACTIVITY_PREVIEW_COUNT
25
26
  from soothe_cli.tui.tool_display import (
@@ -348,13 +349,9 @@ def phase_icon(
348
349
  def task_tool_row_tone_for_phase(phase: str, colors: Any) -> str:
349
350
  """Textual style for a tool row or task label from lifecycle phase."""
350
351
  p = (phase or "pending").strip().lower()
351
- if p in ("success", "done"):
352
- return colors.cognition
353
352
  if p in ("error", "rejected", "failed"):
354
353
  return colors.error
355
- if p == "running":
356
- return colors.cognition
357
- return colors.muted
354
+ return theme.SECONDARY_TEXT_STYLE
358
355
 
359
356
 
360
357
  def task_tool_row_tone(row: StepToolRow, colors: Any) -> str:
@@ -441,7 +438,7 @@ class StepCardStatusLine:
441
438
  tail = f"{stats_suffix}{token_suffix}"
442
439
  parts: list[object] = [Content.styled(head, colors.warning)]
443
440
  if tail:
444
- parts.append(Content.styled(tail, colors.cognition))
441
+ parts.append(Content.styled(tail, theme.SECONDARY_TEXT_STYLE))
445
442
  return Content.assemble(*parts)
446
443
 
447
444
  @staticmethod
@@ -454,8 +451,12 @@ class StepCardStatusLine:
454
451
  colors: Any,
455
452
  ) -> Content:
456
453
  """Step card footer for planned steps not yet executing."""
457
- line = f"{gutter}{circle_empty} Pending...{stats_suffix}{token_suffix}"
458
- return Content.styled(line, colors.cognition)
454
+ head = f"{gutter}{circle_empty} Pending..."
455
+ tail = f"{stats_suffix}{token_suffix}"
456
+ parts: list[object] = [Content.styled(head, colors.cognition)]
457
+ if tail:
458
+ parts.append(Content.styled(tail, theme.SECONDARY_TEXT_STYLE))
459
+ return Content.assemble(*parts)
459
460
 
460
461
  @staticmethod
461
462
  def footer_queued(
@@ -467,8 +468,12 @@ class StepCardStatusLine:
467
468
  colors: Any,
468
469
  ) -> Content:
469
470
  """Step card footer for ready steps waiting for a concurrency slot."""
470
- line = f"{gutter}{circle_empty} Queued...{stats_suffix}{token_suffix}"
471
- return Content.styled(line, colors.cognition)
471
+ head = f"{gutter}{circle_empty} Queued..."
472
+ tail = f"{stats_suffix}{token_suffix}"
473
+ parts: list[object] = [Content.styled(head, colors.cognition)]
474
+ if tail:
475
+ parts.append(Content.styled(tail, theme.SECONDARY_TEXT_STYLE))
476
+ return Content.assemble(*parts)
472
477
 
473
478
 
474
479
  # Activity tree renderer
@@ -523,7 +528,7 @@ class StepActivityTree:
523
528
  parts.append(
524
529
  Content.styled(
525
530
  f"{branch_gutter}{task_icon} {label}",
526
- task_tone if task_phase != "pending" else colors.foreground,
531
+ task_tone,
527
532
  )
528
533
  )
529
534
 
@@ -533,7 +538,7 @@ class StepActivityTree:
533
538
  if not text:
534
539
  continue
535
540
  parts.append("\n")
536
- parts.append(Content.styled(f"{branch_gutter}{text}", colors.muted))
541
+ parts.append(Content.styled(f"{branch_gutter}{text}", theme.SECONDARY_TEXT_STYLE))
537
542
 
538
543
  # Main-agent tool preview
539
544
  if main_preview:
@@ -551,7 +556,9 @@ class StepActivityTree:
551
556
  if hidden_tools > 0:
552
557
  label = f"+{hidden_tools} more tool{'s' if hidden_tools != 1 else ''}"
553
558
  parts.append("\n")
554
- parts.append(Content.styled(f"{branch_gutter}· {label}", colors.muted))
559
+ parts.append(
560
+ Content.styled(f"{branch_gutter}· {label}", theme.SECONDARY_TEXT_STYLE)
561
+ )
555
562
 
556
563
  # Global subagent notes
557
564
  for note in subagent_notes:
@@ -561,6 +568,6 @@ class StepActivityTree:
561
568
  if not first_block:
562
569
  parts.append("\n")
563
570
  first_block = False
564
- parts.append(Content.styled(f"{branch_gutter}{t}", colors.muted))
571
+ parts.append(Content.styled(f"{branch_gutter}{t}", theme.SECONDARY_TEXT_STYLE))
565
572
 
566
573
  return Content.assemble(*parts) if parts else Content("")
@@ -26,7 +26,7 @@ class DiffMessage(Static):
26
26
  DiffMessage {
27
27
  height: auto;
28
28
  padding: 0 1;
29
- margin: 0;
29
+ margin: 0 0 1 0;
30
30
  background: $surface;
31
31
  border: solid $primary;
32
32
  }
@@ -297,7 +297,7 @@ class StatusBar(Horizontal):
297
297
  except NoMatches:
298
298
  return
299
299
  text = (new_value or "").strip()
300
- tip_widget.update(f"Tips: {text}" if text else "")
300
+ tip_widget.update(f"Tip: {text}" if text else "")
301
301
 
302
302
  def watch_cwd(self, new_value: str) -> None:
303
303
  """Update cwd display when it changes."""
@@ -144,7 +144,7 @@ class WelcomeBanner(Static):
144
144
  def _build_metadata_line(self, *, colors: theme.ThemeColors, ansi: bool) -> Content | None:
145
145
  """Build the single metadata row: loop id plus version or update notice."""
146
146
  segments: list[str | tuple[str, str | TStyle]] = []
147
- dim_style: str | TStyle = "dim"
147
+ dim_style: str | TStyle = theme.SECONDARY_TEXT_STYLE
148
148
 
149
149
  if self._cli_loop_id:
150
150
  segments.append((f"Loop: {self._cli_loop_id}", dim_style))
File without changes
File without changes