soothe-cli 0.7.15__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.15 → 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.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/file_tracker.py +353 -29
  4. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/transport/session.py +2 -0
  5. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/_cli_context.py +3 -0
  6. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_app.py +2 -0
  7. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_execution.py +22 -6
  8. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_history.py +14 -0
  9. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_messages_mixin.py +128 -45
  10. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_model.py +95 -1
  11. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/command_registry.py +6 -0
  12. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/config.py +11 -5
  13. soothe_cli-0.7.17/src/soothe_cli/tui/file_change_notify.py +277 -0
  14. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/textual_adapter.py +48 -53
  15. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/theme.py +1 -1
  16. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/tips.py +1 -0
  17. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/chat_input.py +23 -0
  18. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/context_viewer.py +185 -55
  19. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/file_change_preview.py +9 -5
  20. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/loop_selector.py +33 -9
  21. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/_helpers.py +10 -10
  22. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/assistant.py +1 -1
  23. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +109 -49
  24. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +1 -1
  25. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_step.py +15 -12
  26. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +2 -0
  27. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/user.py +18 -1
  28. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +42 -6
  29. soothe_cli-0.7.17/src/soothe_cli/tui/widgets/router_profile_selector.py +138 -0
  30. soothe_cli-0.7.15/src/soothe_cli/tui/file_change_notify.py +0 -124
  31. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/.gitignore +0 -0
  32. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/README.md +0 -0
  33. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/pyproject.toml +0 -0
  34. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/__init__.py +0 -0
  35. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/__init__.py +0 -0
  36. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/__init__.py +0 -0
  37. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  38. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  39. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  40. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  41. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  42. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  43. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/__init__.py +0 -0
  44. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/daemon.py +0 -0
  45. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  46. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/headless.py +0 -0
  47. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  48. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/launcher.py +0 -0
  49. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/main.py +0 -0
  50. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/config/__init__.py +0 -0
  51. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/config/cli_config.py +0 -0
  52. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/config/loader.py +0 -0
  53. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/config/logging_setup.py +0 -0
  54. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/__init__.py +0 -0
  55. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/headless/processor.py +0 -0
  56. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  57. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  58. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  59. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  60. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  61. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  62. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  63. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  64. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  65. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  66. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  67. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
  68. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  69. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/step_router.py +0 -0
  70. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  71. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/transcript.py +0 -0
  72. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/token_events_debug.py +0 -0
  73. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/token_usage.py +0 -0
  74. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  75. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  76. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  77. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  78. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/wire/messages.py +0 -0
  79. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/__init__.py +0 -0
  80. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/_env_vars.py +0 -0
  81. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/_version.py +0 -0
  82. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/__init__.py +0 -0
  83. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_module_init.py +0 -0
  84. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_startup.py +0 -0
  85. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_ui.py +0 -0
  86. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/app.tcss +0 -0
  87. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/binding.py +0 -0
  88. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/commands/__init__.py +0 -0
  89. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/commands/command_router.py +0 -0
  90. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
  91. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  92. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  93. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/hooks.py +0 -0
  94. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/input.py +0 -0
  95. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/markdown_theme.py +0 -0
  96. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/media_utils.py +0 -0
  97. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/model_config.py +0 -0
  98. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/path_utils.py +0 -0
  99. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/preview_limits.py +0 -0
  100. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/project_utils.py +0 -0
  101. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/sessions.py +0 -0
  102. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/shell_color.py +0 -0
  103. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/skills/__init__.py +0 -0
  104. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/skills/invocation.py +0 -0
  105. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/skills/load.py +0 -0
  106. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/spinner_labels.py +0 -0
  107. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/tool_display.py +0 -0
  108. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/unicode_security.py +0 -0
  109. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/update_check.py +0 -0
  110. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  111. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/_links.py +0 -0
  112. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  113. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  114. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/context_data.py +0 -0
  115. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/diff.py +0 -0
  116. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/editor.py +0 -0
  117. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
  118. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/history.py +0 -0
  119. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/loading.py +0 -0
  120. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  121. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  122. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  123. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  124. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  125. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
  126. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  127. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  128. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  129. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  130. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  131. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/status.py +0 -0
  132. {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  133. {soothe_cli-0.7.15 → 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.15
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"
@@ -179,6 +179,7 @@ class _ExecutionMixin:
179
179
  self._pending_messages.append(QueuedMessage(text=value, mode=mode))
180
180
  queued_widget = QueuedUserMessage(value)
181
181
  self._queued_widgets.append(queued_widget)
182
+ self._refresh_queued_goal_tips()
182
183
  await self._mount_message(queued_widget)
183
184
  return
184
185
 
@@ -417,12 +418,14 @@ class _ExecutionMixin:
417
418
  if self._agent_running or self._shell_running:
418
419
  queued_widget = QueuedUserMessage(command)
419
420
  self._queued_widgets.append(queued_widget)
421
+ self._refresh_queued_goal_tips()
420
422
  await self._mount_message(queued_widget)
421
423
 
422
424
  async def _mount_output() -> None:
423
425
  # Remove the ephemeral queued widget, then mount real output.
424
426
  if queued_widget in self._queued_widgets:
425
427
  self._queued_widgets.remove(queued_widget)
428
+ self._refresh_queued_goal_tips()
426
429
  with suppress(Exception):
427
430
  await queued_widget.remove()
428
431
  await self._mount_message(UserMessage(command))
@@ -539,12 +542,26 @@ class _ExecutionMixin:
539
542
  banner.update_loop_id(new_loop_id)
540
543
  except NoMatches:
541
544
  pass
542
- self._clear_loop_model_override()
545
+ self._clear_loop_session_overrides()
543
546
  await self._mount_message(AppMessage(f"Started new loop: {new_loop_id}"))
544
547
  elif cmd == "/editor":
545
548
  await self.action_open_editor()
546
549
  elif cmd == "/resume":
547
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)
548
565
  elif cmd == "/update":
549
566
  await self._handle_update_command()
550
567
  elif cmd == "/auto-update":
@@ -764,11 +781,8 @@ class _ExecutionMixin:
764
781
  return
765
782
  self._agent_running = True
766
783
 
767
- # Cancel background event consumer so it doesn't compete for
768
- # WebSocket reads with the active turn's iter_turn_chunks().
769
- if self._bg_event_worker is not None:
770
- self._bg_event_worker.cancel()
771
- 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)
772
786
 
773
787
  if self._chat_input:
774
788
  self._chat_input.set_cursor_active(active=False)
@@ -837,6 +851,7 @@ class _ExecutionMixin:
837
851
  context=CLIContext(
838
852
  model=self._model_override,
839
853
  model_params=self._model_params_override or {},
854
+ router_profile=self._router_profile_override,
840
855
  ),
841
856
  turn_stats=turn_stats,
842
857
  skip_daemon_send_turn=skip_daemon_send_turn,
@@ -908,6 +923,7 @@ class _ExecutionMixin:
908
923
  if self._queued_widgets:
909
924
  widget = self._queued_widgets.popleft()
910
925
  await widget.remove()
926
+ self._refresh_queued_goal_tips()
911
927
 
912
928
  await self._process_message(msg.text, msg.mode)
913
929
  except Exception:
@@ -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