soothe-cli 0.5.26__tar.gz → 0.5.28__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.5.26 → soothe_cli-0.5.28}/.gitignore +1 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/PKG-INFO +1 -1
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/execution/daemon.py +0 -1
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/main.py +15 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/config/cli_config.py +4 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/headless/processor.py +2 -5
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/policy/display_policy.py +5 -8
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/transport/session.py +36 -8
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/turn/prepare.py +2 -2
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_app.py +5 -3
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_execution.py +61 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_messages_mixin.py +66 -9
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_startup.py +8 -1
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/app.tcss +9 -4
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/preview_limits.py +2 -2
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/textual_adapter.py +175 -1
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/messages.py +353 -74
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/status.py +106 -19
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/README.md +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/pyproject.toml +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/__init__.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/__init__.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/commands/__init__.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/execution/__init__.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/execution/headless.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/execution/launcher.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/config/__init__.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/config/loader.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/config/logging_setup.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/__init__.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/parse/_utils.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/parse/tool_message_format.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/presentation/async_renderer_protocol.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/presentation/engine.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/state/session_stats.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/state/step_router.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/state/transcript.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/task_scope.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/wire/display_text.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/wire/message_text.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/wire/messages.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/__init__.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/_cli_context.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/_env_vars.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/_version.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/__init__.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_commands.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_history.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_model.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_module_init.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_ui.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/binding.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/command_registry.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/commands/__init__.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/commands/command_router.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/config.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/file_change_notify.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/file_change_renderers.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/hooks.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/input.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/media_utils.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/model_config.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/path_utils.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/project_utils.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/sessions.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/skills/__init__.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/skills/invocation.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/skills/load.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/theme.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/tips.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/tool_display.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/unicode_security.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/update_check.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/__init__.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/_links.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/diff.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/editor.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/history.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/loading.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/message_store.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
- {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/welcome.py +0 -0
|
@@ -91,7 +91,6 @@ async def _run_headless_session_once(
|
|
|
91
91
|
status_event = await bootstrap_loop_session(
|
|
92
92
|
client,
|
|
93
93
|
resume_loop_id=resume_loop_id,
|
|
94
|
-
verbosity="normal",
|
|
95
94
|
stream_delivery=stream_delivery,
|
|
96
95
|
workspace=cli_ws,
|
|
97
96
|
subscribe_timeout_s=_SESSION_BOOTSTRAP_TIMEOUT_S,
|
|
@@ -120,6 +120,17 @@ def main(
|
|
|
120
120
|
help="Path to additional MCP server config (JSON/YAML) to merge into daemon config.",
|
|
121
121
|
),
|
|
122
122
|
] = None,
|
|
123
|
+
mode: Annotated[
|
|
124
|
+
str | None,
|
|
125
|
+
typer.Option(
|
|
126
|
+
"--mode",
|
|
127
|
+
help=(
|
|
128
|
+
"Clarification mode: 'manual' (relay AI questions to you) or "
|
|
129
|
+
"'auto' (veritas auto-answers). Default: 'manual' when stdin is "
|
|
130
|
+
"a TTY, 'auto' otherwise."
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
] = None,
|
|
123
134
|
show_help: Annotated[ # noqa: FBT002
|
|
124
135
|
bool,
|
|
125
136
|
typer.Option("--help", "-h", is_flag=True, help="Show this message and exit."),
|
|
@@ -154,6 +165,9 @@ def main(
|
|
|
154
165
|
raise typer.Exit
|
|
155
166
|
|
|
156
167
|
home_path = Path(soothe_home).expanduser() if soothe_home else Path(SOOTHE_HOME)
|
|
168
|
+
if mode is not None and mode not in ("manual", "auto"):
|
|
169
|
+
typer.echo(f"Invalid --mode {mode!r}; expected 'manual' or 'auto'.", err=True)
|
|
170
|
+
raise typer.Exit(code=2)
|
|
157
171
|
cli_cfg = CLIConfig(
|
|
158
172
|
daemon_host=daemon_host,
|
|
159
173
|
daemon_port=daemon_port,
|
|
@@ -161,6 +175,7 @@ def main(
|
|
|
161
175
|
render_markdown=render_markdown,
|
|
162
176
|
output_streaming_enabled=streaming,
|
|
163
177
|
output_streaming_mode=streaming_mode,
|
|
178
|
+
clarification_mode=mode,
|
|
164
179
|
soothe_home=home_path,
|
|
165
180
|
)
|
|
166
181
|
set_runtime_config(cli_cfg)
|
|
@@ -37,6 +37,10 @@ class CLIConfig:
|
|
|
37
37
|
output_streaming_mode: str | None = None
|
|
38
38
|
"""Override daemon streaming mode: 'streaming' or 'batch'."""
|
|
39
39
|
|
|
40
|
+
# RFC-622: clarification relay mode
|
|
41
|
+
clarification_mode: str | None = None
|
|
42
|
+
"""'manual' (relay to human) or 'auto' (veritas auto-answer). None = auto-detect from TTY."""
|
|
43
|
+
|
|
40
44
|
# Paths
|
|
41
45
|
soothe_home: Path = field(default_factory=lambda: Path(SOOTHE_HOME))
|
|
42
46
|
|
|
@@ -273,8 +273,6 @@ class EventProcessor:
|
|
|
273
273
|
"""Display logic for RFC-614 loop-tagged assistant messages (IG-317 / IG-343)."""
|
|
274
274
|
if phase not in LOOP_ASSISTANT_OUTPUT_PHASES:
|
|
275
275
|
return
|
|
276
|
-
if not self._presentation.tier_visible(VerbosityTier.QUIET):
|
|
277
|
-
return
|
|
278
276
|
|
|
279
277
|
accum_key = _loop_msg_accum_event_key(phase)
|
|
280
278
|
streaming_config = self._get_effective_streaming_config()
|
|
@@ -925,8 +923,7 @@ class EventProcessor:
|
|
|
925
923
|
etype = data.get("type", "")
|
|
926
924
|
|
|
927
925
|
if self._headless_output:
|
|
928
|
-
|
|
929
|
-
if category == VerbosityTier.QUIET and "error" in etype:
|
|
926
|
+
if etype.startswith("soothe.error."):
|
|
930
927
|
error_text = data.get("error", data.get("message", str(etype)))
|
|
931
928
|
self._renderer.on_error(error_text)
|
|
932
929
|
return
|
|
@@ -938,7 +935,7 @@ class EventProcessor:
|
|
|
938
935
|
# Update plan state and call specific hooks
|
|
939
936
|
if etype == PLAN_CREATED:
|
|
940
937
|
self._handle_plan_created(data)
|
|
941
|
-
elif
|
|
938
|
+
elif etype.startswith("soothe.error."):
|
|
942
939
|
error_text = data.get("error", data.get("message", str(etype)))
|
|
943
940
|
self._renderer.on_error(error_text)
|
|
944
941
|
elif self._presentation.tier_visible(category):
|
|
@@ -38,13 +38,10 @@ from soothe_sdk.ux import classify_event_to_tier
|
|
|
38
38
|
# Event types that should NEVER be shown (internal implementation details)
|
|
39
39
|
INTERNAL_EVENT_TYPES: frozenset[str] = frozenset()
|
|
40
40
|
|
|
41
|
-
# Event types to skip in progress display (handled by plan update mechanism or not rendered)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"soothe.cognition.plan.batch.started",
|
|
46
|
-
}
|
|
47
|
-
)
|
|
41
|
+
# Event types to skip in progress display (handled by plan update mechanism or not rendered).
|
|
42
|
+
# `soothe.cognition.plan.batch.started` was moved to `soothe.internal.plan.batch.started`
|
|
43
|
+
# and is now filtered automatically by `is_internal_event`.
|
|
44
|
+
SKIP_EVENT_TYPES: frozenset[str] = frozenset()
|
|
48
45
|
|
|
49
46
|
PLAN_EVENT_TYPES = frozenset(
|
|
50
47
|
{
|
|
@@ -152,7 +149,7 @@ class DisplayPolicy:
|
|
|
152
149
|
|
|
153
150
|
def is_internal_event(self, event_type: str) -> bool:
|
|
154
151
|
"""Check if this is an internal (never-shown) event."""
|
|
155
|
-
return event_type in INTERNAL_EVENT_TYPES or "internal"
|
|
152
|
+
return event_type in INTERNAL_EVENT_TYPES or event_type.startswith("soothe.internal.")
|
|
156
153
|
|
|
157
154
|
|
|
158
155
|
# =============================================================================
|
|
@@ -64,7 +64,6 @@ class TuiDaemonSession:
|
|
|
64
64
|
status_event = await bootstrap_loop_session(
|
|
65
65
|
self._client,
|
|
66
66
|
resume_loop_id=resume_loop_id,
|
|
67
|
-
verbosity="normal",
|
|
68
67
|
stream_delivery=stream_delivery,
|
|
69
68
|
workspace=self._workspace,
|
|
70
69
|
)
|
|
@@ -147,6 +146,9 @@ class TuiDaemonSession:
|
|
|
147
146
|
model: str | None = None,
|
|
148
147
|
model_params: dict[str, Any] | None = None,
|
|
149
148
|
attachments: list[dict[str, str]] | None = None,
|
|
149
|
+
clarification_mode: str | None = None,
|
|
150
|
+
clarification_answer: bool = False,
|
|
151
|
+
clarification_answers: list[str] | None = None,
|
|
150
152
|
) -> None:
|
|
151
153
|
"""Send a new user turn to the daemon."""
|
|
152
154
|
if not self._loop_id:
|
|
@@ -160,6 +162,9 @@ class TuiDaemonSession:
|
|
|
160
162
|
model=model,
|
|
161
163
|
model_params=model_params,
|
|
162
164
|
attachments=attachments,
|
|
165
|
+
clarification_mode=clarification_mode,
|
|
166
|
+
clarification_answer=clarification_answer,
|
|
167
|
+
clarification_answers=clarification_answers,
|
|
163
168
|
)
|
|
164
169
|
|
|
165
170
|
async def cancel_remote_query(self) -> None:
|
|
@@ -343,16 +348,31 @@ class TuiDaemonSession:
|
|
|
343
348
|
await self._ensure_rpc_connected()
|
|
344
349
|
return await self._rpc_client.get_mcp_status(timeout=15.0)
|
|
345
350
|
|
|
346
|
-
async def invoke_skill(
|
|
351
|
+
async def invoke_skill(
|
|
352
|
+
self,
|
|
353
|
+
skill: str,
|
|
354
|
+
args: str = "",
|
|
355
|
+
*,
|
|
356
|
+
clarification_mode: str | None = None,
|
|
357
|
+
) -> dict[str, Any]:
|
|
347
358
|
"""Resolve ``SKILL.md`` on the daemon and receive UI echo before the turn streams.
|
|
348
359
|
|
|
349
360
|
Uses the loop WebSocket (``_client``), not the metadata RPC socket. The daemon
|
|
350
361
|
enqueues the composed prompt on ``_client_subscribed_loop_id``; the RPC-only
|
|
351
362
|
connection never receives ``loop_subscribe``, so skill turns would otherwise
|
|
352
363
|
never start (no ``loop_input`` queue entry).
|
|
364
|
+
|
|
365
|
+
``clarification_mode`` is forwarded so slash-skill turns honor the
|
|
366
|
+
TUI's Manual/Auto badge instead of always falling back to the daemon's
|
|
367
|
+
configured default (RFC-622).
|
|
353
368
|
"""
|
|
354
369
|
async with self._read_lock:
|
|
355
|
-
return await self._client.invoke_skill(
|
|
370
|
+
return await self._client.invoke_skill(
|
|
371
|
+
skill,
|
|
372
|
+
args,
|
|
373
|
+
timeout=120.0,
|
|
374
|
+
clarification_mode=clarification_mode,
|
|
375
|
+
)
|
|
356
376
|
|
|
357
377
|
async def _ensure_rpc_connected(self) -> None:
|
|
358
378
|
"""Ensure dedicated RPC client is connected."""
|
|
@@ -404,6 +424,7 @@ class TuiDaemonSession:
|
|
|
404
424
|
values: dict[str, Any],
|
|
405
425
|
*,
|
|
406
426
|
timeout: float = 10.0,
|
|
427
|
+
as_node: str | None = None,
|
|
407
428
|
) -> None:
|
|
408
429
|
"""Merge partial state into the loop on the daemon host (``loop_state_update`` RPC).
|
|
409
430
|
|
|
@@ -411,6 +432,9 @@ class TuiDaemonSession:
|
|
|
411
432
|
loop_id: AgentLoop id.
|
|
412
433
|
values: Channel updates (e.g. ``messages``) in JSON-serializable form.
|
|
413
434
|
timeout: RPC wait budget in seconds.
|
|
435
|
+
as_node: Optional LangGraph node to attribute the write to. When
|
|
436
|
+
omitted, the daemon picks a sensible default for the underlying
|
|
437
|
+
agent graph.
|
|
414
438
|
"""
|
|
415
439
|
lid = str(loop_id or "").strip()
|
|
416
440
|
if not lid:
|
|
@@ -420,14 +444,18 @@ class TuiDaemonSession:
|
|
|
420
444
|
if not isinstance(payload_values, dict):
|
|
421
445
|
return
|
|
422
446
|
|
|
447
|
+
payload: dict[str, Any] = {
|
|
448
|
+
"type": "loop_state_update",
|
|
449
|
+
"loop_id": lid,
|
|
450
|
+
"values": payload_values,
|
|
451
|
+
}
|
|
452
|
+
if as_node:
|
|
453
|
+
payload["as_node"] = as_node
|
|
454
|
+
|
|
423
455
|
async with self._rpc_lock:
|
|
424
456
|
await self._ensure_rpc_connected()
|
|
425
457
|
await self._rpc_client.request_response(
|
|
426
|
-
|
|
427
|
-
"type": "loop_state_update",
|
|
428
|
-
"loop_id": lid,
|
|
429
|
-
"values": payload_values,
|
|
430
|
-
},
|
|
458
|
+
payload,
|
|
431
459
|
response_type="loop_state_update_response",
|
|
432
460
|
timeout=timeout,
|
|
433
461
|
)
|
|
@@ -18,7 +18,6 @@ from soothe_sdk.core.events import (
|
|
|
18
18
|
AGENT_LOOP_STEP_QUEUED,
|
|
19
19
|
AGENT_LOOP_STEP_STARTED,
|
|
20
20
|
)
|
|
21
|
-
from soothe_sdk.core.verbosity import VerbosityTier
|
|
22
21
|
from soothe_sdk.ux.classification import classify_event_to_tier
|
|
23
22
|
from soothe_sdk.ux.loop_stream import assistant_output_phase
|
|
24
23
|
from soothe_sdk.ux.stream_tool_wire import STREAM_TOOL_CALL_UPDATE, TOOL_CALL_UPDATES_BATCH
|
|
@@ -165,7 +164,8 @@ def _prepare_custom_chunk(
|
|
|
165
164
|
prepared.skip = True
|
|
166
165
|
return prepared
|
|
167
166
|
|
|
168
|
-
|
|
167
|
+
# Output events have a dedicated renderer path; drop the generic copy.
|
|
168
|
+
if event_type.startswith("soothe.output."):
|
|
169
169
|
prepared.skip = True
|
|
170
170
|
return prepared
|
|
171
171
|
|
|
@@ -203,6 +203,10 @@ class SootheApp(
|
|
|
203
203
|
|
|
204
204
|
self._model_params_override: dict[str, Any] | None = None
|
|
205
205
|
|
|
206
|
+
# RFC-622: clarification relay mode. Seeded from --mode flag (CLIConfig);
|
|
207
|
+
# default to Auto so loops keep moving when the user hasn't opted in.
|
|
208
|
+
self._clarification_mode: str = getattr(daemon_config, "clarification_mode", None) or "auto"
|
|
209
|
+
|
|
206
210
|
self._mcp_tool_count = sum(len(s.tools) for s in (mcp_server_info or []))
|
|
207
211
|
|
|
208
212
|
self._status_bar: StatusBar | None = None
|
|
@@ -352,6 +356,4 @@ class SootheApp(
|
|
|
352
356
|
image_tracker=self._image_tracker,
|
|
353
357
|
id="input-area",
|
|
354
358
|
)
|
|
355
|
-
|
|
356
|
-
# Status bar at bottom
|
|
357
|
-
yield StatusBar(cwd=self._cwd, id="status-bar")
|
|
359
|
+
yield StatusBar(cwd=self._cwd, id="status-bar")
|
|
@@ -40,6 +40,7 @@ from soothe_cli.tui.widgets.chat_input import ChatInput
|
|
|
40
40
|
from soothe_cli.tui.widgets.messages import (
|
|
41
41
|
AppMessage,
|
|
42
42
|
AssistantMessage,
|
|
43
|
+
ClarificationInputMessage,
|
|
43
44
|
ErrorMessage,
|
|
44
45
|
QueuedUserMessage,
|
|
45
46
|
UserMessage,
|
|
@@ -188,6 +189,65 @@ class _ExecutionMixin:
|
|
|
188
189
|
if self._status_bar:
|
|
189
190
|
self._status_bar.set_mode(event.mode)
|
|
190
191
|
|
|
192
|
+
async def on_clarification_input_message_submitted(
|
|
193
|
+
self,
|
|
194
|
+
event: ClarificationInputMessage.Submitted,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Forward a clarification answer to the daemon and refresh the step card.
|
|
197
|
+
|
|
198
|
+
Wired to ``ClarificationInputMessage.Submitted`` (RFC-622). The inline
|
|
199
|
+
widget collects per-question answers; here we render them on the
|
|
200
|
+
matching step card and trigger ``_run_agent_task`` with the answer
|
|
201
|
+
text. ``execute_task_textual`` reads ``adapter._clarification_pending``
|
|
202
|
+
and attaches ``clarification_answer=True`` to the wire so the daemon
|
|
203
|
+
resumes the suspended loop graph rather than starting a new turn.
|
|
204
|
+
"""
|
|
205
|
+
event.stop()
|
|
206
|
+
adapter = self._ui_adapter
|
|
207
|
+
if adapter is None:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
# Render answers on the corresponding step card so the user sees
|
|
211
|
+
# confirmation in-place. ``set_clarification_details`` handles styling
|
|
212
|
+
# and the detail-area layout.
|
|
213
|
+
step_widget = adapter._current_step_messages.get(event.step_id)
|
|
214
|
+
if step_widget is not None:
|
|
215
|
+
try:
|
|
216
|
+
step_widget.set_clarification_details(
|
|
217
|
+
questions=list(event.questions),
|
|
218
|
+
answers=list(event.answers),
|
|
219
|
+
source="human",
|
|
220
|
+
confidence=None,
|
|
221
|
+
)
|
|
222
|
+
except Exception: # noqa: BLE001
|
|
223
|
+
logger.debug("Failed to render clarification answers on step card", exc_info=True)
|
|
224
|
+
|
|
225
|
+
# Drop tracking; the inline widget itself stays mounted (disabled) so
|
|
226
|
+
# the user can still see what they answered.
|
|
227
|
+
adapter._clarification_input_by_step.pop(event.step_id, None)
|
|
228
|
+
|
|
229
|
+
non_empty = [a for a in event.answers if a.strip()]
|
|
230
|
+
if not non_empty:
|
|
231
|
+
return
|
|
232
|
+
# Send the answers as a structured list so the daemon resumes the
|
|
233
|
+
# graph with one answer per question instead of broadcasting a single
|
|
234
|
+
# concatenated string. ``content`` carries a human-readable summary
|
|
235
|
+
# for clients that look at it; the authoritative payload is the
|
|
236
|
+
# ``clarification_answers`` wire field.
|
|
237
|
+
payload_text = (
|
|
238
|
+
non_empty[0]
|
|
239
|
+
if len(non_empty) == 1
|
|
240
|
+
else " | ".join(f"A{i + 1}: {a}" for i, a in enumerate(event.answers) if a.strip())
|
|
241
|
+
)
|
|
242
|
+
adapter._clarification_answers_pending = list(event.answers)
|
|
243
|
+
|
|
244
|
+
# Hand off to the standard turn pipeline. ``execute_task_textual``
|
|
245
|
+
# snapshots ``adapter._clarification_pending`` (still True) and sets
|
|
246
|
+
# the wire ``clarification_answer`` flag plus the ``clarification_answers``
|
|
247
|
+
# list, then clears the persisted flag so a follow-up turn is treated
|
|
248
|
+
# as a new goal.
|
|
249
|
+
await self._run_agent_task(payload_text)
|
|
250
|
+
|
|
191
251
|
async def _handle_shell_command(self, command: str) -> None:
|
|
192
252
|
"""Handle a shell command (! prefix).
|
|
193
253
|
|
|
@@ -821,6 +881,7 @@ class _ExecutionMixin:
|
|
|
821
881
|
),
|
|
822
882
|
turn_stats=turn_stats,
|
|
823
883
|
skip_daemon_send_turn=skip_daemon_send_turn,
|
|
884
|
+
clarification_mode=getattr(self, "_clarification_mode", None),
|
|
824
885
|
)
|
|
825
886
|
except Exception as e: # Resilient tool rendering
|
|
826
887
|
logger.exception("Agent execution failed")
|
|
@@ -396,17 +396,14 @@ class _MessagesMixin:
|
|
|
396
396
|
"""Handle Ctrl+C - interrupt agent or quit on double press.
|
|
397
397
|
|
|
398
398
|
Priority order:
|
|
399
|
-
0. If text is selected, copy it (Textual screen.copy_text semantics)
|
|
400
399
|
1. If shell command is running, kill it
|
|
401
400
|
2. If agent is running, interrupt it (preserve input)
|
|
402
401
|
3. If double press (quit_pending), quit
|
|
403
402
|
4. Otherwise clear draft input and show quit hint
|
|
404
|
-
"""
|
|
405
|
-
from soothe_cli.tui.widgets.clipboard import copy_selection_to_clipboard
|
|
406
403
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
404
|
+
Note: Copying selected text is bound to Ctrl+Y (`action_copy_selection`)
|
|
405
|
+
so Ctrl+C is reserved for interrupt/quit only.
|
|
406
|
+
"""
|
|
410
407
|
# If shell command is running, cancel the worker
|
|
411
408
|
if self._shell_running and self._shell_worker:
|
|
412
409
|
self._cancel_worker(self._shell_worker)
|
|
@@ -572,11 +569,34 @@ class _MessagesMixin:
|
|
|
572
569
|
super().exit(result=result, return_code=return_code, message=message)
|
|
573
570
|
|
|
574
571
|
def action_shift_tab(self) -> None:
|
|
575
|
-
"""Shift+Tab: navigate loop selector
|
|
572
|
+
"""Shift+Tab: navigate loop selector when active, otherwise flip relay mode.
|
|
573
|
+
|
|
574
|
+
- In the LoopSelectorScreen, defer to its filter navigation.
|
|
575
|
+
- On the main screen, toggle the clarification relay mode
|
|
576
|
+
(Auto ↔ Manual) so users can switch between the veritas
|
|
577
|
+
auto-answerer and human-in-the-loop relay at any point (RFC-622).
|
|
578
|
+
"""
|
|
576
579
|
from soothe_cli.tui.widgets.loop_selector import LoopSelectorScreen
|
|
577
580
|
|
|
578
581
|
if isinstance(self.screen, LoopSelectorScreen):
|
|
579
582
|
self.screen.action_focus_previous_filter()
|
|
583
|
+
return
|
|
584
|
+
self.toggle_clarification_mode()
|
|
585
|
+
|
|
586
|
+
def toggle_clarification_mode(self) -> None:
|
|
587
|
+
"""Flip clarification mode between Auto and Manual and refresh the badge.
|
|
588
|
+
|
|
589
|
+
The new mode is held on the app (``self._clarification_mode``) and
|
|
590
|
+
attached to every subsequent ``send_turn`` via the
|
|
591
|
+
``clarification_mode`` field of the daemon's ``loop_input`` payload
|
|
592
|
+
(RFC-622). The status-bar badge updates immediately; no toast is
|
|
593
|
+
emitted because the badge itself is the visual feedback.
|
|
594
|
+
"""
|
|
595
|
+
current = getattr(self, "_clarification_mode", "auto")
|
|
596
|
+
new_mode = "manual" if current == "auto" else "auto"
|
|
597
|
+
self._clarification_mode = new_mode
|
|
598
|
+
if self._status_bar is not None:
|
|
599
|
+
self._status_bar.set_clarification_mode(new_mode)
|
|
580
600
|
|
|
581
601
|
def action_toggle_tool_output(self) -> None:
|
|
582
602
|
"""Toggle expand/collapse of the most recent skill or tool card."""
|
|
@@ -648,16 +668,30 @@ class _MessagesMixin:
|
|
|
648
668
|
When the user opens a link via `webbrowser.open`, OS focus shifts to
|
|
649
669
|
the browser. On returning to the terminal, Textual fires `AppFocus`
|
|
650
670
|
(requires a terminal that supports FocusIn events). Re-focusing the chat
|
|
651
|
-
input here keeps it ready for typing
|
|
671
|
+
input here keeps it ready for typing — but only when no other focusable
|
|
672
|
+
widget (e.g., an inline clarification Input) currently owns focus, so
|
|
673
|
+
the user does not lose an in-progress answer to a tab-out and back.
|
|
652
674
|
"""
|
|
653
675
|
if not self._chat_input:
|
|
654
676
|
return
|
|
655
677
|
if self.screen.is_modal:
|
|
656
678
|
return
|
|
679
|
+
focused = self.focused
|
|
680
|
+
if focused is not None and not self._is_input_focused():
|
|
681
|
+
return
|
|
657
682
|
self._chat_input.focus_input()
|
|
658
683
|
|
|
659
684
|
def on_click(self, _event: Click) -> None:
|
|
660
|
-
"""
|
|
685
|
+
"""Focus the chat input when the click landed on non-focusable chrome.
|
|
686
|
+
|
|
687
|
+
Original intent: clicking the dead transcript area should drop the
|
|
688
|
+
caret back in the prompt. But this handler bubbles for *every* click,
|
|
689
|
+
so an unconditional refocus also steals focus from inline focusable
|
|
690
|
+
widgets (e.g., the ClarificationInputMessage answer field) on the same
|
|
691
|
+
click that Textual just used to focus them. Skip the refocus whenever
|
|
692
|
+
the click landed on a focusable widget — Textual's default focus
|
|
693
|
+
handling already does the right thing there.
|
|
694
|
+
"""
|
|
661
695
|
if not self._chat_input:
|
|
662
696
|
return
|
|
663
697
|
# Preserve an active text selection (focus would clear highlight for copy).
|
|
@@ -665,8 +699,31 @@ class _MessagesMixin:
|
|
|
665
699
|
|
|
666
700
|
if screen_has_text_selection(self.screen):
|
|
667
701
|
return
|
|
702
|
+
if self._click_landed_on_focusable(_event):
|
|
703
|
+
return
|
|
668
704
|
self.call_after_refresh(self._chat_input.focus_input)
|
|
669
705
|
|
|
706
|
+
def _click_landed_on_focusable(self, event: Click) -> bool:
|
|
707
|
+
"""Return True if the click target (or any ancestor) is focusable.
|
|
708
|
+
|
|
709
|
+
Walks up from `event.widget` toward the screen. Stops at the screen
|
|
710
|
+
so non-focusable container chrome (Containers, Statics) does not
|
|
711
|
+
suppress the dead-area refocus behavior.
|
|
712
|
+
"""
|
|
713
|
+
widget = getattr(event, "widget", None)
|
|
714
|
+
if widget is None:
|
|
715
|
+
return False
|
|
716
|
+
node: Any = widget
|
|
717
|
+
screen = self.screen
|
|
718
|
+
while node is not None and node is not screen:
|
|
719
|
+
try:
|
|
720
|
+
if getattr(node, "can_focus", False):
|
|
721
|
+
return True
|
|
722
|
+
except Exception: # noqa: BLE001
|
|
723
|
+
pass
|
|
724
|
+
node = getattr(node, "parent", None)
|
|
725
|
+
return False
|
|
726
|
+
|
|
670
727
|
def on_text_selected(self, _event: TextSelected) -> None:
|
|
671
728
|
"""Copy selected transcript text on mouse release.
|
|
672
729
|
|
|
@@ -119,6 +119,9 @@ class _StartupMixin:
|
|
|
119
119
|
self._status_bar = self.query_one("#status-bar", StatusBar)
|
|
120
120
|
self._chat_input = self.query_one("#input-area", ChatInput)
|
|
121
121
|
|
|
122
|
+
# Seed the badge from the app-level clarification mode (CLI flag, default Auto).
|
|
123
|
+
self._status_bar.set_clarification_mode(self._clarification_mode)
|
|
124
|
+
|
|
122
125
|
with suppress(NoMatches):
|
|
123
126
|
banner = self.query_one("#welcome-banner", WelcomeBanner)
|
|
124
127
|
self._status_bar.set_session_tip(banner.session_tip)
|
|
@@ -378,7 +381,11 @@ class _StartupMixin:
|
|
|
378
381
|
)
|
|
379
382
|
return
|
|
380
383
|
try:
|
|
381
|
-
resp = await self._daemon_session.invoke_skill(
|
|
384
|
+
resp = await self._daemon_session.invoke_skill(
|
|
385
|
+
skill_name,
|
|
386
|
+
args,
|
|
387
|
+
clarification_mode=getattr(self, "_clarification_mode", None),
|
|
388
|
+
)
|
|
382
389
|
except RuntimeError as exc:
|
|
383
390
|
await self._mount_message(UserMessage(command))
|
|
384
391
|
await self._mount_message(AppMessage(str(exc)))
|
|
@@ -52,10 +52,11 @@ Screen {
|
|
|
52
52
|
height: auto;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/* Bottom app container - holds ChatInput
|
|
55
|
+
/* Bottom app container - holds thinking row, ChatInput, and status bar. */
|
|
56
56
|
#bottom-app-container {
|
|
57
57
|
height: auto;
|
|
58
58
|
margin-top: 0;
|
|
59
|
+
margin-bottom: 1;
|
|
59
60
|
padding: 0 1;
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -73,11 +74,15 @@ Screen {
|
|
|
73
74
|
max-height: 25;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
/* Status bar */
|
|
77
|
+
/* Status bar lives inside #bottom-app-container — share its padding only. */
|
|
77
78
|
#status-bar {
|
|
78
79
|
height: 1;
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
padding: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#status-bar ClarificationModeBadge {
|
|
84
|
+
margin-left: 1;
|
|
85
|
+
margin-right: 1;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
/* Non-blocking filesystem change previews (write / edit / delete) */
|
|
@@ -15,10 +15,10 @@ from typing import Final
|
|
|
15
15
|
STEP_CARD_SHOW_TOOL_ROW_DETAILS: Final[bool] = False
|
|
16
16
|
|
|
17
17
|
# Latest per-tool invocation lines shown per scope (task branch vs main-agent branch).
|
|
18
|
-
STEP_CARD_TOOL_ACTIVITY_PREVIEW_COUNT: Final[int] =
|
|
18
|
+
STEP_CARD_TOOL_ACTIVITY_PREVIEW_COUNT: Final[int] = 5
|
|
19
19
|
|
|
20
20
|
# When estimated body lines exceed this count, the card auto-collapses (strict `>`).
|
|
21
|
-
STEP_TASK_CARD_COLLAPSE_LINE_THRESHOLD: Final[int] =
|
|
21
|
+
STEP_TASK_CARD_COLLAPSE_LINE_THRESHOLD: Final[int] = 5
|
|
22
22
|
|
|
23
23
|
# --- Skill invocation cards (`SkillMessage` collapsed SKILL.md body) ---
|
|
24
24
|
SKILL_CARD_PREVIEW_LINES: Final[int] = 4
|