soothe-cli 0.7.16__tar.gz → 0.7.17__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 (133) hide show
  1. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/PKG-INFO +1 -1
  2. soothe_cli-0.7.17/src/soothe_cli/runtime/presentation/id_format.py +37 -0
  3. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/file_tracker.py +353 -29
  4. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/transport/session.py +2 -0
  5. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/_cli_context.py +3 -0
  6. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_app.py +2 -0
  7. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_execution.py +18 -6
  8. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_history.py +14 -0
  9. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_messages_mixin.py +4 -4
  10. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_model.py +95 -1
  11. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/command_registry.py +6 -0
  12. soothe_cli-0.7.17/src/soothe_cli/tui/file_change_notify.py +277 -0
  13. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/textual_adapter.py +48 -53
  14. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/tips.py +1 -0
  15. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/context_viewer.py +2 -9
  16. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/file_change_preview.py +7 -4
  17. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/loop_selector.py +25 -5
  18. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -3
  19. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +108 -48
  20. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_step.py +14 -11
  21. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/user.py +6 -6
  22. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +23 -12
  23. soothe_cli-0.7.17/src/soothe_cli/tui/widgets/router_profile_selector.py +138 -0
  24. soothe_cli-0.7.16/src/soothe_cli/tui/file_change_notify.py +0 -124
  25. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/.gitignore +0 -0
  26. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/README.md +0 -0
  27. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/pyproject.toml +0 -0
  28. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/__init__.py +0 -0
  29. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/__init__.py +0 -0
  30. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/__init__.py +0 -0
  31. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  32. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  33. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  34. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  35. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  36. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  37. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/__init__.py +0 -0
  38. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/daemon.py +0 -0
  39. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  40. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/headless.py +0 -0
  41. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  42. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/launcher.py +0 -0
  43. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/cli/main.py +0 -0
  44. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/config/__init__.py +0 -0
  45. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/config/cli_config.py +0 -0
  46. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/config/loader.py +0 -0
  47. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/config/logging_setup.py +0 -0
  48. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/__init__.py +0 -0
  49. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/headless/processor.py +0 -0
  50. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  51. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  52. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  53. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  54. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  55. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  56. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  57. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  58. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  59. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  60. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  61. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
  62. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  63. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/step_router.py +0 -0
  64. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  65. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/transcript.py +0 -0
  66. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/token_events_debug.py +0 -0
  67. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/token_usage.py +0 -0
  68. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  69. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  70. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  71. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  72. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/runtime/wire/messages.py +0 -0
  73. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/__init__.py +0 -0
  74. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/_env_vars.py +0 -0
  75. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/_version.py +0 -0
  76. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/__init__.py +0 -0
  77. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_module_init.py +0 -0
  78. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_startup.py +0 -0
  79. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_ui.py +0 -0
  80. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/app.tcss +0 -0
  81. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/binding.py +0 -0
  82. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/commands/__init__.py +0 -0
  83. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/commands/command_router.py +0 -0
  84. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
  85. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  86. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/config.py +0 -0
  87. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  88. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/hooks.py +0 -0
  89. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/input.py +0 -0
  90. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/markdown_theme.py +0 -0
  91. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/media_utils.py +0 -0
  92. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/model_config.py +0 -0
  93. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/path_utils.py +0 -0
  94. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/preview_limits.py +0 -0
  95. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/project_utils.py +0 -0
  96. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/sessions.py +0 -0
  97. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/shell_color.py +0 -0
  98. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/skills/__init__.py +0 -0
  99. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/skills/invocation.py +0 -0
  100. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/skills/load.py +0 -0
  101. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/spinner_labels.py +0 -0
  102. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/theme.py +0 -0
  103. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/tool_display.py +0 -0
  104. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/unicode_security.py +0 -0
  105. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/update_check.py +0 -0
  106. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  107. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/_links.py +0 -0
  108. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  109. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  110. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  111. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/context_data.py +0 -0
  112. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/diff.py +0 -0
  113. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/editor.py +0 -0
  114. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
  115. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/history.py +0 -0
  116. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/loading.py +0 -0
  117. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  118. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  119. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  120. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  121. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
  122. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  123. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  124. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
  125. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
  126. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  127. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  128. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  129. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  130. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  131. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/status.py +0 -0
  132. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  133. {soothe_cli-0.7.16 → soothe_cli-0.7.17}/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.16
