soothe-cli 0.7.17__tar.gz → 0.8.0__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.17 → soothe_cli-0.8.0}/PKG-INFO +1 -1
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/loop_cmd.py +12 -2
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/state/file_tracker.py +3 -3
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/state/step_router.py +52 -1
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/transport/session.py +16 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_app.py +54 -2
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_execution.py +2 -13
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_messages_mixin.py +8 -34
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_startup.py +2 -2
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/command_registry.py +6 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/commands/__init__.py +2 -2
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/commands/command_router.py +7 -6
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/commands/slash_commands.py +9 -3
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/commands/subagent_routing.py +18 -6
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/file_change_notify.py +1 -1
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/file_change_renderers.py +4 -6
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/spinner_labels.py +8 -1
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/textual_adapter.py +246 -16
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/tips.py +1 -1
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/file_change_preview.py +1 -1
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/help_screen.py +3 -3
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +1 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +10 -3
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/status.py +25 -4
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/.gitignore +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/README.md +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/pyproject.toml +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/__init__.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/__init__.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/__init__.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/execution/__init__.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/execution/daemon.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/execution/headless.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/execution/launcher.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/cli/main.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/config/__init__.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/config/cli_config.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/config/loader.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/config/logging_setup.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/__init__.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/headless/processor.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/presentation/engine.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/presentation/id_format.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/state/session_stats.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/state/transcript.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/token_events_debug.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/token_usage.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/turn/prepare.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/wire/message_text.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/runtime/wire/messages.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/__init__.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/_cli_context.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/_env_vars.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/_version.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/__init__.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_history.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_model.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_module_init.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_ui.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/app.tcss +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/binding.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/config.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/hooks.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/input.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/markdown_theme.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/media_utils.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/model_config.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/path_utils.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/preview_limits.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/project_utils.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/sessions.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/shell_color.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/skills/__init__.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/skills/invocation.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/skills/load.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/theme.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/tool_display.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/unicode_security.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/update_check.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/__init__.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/_links.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/context_data.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/context_viewer.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/diff.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/editor.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/history.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/loading.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/message_store.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/cognition_step.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/router_profile_selector.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
- {soothe_cli-0.7.17 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/welcome.py +0 -0
|
@@ -79,13 +79,23 @@ async def _rpc(
|
|
|
79
79
|
client = WebSocketClient(url=ws_url)
|
|
80
80
|
try:
|
|
81
81
|
await client.connect()
|
|
82
|
+
await asyncio.wait_for(client.request_connection_init(), timeout=timeout)
|
|
83
|
+
await asyncio.wait_for(
|
|
84
|
+
client.wait_for_connection_ack(ack_timeout_s=timeout), timeout=timeout
|
|
85
|
+
)
|
|
82
86
|
if mode == "notify":
|
|
83
87
|
await asyncio.wait_for(client.notify(method, params or {}), timeout=timeout)
|
|
84
88
|
return {}
|
|
85
89
|
if mode == "subscribe":
|
|
86
|
-
sub_id = await asyncio.wait_for(
|
|
90
|
+
sub_id = await asyncio.wait_for(
|
|
91
|
+
client.subscribe(method, params or {}, timeout=timeout),
|
|
92
|
+
timeout=timeout,
|
|
93
|
+
)
|
|
87
94
|
return {"subscription_id": sub_id}
|
|
88
|
-
result = await asyncio.wait_for(
|
|
95
|
+
result = await asyncio.wait_for(
|
|
96
|
+
client.request(method, params or {}, timeout=timeout),
|
|
97
|
+
timeout=timeout,
|
|
98
|
+
)
|
|
89
99
|
return result if isinstance(result, dict) else {"result": result}
|
|
90
100
|
except TimeoutError:
|
|
91
101
|
return {"error": "Timed out waiting for daemon response"}
|
|
@@ -226,7 +226,7 @@ def apply_insert_lines_to_content(content: str, line: int, insert_content: str)
|
|
|
226
226
|
return "".join(lines)
|
|
227
227
|
|
|
228
228
|
|
|
229
|
-
def
|
|
229
|
+
def apply_edit_lines_to_content(
|
|
230
230
|
content: str,
|
|
231
231
|
start_line: int,
|
|
232
232
|
end_line: int,
|
|
@@ -747,7 +747,7 @@ def file_change_label(
|
|
|
747
747
|
return "Deleting"
|
|
748
748
|
if tool_name == "write_file":
|
|
749
749
|
return "Creating" if is_new_file else "Writing"
|
|
750
|
-
if tool_name in ("edit_file", "
|
|
750
|
+
if tool_name in ("edit_file", "edit_lines"):
|
|
751
751
|
return "Editing"
|
|
752
752
|
if tool_name == "insert_lines":
|
|
753
753
|
return "Inserting"
|
|
@@ -759,7 +759,7 @@ def file_change_label(
|
|
|
759
759
|
return "Deleted"
|
|
760
760
|
if tool_name == "write_file":
|
|
761
761
|
return "Created" if is_new_file else "Written"
|
|
762
|
-
if tool_name in ("edit_file", "
|
|
762
|
+
if tool_name in ("edit_file", "edit_lines"):
|
|
763
763
|
return "Edited"
|
|
764
764
|
if tool_name == "insert_lines":
|
|
765
765
|
return "Inserted"
|
|
@@ -24,6 +24,7 @@ from soothe_sdk.ux.task_namespace import (
|
|
|
24
24
|
resolve_task_parent_lookup,
|
|
25
25
|
resolve_task_scope_for_namespace,
|
|
26
26
|
row_key_for_subgraph_tool,
|
|
27
|
+
step_level_parent_task_call_id,
|
|
27
28
|
task_scope_step_id,
|
|
28
29
|
try_bind_namespace_from_tool_call_id,
|
|
29
30
|
)
|
|
@@ -93,6 +94,10 @@ class StepTaskRouter:
|
|
|
93
94
|
_spawns_by_task_id: dict[str, TaskScope] = field(default_factory=dict, repr=False)
|
|
94
95
|
_pending_unscoped_namespaces: deque[tuple[str, ...]] = field(default_factory=deque, repr=False)
|
|
95
96
|
_spawn_recorded: set[tuple[str, str]] = field(default_factory=set, repr=False)
|
|
97
|
+
_task_index_by_step_and_call: dict[tuple[str, str], int] = field(
|
|
98
|
+
default_factory=dict, repr=False
|
|
99
|
+
)
|
|
100
|
+
_next_task_index_by_step: dict[str, int] = field(default_factory=dict, repr=False)
|
|
96
101
|
_pending_main_tools: dict[str, PendingMainTool] = field(default_factory=dict, repr=False)
|
|
97
102
|
_pending_subgraph_tools: dict[tuple[tuple[str, ...], str], PendingSubgraphTool] = field(
|
|
98
103
|
default_factory=dict,
|
|
@@ -108,6 +113,8 @@ class StepTaskRouter:
|
|
|
108
113
|
self._spawns_by_task_id.clear()
|
|
109
114
|
self._pending_unscoped_namespaces.clear()
|
|
110
115
|
self._spawn_recorded.clear()
|
|
116
|
+
self._task_index_by_step_and_call.clear()
|
|
117
|
+
self._next_task_index_by_step.clear()
|
|
111
118
|
self._pending_main_tools.clear()
|
|
112
119
|
self._pending_subgraph_tools.clear()
|
|
113
120
|
|
|
@@ -220,6 +227,11 @@ class StepTaskRouter:
|
|
|
220
227
|
]
|
|
221
228
|
for tcid in drop_tcid:
|
|
222
229
|
self._spawns_by_task_id.pop(tcid, None)
|
|
230
|
+
# Cleanse per-step synthetic task-index state once the step is done.
|
|
231
|
+
self._next_task_index_by_step.pop(sid, None)
|
|
232
|
+
stale_index_keys = [key for key in self._task_index_by_step_and_call if key[0] == sid]
|
|
233
|
+
for key in stale_index_keys:
|
|
234
|
+
self._task_index_by_step_and_call.pop(key, None)
|
|
223
235
|
|
|
224
236
|
def step_id_for_tool(self, tool_call_id: str) -> str:
|
|
225
237
|
"""Return execute step id encoded in a unified root tool_call_id."""
|
|
@@ -287,7 +299,10 @@ class StepTaskRouter:
|
|
|
287
299
|
sid = str(step_id).strip()
|
|
288
300
|
if not sid:
|
|
289
301
|
return False
|
|
290
|
-
normalized_tcid =
|
|
302
|
+
normalized_tcid = self.normalize_task_delegation_id(
|
|
303
|
+
step_id=sid,
|
|
304
|
+
tool_call_id=tcid,
|
|
305
|
+
)
|
|
291
306
|
spawn_key = (sid, normalized_tcid)
|
|
292
307
|
if spawn_key in self._spawn_recorded:
|
|
293
308
|
return False
|
|
@@ -311,6 +326,42 @@ class StepTaskRouter:
|
|
|
311
326
|
)
|
|
312
327
|
return True
|
|
313
328
|
|
|
329
|
+
def normalize_task_delegation_id(self, *, step_id: str, tool_call_id: str) -> str:
|
|
330
|
+
"""Return canonical ``{step}:s:task:{idx}`` for main ``task`` delegations.
|
|
331
|
+
|
|
332
|
+
Some providers emit unified ids like ``{step}:s:call_<uuid>`` that do not
|
|
333
|
+
encode a task index. In that case allocate a stable per-step synthetic
|
|
334
|
+
index so task activity rows and standalone SubAgent cards stay 1:1.
|
|
335
|
+
"""
|
|
336
|
+
sid = str(step_id).strip()
|
|
337
|
+
tcid = str(tool_call_id).strip()
|
|
338
|
+
if not sid or not tcid:
|
|
339
|
+
return tcid
|
|
340
|
+
|
|
341
|
+
normalized = normalize_main_task_delegation_id(sid, tcid, tool_name="task")
|
|
342
|
+
_, type_code, _, tool_info = parse_unified_tool_call_id(normalized)
|
|
343
|
+
if type_code == "s" and isinstance(tool_info, str):
|
|
344
|
+
parts = tool_info.split(":")
|
|
345
|
+
if parts and parts[0] == "task":
|
|
346
|
+
tail = parts[-1] if parts else ""
|
|
347
|
+
if tail.isdigit():
|
|
348
|
+
idx = int(tail)
|
|
349
|
+
key = (sid, tcid)
|
|
350
|
+
self._task_index_by_step_and_call[key] = idx
|
|
351
|
+
next_idx = self._next_task_index_by_step.get(sid, 0)
|
|
352
|
+
if idx >= next_idx:
|
|
353
|
+
self._next_task_index_by_step[sid] = idx + 1
|
|
354
|
+
return normalized
|
|
355
|
+
|
|
356
|
+
key = (sid, tcid)
|
|
357
|
+
if key in self._task_index_by_step_and_call:
|
|
358
|
+
idx = self._task_index_by_step_and_call[key]
|
|
359
|
+
else:
|
|
360
|
+
idx = self._next_task_index_by_step.get(sid, 0)
|
|
361
|
+
self._task_index_by_step_and_call[key] = idx
|
|
362
|
+
self._next_task_index_by_step[sid] = idx + 1
|
|
363
|
+
return step_level_parent_task_call_id(sid, idx)
|
|
364
|
+
|
|
314
365
|
def resolve_task_scope(self, namespace: tuple[str, ...]) -> TaskScope | None:
|
|
315
366
|
"""Task scope for a stream namespace, if bound."""
|
|
316
367
|
return resolve_task_scope_for_namespace(self._namespace_bindings, namespace)
|
|
@@ -30,6 +30,9 @@ _POST_IDLE_DRAIN_DEADLINE_S = 0.5
|
|
|
30
30
|
|
|
31
31
|
# Align with ``bootstrap_loop_session`` daemon-ready wait (RFC-450 §8.2).
|
|
32
32
|
_RPC_HANDSHAKE_TIMEOUT_S = 20.0
|
|
33
|
+
_TURN_END_CUSTOM_TYPES = {
|
|
34
|
+
"soothe.cognition.strange_loop.completed",
|
|
35
|
+
}
|
|
33
36
|
|
|
34
37
|
# Brief close handshake on TUI exit — the daemon cleans up on disconnect anyway.
|
|
35
38
|
TUI_EXIT_HANDSHAKE_TIMEOUT_S = 0.3
|
|
@@ -448,6 +451,19 @@ class TuiDaemonSession:
|
|
|
448
451
|
progress_seen = True
|
|
449
452
|
stream_payload_seen = True
|
|
450
453
|
yield (namespace, mode, data)
|
|
454
|
+
if mode == "custom" and isinstance(data, dict):
|
|
455
|
+
custom_type = str(data.get("type", "")).strip()
|
|
456
|
+
if custom_type in _TURN_END_CUSTOM_TYPES:
|
|
457
|
+
# Some turns can miss the trailing status=idle/stopped
|
|
458
|
+
# despite emitting terminal custom events. Treat these
|
|
459
|
+
# as explicit turn completion and briefly drain late
|
|
460
|
+
# frames to preserve post-terminal payloads.
|
|
461
|
+
self.last_turn_end_state = "completed"
|
|
462
|
+
async for chunk in self._drain_stream_events_after_idle(
|
|
463
|
+
expected_loop_id=expected_loop_id,
|
|
464
|
+
):
|
|
465
|
+
yield chunk
|
|
466
|
+
break
|
|
451
467
|
# Graph auto-resumes LangGraph interrupts server-side; keep consuming events.
|
|
452
468
|
if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
|
|
453
469
|
continue
|
|
@@ -6,11 +6,13 @@ import asyncio
|
|
|
6
6
|
import logging
|
|
7
7
|
import time
|
|
8
8
|
from collections import deque
|
|
9
|
+
from contextlib import suppress
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
11
12
|
|
|
12
13
|
if TYPE_CHECKING:
|
|
13
14
|
from textual.app import ComposeResult
|
|
15
|
+
from textual.timer import Timer
|
|
14
16
|
from textual.worker import Worker
|
|
15
17
|
|
|
16
18
|
from soothe_cli.tui.skills.load import ExtendedSkillMetadata
|
|
@@ -222,12 +224,13 @@ class SootheApp(
|
|
|
222
224
|
self._mcp_tool_count = sum(len(s.tools) for s in (mcp_server_info or []))
|
|
223
225
|
|
|
224
226
|
self._status_bar: StatusBar | None = None
|
|
227
|
+
self._default_session_tip: str = ""
|
|
228
|
+
self._status_notification_timer: Timer | None = None
|
|
229
|
+
self._status_notification_active = False
|
|
225
230
|
|
|
226
231
|
self._chat_input: ChatInput | None = None
|
|
227
232
|
self._plan_quick_view_overlay: PlanQuickViewOverlay | None = None
|
|
228
233
|
|
|
229
|
-
self._quit_pending = False
|
|
230
|
-
|
|
231
234
|
self._session_state: TextualSessionState | None = None
|
|
232
235
|
|
|
233
236
|
self._ui_adapter: TextualUIAdapter | None = None
|
|
@@ -361,6 +364,55 @@ class SootheApp(
|
|
|
361
364
|
"""Return whether the app has a connected daemon session."""
|
|
362
365
|
return self._daemon_session is not None
|
|
363
366
|
|
|
367
|
+
def set_default_session_tip(self, tip: str) -> None:
|
|
368
|
+
"""Persist and render the fallback tip shown when no notification is active."""
|
|
369
|
+
cleaned = (tip or "").strip()
|
|
370
|
+
self._default_session_tip = cleaned
|
|
371
|
+
if self._status_bar is not None and not self._status_notification_active:
|
|
372
|
+
self._status_bar.set_session_tip(cleaned)
|
|
373
|
+
|
|
374
|
+
def _set_status_notification(self, message: str, *, timeout: float | None = None) -> None:
|
|
375
|
+
"""Render a transient notification in the status-tip area."""
|
|
376
|
+
text = (message or "").strip()
|
|
377
|
+
if not text:
|
|
378
|
+
return
|
|
379
|
+
if self._status_notification_timer is not None:
|
|
380
|
+
with suppress(Exception):
|
|
381
|
+
self._status_notification_timer.stop()
|
|
382
|
+
self._status_notification_timer = None
|
|
383
|
+
self._status_notification_active = True
|
|
384
|
+
if self._status_bar is not None:
|
|
385
|
+
self._status_bar.set_notification_message(text)
|
|
386
|
+
|
|
387
|
+
duration = timeout if timeout and timeout > 0 else 3.0
|
|
388
|
+
self._status_notification_timer = self.set_timer(duration, self._clear_status_notification)
|
|
389
|
+
|
|
390
|
+
def _clear_status_notification(self) -> None:
|
|
391
|
+
"""Restore the default tip after transient notification timeout."""
|
|
392
|
+
self._status_notification_timer = None
|
|
393
|
+
self._status_notification_active = False
|
|
394
|
+
if self._status_bar is not None:
|
|
395
|
+
self._status_bar.set_session_tip(self._default_session_tip)
|
|
396
|
+
|
|
397
|
+
def notify( # type: ignore[override]
|
|
398
|
+
self,
|
|
399
|
+
message: Any,
|
|
400
|
+
*,
|
|
401
|
+
title: str = "",
|
|
402
|
+
severity: str = "information",
|
|
403
|
+
timeout: float | None = None,
|
|
404
|
+
markup: bool = True, # noqa: ARG002
|
|
405
|
+
) -> None:
|
|
406
|
+
"""Display notifications in the status-tip area instead of toast bubbles."""
|
|
407
|
+
text = str(message or "").strip()
|
|
408
|
+
if not text:
|
|
409
|
+
return
|
|
410
|
+
if title:
|
|
411
|
+
text = f"{title}: {text}"
|
|
412
|
+
if severity and severity.lower() in {"warning", "error"}:
|
|
413
|
+
text = f"{severity.title()}: {text}"
|
|
414
|
+
self._set_status_notification(text, timeout=timeout)
|
|
415
|
+
|
|
364
416
|
def get_theme_variable_defaults(self) -> dict[str, str]:
|
|
365
417
|
"""Return custom CSS variable defaults for the current theme.
|
|
366
418
|
|
|
@@ -148,9 +148,6 @@ class _ExecutionMixin:
|
|
|
148
148
|
value = event.value
|
|
149
149
|
mode: InputMode = event.mode # type: ignore[assignment] # Textual event mode is str at type level but InputMode at runtime
|
|
150
150
|
|
|
151
|
-
# Reset quit pending state on any input
|
|
152
|
-
self._quit_pending = False
|
|
153
|
-
|
|
154
151
|
await dispatch_hook("user.prompt", {})
|
|
155
152
|
|
|
156
153
|
# /quit and /q always execute immediately, even mid-loop-switch.
|
|
@@ -465,10 +462,10 @@ class _ExecutionMixin:
|
|
|
465
462
|
)
|
|
466
463
|
from soothe_cli.tui.commands.slash_commands import COMMANDS as _RFC404_COMMANDS
|
|
467
464
|
|
|
468
|
-
# RFC-454 daemon *routing* commands (/
|
|
465
|
+
# RFC-454 daemon *routing* commands (/deep_research, /plan, /browser_use, …):
|
|
469
466
|
# send the full line as a normal user turn so ``parse_subagent_from_input``
|
|
470
467
|
# runs in the daemon adapter (same as headless CLI). Without this branch,
|
|
471
|
-
# ``cmd == "/
|
|
468
|
+
# ``cmd == "/deep_research …"`` never matches the bare single-token handlers below.
|
|
472
469
|
full_stripped = command.strip()
|
|
473
470
|
first_word, query = parse_slash_command(full_stripped)
|
|
474
471
|
if first_word:
|
|
@@ -747,7 +744,6 @@ class _ExecutionMixin:
|
|
|
747
744
|
self,
|
|
748
745
|
message: str,
|
|
749
746
|
*,
|
|
750
|
-
message_kwargs: dict[str, Any] | None = None,
|
|
751
747
|
skip_daemon_send_turn: bool = False,
|
|
752
748
|
) -> None:
|
|
753
749
|
"""Send a message to the agent and start execution.
|
|
@@ -758,8 +754,6 @@ class _ExecutionMixin:
|
|
|
758
754
|
|
|
759
755
|
Args:
|
|
760
756
|
message: The prompt to send to the agent.
|
|
761
|
-
message_kwargs: Extra fields merged into the stream input message
|
|
762
|
-
dict (e.g., `additional_kwargs` for skill metadata).
|
|
763
757
|
skip_daemon_send_turn: When using a daemon session, only attach to
|
|
764
758
|
the in-flight stream (prompt already queued on the daemon).
|
|
765
759
|
"""
|
|
@@ -792,7 +786,6 @@ class _ExecutionMixin:
|
|
|
792
786
|
self._agent_worker = self.run_worker(
|
|
793
787
|
self._run_agent_task(
|
|
794
788
|
message,
|
|
795
|
-
message_kwargs=message_kwargs,
|
|
796
789
|
skip_daemon_send_turn=skip_daemon_send_turn,
|
|
797
790
|
),
|
|
798
791
|
exclusive=False,
|
|
@@ -808,7 +801,6 @@ class _ExecutionMixin:
|
|
|
808
801
|
self,
|
|
809
802
|
message: str,
|
|
810
803
|
*,
|
|
811
|
-
message_kwargs: dict[str, Any] | None = None,
|
|
812
804
|
skip_daemon_send_turn: bool = False,
|
|
813
805
|
) -> None:
|
|
814
806
|
"""Run the agent task in a background worker.
|
|
@@ -817,8 +809,6 @@ class _ExecutionMixin:
|
|
|
817
809
|
|
|
818
810
|
Args:
|
|
819
811
|
message: The prompt to send to the agent.
|
|
820
|
-
message_kwargs: Extra fields merged into the stream input message
|
|
821
|
-
dict (e.g., `additional_kwargs` for skill metadata).
|
|
822
812
|
skip_daemon_send_turn: When ``True`` with a daemon session, only
|
|
823
813
|
consume the daemon stream (prompt already queued server-side).
|
|
824
814
|
"""
|
|
@@ -847,7 +837,6 @@ class _ExecutionMixin:
|
|
|
847
837
|
image_tracker=self._image_tracker,
|
|
848
838
|
sandbox_type=self._sandbox_type,
|
|
849
839
|
workspace=self._cwd,
|
|
850
|
-
message_kwargs=message_kwargs,
|
|
851
840
|
context=CLIContext(
|
|
852
841
|
model=self._model_override,
|
|
853
842
|
model_params=self._model_params_override or {},
|
|
@@ -104,7 +104,6 @@ class _MessagesMixin:
|
|
|
104
104
|
group="interrupt-ui",
|
|
105
105
|
)
|
|
106
106
|
self._cancel_worker(self._agent_worker, discard_queue=False)
|
|
107
|
-
self._quit_pending = False
|
|
108
107
|
return True
|
|
109
108
|
|
|
110
109
|
async def _load_loop_history(
|
|
@@ -554,18 +553,16 @@ class _MessagesMixin:
|
|
|
554
553
|
copy_selection_to_clipboard(self, notify_if_empty=True)
|
|
555
554
|
|
|
556
555
|
def action_quit_or_interrupt(self) -> None:
|
|
557
|
-
"""Handle Ctrl+C - clear input
|
|
556
|
+
"""Handle Ctrl+C - clear input or interrupt running work.
|
|
558
557
|
|
|
559
558
|
Priority order when task is running (agent/shell):
|
|
560
559
|
1. If input has pending text, clear it (first Ctrl+C)
|
|
561
|
-
2. If input is empty, interrupt the running task
|
|
560
|
+
2. If input is empty, interrupt the running task
|
|
562
561
|
|
|
563
|
-
|
|
564
|
-
1. If double press (quit_pending), quit
|
|
565
|
-
2. Otherwise clear pending input and show quit hint
|
|
562
|
+
When idle, Ctrl+C never exits the TUI. Use `/quit` (or `/q`) to exit.
|
|
566
563
|
|
|
567
564
|
Note: Copying selected text is bound to Ctrl+Y (`action_copy_selection`)
|
|
568
|
-
so Ctrl+C is reserved for interrupt
|
|
565
|
+
so Ctrl+C is reserved for interrupt behavior.
|
|
569
566
|
"""
|
|
570
567
|
# Check if input has pending content (text, mode, or completion)
|
|
571
568
|
has_pending_input = self._has_pending_chat_input()
|
|
@@ -574,39 +571,22 @@ class _MessagesMixin:
|
|
|
574
571
|
if self._shell_running and self._shell_worker:
|
|
575
572
|
if has_pending_input:
|
|
576
573
|
self._chat_input.clear_input()
|
|
577
|
-
self._quit_pending = False
|
|
578
574
|
return
|
|
579
575
|
self._cancel_worker(self._shell_worker, discard_queue=False)
|
|
580
|
-
self._quit_pending = False
|
|
581
576
|
return
|
|
582
577
|
|
|
583
578
|
# If agent is running: clear input first, then interrupt
|
|
584
579
|
if self._agent_running and self._agent_worker:
|
|
585
580
|
if has_pending_input:
|
|
586
581
|
self._chat_input.clear_input()
|
|
587
|
-
self._quit_pending = False
|
|
588
582
|
return
|
|
589
583
|
self._interrupt_running_goal_preserving_queue()
|
|
590
584
|
return
|
|
591
585
|
|
|
592
|
-
#
|
|
593
|
-
if self._quit_pending:
|
|
594
|
-
self._detach_or_exit()
|
|
595
|
-
else:
|
|
596
|
-
self._arm_quit_pending("Ctrl+C")
|
|
597
|
-
|
|
598
|
-
def _arm_quit_pending(self, shortcut: str) -> None:
|
|
599
|
-
"""Set the pending-quit flag, clear pending input, and show a matching hint.
|
|
600
|
-
|
|
601
|
-
Args:
|
|
602
|
-
shortcut: The key chord to show in the quit hint.
|
|
603
|
-
"""
|
|
586
|
+
# Idle path: clear any pending draft, but never quit via keyboard shortcut.
|
|
604
587
|
if self._chat_input:
|
|
605
588
|
self._chat_input.clear_input()
|
|
606
|
-
self.
|
|
607
|
-
quit_timeout = 3
|
|
608
|
-
self.notify(f"Press {shortcut} again to quit", timeout=quit_timeout, markup=False)
|
|
609
|
-
self.set_timer(quit_timeout, lambda: setattr(self, "_quit_pending", False))
|
|
589
|
+
self.notify("Use /quit (or /q) to exit the TUI", timeout=2, markup=False)
|
|
610
590
|
|
|
611
591
|
def action_dismiss_ui(self) -> None:
|
|
612
592
|
"""Handle Escape — dismiss overlays and optionally cancel queued goals.
|
|
@@ -661,14 +641,8 @@ class _MessagesMixin:
|
|
|
661
641
|
self.action_dismiss_ui()
|
|
662
642
|
|
|
663
643
|
def action_quit_app(self) -> None:
|
|
664
|
-
"""Handle
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
if isinstance(self.screen, LoopSelectorScreen):
|
|
668
|
-
# Loop selector doesn't have delete confirmation - just detach
|
|
669
|
-
self._detach_or_exit()
|
|
670
|
-
return
|
|
671
|
-
self._detach_or_exit()
|
|
644
|
+
"""Handle Ctrl+D by hinting explicit slash-command exit."""
|
|
645
|
+
self.notify("Use /quit (or /q) to exit the TUI", timeout=2, markup=False)
|
|
672
646
|
|
|
673
647
|
async def _detach_then_exit(self) -> None:
|
|
674
648
|
"""Detach from daemon, then exit the app."""
|
|
@@ -139,7 +139,7 @@ class _StartupMixin:
|
|
|
139
139
|
|
|
140
140
|
with suppress(NoMatches):
|
|
141
141
|
banner = self.query_one("#welcome-banner", WelcomeBanner)
|
|
142
|
-
self.
|
|
142
|
+
self.set_default_session_tip(banner.session_tip)
|
|
143
143
|
|
|
144
144
|
# Focus the input immediately so the cursor is visible on first paint
|
|
145
145
|
self._chat_input.focus_input()
|
|
@@ -535,7 +535,7 @@ class _StartupMixin:
|
|
|
535
535
|
if self._lc_loop_id:
|
|
536
536
|
banner.update_loop_id(self._lc_loop_id)
|
|
537
537
|
if self._status_bar is not None:
|
|
538
|
-
self.
|
|
538
|
+
self.set_default_session_tip(banner.session_tip)
|
|
539
539
|
except NoMatches:
|
|
540
540
|
logger.warning("Welcome banner not found during daemon ready transition")
|
|
541
541
|
|
|
@@ -130,6 +130,12 @@ COMMANDS: tuple[SlashCommand, ...] = (
|
|
|
130
130
|
description="Route prompt to academic_research subagent (usage: /academic_research <query>)",
|
|
131
131
|
bypass_tier=BypassTier.QUEUED,
|
|
132
132
|
),
|
|
133
|
+
SlashCommand(
|
|
134
|
+
name="/browser_use",
|
|
135
|
+
description="Route prompt to browser_use subagent (usage: /browser_use <task>)",
|
|
136
|
+
bypass_tier=BypassTier.QUEUED,
|
|
137
|
+
hidden_keywords="browser automation web",
|
|
138
|
+
),
|
|
133
139
|
SlashCommand(
|
|
134
140
|
name="/plan",
|
|
135
141
|
description="Send input in plan mode (usage: /plan or /plan <prompt>)",
|
|
@@ -10,15 +10,15 @@ from soothe_cli.tui.commands.command_router import (
|
|
|
10
10
|
validate_command,
|
|
11
11
|
)
|
|
12
12
|
from soothe_cli.tui.commands.subagent_routing import (
|
|
13
|
-
BUILTIN_SUBAGENT_NAMES,
|
|
14
13
|
SUBAGENT_DISPLAY_NAMES,
|
|
14
|
+
SUBAGENT_SLASH_ROUTE_IDS,
|
|
15
15
|
get_subagent_display_name,
|
|
16
16
|
parse_subagent_from_input,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
__all__ = [
|
|
20
|
-
"BUILTIN_SUBAGENT_NAMES",
|
|
21
20
|
"SUBAGENT_DISPLAY_NAMES",
|
|
21
|
+
"SUBAGENT_SLASH_ROUTE_IDS",
|
|
22
22
|
"find_command_by_daemon_command",
|
|
23
23
|
"get_subagent_display_name",
|
|
24
24
|
"handle_rpc_command",
|
|
@@ -25,7 +25,7 @@ def parse_slash_command(input_text: str) -> tuple[str, str | None]:
|
|
|
25
25
|
"""Parse slash command and extract command + query.
|
|
26
26
|
|
|
27
27
|
Args:
|
|
28
|
-
input_text: Full user input (e.g., "/
|
|
28
|
+
input_text: Full user input (e.g., "/deep_research topic summary")
|
|
29
29
|
|
|
30
30
|
Returns:
|
|
31
31
|
Tuple of (command, query) where query may be None
|
|
@@ -119,7 +119,7 @@ async def route_slash_command(
|
|
|
119
119
|
"""Route slash command based on registry metadata (RFC-454).
|
|
120
120
|
|
|
121
121
|
Args:
|
|
122
|
-
cmd_input: Full command input (e.g., "/memory", "/
|
|
122
|
+
cmd_input: Full command input (e.g., "/memory", "/deep_research topic")
|
|
123
123
|
console: Rich console for rendering
|
|
124
124
|
client: WebSocket client for daemon communication
|
|
125
125
|
|
|
@@ -237,12 +237,13 @@ async def handle_routing_command(
|
|
|
237
237
|
) -> None:
|
|
238
238
|
"""Handle daemon routing command by sending input with optional subagent (RFC-454).
|
|
239
239
|
|
|
240
|
-
For routing commands that map to a configured subagent id (e.g. ``/
|
|
241
|
-
sets the WebSocket ``preferred_subagent`` field so the daemon merges a
|
|
242
|
-
StrangeLoop (IG-349). Other routing commands (e.g. ``/plan``) are sent
|
|
240
|
+
For routing commands that map to a configured subagent id (e.g. ``/deep_research``,
|
|
241
|
+
``/browser_use``), sets the WebSocket ``preferred_subagent`` field so the daemon merges a
|
|
242
|
+
subagent hint into StrangeLoop (IG-349). Other routing commands (e.g. ``/plan``) are sent
|
|
243
|
+
as plain text unchanged.
|
|
243
244
|
|
|
244
245
|
Args:
|
|
245
|
-
cmd_input: Full command input (e.g., "/
|
|
246
|
+
cmd_input: Full command input (e.g., "/deep_research topic summary")
|
|
246
247
|
console: Rich console
|
|
247
248
|
client: WebSocket client
|
|
248
249
|
loop_id: Subscribed loop to target (required for ``loop_input``)
|
|
@@ -154,8 +154,8 @@ def show_cron_add(console: Console, data: dict[str, Any]) -> None:
|
|
|
154
154
|
|
|
155
155
|
KEYBOARD_SHORTCUTS: dict[str, str] = {
|
|
156
156
|
"Esc": "Dismiss modal, plan overlay, or autocomplete",
|
|
157
|
-
"Ctrl+D": "
|
|
158
|
-
"Ctrl+C": "Clear input
|
|
157
|
+
"Ctrl+D": "Use /quit (or /q) to exit the TUI",
|
|
158
|
+
"Ctrl+C": "Clear input or interrupt running agent/shell",
|
|
159
159
|
"Ctrl+X": "Open prompt in external editor ($VISUAL/$EDITOR)",
|
|
160
160
|
"Ctrl+Y": "Copy selected text to clipboard (or show hint if none)",
|
|
161
161
|
"Ctrl+T": "Toggle plan quick-view overlay",
|
|
@@ -272,7 +272,7 @@ COMMANDS: dict[str, dict[str, Any]] = {
|
|
|
272
272
|
"requires_query": True,
|
|
273
273
|
"handler": show_cron_add,
|
|
274
274
|
},
|
|
275
|
-
# Daemon routing commands
|
|
275
|
+
# Daemon routing commands
|
|
276
276
|
"/plan": {"location": "daemon", "type": "routing", "description": "Trigger plan mode"},
|
|
277
277
|
"/deep_research": {
|
|
278
278
|
"location": "daemon",
|
|
@@ -286,6 +286,12 @@ COMMANDS: dict[str, dict[str, Any]] = {
|
|
|
286
286
|
"description": "Route query to Academic Research subagent",
|
|
287
287
|
"requires_query": True,
|
|
288
288
|
},
|
|
289
|
+
"/browser_use": {
|
|
290
|
+
"location": "daemon",
|
|
291
|
+
"type": "routing",
|
|
292
|
+
"description": "Route query to Browser Use subagent",
|
|
293
|
+
"requires_query": True,
|
|
294
|
+
},
|
|
289
295
|
}
|
|
290
296
|
|
|
291
297
|
|
|
@@ -11,9 +11,13 @@ SUBAGENT_DISPLAY_NAMES: dict[str, str] = {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
# Lowercase ids matched after ``/`` for preferred_subagent routing (core only).
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
# Longest-first so a shorter id never shadows a longer prefix match.
|
|
15
|
+
# Intake-only specialists (IG-600/601) must stay here so slash sets preferred_subagent.
|
|
16
|
+
SUBAGENT_SLASH_ROUTE_IDS: tuple[str, ...] = (
|
|
17
|
+
"academic_research",
|
|
18
|
+
"deep_research",
|
|
19
|
+
"browser_use",
|
|
20
|
+
)
|
|
17
21
|
|
|
18
22
|
|
|
19
23
|
def get_subagent_display_name(technical_name: str) -> str:
|
|
@@ -34,7 +38,7 @@ def get_subagent_display_name(technical_name: str) -> str:
|
|
|
34
38
|
def parse_subagent_from_input(user_input: str) -> tuple[str | None, str]:
|
|
35
39
|
"""Parse subagent subcommand from user input.
|
|
36
40
|
|
|
37
|
-
Detects subagent routing commands (e.g. ``/deep_research``)
|
|
41
|
+
Detects subagent routing commands (e.g. ``/deep_research``, ``/browser_use``)
|
|
38
42
|
and extracts the subagent name along with the cleaned input text.
|
|
39
43
|
|
|
40
44
|
Args:
|
|
@@ -47,14 +51,22 @@ def parse_subagent_from_input(user_input: str) -> tuple[str | None, str]:
|
|
|
47
51
|
|
|
48
52
|
Examples:
|
|
49
53
|
``"/deep_research check this"`` -> ``("deep_research", "check this")``
|
|
54
|
+
``"/browser_use open example.com"`` -> ``("browser_use", "open example.com")``
|
|
50
55
|
``"hello world"`` -> ``(None, "hello world")``
|
|
51
56
|
"""
|
|
52
57
|
first_match: tuple[int, str] | None = None
|
|
58
|
+
lowered = user_input.lower()
|
|
53
59
|
|
|
54
60
|
for subagent_name in SUBAGENT_SLASH_ROUTE_IDS:
|
|
55
61
|
subcommand = f"/{subagent_name}"
|
|
56
|
-
idx =
|
|
57
|
-
if idx
|
|
62
|
+
idx = lowered.find(subcommand)
|
|
63
|
+
if idx == -1:
|
|
64
|
+
continue
|
|
65
|
+
end = idx + len(subcommand)
|
|
66
|
+
# Require end-of-string or whitespace so ``/browser`` does not match ``/browser_use``.
|
|
67
|
+
if end < len(lowered) and not lowered[end].isspace():
|
|
68
|
+
continue
|
|
69
|
+
if first_match is None or idx < first_match[0]:
|
|
58
70
|
first_match = (idx, subagent_name)
|
|
59
71
|
|
|
60
72
|
if first_match:
|
|
@@ -115,7 +115,7 @@ async def mount_file_change_preview(
|
|
|
115
115
|
str(args.get("old_string") or "") or str(args.get("new_string") or "")
|
|
116
116
|
):
|
|
117
117
|
return
|
|
118
|
-
if tool_name == "
|
|
118
|
+
if tool_name == "edit_lines" and parse_line_range_args(args) is None:
|
|
119
119
|
return
|
|
120
120
|
if tool_name == "insert_lines" and parse_insert_line_arg(args) is None:
|
|
121
121
|
return
|
|
@@ -7,7 +7,7 @@ from typing import Any
|
|
|
7
7
|
from soothe_cli.runtime.state.file_tracker import (
|
|
8
8
|
FILE_CHANGE_TOOLS,
|
|
9
9
|
FileOperationRecord,
|
|
10
|
-
|
|
10
|
+
apply_edit_lines_to_content,
|
|
11
11
|
apply_insert_lines_to_content,
|
|
12
12
|
extract_line_range_text,
|
|
13
13
|
parse_insert_line_arg,
|
|
@@ -80,7 +80,7 @@ def build_file_change_preview(
|
|
|
80
80
|
"new_string": new_string,
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
if tool_name == "
|
|
83
|
+
if tool_name == "edit_lines":
|
|
84
84
|
line_range = parse_line_range_args(args)
|
|
85
85
|
if line_range is None:
|
|
86
86
|
return None
|
|
@@ -128,9 +128,7 @@ def build_file_change_preview(
|
|
|
128
128
|
if physical and physical.is_file():
|
|
129
129
|
before = read_physical_file_text(physical) or ""
|
|
130
130
|
old_segment = extract_line_range_text(before, start_line, end_line) if before else ""
|
|
131
|
-
after_text = (
|
|
132
|
-
apply_edit_file_lines_to_content(before, start_line, end_line, "") if before else ""
|
|
133
|
-
)
|
|
131
|
+
after_text = apply_edit_lines_to_content(before, start_line, end_line, "") if before else ""
|
|
134
132
|
if after_text is None:
|
|
135
133
|
after_text = before
|
|
136
134
|
return EditFileLinesPreviewWidget, {
|
|
@@ -203,7 +201,7 @@ def update_preview_data_from_record(data: dict[str, Any], record: FileOperationR
|
|
|
203
201
|
data["total_lines"] = len(lines)
|
|
204
202
|
return
|
|
205
203
|
|
|
206
|
-
if record.tool_name == "
|
|
204
|
+
if record.tool_name == "edit_lines":
|
|
207
205
|
line_range = parse_line_range_args(record.args)
|
|
208
206
|
if line_range is not None:
|
|
209
207
|
data["start_line"], data["end_line"] = line_range
|