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.
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/PKG-INFO +1 -1
- soothe_cli-0.7.17/src/soothe_cli/runtime/presentation/id_format.py +37 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/file_tracker.py +353 -29
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/transport/session.py +2 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/_cli_context.py +3 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_app.py +2 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_execution.py +22 -6
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_history.py +14 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_messages_mixin.py +128 -45
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_model.py +95 -1
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/command_registry.py +6 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/config.py +11 -5
- soothe_cli-0.7.17/src/soothe_cli/tui/file_change_notify.py +277 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/textual_adapter.py +48 -53
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/theme.py +1 -1
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/tips.py +1 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/chat_input.py +23 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/context_viewer.py +185 -55
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/file_change_preview.py +9 -5
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/loop_selector.py +33 -9
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/_helpers.py +10 -10
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/assistant.py +1 -1
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +109 -49
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +1 -1
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_step.py +15 -12
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +2 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/user.py +18 -1
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +42 -6
- soothe_cli-0.7.17/src/soothe_cli/tui/widgets/router_profile_selector.py +138 -0
- soothe_cli-0.7.15/src/soothe_cli/tui/file_change_notify.py +0 -124
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/.gitignore +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/README.md +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/pyproject.toml +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/__init__.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/__init__.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/__init__.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/__init__.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/daemon.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/headless.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/execution/launcher.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/cli/main.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/config/__init__.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/config/cli_config.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/config/loader.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/config/logging_setup.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/__init__.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/headless/processor.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/engine.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/session_stats.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/step_router.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/state/transcript.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/token_events_debug.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/token_usage.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/turn/prepare.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/wire/message_text.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/runtime/wire/messages.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/__init__.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/_env_vars.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/_version.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/__init__.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_module_init.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_startup.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/_ui.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/app/app.tcss +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/binding.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/commands/__init__.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/commands/command_router.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/file_change_renderers.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/hooks.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/input.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/markdown_theme.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/media_utils.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/model_config.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/path_utils.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/preview_limits.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/project_utils.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/sessions.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/shell_color.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/skills/__init__.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/skills/invocation.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/skills/load.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/spinner_labels.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/tool_display.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/unicode_security.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/update_check.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/__init__.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/_links.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/context_data.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/diff.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/editor.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/history.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/loading.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/message_store.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/status.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
- {soothe_cli-0.7.15 → soothe_cli-0.7.17}/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."""
|
|
@@ -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,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
|
|
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_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
|
|
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.
|
|
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
|
-
#
|
|
768
|
-
|
|
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
|