3
+ Version: 0.7.17
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
@@ -0,0 +1,37 @@
1
+ """Compact id formatting for CLI and TUI status lines."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def abbreviate_compact_id(
7
+ value: str,
8
+ *,
9
+ head: int = 8,
10
+ tail: int = 4,
11
+ max_len: int = 14,
12
+ empty: str = "",
13
+ ) -> str:
14
+ """Render a UUID-like id as ``prefix...suffix`` for compact UI surfaces.
15
+
16
+ Strips surrounding brackets and hyphens before measuring length. Values that
17
+ already contain ``...`` are returned unchanged (after hyphen stripping).
18
+
19
+ Args:
20
+ value: Raw id (loop id, etc.).
21
+ head: Prefix length when abbreviating.
22
+ tail: Suffix length when abbreviating.
23
+ max_len: Keep the compact form intact when at most this many characters.
24
+ empty: Returned when ``value`` is blank after stripping.
25
+
26
+ Returns:
27
+ Compact display string such as ``019f17e6...6543``.
28
+ """
29
+ raw = str(value or "").strip().strip("[]")
30
+ if not raw:
31
+ return empty
32
+ compact = raw.replace("-", "")
33
+ if "..." in compact:
34
+ return compact
35
+ if len(compact) <= max_len:
36
+ return compact
37
+ return f"{compact[:head]}...{compact[-tail:]}"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import difflib
6
6
  import logging
7
+ import re
7
8
  from dataclasses import dataclass, field
8
9
  from pathlib import Path
9
10
  from typing import Any, Literal
@@ -15,6 +16,7 @@ from soothe_cli.tui.preview_limits import APPROVAL_DIFF_MAX_LINES
15
16
  logger = logging.getLogger(__name__)
16
17
 
17
18
  FileOpStatus = Literal["pending", "success", "error"]
19
+ FileChangeLabelPhase = Literal["pending", "success"]
18
20
 
19
21
  FILE_CHANGE_TOOLS: frozenset[str] = get_file_write_tool_names()
20
22
  """Filesystem tools that produce before/after diffs in the TUI chat."""
@@ -266,6 +268,205 @@ def format_display_path(path_str: str | None) -> str:
266
268
  return str(path_str)
267
269
 
268
270
 
271
+ def _is_opaque_provider_call_fragment(tool_info: str) -> bool:
272
+ """True for provider opaque ids preserved as ``call_<uuid>`` fragments."""
273
+ info = (tool_info or "").strip()
274
+ return info.startswith("call_")
275
+
276
+
277
+ def _is_stream_predicted_tool_index(tool_info: str, tool_name: str) -> bool:
278
+ """True when ``tool_info`` looks like a stream-predicted ``{tool}:{idx}`` id."""
279
+ tname = str(tool_name or "").strip()
280
+ info = (tool_info or "").strip()
281
+ if not tname or not info:
282
+ return False
283
+ if ":" not in info:
284
+ return False
285
+ head, _, suffix = info.partition(":")
286
+ return head == tname and suffix.isdigit()
287
+
288
+
289
+ def file_change_tool_call_ids_match(
290
+ candidate_id: str,
291
+ lookup_id: str,
292
+ *,
293
+ tool_name: str,
294
+ ) -> bool:
295
+ """True when two tool_call_ids refer to the same logical file-change invocation.
296
+
297
+ Handles provider opaque ``call_*`` ids vs stream-predicted ``edit_file:N`` keys
298
+ on the same execute step (Kimi/OpenAI-style providers).
299
+ """
300
+ from soothe_sdk.display.message_processing import _pending_or_overlay_id_matches_lookup
301
+
302
+ cid = str(candidate_id).strip()
303
+ lid = str(lookup_id).strip()
304
+ if not cid or not lid:
305
+ return False
306
+ if cid == lid:
307
+ return True
308
+ if _pending_or_overlay_id_matches_lookup(cid, lid, tool_name=tool_name):
309
+ return True
310
+
311
+ from soothe_sdk.ux.task_namespace import parse_unified_tool_call_id
312
+
313
+ cand_step, cand_type, cand_tidx, cand_info = parse_unified_tool_call_id(cid)
314
+ lookup_step, lookup_type, lookup_tidx, lookup_info = parse_unified_tool_call_id(lid)
315
+ if not cand_step or not lookup_step or cand_step != lookup_step:
316
+ return False
317
+ if cand_type != lookup_type:
318
+ return False
319
+ if cand_type == "t" and cand_tidx != lookup_tidx:
320
+ return False
321
+
322
+ tname = str(tool_name or "").strip()
323
+ if not tname or tname == "tool":
324
+ return False
325
+
326
+ cand_opaque = _is_opaque_provider_call_fragment(cand_info)
327
+ lookup_opaque = _is_opaque_provider_call_fragment(lookup_info)
328
+ cand_predicted = _is_stream_predicted_tool_index(cand_info, tname)
329
+ lookup_predicted = _is_stream_predicted_tool_index(lookup_info, tname)
330
+ return (cand_opaque and lookup_predicted) or (lookup_opaque and cand_predicted)
331
+
332
+
333
+ def file_path_from_tool_message_content(content_text: str) -> str | None:
334
+ """Best-effort file path extraction from a filesystem tool result string."""
335
+ text = str(content_text or "").strip()
336
+ if not text:
337
+ return None
338
+ quoted = re.search(r"in '([^']+)'", text)
339
+ if quoted:
340
+ return quoted.group(1).strip()
341
+ return None
342
+
343
+
344
+ def resolve_active_file_operation(
345
+ tracker: FileOpTracker,
346
+ tool_call_id: str,
347
+ *,
348
+ tool_name: str,
349
+ content_text: str = "",
350
+ ) -> tuple[str | None, FileOperationRecord | None]:
351
+ """Resolve a pending :class:`FileOperationRecord` for a tool result id.
352
+
353
+ Returns:
354
+ ``(active_dict_key, record)`` when a unique match exists, else ``(None, None)``.
355
+ """
356
+ tcid = str(tool_call_id or "").strip()
357
+ if not tcid:
358
+ return None, None
359
+ direct = tracker.active.get(tcid)
360
+ if direct is not None:
361
+ return tcid, direct
362
+
363
+ tname = str(tool_name or "").strip()
364
+ msg_path = file_path_from_tool_message_content(content_text)
365
+ matches: list[tuple[str, FileOperationRecord]] = []
366
+ for key, record in tracker.active.items():
367
+ if not key:
368
+ continue
369
+ rname = str(record.tool_name or "").strip()
370
+ if tname and rname and tname != rname:
371
+ continue
372
+ alias_name = tname or rname
373
+ if not file_change_tool_call_ids_match(str(key), tcid, tool_name=alias_name):
374
+ continue
375
+ if msg_path:
376
+ rec_path = str(record.args.get("file_path") or record.args.get("path") or "").strip()
377
+ if rec_path and rec_path != msg_path:
378
+ continue
379
+ matches.append((str(key), record))
380
+
381
+ if len(matches) == 1:
382
+ return matches[0]
383
+ if len(matches) > 1:
384
+ logger.debug(
385
+ "Ambiguous file-change alias for tool_call_id=%s tool_name=%s candidates=%s",
386
+ tcid,
387
+ tname,
388
+ [key for key, _ in matches],
389
+ )
390
+ return None, None
391
+
392
+
393
+ def file_change_preview_alias_already_shown(
394
+ previews_shown: set[str],
395
+ tool_call_id: str,
396
+ *,
397
+ tool_name: str,
398
+ ) -> bool:
399
+ """Return True when a preview for this logical invocation is already mounted."""
400
+ tcid = str(tool_call_id or "").strip()
401
+ if not tcid:
402
+ return False
403
+ if tcid in previews_shown:
404
+ return True
405
+ return any(
406
+ file_change_tool_call_ids_match(existing, tcid, tool_name=tool_name)
407
+ for existing in previews_shown
408
+ )
409
+
410
+
411
+ def find_mounted_file_change_widget(
412
+ widgets: dict[str, Any],
413
+ tool_call_id: str,
414
+ *,
415
+ tool_name: str,
416
+ ) -> tuple[str | None, Any]:
417
+ """Locate a mounted preview widget, including stream/provider id aliases."""
418
+ tcid = str(tool_call_id or "").strip()
419
+ if not tcid:
420
+ return None, None
421
+ direct = widgets.get(tcid)
422
+ if direct is not None:
423
+ return tcid, direct
424
+ for key, widget in widgets.items():
425
+ if file_change_tool_call_ids_match(str(key), tcid, tool_name=tool_name):
426
+ return str(key), widget
427
+ return None, None
428
+
429
+
430
+ def file_change_operation_already_tracked(
431
+ active: dict[str | None, FileOperationRecord],
432
+ tool_call_id: str,
433
+ *,
434
+ tool_name: str,
435
+ ) -> bool:
436
+ """Return True when ``active`` already holds this logical file-change operation."""
437
+ tcid = str(tool_call_id or "").strip()
438
+ if not tcid:
439
+ return False
440
+ if tcid in active:
441
+ return True
442
+ return any(
443
+ file_change_tool_call_ids_match(str(key), tcid, tool_name=tool_name)
444
+ for key in active
445
+ if key
446
+ )
447
+
448
+
449
+ def _tool_message_content_text(tool_message: Any) -> str: # noqa: ANN401
450
+ """Flatten a LangChain tool message content payload to plain text."""
451
+ content = getattr(tool_message, "content", None)
452
+ if isinstance(content, list):
453
+ joined: list[str] = []
454
+ for item in content:
455
+ if isinstance(item, str):
456
+ joined.append(item)
457
+ else:
458
+ joined.append(str(item))
459
+ return "\n".join(joined)
460
+ return str(content) if content is not None else ""
461
+
462
+
463
+ def _tool_message_indicates_error(tool_message: Any, content_text: str) -> bool: # noqa: ANN401
464
+ """Match :meth:`FileOpTracker.complete_with_message` error detection."""
465
+ return getattr(
466
+ tool_message, "status", "success"
467
+ ) != "success" or content_text.lower().startswith("error")
468
+
469
+
269
470
  class FileOpTracker:
270
471
  """Collect file operation metrics during a CLI interaction."""
271
472
 
@@ -273,7 +474,8 @@ class FileOpTracker:
273
474
  """Initialize the tracker."""
274
475
  self.assistant_id = assistant_id
275
476
  self.active: dict[str | None, FileOperationRecord] = {}
276
- self.completed: list[FileOperationRecord] = []
477
+ self.recently_completed: dict[str, FileOperationRecord] = {}
478
+ """Completed records keyed by tool_call_id for late preview mounts."""
277
479
 
278
480
  def start_operation(
279
481
  self, tool_name: str, args: dict[str, Any], tool_call_id: str | None
@@ -305,29 +507,22 @@ class FileOpTracker:
305
507
  The completed FileOperationRecord, or None if no matching operation.
306
508
  """
