soothe-cli 0.7.16__tar.gz → 0.7.18__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.
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/PKG-INFO +1 -1
- soothe_cli-0.7.18/src/soothe_cli/runtime/presentation/id_format.py +37 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/state/file_tracker.py +355 -31
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/transport/session.py +2 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/_cli_context.py +3 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/app/_app.py +2 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/app/_execution.py +18 -6
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/app/_history.py +14 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/app/_messages_mixin.py +4 -4
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/app/_model.py +95 -1
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/command_registry.py +6 -0
- soothe_cli-0.7.18/src/soothe_cli/tui/file_change_notify.py +277 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/file_change_renderers.py +4 -6
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/textual_adapter.py +48 -53
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/tips.py +1 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/context_viewer.py +2 -9
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/file_change_preview.py +8 -5
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/loop_selector.py +25 -5
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -3
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +109 -48
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/cognition_step.py +14 -11
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/user.py +6 -6
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +23 -12
- soothe_cli-0.7.18/src/soothe_cli/tui/widgets/router_profile_selector.py +138 -0
- soothe_cli-0.7.16/src/soothe_cli/tui/file_change_notify.py +0 -124
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/.gitignore +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/README.md +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/pyproject.toml +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/__init__.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/__init__.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/commands/__init__.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/execution/__init__.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/execution/daemon.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/execution/headless.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/execution/launcher.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/cli/main.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/config/__init__.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/config/cli_config.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/config/loader.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/config/logging_setup.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/__init__.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/headless/processor.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/presentation/engine.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/state/session_stats.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/state/step_router.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/state/transcript.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/token_events_debug.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/token_usage.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/turn/prepare.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/wire/message_text.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/runtime/wire/messages.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/__init__.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/_env_vars.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/_version.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/app/__init__.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/app/_module_init.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/app/_startup.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/app/_ui.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/app/app.tcss +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/binding.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/commands/__init__.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/commands/command_router.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/config.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/hooks.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/input.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/markdown_theme.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/media_utils.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/model_config.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/path_utils.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/preview_limits.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/project_utils.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/sessions.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/shell_color.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/skills/__init__.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/skills/invocation.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/skills/load.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/spinner_labels.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/theme.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/tool_display.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/unicode_security.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/update_check.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/__init__.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/_links.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/context_data.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/diff.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/editor.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/history.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/loading.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/message_store.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/status.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
- {soothe_cli-0.7.16 → soothe_cli-0.7.18}/src/soothe_cli/tui/widgets/welcome.py +0 -0
|
@@ -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."""
|
|
@@ -224,7 +226,7 @@ def apply_insert_lines_to_content(content: str, line: int, insert_content: str)
|
|
|
224
226
|
return "".join(lines)
|
|
225
227
|
|
|
226
228
|
|
|
227
|
-
def
|
|
229
|
+
def apply_edit_lines_to_content(
|
|
228
230
|
content: str,
|
|
229
231
|
start_line: int,
|
|
230
232
|
end_line: int,
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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,18 +716,50 @@ 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
|
|
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(
|
|
433
|
-
|
|
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_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":
|
|
437
761
|
return "Created" if is_new_file else "Written"
|
|
438
|
-
if tool_name in ("edit_file", "
|
|
762
|
+
if tool_name in ("edit_file", "edit_lines"):
|
|
439
763
|
return "Edited"
|
|
440
764
|
if tool_name == "insert_lines":
|
|
441
765
|
return "Inserted"
|
|
@@ -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
|
|
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.
|
|
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
|
-
#
|
|
771
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
|
|
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:
|