soothe-cli 0.10.2__tar.gz → 0.10.4__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.10.2 → soothe_cli-0.10.4}/PKG-INFO +4 -5
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/pyproject.toml +3 -4
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/autopilot_cmd.py +39 -1
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/cron_cmd.py +7 -4
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/execution/daemon_errors.py +20 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/main.py +4 -3
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/config/cli_config.py +3 -2
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_app.py +4 -3
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_execution.py +134 -17
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/command_registry.py +47 -18
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/commands/slash_commands.py +2 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/textual_adapter.py +12 -1
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/autocomplete.py +10 -2
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/help_screen.py +2 -1
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +38 -10
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/status.py +4 -17
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/.gitignore +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/README.md +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/__init__.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/__init__.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/__init__.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/execution/__init__.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/execution/daemon.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/execution/headless.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/execution/launcher.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/config/__init__.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/config/loader.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/config/logging_setup.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/__init__.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/headless/processor.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/engine.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/id_format.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/step_id_format.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/state/session_stats.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/state/step_router.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/token_events_debug.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/token_usage.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/transport/session.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/turn/prepare.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/wire/message_text.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/wire/messages.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/__init__.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/_cli_context.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/_env_vars.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/_version.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/__init__.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_history.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_messages_mixin.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_model.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_module_init.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_startup.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_ui.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/app.tcss +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/binding.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/card_wire.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/commands/__init__.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/commands/command_router.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/composer_mode.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/config.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/file_change_notify.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/file_change_renderers.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/hooks.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/input.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/markdown_theme.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/media_utils.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/mermaid_render.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/model_config.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/path_utils.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/preview_limits.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/project_utils.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/sessions.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/shell_color.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/skills/__init__.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/skills/invocation.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/skills/load.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/spinner_labels.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/theme.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/tips.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/tool_display.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/unicode_security.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/update_check.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/__init__.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/_links.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/context_data.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/context_viewer.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/diff.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/editor.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/history.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/loading.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/message_store.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/cognition_step.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/router_profile_selector.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
- {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/welcome.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: soothe-cli
|
|
3
|
-
Version: 0.10.
|
|
3
|
+
Version: 0.10.4
|
|
4
4
|
Summary: Soothe CLI client - communicates with daemon via WebSocket
|
|
5
5
|
Project-URL: Homepage, https://github.com/mirasoth/soothe
|
|
6
6
|
Project-URL: Documentation, https://soothe.readthedocs.io
|
|
@@ -14,16 +14,15 @@ Classifier: Programming Language :: Python :: 3
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
18
17
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
18
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
-
Requires-Python: <3.
|
|
19
|
+
Requires-Python: <3.14,>=3.11
|
|
21
20
|
Requires-Dist: pillow<12.0.0,>=10.0.0
|
|
22
21
|
Requires-Dist: python-dotenv<2.0.0,>=1.0.0
|
|
23
22
|
Requires-Dist: pyyaml<7.0.0,>=6.0.0
|
|
24
23
|
Requires-Dist: rich<15.0.0,>=13.0.0
|
|
25
|
-
Requires-Dist: soothe-client-python<2.0.0,>=1.0.
|
|
26
|
-
Requires-Dist: soothe-sdk<2.0.0,>=1.0.
|
|
24
|
+
Requires-Dist: soothe-client-python<2.0.0,>=1.0.15
|
|
25
|
+
Requires-Dist: soothe-sdk<2.0.0,>=1.0.8
|
|
27
26
|
Requires-Dist: termaid<1.0.0,>=0.7.1
|
|
28
27
|
Requires-Dist: textual<10.0.0,>=8.0.0
|
|
29
28
|
Requires-Dist: typer<2.0.0,>=0.9.0
|
|
@@ -8,7 +8,7 @@ dynamic = ["version"]
|
|
|
8
8
|
description = "Soothe CLI client - communicates with daemon via WebSocket"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
11
|
-
requires-python = ">=3.11,<3.
|
|
11
|
+
requires-python = ">=3.11,<3.14"
|
|
12
12
|
keywords = ["soothe", "cli", "client", "tui", "websocket"]
|
|
13
13
|
classifiers = [
|
|
14
14
|
"Development Status :: 3 - Alpha",
|
|
@@ -18,13 +18,12 @@ classifiers = [
|
|
|
18
18
|
"Programming Language :: Python :: 3.11",
|
|
19
19
|
"Programming Language :: Python :: 3.12",
|
|
20
20
|
"Programming Language :: Python :: 3.13",
|
|
21
|
-
"Programming Language :: Python :: 3.14",
|
|
22
21
|
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
22
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
23
|
]
|
|
25
24
|
dependencies = [
|
|
26
|
-
"soothe-sdk>=1.0.
|
|
27
|
-
"soothe-client-python>=1.0.
|
|
25
|
+
"soothe-sdk>=1.0.8,<2.0.0", # events/display/wire contracts
|
|
26
|
+
"soothe-client-python>=1.0.15,<2.0.0", # WebSocket transport client; matched to latest release v1.0.15
|
|
28
27
|
"typer>=0.9.0,<2.0.0", # CLI framework
|
|
29
28
|
"textual>=8.0.0,<10.0.0", # TUI framework
|
|
30
29
|
"rich>=13.0.0,<15.0.0", # Console output
|
|
@@ -66,7 +66,8 @@ _STYLE_METRIC_RAIL = "bold bright_green"
|
|
|
66
66
|
# Compact description previews at the row tail so metrics stay visible first.
|
|
67
67
|
_TOP_DESC_JOB = 32
|
|
68
68
|
_TOP_DESC_GOAL = 32
|
|
69
|
-
|
|
69
|
+
# Steps can show a bit more than job/goal: deps trail after the desc (`→id`).
|
|
70
|
+
_TOP_DESC_STEP = 40
|
|
70
71
|
_STATUS_STYLE: dict[str, str] = {
|
|
71
72
|
"active": _STYLE_ACTIVE,
|
|
72
73
|
"pending": "yellow",
|
|
@@ -560,6 +561,43 @@ def resume_goal(
|
|
|
560
561
|
typer.echo(f"Goal resumed: {resumed_id[:8]} → {result.get('new_status', 'pending')}")
|
|
561
562
|
|
|
562
563
|
|
|
564
|
+
@app.command("guide")
|
|
565
|
+
def guide_job(
|
|
566
|
+
job_id: str = typer.Argument(..., help="Job (root goal) ID."),
|
|
567
|
+
text: str = typer.Argument(..., help="Guidance text for the next Autopilot dispatch."),
|
|
568
|
+
goal_id: str | None = typer.Option(
|
|
569
|
+
None,
|
|
570
|
+
"--goal",
|
|
571
|
+
"-g",
|
|
572
|
+
help="Optional goal under the job (default: job-root / job-wide scope).",
|
|
573
|
+
),
|
|
574
|
+
) -> None:
|
|
575
|
+
"""Absorb operator guidance into ContextEngine (does not create goals).
|
|
576
|
+
|
|
577
|
+
Text is stored on the job/goal for Autopilot cognition and applied on the
|
|
578
|
+
next worker dispatch. It does not spawn or inject new goals.
|
|
579
|
+
"""
|
|
580
|
+
cleaned = (text or "").strip()
|
|
581
|
+
if not cleaned:
|
|
582
|
+
typer.echo("Guidance text must be non-empty.", err=True)
|
|
583
|
+
raise typer.Exit(1)
|
|
584
|
+
client = _require_daemon_ws()
|
|
585
|
+
try:
|
|
586
|
+
result = client.job_guidance(job_id, cleaned, goal_id=goal_id)
|
|
587
|
+
except RuntimeError as exc:
|
|
588
|
+
typer.echo(str(exc), err=True)
|
|
589
|
+
raise typer.Exit(1) from exc
|
|
590
|
+
absorbed = bool(result.get("absorbed"))
|
|
591
|
+
target = str(result.get("goal_id") or goal_id or job_id)
|
|
592
|
+
if not absorbed:
|
|
593
|
+
typer.echo(
|
|
594
|
+
f"Guidance not absorbed for job {job_id[:8]} goal {target[:8]}.",
|
|
595
|
+
err=True,
|
|
596
|
+
)
|
|
597
|
+
raise typer.Exit(1)
|
|
598
|
+
typer.echo(f"Guidance absorbed: job {job_id[:8]} → goal {target[:8]}")
|
|
599
|
+
|
|
600
|
+
|
|
563
601
|
def _short_loop_id(loop_id: str, *, head: int = 4, tail: int = 4) -> str:
|
|
564
602
|
"""Shorten loop ids as ``prefix…suffix`` (4 chars each by default)."""
|
|
565
603
|
text = str(loop_id or "")
|
|
@@ -29,7 +29,10 @@ from soothe_client import (
|
|
|
29
29
|
|
|
30
30
|
from soothe_cli.runtime import load_config
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
# Rich reads width from the TTY; under CI / test runners (no TTY) it falls back
|
|
33
|
+
# to a narrow default that truncates the Job ID column. Use a comfortable fixed
|
|
34
|
+
# width when stdout is not interactive so IDs render in full.
|
|
35
|
+
console = Console() if sys.stdout.isatty() else Console(width=100)
|
|
33
36
|
|
|
34
37
|
app = typer.Typer(help="Manage scheduled cron jobs — natural language scheduled tasks.")
|
|
35
38
|
|
|
@@ -148,7 +151,7 @@ def list_jobs(
|
|
|
148
151
|
return
|
|
149
152
|
|
|
150
153
|
table = Table(title="Scheduled Jobs")
|
|
151
|
-
table.add_column("Job ID", style="cyan", width=12)
|
|
154
|
+
table.add_column("Job ID", style="cyan", no_wrap=True, width=12)
|
|
152
155
|
table.add_column("Description", width=40)
|
|
153
156
|
table.add_column("Status", style="green", width=10)
|
|
154
157
|
table.add_column("Next Run", style="yellow", width=20)
|
|
@@ -156,8 +159,8 @@ def list_jobs(
|
|
|
156
159
|
|
|
157
160
|
for job in jobs:
|
|
158
161
|
desc = job.get("description", "")
|
|
159
|
-
if len(desc) >
|
|
160
|
-
desc = desc[:
|
|
162
|
+
if len(desc) > 28:
|
|
163
|
+
desc = desc[:25] + "..."
|
|
161
164
|
table.add_row(
|
|
162
165
|
job.get("id", "?")[:12],
|
|
163
166
|
desc,
|
|
@@ -23,6 +23,15 @@ _FRIENDLY_DAEMON_CONNECTION_LOST = (
|
|
|
23
23
|
"Send your message again to reconnect and continue this loop."
|
|
24
24
|
)
|
|
25
25
|
|
|
26
|
+
# Partial match for the attach-only idle timeout raised by ``iter_turn_chunks``
|
|
27
|
+
# when a stale ``live`` probe attaches to a loop whose runner already exited.
|
|
28
|
+
ATTACH_IDLE_TIMEOUT_MARKER = "attach window"
|
|
29
|
+
|
|
30
|
+
_FRIENDLY_ATTACH_IDLE_TIMEOUT = (
|
|
31
|
+
"No follow-on turn started within the attach window; the prior turn had "
|
|
32
|
+
"already completed. Ready for your next message."
|
|
33
|
+
)
|
|
34
|
+
|
|
26
35
|
|
|
27
36
|
def is_daemon_connection_error(exc: BaseException | str) -> bool:
|
|
28
37
|
"""Return whether an error indicates the daemon WebSocket is unavailable."""
|
|
@@ -51,6 +60,8 @@ def friendly_daemon_execution_error(exc: BaseException | str) -> str:
|
|
|
51
60
|
"""Map known daemon failures to concise, actionable copy."""
|
|
52
61
|
if is_daemon_connection_error(exc):
|
|
53
62
|
return _FRIENDLY_DAEMON_CONNECTION_LOST
|
|
63
|
+
if is_attach_idle_timeout(exc):
|
|
64
|
+
return _FRIENDLY_ATTACH_IDLE_TIMEOUT
|
|
54
65
|
if isinstance(exc, RuntimeError) and DAEMON_WORKER_SUBPROCESS_LOST in str(exc):
|
|
55
66
|
return _FRIENDLY_WORKER_SUBPROCESS_LOST
|
|
56
67
|
if isinstance(exc, RuntimeError) and DAEMON_WORKER_THREAD_LOST in str(exc):
|
|
@@ -73,3 +84,12 @@ def is_daemon_worker_thread_lost(exc: BaseException | str) -> bool:
|
|
|
73
84
|
"""Return whether an error indicates a pool worker thread exited mid-query."""
|
|
74
85
|
text = str(exc)
|
|
75
86
|
return DAEMON_WORKER_THREAD_LOST in text
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_attach_idle_timeout(exc: BaseException | str) -> bool:
|
|
90
|
+
"""Return whether an error is the attach-only idle timeout.
|
|
91
|
+
|
|
92
|
+
Raised by ``iter_turn_chunks`` when a stale ``live`` probe attached to a
|
|
93
|
+
loop whose runner had already exited (no follow-on turn materialized).
|
|
94
|
+
"""
|
|
95
|
+
return ATTACH_IDLE_TIMEOUT_MARKER in str(exc)
|
|
@@ -217,11 +217,12 @@ def main(
|
|
|
217
217
|
typer.Option(
|
|
218
218
|
"--plan-panel/--no-plan-panel",
|
|
219
219
|
help=(
|
|
220
|
-
"Auto-show the in-flow plan panel
|
|
221
|
-
"Ctrl+t toggles
|
|
220
|
+
"Auto-show the in-flow plan panel while a goal is executing "
|
|
221
|
+
"and hide it when the goal completes. Ctrl+t toggles "
|
|
222
|
+
"thereafter. Default: enabled."
|
|
222
223
|
),
|
|
223
224
|
),
|
|
224
|
-
] =
|
|
225
|
+
] = True,
|
|
225
226
|
show_version: Annotated[ # noqa: FBT002
|
|
226
227
|
bool,
|
|
227
228
|
typer.Option("--version", help="Show version and exit."),
|
|
@@ -33,8 +33,9 @@ class CLIConfig:
|
|
|
33
33
|
"""Markdown appearance preset (``match-app``, ``langchain``, ``standard``, …)."""
|
|
34
34
|
|
|
35
35
|
# Plan panel visibility preference
|
|
36
|
-
plan_panel_default_visible: bool =
|
|
37
|
-
"""Auto-show the in-flow plan panel
|
|
36
|
+
plan_panel_default_visible: bool = True
|
|
37
|
+
"""Auto-show the in-flow plan panel while a goal is executing and hide it
|
|
38
|
+
when the goal completes. Ctrl+t toggles the panel thereafter."""
|
|
38
39
|
|
|
39
40
|
# Output streaming overrides (RFC-614)
|
|
40
41
|
output_streaming_enabled: bool | None = None
|
|
@@ -477,9 +477,10 @@ class SootheApp(
|
|
|
477
477
|
)
|
|
478
478
|
yield Container(id="messages")
|
|
479
479
|
# In-flow plan panel: sits above the sticky bottom chrome so Ctrl+t
|
|
480
|
-
# never covers the thinking row or chat input. Auto-shows
|
|
481
|
-
# is
|
|
482
|
-
|
|
480
|
+
# never covers the thinking row or chat input. Auto-shows while a goal
|
|
481
|
+
# is executing and auto-hides when it completes (default) unless
|
|
482
|
+
# CLIConfig.plan_panel_default_visible is False.
|
|
483
|
+
plan_visible = getattr(self._daemon_config, "plan_panel_default_visible", True)
|
|
483
484
|
yield PlanQuickViewOverlay(
|
|
484
485
|
id="plan-quick-view-overlay",
|
|
485
486
|
default_visible=plan_visible,
|
|
@@ -20,6 +20,7 @@ from textual.style import Style as TStyle
|
|
|
20
20
|
|
|
21
21
|
from soothe_cli.cli.execution.daemon_errors import (
|
|
22
22
|
friendly_daemon_connection_error,
|
|
23
|
+
is_attach_idle_timeout,
|
|
23
24
|
is_daemon_connection_error,
|
|
24
25
|
)
|
|
25
26
|
from soothe_cli.cli.execution.daemon_errors import (
|
|
@@ -271,6 +272,15 @@ class _ExecutionMixin:
|
|
|
271
272
|
non_empty = [a for a in event.answers if a.strip()]
|
|
272
273
|
if not non_empty:
|
|
273
274
|
return
|
|
275
|
+
|
|
276
|
+
# When a plan is approved for execution, switch composer mode to Auto
|
|
277
|
+
# so subsequent turns use standard loop routing without planner hint.
|
|
278
|
+
first_answer = str(event.answers[0]).strip() if event.answers else ""
|
|
279
|
+
if first_answer == "Approve":
|
|
280
|
+
self._composer_mode = "auto"
|
|
281
|
+
if self._status_bar is not None:
|
|
282
|
+
self._status_bar.set_clarification_mode("auto")
|
|
283
|
+
|
|
274
284
|
# Send the answers as a structured list so the daemon resumes the
|
|
275
285
|
# graph with one answer per question instead of broadcasting a single
|
|
276
286
|
# concatenated string. ``content`` carries a human-readable summary
|
|
@@ -848,6 +858,51 @@ class _ExecutionMixin:
|
|
|
848
858
|
else:
|
|
849
859
|
await self._mount_message(AppMessage("Agent not configured for this session."))
|
|
850
860
|
|
|
861
|
+
async def _daemon_loop_is_live(self) -> bool:
|
|
862
|
+
"""Return True when the subscribed daemon loop has a live runner now.
|
|
863
|
+
|
|
864
|
+
Used to attach with ``skip_daemon_send_turn`` instead of enqueueing a
|
|
865
|
+
duplicate ``loop_input`` (stale-reader / ghost follow-on goals).
|
|
866
|
+
|
|
867
|
+
The active-runner signal (``active_runner``) is authoritative: a loop's
|
|
868
|
+
metadata ``status`` field can lag ``"running"`` for up to the 5-minute
|
|
869
|
+
reconciliation window after its runner exits, so status alone produces
|
|
870
|
+
false positives that leave the TUI attached to a phantom follow-on turn
|
|
871
|
+
for minutes. When the daemon exposes ``active_runner`` we require it;
|
|
872
|
+
only as a fallback for daemons without the field do we fall back to the
|
|
873
|
+
status check, and even then we treat a ``None`` (unknown) signal as
|
|
874
|
+
not-live to avoid the stale-status hang.
|
|
875
|
+
"""
|
|
876
|
+
session = self._daemon_session
|
|
877
|
+
state = self._session_state
|
|
878
|
+
if session is None or state is None:
|
|
879
|
+
return False
|
|
880
|
+
loop_id = str(getattr(state, "loop_id", "") or "").strip()
|
|
881
|
+
if not loop_id:
|
|
882
|
+
return False
|
|
883
|
+
try:
|
|
884
|
+
exec_state = await session.fetch_execution_state(loop_id)
|
|
885
|
+
except Exception:
|
|
886
|
+
logger.debug("live probe via fetch_execution_state failed", exc_info=True)
|
|
887
|
+
return False
|
|
888
|
+
status = str(getattr(exec_state, "status", "") or "").strip().lower()
|
|
889
|
+
if status != "running":
|
|
890
|
+
return False
|
|
891
|
+
active_runner = getattr(exec_state, "active_runner", None)
|
|
892
|
+
if active_runner is True:
|
|
893
|
+
return True
|
|
894
|
+
# ``None`` = daemon older than the field, or probe failed. Only then do
|
|
895
|
+
# we consult the card-ledger live-goal index — and only as a weak hint
|
|
896
|
+
# that still requires status=="running" (already checked above).
|
|
897
|
+
if active_runner is None:
|
|
898
|
+
try:
|
|
899
|
+
history = await session.fetch_loop_history(loop_id)
|
|
900
|
+
if getattr(history, "live_goal_index", None) is not None:
|
|
901
|
+
return True
|
|
902
|
+
except Exception:
|
|
903
|
+
logger.debug("live probe via fetch_loop_history failed", exc_info=True)
|
|
904
|
+
return False
|
|
905
|
+
|
|
851
906
|
async def _run_agent_task(
|
|
852
907
|
self,
|
|
853
908
|
message: str,
|
|
@@ -883,6 +938,14 @@ class _ExecutionMixin:
|
|
|
883
938
|
try:
|
|
884
939
|
for attempt in (1, 2):
|
|
885
940
|
try:
|
|
941
|
+
attach_only = skip_daemon_send_turn
|
|
942
|
+
if (
|
|
943
|
+
not attach_only
|
|
944
|
+
and self._daemon_session is not None
|
|
945
|
+
and await self._daemon_loop_is_live()
|
|
946
|
+
):
|
|
947
|
+
attach_only = True
|
|
948
|
+
logger.info("Daemon loop already live; attaching without send_turn")
|
|
886
949
|
await execute_task_textual(
|
|
887
950
|
user_input=message,
|
|
888
951
|
daemon_session=self._daemon_session,
|
|
@@ -898,7 +961,7 @@ class _ExecutionMixin:
|
|
|
898
961
|
router_profile=self._router_profile_override,
|
|
899
962
|
),
|
|
900
963
|
turn_stats=turn_stats,
|
|
901
|
-
skip_daemon_send_turn=
|
|
964
|
+
skip_daemon_send_turn=attach_only,
|
|
902
965
|
clarification_mode=wire_clar,
|
|
903
966
|
sticky_preferred_subagent=sticky_subagent,
|
|
904
967
|
is_shutting_down=lambda: getattr(self, "_exit", False),
|
|
@@ -923,23 +986,40 @@ class _ExecutionMixin:
|
|
|
923
986
|
pass
|
|
924
987
|
raise
|
|
925
988
|
except Exception as e: # Resilient tool rendering
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
989
|
+
# Attach-only idle timeout is benign: the prior turn had already
|
|
990
|
+
# completed and no follow-on turn materialized. Surface it as an
|
|
991
|
+
# informational message (not a red error) and fall through to
|
|
992
|
+
# normal cleanup so the TUI returns to a ready state.
|
|
993
|
+
if is_attach_idle_timeout(e):
|
|
994
|
+
logger.info("Attach-only idle timeout: no follow-on turn; returning to ready")
|
|
995
|
+
try:
|
|
996
|
+
await self._mount_message(AppMessage(_friendly_agent_execution_error(e)))
|
|
997
|
+
except Exception:
|
|
998
|
+
logger.debug(
|
|
999
|
+
"Could not mount attach-timeout message (app closing?)", exc_info=True
|
|
1000
|
+
)
|
|
930
1001
|
else:
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1002
|
+
logger.exception("Agent execution failed")
|
|
1003
|
+
if is_daemon_connection_error(e):
|
|
1004
|
+
display_err = friendly_daemon_connection_error(e)
|
|
1005
|
+
error_title = "Daemon connection error"
|
|
1006
|
+
else:
|
|
1007
|
+
display_err = _friendly_agent_execution_error(e)
|
|
1008
|
+
error_title = "Agent error"
|
|
1009
|
+
# Ensure any in-flight tool calls don't remain stuck in "Running..."
|
|
1010
|
+
# when streaming aborts before tool results arrive.
|
|
1011
|
+
if self._ui_adapter:
|
|
1012
|
+
self._ui_adapter.finalize_pending_tools_with_error(
|
|
1013
|
+
f"{error_title}: {display_err}"
|
|
1014
|
+
)
|
|
1015
|
+
self._ui_adapter.finalize_pending_steps_with_error(
|
|
1016
|
+
f"{error_title}: {display_err}"
|
|
1017
|
+
)
|
|
1018
|
+
await self._try_recover_goal_completion_from_ledger()
|
|
1019
|
+
try:
|
|
1020
|
+
await self._mount_message(ErrorMessage(f"{error_title}. {display_err}"))
|
|
1021
|
+
except Exception:
|
|
1022
|
+
logger.debug("Could not mount error message (app closing?)", exc_info=True)
|
|
943
1023
|
finally:
|
|
944
1024
|
# Merge turn stats before cleanup — _cleanup_agent_task may raise
|
|
945
1025
|
# during teardown (widget removal on a torn-down DOM), and stats
|
|
@@ -1049,5 +1129,42 @@ class _ExecutionMixin:
|
|
|
1049
1129
|
)
|
|
1050
1130
|
)
|
|
1051
1131
|
|
|
1132
|
+
# If the daemon already admitted a follow-on goal (serial input queue /
|
|
1133
|
+
# successor turn), re-attach without sending another loop_input.
|
|
1134
|
+
if (
|
|
1135
|
+
not self._exit
|
|
1136
|
+
and self._daemon_session is not None
|
|
1137
|
+
and self._ui_adapter is not None
|
|
1138
|
+
and self._runtime_backend_ready()
|
|
1139
|
+
and await self._daemon_loop_is_live()
|
|
1140
|
+
):
|
|
1141
|
+
await self._attach_to_live_daemon_turn()
|
|
1142
|
+
return
|
|
1143
|
+
|
|
1052
1144
|
# Process next message from queue if any
|
|
1053
1145
|
await self._process_next_from_queue()
|
|
1146
|
+
|
|
1147
|
+
async def _attach_to_live_daemon_turn(self) -> None:
|
|
1148
|
+
"""Attach the TUI reader to an already-running daemon turn.
|
|
1149
|
+
|
|
1150
|
+
When a local queue head exists, mount it as the user echo then attach
|
|
1151
|
+
with ``skip_daemon_send_turn`` (prompt already on the daemon). Otherwise
|
|
1152
|
+
attach with an empty prompt so activity keeps streaming.
|
|
1153
|
+
"""
|
|
1154
|
+
prompt = ""
|
|
1155
|
+
if self._pending_messages:
|
|
1156
|
+
msg = self._pending_messages.popleft()
|
|
1157
|
+
prompt = msg.text
|
|
1158
|
+
if self._queued_widgets:
|
|
1159
|
+
widget = self._queued_widgets.popleft()
|
|
1160
|
+
with suppress(Exception):
|
|
1161
|
+
await widget.remove()
|
|
1162
|
+
self._refresh_queued_goal_tips()
|
|
1163
|
+
if msg.mode != "normal":
|
|
1164
|
+
# Shell/command still need their normal handlers.
|
|
1165
|
+
await self._process_message(msg.text, msg.mode)
|
|
1166
|
+
return
|
|
1167
|
+
if prompt.strip():
|
|
1168
|
+
await self._mount_message(UserMessage(prompt))
|
|
1169
|
+
logger.info("Attaching TUI to live daemon turn (skip send_turn)")
|
|
1170
|
+
await self._send_to_agent(prompt, skip_daemon_send_turn=True)
|
|
@@ -34,6 +34,19 @@ class BypassTier(StrEnum):
|
|
|
34
34
|
"""Must wait in the queue when the app is busy."""
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
class EnterAction(StrEnum):
|
|
38
|
+
"""How Enter behaves when a slash autocomplete suggestion is selected.
|
|
39
|
+
|
|
40
|
+
Tab always completes without submitting. Click also completes only.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
EXECUTE = "execute"
|
|
44
|
+
"""One-stage: insert the command token and submit immediately."""
|
|
45
|
+
|
|
46
|
+
COMPLETE = "complete"
|
|
47
|
+
"""Two-stage: insert the command token (with trailing space) for more typing."""
|
|
48
|
+
|
|
49
|
+
|
|
37
50
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
38
51
|
class SlashCommand:
|
|
39
52
|
"""A single slash-command definition."""
|
|
@@ -53,6 +66,9 @@ class SlashCommand:
|
|
|
53
66
|
aliases: tuple[str, ...] = ()
|
|
54
67
|
"""Alternative names (e.g. `("/q",)` for `/quit`)."""
|
|
55
68
|
|
|
69
|
+
enter_action: EnterAction = EnterAction.EXECUTE
|
|
70
|
+
"""Enter key behavior when completing this command from autocomplete."""
|
|
71
|
+
|
|
56
72
|
|
|
57
73
|
COMMANDS: tuple[SlashCommand, ...] = (
|
|
58
74
|
SlashCommand(
|
|
@@ -60,12 +76,14 @@ COMMANDS: tuple[SlashCommand, ...] = (
|
|
|
60
76
|
description="Submit autopilot job (usage: /autopilot <task>)",
|
|
61
77
|
bypass_tier=BypassTier.QUEUED,
|
|
62
78
|
hidden_keywords="goals autonomous job submit",
|
|
79
|
+
enter_action=EnterAction.COMPLETE,
|
|
63
80
|
),
|
|
64
81
|
SlashCommand(
|
|
65
82
|
name="/cron",
|
|
66
83
|
description="Add scheduled job (usage: /cron <natural language>)",
|
|
67
84
|
bypass_tier=BypassTier.QUEUED,
|
|
68
85
|
hidden_keywords="schedule timer reminder",
|
|
86
|
+
enter_action=EnterAction.COMPLETE,
|
|
69
87
|
),
|
|
70
88
|
SlashCommand(
|
|
71
89
|
name="/clear",
|
|
@@ -124,22 +142,26 @@ COMMANDS: tuple[SlashCommand, ...] = (
|
|
|
124
142
|
name="/deep_research",
|
|
125
143
|
description="Route prompt to deep_research subagent (usage: /deep_research <query>)",
|
|
126
144
|
bypass_tier=BypassTier.QUEUED,
|
|
145
|
+
enter_action=EnterAction.COMPLETE,
|
|
127
146
|
),
|
|
128
147
|
SlashCommand(
|
|
129
148
|
name="/academic_research",
|
|
130
149
|
description="Route prompt to academic_research subagent (usage: /academic_research <query>)",
|
|
131
150
|
bypass_tier=BypassTier.QUEUED,
|
|
151
|
+
enter_action=EnterAction.COMPLETE,
|
|
132
152
|
),
|
|
133
153
|
SlashCommand(
|
|
134
154
|
name="/browser_use",
|
|
135
155
|
description="Route prompt to browser_use subagent (usage: /browser_use <task>)",
|
|
136
156
|
bypass_tier=BypassTier.QUEUED,
|
|
137
157
|
hidden_keywords="browser automation web",
|
|
158
|
+
enter_action=EnterAction.COMPLETE,
|
|
138
159
|
),
|
|
139
160
|
SlashCommand(
|
|
140
161
|
name="/plan",
|
|
141
162
|
description="Send input in plan mode (usage: /plan or /plan <prompt>)",
|
|
142
163
|
bypass_tier=BypassTier.QUEUED,
|
|
164
|
+
enter_action=EnterAction.COMPLETE,
|
|
143
165
|
),
|
|
144
166
|
SlashCommand(
|
|
145
167
|
name="/reload",
|
|
@@ -229,6 +251,29 @@ def resolve_command_head(command: str) -> str:
|
|
|
229
251
|
return _ALIAS_TO_CANONICAL.get(head, head)
|
|
230
252
|
|
|
231
253
|
|
|
254
|
+
_CANONICAL_ENTER_ACTION: dict[str, EnterAction] = {cmd.name: cmd.enter_action for cmd in COMMANDS}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def enter_action_for(command: str) -> EnterAction:
|
|
258
|
+
"""Resolve Enter autocomplete behavior for a completed slash token.
|
|
259
|
+
|
|
260
|
+
Dynamic skill rows (`/skill:<name>`) always use two-stage complete so the
|
|
261
|
+
operator can append arguments before submitting.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
command: Completed suggestion label (e.g. ``/clear`` or ``/skill:foo``).
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
`EnterAction.EXECUTE` for one-stage submit, or `EnterAction.COMPLETE`
|
|
268
|
+
for insert-only. Unknown static commands default to execute.
|
|
269
|
+
"""
|
|
270
|
+
stripped = command.strip()
|
|
271
|
+
if stripped.startswith(("/skill:", "/skills:")):
|
|
272
|
+
return EnterAction.COMPLETE
|
|
273
|
+
head = resolve_command_head(stripped)
|
|
274
|
+
return _CANONICAL_ENTER_ACTION.get(head, EnterAction.EXECUTE)
|
|
275
|
+
|
|
276
|
+
|
|
232
277
|
# ---------------------------------------------------------------------------
|
|
233
278
|
# Derived bypass-tier frozensets
|
|
234
279
|
# ---------------------------------------------------------------------------
|
|
@@ -299,15 +344,6 @@ def parse_skill_command(command: str) -> tuple[str, str]:
|
|
|
299
344
|
return skill_name, args
|
|
300
345
|
|
|
301
346
|
|
|
302
|
-
_STATIC_SKILL_ALIASES: frozenset[str] = frozenset()
|
|
303
|
-
"""Skill names that have a dedicated top-level slash command in `COMMANDS`.
|
|
304
|
-
|
|
305
|
-
Only list skills whose `/skill:<name>` autocomplete entry is redundant because
|
|
306
|
-
a `/<name>` convenience alias exists. Do **not** add every command name here —
|
|
307
|
-
that would suppress unrelated user skills that share a name with a slash command.
|
|
308
|
-
"""
|
|
309
|
-
|
|
310
|
-
|
|
311
347
|
def build_skill_commands_from_wire(
|
|
312
348
|
rows: list[dict[str, Any]],
|
|
313
349
|
) -> list[tuple[str, str, str]]:
|
|
@@ -322,7 +358,7 @@ def build_skill_commands_from_wire(
|
|
|
322
358
|
tuples: list[tuple[str, str, str]] = []
|
|
323
359
|
for row in rows:
|
|
324
360
|
name = str(row.get("name", "")).strip().lower()
|
|
325
|
-
if not name
|
|
361
|
+
if not name:
|
|
326
362
|
continue
|
|
327
363
|
desc = str(row.get("description", "")).strip()
|
|
328
364
|
tuples.append((f"/skill:{name}", desc, name))
|
|
@@ -338,17 +374,10 @@ def build_skill_commands(
|
|
|
338
374
|
Each skill becomes a `/skill:<name>` entry with its description
|
|
339
375
|
and the skill name as a hidden keyword for fuzzy matching.
|
|
340
376
|
|
|
341
|
-
Skills listed in `_STATIC_SKILL_ALIASES` are excluded to avoid duplicate
|
|
342
|
-
autocomplete entries when a top-level alias exists.
|
|
343
|
-
|
|
344
377
|
Args:
|
|
345
378
|
skills: List of discovered skill metadata.
|
|
346
379
|
|
|
347
380
|
Returns:
|
|
348
381
|
List of `(name, description, hidden_keywords)` tuples.
|
|
349
382
|
"""
|
|
350
|
-
return [
|
|
351
|
-
(f"/skill:{skill['name']}", skill["description"], skill["name"])
|
|
352
|
-
for skill in skills
|
|
353
|
-
if skill["name"] not in _STATIC_SKILL_ALIASES
|
|
354
|
-
]
|
|
383
|
+
return [(f"/skill:{skill['name']}", skill["description"], skill["name"]) for skill in skills]
|
|
@@ -153,6 +153,8 @@ def show_cron_add(console: Console, data: dict[str, Any]) -> None:
|
|
|
153
153
|
# ---------------------------------------------------------------------------
|
|
154
154
|
|
|
155
155
|
KEYBOARD_SHORTCUTS: dict[str, str] = {
|
|
156
|
+
"Enter": "Submit message; with slash suggestions, run or fill for more input",
|
|
157
|
+
"Tab": "Accept slash or @ file autocomplete (never submits)",
|
|
156
158
|
"Esc": "Dismiss modal, plan overlay, or autocomplete",
|
|
157
159
|
"Ctrl+D": "Type exit, quit, or /quit to exit the TUI",
|
|
158
160
|
"Ctrl+C": "Clear input or interrupt running agent/shell",
|
|
@@ -535,6 +535,15 @@ _EXECUTE_WAVE_UI_COALESCE_SEC = 0.2
|
|
|
535
535
|
_CHUNK_YIELD_INTERVAL = 12
|
|
536
536
|
_CHUNK_YIELD_BUDGET_SEC = 0.016
|
|
537
537
|
|
|
538
|
+
# Hard cap on how long an attach-only read (``skip_daemon_send_turn``) waits
|
|
539
|
+
# for the first progress event before giving up. A stale ``live`` probe can
|
|
540
|
+
# leave the TUI attached to a phantom follow-on turn whose runner already
|
|
541
|
+
# exited; without this bound the thinking row spins for minutes until the
|
|
542
|
+
# daemon's 5-minute status reconciliation catches up. ``TimeoutError`` raised
|
|
543
|
+
# here is caught by the normal agent-execution error path, which falls back to
|
|
544
|
+
# ``_process_next_from_queue``.
|
|
545
|
+
_ATTACH_ONLY_IDLE_TIMEOUT_S = 45.0
|
|
546
|
+
|
|
538
547
|
|
|
539
548
|
class TurnToolUiCoalescer:
|
|
540
549
|
"""Batch tool-card repaints, dedupe wire kwargs, and yield during dense streams."""
|
|
@@ -3139,7 +3148,9 @@ async def execute_task_textual(
|
|
|
3139
3148
|
adapter._clarification_answers_pending = None
|
|
3140
3149
|
try:
|
|
3141
3150
|
if skip_daemon_send_turn:
|
|
3142
|
-
chunk_source = daemon_session.iter_turn_chunks(
|
|
3151
|
+
chunk_source = daemon_session.iter_turn_chunks(
|
|
3152
|
+
idle_timeout_s=_ATTACH_ONLY_IDLE_TIMEOUT_S
|
|
3153
|
+
)
|
|
3143
3154
|
else:
|
|
3144
3155
|
subagent_name, routed_text = parse_subagent_from_input(final_input)
|
|
3145
3156
|
if subagent_name is None and sticky_preferred_subagent:
|
|
@@ -17,6 +17,7 @@ from enum import StrEnum
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from typing import TYPE_CHECKING, Protocol
|
|
19
19
|
|
|
20
|
+
from soothe_cli.tui.command_registry import EnterAction, enter_action_for
|
|
20
21
|
from soothe_cli.tui.path_utils import path_is_file
|
|
21
22
|
from soothe_cli.tui.project_utils import find_project_root
|
|
22
23
|
|
|
@@ -39,7 +40,7 @@ class CompletionResult(StrEnum):
|
|
|
39
40
|
|
|
40
41
|
IGNORED = "ignored" # Key not handled, let default behavior proceed
|
|
41
42
|
HANDLED = "handled" # Key handled, prevent default
|
|
42
|
-
SUBMIT = "submit" #
|
|
43
|
+
SUBMIT = "submit" # Complete + submit (one-stage slash Enter)
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
class CompletionView(Protocol):
|
|
@@ -235,6 +236,10 @@ class SlashCommandController:
|
|
|
235
236
|
def on_key(self, event: events.Key, _text: str, cursor_index: int) -> CompletionResult:
|
|
236
237
|
"""Handle key events for navigation and selection.
|
|
237
238
|
|
|
239
|
+
Enter follows each command's `enter_action`: one-stage commands submit
|
|
240
|
+
immediately; two-stage commands (skills, query routers) only complete
|
|
241
|
+
into the input. Tab always completes without submitting.
|
|
242
|
+
|
|
238
243
|
Returns:
|
|
239
244
|
CompletionResult indicating how the key was handled.
|
|
240
245
|
"""
|
|
@@ -247,7 +252,10 @@ class SlashCommandController:
|
|
|
247
252
|
return CompletionResult.HANDLED
|
|
248
253
|
return CompletionResult.IGNORED
|
|
249
254
|
case "enter":
|
|
250
|
-
|
|
255
|
+
selected = self._suggestions[self._selected_index][0]
|
|
256
|
+
if not self._apply_selected_completion(cursor_index):
|
|
257
|
+
return CompletionResult.HANDLED
|
|
258
|
+
if enter_action_for(selected) == EnterAction.EXECUTE:
|
|
251
259
|
return CompletionResult.SUBMIT
|
|
252
260
|
return CompletionResult.HANDLED
|
|
253
261
|
case "down":
|
|
@@ -49,7 +49,8 @@ def build_keyboard_shortcut_rows() -> list[tuple[str, str]]:
|
|
|
49
49
|
"""
|
|
50
50
|
newline = newline_shortcut()
|
|
51
51
|
return [
|
|
52
|
-
("Enter", "Submit message"),
|
|
52
|
+
("Enter", "Submit message; with slash suggestions, run or fill for more input"),
|
|
53
|
+
("Tab", "Accept slash or @ file autocomplete (never submits)"),
|
|
53
54
|
(
|
|
54
55
|
f"{newline}, Shift+Enter, Alt+Enter, Ctrl+Enter",
|
|
55
56
|
"Insert newline in the chat input",
|