307
509
  tool_call_id = getattr(tool_message, "tool_call_id", None)
308
- record = self.active.get(tool_call_id)
510
+ tool_name = str(getattr(tool_message, "name", "") or "").strip()
511
+ content_text = _tool_message_content_text(tool_message)
512
+
513
+ active_key, record = resolve_active_file_operation(
514
+ self,
515
+ str(tool_call_id or ""),
516
+ tool_name=tool_name,
517
+ content_text=content_text,
518
+ )
309
519
  if record is None:
310
520
  return None
311
521
 
312
- content = tool_message.content
313
- if isinstance(content, list):
314
- # Some tool messages may return list segments; join them for analysis.
315
- joined = []
316
- for item in content:
317
- if isinstance(item, str):
318
- joined.append(item)
319
- else:
320
- joined.append(str(item))
321
- content_text = "\n".join(joined)
322
- else:
323
- content_text = str(content) if content is not None else ""
324
-
325
- if getattr(
326
- tool_message, "status", "success"
327
- ) != "success" or content_text.lower().startswith("error"):
522
+ if _tool_message_indicates_error(tool_message, content_text):
328
523
  record.status = "error"
329
524
  record.error = content_text
330
- self._finalize(record)
525
+ self._finalize(record, active_key=active_key)
331
526
  return record
332
527
 
333
528
  record.status = "success"
@@ -357,7 +552,7 @@ class FileOpTracker:
357
552
  if record.after_content is None:
358
553
  record.status = "error"
359
554
  record.error = "Could not read updated file content."
360
- self._finalize(record)
555
+ self._finalize(record, active_key=active_key)
361
556
  return record
362
557
  record.metrics.lines_written = _count_lines(record.after_content)
363
558
 
@@ -399,7 +594,7 @@ class FileOpTracker:
399
594
  ):
400
595
  record.metrics.lines_added = max(record.metrics.lines_written - before_lines, 0)
401
596
 
402
- self._finalize(record)
597
+ self._finalize(record, active_key=active_key)
403
598
  return record
404
599
 
405
600
  def _populate_after_content(self, record: FileOperationRecord) -> None:
@@ -409,9 +604,106 @@ class FileOpTracker:
409
604
  return
410
605
  record.after_content = _safe_read(record.physical_path)
411
606
 
412
- def _finalize(self, record: FileOperationRecord) -> None:
413
- self.completed.append(record)
414
- self.active.pop(record.tool_call_id, None)
607
+ def _finalize(self, record: FileOperationRecord, *, active_key: str | None = None) -> None:
608
+ key = str(active_key or record.tool_call_id or "").strip()
609
+ if key:
610
+ self.active.pop(key, None)
611
+ self.recently_completed[key] = record
612
+ else:
613
+ self.active.pop(record.tool_call_id, None)
614
+ completed_id = str(record.tool_call_id or "").strip()
615
+ if completed_id:
616
+ self.recently_completed[completed_id] = record
617
+
618
+ def stash_untracked_file_change_result(
619
+ self,
620
+ tool_message: Any, # noqa: ANN401
621
+ ) -> FileOperationRecord | None:
622
+ """Remember a filesystem tool result that arrived before tracking started.
623
+
624
+ Used when the CLI receives ``ToolMessage`` before streamed args mount a
625
+ preview card. Late mounts consult :attr:`recently_completed`.
626
+
627
+ Returns:
628
+ The stashed record, or None when the message is not a file-change tool.
629
+ """
630
+ tool_name = str(getattr(tool_message, "name", "") or "").strip()
631
+ if tool_name not in FILE_CHANGE_TOOLS:
632
+ return None
633
+ tcid = str(getattr(tool_message, "tool_call_id", None) or "").strip()
634
+ if not tcid:
635
+ return None
636
+ existing = self.peek_recently_completed(tcid, tool_name=tool_name)
637
+ if existing is not None:
638
+ return existing
639
+
640
+ content_text = _tool_message_content_text(tool_message)
641
+ path_str = file_path_from_tool_message_content(content_text) or ""
642
+ record = FileOperationRecord(
643
+ tool_name=tool_name,
644
+ display_path=format_display_path(path_str),
645
+ physical_path=resolve_physical_path(path_str, self.assistant_id),
646
+ tool_call_id=tcid,
647
+ args={"file_path": path_str} if path_str else {},
648
+ )
649
+ if _tool_message_indicates_error(tool_message, content_text):
650
+ record.status = "error"
651
+ record.error = content_text
652
+ else:
653
+ record.status = "success"
654
+ if record.physical_path is not None:
655
+ after = _safe_read(record.physical_path)
656
+ if after is not None:
657
+ record.after_content = after
658
+ record.metrics.lines_written = _count_lines(after)
659
+ record.metrics.bytes_written = len(after.encode("utf-8"))
660
+ self.recently_completed[tcid] = record
661
+ return record
662
+
663
+ def _recently_completed_key(
664
+ self,
665
+ tool_call_id: str,
666
+ *,
667
+ tool_name: str,
668
+ ) -> str | None:
669
+ """Resolve the map key for a recently completed file-change id/alias."""
670
+ tcid = str(tool_call_id or "").strip()
671
+ if not tcid:
672
+ return None
673
+ if tcid in self.recently_completed:
674
+ return tcid
675
+ for key, record in self.recently_completed.items():
676
+ rname = str(record.tool_name or "").strip()
677
+ alias_name = str(tool_name or rname).strip()
678
+ if tool_name and rname and tool_name != rname:
679
+ continue
680
+ if file_change_tool_call_ids_match(str(key), tcid, tool_name=alias_name):
681
+ return str(key)
682
+ return None
683
+
684
+ def peek_recently_completed(
685
+ self,
686
+ tool_call_id: str,
687
+ *,
688
+ tool_name: str,
689
+ ) -> FileOperationRecord | None:
690
+ """Return a recently completed record without removing it."""
691
+ key = self._recently_completed_key(tool_call_id, tool_name=tool_name)
692
+ if key is None:
693
+ return None
694
+ return self.recently_completed.get(key)
695
+
696
+ def take_recently_completed(
697
+ self,
698
+ tool_call_id: str,
699
+ *,
700
+ tool_name: str,
701
+ ) -> FileOperationRecord | None:
702
+ """Pop a recently completed record for late preview finalization."""
703
+ key = self._recently_completed_key(tool_call_id, tool_name=tool_name)
704
+ if key is None:
705
+ return None
706
+ return self.recently_completed.pop(key, None)
415
707
 
416
708
 
417
709
  def track_file_operation(
@@ -424,13 +716,45 @@ def track_file_operation(
424
716
  if tool_name not in FILE_CHANGE_TOOLS:
425
717
  return
426
718
  tcid = str(tool_call_id).strip() if tool_call_id else ""
427
- if not tcid or tcid in tracker.active:
719
+ if not tcid:
720
+ return
721
+ # Result already landed (result-before-preview race): do not capture
722
+ # post-edit on-disk bytes as before_content.
723
+ if tracker.peek_recently_completed(tcid, tool_name=tool_name) is not None:
724
+ return
725
+ if file_change_operation_already_tracked(
726
+ tracker.active,
727
+ tcid,
728
+ tool_name=tool_name,
729
+ ):
428
730
  return
429
731
  tracker.start_operation(tool_name, args, tcid)
430
732
 
431
733
 
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."""
734
+ def file_change_label(
735
+ tool_name: str,
736
+ *,
737
+ is_new_file: bool = False,
738
+ phase: FileChangeLabelPhase = "success",
739
+ ) -> str:
740
+ """Single-word action prefix for a file-change card header.
741
+
742
+ ``pending`` is used while the tool is still running (progressive tense),
743
+ and ``success`` is used once the tool completes (past tense).
744
+ """
745
+ if phase == "pending":
746
+ if tool_name in ("delete_file", "delete_lines"):
747
+ return "Deleting"
748
+ if tool_name == "write_file":
749
+ return "Creating" if is_new_file else "Writing"
750
+ if tool_name in ("edit_file", "edit_file_lines"):
751
+ return "Editing"
752
+ if tool_name == "insert_lines":
753
+ return "Inserting"
754
+ if tool_name == "apply_diff":
755
+ return "Patching"
756
+ return "Changing"
757
+
434
758
  if tool_name in ("delete_file", "delete_lines"):
435
759
  return "Deleted"
436
760
  if tool_name == "write_file":
@@ -445,9 +769,9 @@ def file_change_label(tool_name: str, *, is_new_file: bool = False) -> str:
445
769
 
446
770
 
447
771
  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."""
772
+ """Derive an in-progress preview header label from renderer-built data."""
449
773
  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)
774
+ return file_change_label(tool_name, is_new_file=is_new, phase="pending")
451
775
 
452
776
 
453
777
  def file_change_action_label(record: FileOperationRecord) -> str:
@@ -215,6 +215,7 @@ class TuiDaemonSession:
215
215
  preferred_subagent: str | None = None,
216
216
  model: str | None = None,
217
217
  model_params: dict[str, Any] | None = None,
218
+ router_profile: str | None = None,
218
219
  attachments: list[dict[str, str]] | None = None,
219
220
  clarification_mode: str | None = None,
220
221
  clarification_answer: bool = False,
@@ -231,6 +232,7 @@ class TuiDaemonSession:
231
232
  preferred_subagent=preferred_subagent,
232
233
  model=model,
233
234
  model_params=model_params,
235
+ router_profile=router_profile,
234
236
  attachments=attachments,
235
237
  clarification_mode=clarification_mode,
236
238
  clarification_answer=clarification_answer,
@@ -25,3 +25,6 @@ class CLIContext(TypedDict, total=False):
25
25
  model_params: dict[str, Any]
26
26
  """Invocation params (e.g. `temperature`, `max_tokens`) to merge
27
27
  into `model_settings`."""
28
+
29
+ router_profile: str | None
30
+ """Named ``router_profiles`` entry for chat-role overlay this turn."""
@@ -213,6 +213,8 @@ class SootheApp(
213
213
 
214
214
  self._model_params_override: dict[str, Any] | None = None
215
215
 
216
+ self._router_profile_override: str | None = None
217
+
216
218
  # RFC-622: clarification relay mode. Seeded from --mode flag (CLIConfig);
217
219
  # default to Auto so loops keep moving when the user hasn't opted in.
218
220
  self._clarification_mode: str = getattr(daemon_config, "clarification_mode", None) or "auto"
@@ -542,12 +542,26 @@ class _ExecutionMixin:
542
542
  banner.update_loop_id(new_loop_id)
543
543
  except NoMatches:
544
544
  pass
545
- self._clear_loop_model_override()
545
+ self._clear_loop_session_overrides()
546
546
  await self._mount_message(AppMessage(f"Started new loop: {new_loop_id}"))
547
547
  elif cmd == "/editor":
548
548
  await self.action_open_editor()
549
549
  elif cmd == "/resume":
550
550
  await self._show_loop_selector()
551
+ elif cmd == "/model-router" or cmd.startswith("/model-router "):
552
+ await self._mount_message(UserMessage(command))
553
+ arg = ""
554
+ if cmd.startswith("/model-router "):
555
+ arg = command.strip()[len("/model-router ") :].strip()
556
+ if not arg:
557
+ await self._show_router_profile_selector()
558
+ elif arg == "--clear":
559
+ self._clear_loop_router_profile_override()
560
+ await self._mount_message(
561
+ AppMessage("Cleared loop model router; using config active profile.")
562
+ )
563
+ else:
564
+ await self._switch_router_profile(arg)
551
565
  elif cmd == "/update":
552
566
  await self._handle_update_command()
553
567
  elif cmd == "/auto-update":
@@ -767,11 +781,8 @@ class _ExecutionMixin:
767
781
  return
768
782
  self._agent_running = True
769
783
 
770
- # Cancel background event consumer so it doesn't compete for
771
- # WebSocket reads with the active turn's iter_turn_chunks().
772
- if self._bg_event_worker is not None:
773
- self._bg_event_worker.cancel()
774
- self._bg_event_worker = None
784
+ # Stop passive reads so active turn streaming has sole websocket access.
785
+ await self._stop_bg_event_worker(wait_timeout=2.0)
775
786
 
776
787
  if self._chat_input:
777
788
  self._chat_input.set_cursor_active(active=False)
@@ -840,6 +851,7 @@ class _ExecutionMixin:
840
851
  context=CLIContext(
841
852
  model=self._model_override,
842
853
  model_params=self._model_params_override or {},
854
+ router_profile=self._router_profile_override,
843
855
  ),
844
856
  turn_stats=turn_stats,
845
857
  skip_daemon_send_turn=skip_daemon_send_turn,
@@ -40,6 +40,20 @@ logger = logging.getLogger(__name__)
40
40
  class _HistoryMixin:
41
41
  """History conversion, loading, and daemon WebSocket event consumption."""
42
42
 
43
+ async def _stop_bg_event_worker(self, *, wait_timeout: float = 2.0) -> None:
44
+ """Cancel and await the passive daemon event worker, if running.
45
+
46
+ This keeps loop re-bootstrap and active turns from racing with an old
47
+ background reader on the same websocket.
48
+ """
49
+ worker = getattr(self, "_bg_event_worker", None)
50
+ if worker is None:
51
+ return
52
+ worker.cancel()
53
+ with suppress(Exception):
54
+ await asyncio.wait_for(worker.wait(), timeout=wait_timeout)
55
+ self._bg_event_worker = None
56
+
43
57
  # ------------------------------------------------------------------
44
58
  # Binder delegation (RFC-413).
45
59
  # Pure logic lives in `soothe_sdk.display.card_binder`; these wrappers
@@ -47,14 +47,14 @@ class _MessagesMixin:
47
47
  """Show queue interaction tips on the most recently queued goal only."""
48
48
  if not self._queued_widgets:
49
49
  return
50
- tip_index = -1
50
+ tip_index: int | None = None
51
51
  if self._pending_messages and self._pending_messages[-1].mode == "normal":
52
52
  pending_tail_index = len(self._pending_messages) - 1
53
53
  if pending_tail_index < len(self._queued_widgets):
54
54
  tip_index = pending_tail_index
55
55
  for index, widget in enumerate(self._queued_widgets):
56
56
  with suppress(Exception):
57
- widget.set_show_queue_tips(index == tip_index if tip_index >= 0 else False)
57
+ widget.set_show_queue_tips(tip_index is not None and index == tip_index)
58
58
 
59
59
  def _has_pending_chat_input(self) -> bool:
60
60
  """Return whether chat input has draft content that should be preserved."""
@@ -493,7 +493,7 @@ class _MessagesMixin:
493
493
  """Immediately stop in-flight step/goal UI when the user interrupts a turn.
494
494
 
495
495
  Daemon cancel and worker teardown can take several seconds; without an
496
- eager UI pass the thinking spinner and goal-tree running rows stay live.
496
+ eager UI pass the thinking spinner and plan running status stay live.
497
497
  """
498
498
  label = message or self._INTERRUPT_UI_MESSAGE
499
499
  adapter = getattr(self, "_ui_adapter", None)
@@ -522,7 +522,7 @@ class _MessagesMixin:
522
522
  """Stop in-flight UI and request daemon-side cancel.
523
523
 
524
524
  UI teardown runs first so Ctrl+C does not leave the thinking spinner or
525
- goal-tree running rows active while the daemon winds down (which can take
525
+ plan running status active while the daemon winds down (which can take
526
526
  several seconds on long execute steps).
527
527
 
528
528
  Args: