soothe-cli 0.3.5__tar.gz → 0.3.6__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.3.5 → soothe_cli-0.3.6}/PKG-INFO +1 -1
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/execution/daemon.py +1 -1
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/execution/headless.py +1 -1
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/stream/formatter.py +7 -4
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/app.py +188 -29
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/config.py +6 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/daemon_session.py +32 -13
- soothe_cli-0.3.6/src/soothe_cli/tui/message_display_filter.py +73 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/model_config.py +36 -22
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/textual_adapter.py +18 -1
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/messages.py +50 -9
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/.gitignore +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/README.md +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/pyproject.toml +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/__init__.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/__init__.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/__init__.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/subagent_names.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/thread_cmd.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/execution/__init__.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/execution/launcher.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/main.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/renderer.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/stream/__init__.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/stream/context.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/stream/display_line.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/stream/pipeline.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/utils.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/config/__init__.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/config/cli_config.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/plan/__init__.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/plan/rich_tree.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/__init__.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/command_router.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/config_loader.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/display_policy.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/essential_events.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/event_processor.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/message_processing.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/presentation_engine.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/processor_state.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/renderer_protocol.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/rendering.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/slash_commands.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/subagent_routing.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/suppression_state.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_call_resolution.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_card_payload.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/__init__.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/base.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/execution.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/fallback.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/file_ops.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/goal_formatter.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/media.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/structured.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/web.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_message_format.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_output_formatter.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tui_trace_log.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/__init__.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/_ask_user_types.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/_cli_context.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/_env_vars.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/_session_stats.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/_version.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/app.tcss +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/command_registry.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/file_ops.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/formatting.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/hooks.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/input.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/media_utils.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/output.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/project_utils.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/sessions.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/skills/__init__.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/skills/invocation.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/skills/load.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/theme.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/tool_display.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/unicode_security.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/update_check.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/__init__.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/_links.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/approval.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/ask_user.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/diff.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/editor.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/history.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/loading.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/message_store.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/status.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/thread_selector.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/tool_renderers.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/tool_widgets.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/tools.py +0 -0
- {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/welcome.py +0 -0
|
@@ -180,7 +180,7 @@ async def run_headless_via_daemon(
|
|
|
180
180
|
logger.exception("Daemon connection failed")
|
|
181
181
|
from soothe_sdk.utils import format_cli_error
|
|
182
182
|
|
|
183
|
-
typer.echo(f"Error: {format_cli_error(e
|
|
183
|
+
typer.echo(f"Error: {format_cli_error(e)}", err=True)
|
|
184
184
|
return _DAEMON_FALLBACK_EXIT_CODE
|
|
185
185
|
except Exception as e:
|
|
186
186
|
logger.exception("Failed to run via daemon")
|
|
@@ -95,7 +95,7 @@ def format_goal_header(
|
|
|
95
95
|
DisplayLine for goal header.
|
|
96
96
|
"""
|
|
97
97
|
# Add inline symbol for goal marker
|
|
98
|
-
content = f"
|
|
98
|
+
content = f"⌯⌲ {goal}"
|
|
99
99
|
return DisplayLine(
|
|
100
100
|
level=1,
|
|
101
101
|
content=content,
|
|
@@ -265,13 +265,16 @@ def format_plan_phase_reasoning(
|
|
|
265
265
|
namespace: tuple[str, ...] = (),
|
|
266
266
|
verbosity_tier: VerbosityTier = VerbosityTier.NORMAL,
|
|
267
267
|
) -> DisplayLine:
|
|
268
|
-
"""Format a labeled plan-phase reasoning line (assessment vs plan strategy).
|
|
268
|
+
"""Format a labeled plan-phase reasoning line (assessment vs plan strategy).
|
|
269
|
+
|
|
270
|
+
IG-225: Uses level=2 (flat, no indent) for prominent visibility alongside step headers.
|
|
271
|
+
"""
|
|
269
272
|
content = f"💭 {label}: {text}"
|
|
270
273
|
return DisplayLine(
|
|
271
|
-
level=
|
|
274
|
+
level=2,
|
|
272
275
|
content=content,
|
|
273
276
|
icon="•",
|
|
274
|
-
indent=indent_for_level(
|
|
277
|
+
indent=indent_for_level(2),
|
|
275
278
|
source_prefix=_derive_source_prefix(namespace, verbosity_tier),
|
|
276
279
|
)
|
|
277
280
|
|
|
@@ -45,6 +45,12 @@ from soothe_cli.tui._session_stats import (
|
|
|
45
45
|
# after user interaction begins.
|
|
46
46
|
from soothe_cli.tui._version import CHANGELOG_URL, DOCS_URL
|
|
47
47
|
from soothe_cli.tui.config import is_ascii_mode
|
|
48
|
+
from soothe_cli.tui.message_display_filter import (
|
|
49
|
+
extract_ai_text_for_display,
|
|
50
|
+
extract_message_tool_calls,
|
|
51
|
+
extract_user_text_for_display,
|
|
52
|
+
normalize_stream_message,
|
|
53
|
+
)
|
|
48
54
|
from soothe_cli.tui.widgets.chat_input import ChatInput
|
|
49
55
|
from soothe_cli.tui.widgets.loading import LoadingWidget
|
|
50
56
|
from soothe_cli.tui.widgets.message_store import (
|
|
@@ -714,6 +720,7 @@ class SootheApp(App):
|
|
|
714
720
|
self._thread_switching = False
|
|
715
721
|
|
|
716
722
|
self._model_switching = False
|
|
723
|
+
self._detaching = False
|
|
717
724
|
|
|
718
725
|
self._deferred_actions: list[DeferredAction] = []
|
|
719
726
|
"""Deferred actions executed after the current busy state resolves."""
|
|
@@ -1417,6 +1424,19 @@ class SootheApp(App):
|
|
|
1417
1424
|
except NoMatches:
|
|
1418
1425
|
logger.warning("Welcome banner not found during daemon ready transition")
|
|
1419
1426
|
|
|
1427
|
+
# IG-228: Start background event reader if thread is already running
|
|
1428
|
+
thread_state = event.status_event.get("state", "")
|
|
1429
|
+
if thread_state == "running" and self._daemon_session is not None:
|
|
1430
|
+
logger.info(
|
|
1431
|
+
"Thread %s is running, starting background event reader",
|
|
1432
|
+
status_thread_id[:8] if status_thread_id else "?",
|
|
1433
|
+
)
|
|
1434
|
+
self.run_worker(
|
|
1435
|
+
self._consume_daemon_events_background(),
|
|
1436
|
+
exclusive=False,
|
|
1437
|
+
group="daemon-event-reader",
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1420
1440
|
if not self._schedule_initial_submission() and self._lc_thread_id:
|
|
1421
1441
|
self.call_after_refresh(lambda: asyncio.create_task(self._load_thread_history()))
|
|
1422
1442
|
|
|
@@ -1543,6 +1563,9 @@ class SootheApp(App):
|
|
|
1543
1563
|
|
|
1544
1564
|
async def _prewarm_model_caches(self) -> None:
|
|
1545
1565
|
"""Prewarm model discovery and profile caches without blocking startup."""
|
|
1566
|
+
if self._daemon_config is not None and self._daemon_session is None:
|
|
1567
|
+
logger.debug("Skipping model cache prewarm - daemon session not ready")
|
|
1568
|
+
return
|
|
1546
1569
|
try:
|
|
1547
1570
|
from soothe_cli.tui.model_config import (
|
|
1548
1571
|
get_available_models,
|
|
@@ -3283,7 +3306,7 @@ class SootheApp(App):
|
|
|
3283
3306
|
Returns:
|
|
3284
3307
|
Ordered list of `MessageData` ready for `MessageStore.bulk_load`.
|
|
3285
3308
|
"""
|
|
3286
|
-
from langchain_core.messages import AIMessage,
|
|
3309
|
+
from langchain_core.messages import AIMessage, ToolMessage
|
|
3287
3310
|
|
|
3288
3311
|
from soothe_cli.shared.message_processing import (
|
|
3289
3312
|
extract_tool_args_dict,
|
|
@@ -3295,11 +3318,9 @@ class SootheApp(App):
|
|
|
3295
3318
|
pending_tool_indices: dict[str, int] = {}
|
|
3296
3319
|
|
|
3297
3320
|
for msg in messages:
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
continue
|
|
3302
|
-
|
|
3321
|
+
msg = normalize_stream_message(msg)
|
|
3322
|
+
user_text = extract_user_text_for_display(msg)
|
|
3323
|
+
if user_text is not None:
|
|
3303
3324
|
# Detect skill invocations persisted via additional_kwargs
|
|
3304
3325
|
skill_meta = (msg.additional_kwargs or {}).get("__skill")
|
|
3305
3326
|
if isinstance(skill_meta, dict) and skill_meta.get("name"):
|
|
@@ -3311,26 +3332,14 @@ class SootheApp(App):
|
|
|
3311
3332
|
skill_description=str(skill_meta.get("description", "")),
|
|
3312
3333
|
skill_source=str(skill_meta.get("source", "")),
|
|
3313
3334
|
skill_args=str(skill_meta.get("args", "")),
|
|
3314
|
-
skill_body=
|
|
3335
|
+
skill_body=user_text,
|
|
3315
3336
|
)
|
|
3316
3337
|
)
|
|
3317
3338
|
else:
|
|
3318
|
-
result.append(MessageData(type=MessageType.USER, content=
|
|
3339
|
+
result.append(MessageData(type=MessageType.USER, content=user_text))
|
|
3319
3340
|
|
|
3320
3341
|
elif isinstance(msg, AIMessage):
|
|
3321
|
-
|
|
3322
|
-
content = msg.content
|
|
3323
|
-
text = ""
|
|
3324
|
-
if isinstance(content, str):
|
|
3325
|
-
text = content.strip()
|
|
3326
|
-
elif isinstance(content, list):
|
|
3327
|
-
for block in content:
|
|
3328
|
-
if isinstance(block, dict) and block.get("type") == "text":
|
|
3329
|
-
text += block.get("text", "")
|
|
3330
|
-
elif isinstance(block, str):
|
|
3331
|
-
text += block
|
|
3332
|
-
text = text.strip()
|
|
3333
|
-
|
|
3342
|
+
text = extract_ai_text_for_display(msg)
|
|
3334
3343
|
if text:
|
|
3335
3344
|
result.append(MessageData(type=MessageType.ASSISTANT, content=text))
|
|
3336
3345
|
|
|
@@ -3964,6 +3973,140 @@ class SootheApp(App):
|
|
|
3964
3973
|
exclusive=False,
|
|
3965
3974
|
)
|
|
3966
3975
|
|
|
3976
|
+
async def _consume_daemon_events_background(self) -> None:
|
|
3977
|
+
"""Consume events from daemon when subscribed to a running thread.
|
|
3978
|
+
|
|
3979
|
+
IG-228: This background task reads events from the daemon websocket
|
|
3980
|
+
when the thread is already running passively (not during an active
|
|
3981
|
+
turn). It uses the same event processing pipeline as active queries.
|
|
3982
|
+
"""
|
|
3983
|
+
if not self._daemon_session:
|
|
3984
|
+
return
|
|
3985
|
+
|
|
3986
|
+
logger.info("Starting background event consumer for subscribed thread")
|
|
3987
|
+
from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
|
|
3988
|
+
|
|
3989
|
+
from soothe_cli.cli.stream.pipeline import StreamDisplayPipeline
|
|
3990
|
+
from soothe_cli.shared.config_loader import load_config
|
|
3991
|
+
from soothe_cli.shared.display_policy import normalize_verbosity
|
|
3992
|
+
from soothe_cli.shared.message_processing import extract_tool_args_dict
|
|
3993
|
+
|
|
3994
|
+
pv = normalize_verbosity(load_config().verbosity)
|
|
3995
|
+
progress_pipeline = StreamDisplayPipeline(verbosity=pv)
|
|
3996
|
+
tool_cards: dict[str, ToolCallMessage] = {}
|
|
3997
|
+
assistant_cards_by_ns: dict[tuple[Any, ...], AssistantMessage] = {}
|
|
3998
|
+
last_user_text_by_ns: dict[tuple[Any, ...], str] = {}
|
|
3999
|
+
last_ai_chunk_by_ns: dict[tuple[Any, ...], str] = {}
|
|
4000
|
+
|
|
4001
|
+
try:
|
|
4002
|
+
# Use iter_turn_chunks to read events (same as active turn execution)
|
|
4003
|
+
chunk_source = self._daemon_session.iter_turn_chunks()
|
|
4004
|
+
async for chunk in chunk_source:
|
|
4005
|
+
if not isinstance(chunk, tuple) or len(chunk) != 3:
|
|
4006
|
+
logger.debug("Skipping non-3-tuple chunk: %s", type(chunk).__name__)
|
|
4007
|
+
continue
|
|
4008
|
+
|
|
4009
|
+
namespace, mode, data = chunk
|
|
4010
|
+
ns_key = tuple(namespace) if namespace else ()
|
|
4011
|
+
|
|
4012
|
+
async def _flush_assistant_ns(key: tuple[Any, ...]) -> None:
|
|
4013
|
+
card = assistant_cards_by_ns.pop(key, None)
|
|
4014
|
+
if card is not None:
|
|
4015
|
+
await card.stop_stream()
|
|
4016
|
+
|
|
4017
|
+
if mode == "messages":
|
|
4018
|
+
if not isinstance(data, tuple) or len(data) != 2:
|
|
4019
|
+
continue
|
|
4020
|
+
message, _metadata = data
|
|
4021
|
+
message = normalize_stream_message(message)
|
|
4022
|
+
|
|
4023
|
+
user_text = extract_user_text_for_display(message)
|
|
4024
|
+
if user_text is not None:
|
|
4025
|
+
# Deduplicate immediate replayed user rows after reconnect/resubscribe.
|
|
4026
|
+
if last_user_text_by_ns.get(ns_key) == user_text:
|
|
4027
|
+
continue
|
|
4028
|
+
await _flush_assistant_ns(ns_key)
|
|
4029
|
+
await self._mount_message(UserMessage(user_text))
|
|
4030
|
+
last_user_text_by_ns[ns_key] = user_text
|
|
4031
|
+
continue
|
|
4032
|
+
|
|
4033
|
+
if isinstance(message, ToolMessage):
|
|
4034
|
+
call_id = str(getattr(message, "tool_call_id", "") or "").strip()
|
|
4035
|
+
if call_id and call_id in tool_cards:
|
|
4036
|
+
tool_cards[call_id].set_success(
|
|
4037
|
+
str(getattr(message, "content", "") or "")
|
|
4038
|
+
)
|
|
4039
|
+
continue
|
|
4040
|
+
|
|
4041
|
+
# Render tool calls as cards in background mode too.
|
|
4042
|
+
for raw_call in extract_message_tool_calls(message):
|
|
4043
|
+
call_id = str(
|
|
4044
|
+
raw_call.get("id") or raw_call.get("tool_call_id") or ""
|
|
4045
|
+
).strip()
|
|
4046
|
+
tool_name = str(raw_call.get("name") or "").strip()
|
|
4047
|
+
if not call_id or not tool_name or call_id in tool_cards:
|
|
4048
|
+
continue
|
|
4049
|
+
tool_msg = ToolCallMessage(
|
|
4050
|
+
tool_name,
|
|
4051
|
+
extract_tool_args_dict(raw_call),
|
|
4052
|
+
tool_call_id=call_id,
|
|
4053
|
+
)
|
|
4054
|
+
tool_msg.set_running()
|
|
4055
|
+
await self._mount_message(tool_msg)
|
|
4056
|
+
tool_cards[call_id] = tool_msg
|
|
4057
|
+
|
|
4058
|
+
if isinstance(message, (AIMessage, AIMessageChunk)):
|
|
4059
|
+
extracted = extract_ai_text_for_display(message)
|
|
4060
|
+
if extracted:
|
|
4061
|
+
# Deduplicate immediate replayed AI chunks after reconnect/resubscribe.
|
|
4062
|
+
if last_ai_chunk_by_ns.get(ns_key) == extracted:
|
|
4063
|
+
if getattr(message, "chunk_position", None) == "last":
|
|
4064
|
+
await _flush_assistant_ns(ns_key)
|
|
4065
|
+
continue
|
|
4066
|
+
asst = assistant_cards_by_ns.get(ns_key)
|
|
4067
|
+
if asst is None:
|
|
4068
|
+
asst = AssistantMessage(id=f"asst-{uuid.uuid4().hex[:8]}")
|
|
4069
|
+
await self._mount_message(asst)
|
|
4070
|
+
assistant_cards_by_ns[ns_key] = asst
|
|
4071
|
+
await asst.append_content(extracted)
|
|
4072
|
+
last_ai_chunk_by_ns[ns_key] = extracted
|
|
4073
|
+
|
|
4074
|
+
if getattr(message, "chunk_position", None) == "last":
|
|
4075
|
+
await _flush_assistant_ns(ns_key)
|
|
4076
|
+
last_ai_chunk_by_ns.pop(ns_key, None)
|
|
4077
|
+
continue
|
|
4078
|
+
continue
|
|
4079
|
+
|
|
4080
|
+
if mode != "updates" or not isinstance(data, dict):
|
|
4081
|
+
continue
|
|
4082
|
+
|
|
4083
|
+
await _flush_assistant_ns(ns_key)
|
|
4084
|
+
payloads: list[dict[str, Any]] = []
|
|
4085
|
+
if isinstance(data.get("type"), str):
|
|
4086
|
+
payloads.append(data)
|
|
4087
|
+
for value in data.values():
|
|
4088
|
+
if isinstance(value, dict) and isinstance(value.get("type"), str):
|
|
4089
|
+
payloads.append(value)
|
|
4090
|
+
|
|
4091
|
+
for event_payload in payloads:
|
|
4092
|
+
event_for_pipeline = dict(event_payload)
|
|
4093
|
+
event_for_pipeline["namespace"] = list(namespace)
|
|
4094
|
+
lines = progress_pipeline.process(event_for_pipeline)
|
|
4095
|
+
for line in lines:
|
|
4096
|
+
rendered = line.format().lstrip("\n").rstrip()
|
|
4097
|
+
if rendered:
|
|
4098
|
+
await self._mount_message(AppMessage(rendered))
|
|
4099
|
+
|
|
4100
|
+
except asyncio.CancelledError:
|
|
4101
|
+
logger.info("Background event consumer cancelled")
|
|
4102
|
+
except Exception as exc:
|
|
4103
|
+
logger.warning("Background event consumer error: %s", exc)
|
|
4104
|
+
finally:
|
|
4105
|
+
for card in assistant_cards_by_ns.values():
|
|
4106
|
+
with suppress(Exception):
|
|
4107
|
+
await card.stop_stream()
|
|
4108
|
+
logger.info("Background event consumer stopped")
|
|
4109
|
+
|
|
3967
4110
|
async def _load_thread_history(
|
|
3968
4111
|
self,
|
|
3969
4112
|
*,
|
|
@@ -3972,7 +4115,7 @@ class SootheApp(App):
|
|
|
3972
4115
|
) -> None:
|
|
3973
4116
|
"""Load and render message history when resuming a thread.
|
|
3974
4117
|
|
|
3975
|
-
When `preloaded_payload` is provided (e.g
|
|
4118
|
+
When `preloaded_payload` is provided (e.g. from `_resume_thread`),
|
|
3976
4119
|
this reuses that data. Otherwise, it fetches checkpoint state from the
|
|
3977
4120
|
agent and converts stored messages into lightweight `MessageData`
|
|
3978
4121
|
objects. The method then bulk-loads into the `MessageStore` and mounts
|
|
@@ -4456,11 +4599,31 @@ class SootheApp(App):
|
|
|
4456
4599
|
return
|
|
4457
4600
|
if isinstance(self.screen, DeleteThreadConfirmScreen):
|
|
4458
4601
|
if self._quit_pending:
|
|
4459
|
-
self.
|
|
4602
|
+
self._detach_or_exit()
|
|
4460
4603
|
return
|
|
4461
4604
|
self._arm_quit_pending("Ctrl+D")
|
|
4462
4605
|
return
|
|
4463
|
-
self.
|
|
4606
|
+
self._detach_or_exit()
|
|
4607
|
+
|
|
4608
|
+
async def _detach_then_exit(self) -> None:
|
|
4609
|
+
"""Detach from daemon, then exit the app."""
|
|
4610
|
+
if self._detaching:
|
|
4611
|
+
return
|
|
4612
|
+
self._detaching = True
|
|
4613
|
+
try:
|
|
4614
|
+
if self._daemon_session is not None:
|
|
4615
|
+
await self._daemon_session.detach()
|
|
4616
|
+
self.exit()
|
|
4617
|
+
finally:
|
|
4618
|
+
self._detaching = False
|
|
4619
|
+
|
|
4620
|
+
def _detach_or_exit(self) -> None:
|
|
4621
|
+
"""Exit immediately, or detach first when daemon-backed."""
|
|
4622
|
+
if self._daemon_session is None:
|
|
4623
|
+
self.exit()
|
|
4624
|
+
return
|
|
4625
|
+
self.notify("Detaching from daemon...", severity="info")
|
|
4626
|
+
self.run_worker(self._detach_then_exit(), exclusive=False, group="daemon-detach")
|
|
4464
4627
|
|
|
4465
4628
|
def exit(
|
|
4466
4629
|
self,
|
|
@@ -5304,11 +5467,7 @@ class SootheApp(App):
|
|
|
5304
5467
|
|
|
5305
5468
|
def action_detach(self) -> None:
|
|
5306
5469
|
"""Exit TUI but leave daemon running."""
|
|
5307
|
-
self.
|
|
5308
|
-
if self._daemon_session is not None:
|
|
5309
|
-
self.run_worker(self._daemon_session.detach(), exclusive=False)
|
|
5310
|
-
# Exit without stopping daemon
|
|
5311
|
-
self.exit()
|
|
5470
|
+
self._detach_or_exit()
|
|
5312
5471
|
|
|
5313
5472
|
|
|
5314
5473
|
@dataclass(frozen=True)
|
|
@@ -298,6 +298,8 @@ class Glyphs:
|
|
|
298
298
|
arrow_down: str # down arrow vs v
|
|
299
299
|
bullet: str # bullet vs -
|
|
300
300
|
cursor: str # cursor vs >
|
|
301
|
+
user: str # User/human icon
|
|
302
|
+
assistant: str # AI/assistant icon
|
|
301
303
|
|
|
302
304
|
# Box-drawing characters
|
|
303
305
|
box_vertical: str # │ vs |
|
|
@@ -328,6 +330,8 @@ UNICODE_GLYPHS = Glyphs(
|
|
|
328
330
|
arrow_down="↓",
|
|
329
331
|
bullet="•",
|
|
330
332
|
cursor="›", # noqa: RUF001 # Intentional Unicode glyph
|
|
333
|
+
user="👤", # User/human icon
|
|
334
|
+
assistant="🤖", # AI/assistant icon
|
|
331
335
|
# Box-drawing characters
|
|
332
336
|
box_vertical="│",
|
|
333
337
|
box_horizontal="─",
|
|
@@ -354,6 +358,8 @@ ASCII_GLYPHS = Glyphs(
|
|
|
354
358
|
arrow_down="v",
|
|
355
359
|
bullet="-",
|
|
356
360
|
cursor=">",
|
|
361
|
+
user="[U]", # User/human icon (ASCII)
|
|
362
|
+
assistant="[A]", # AI/assistant icon (ASCII)
|
|
357
363
|
# Box-drawing characters
|
|
358
364
|
box_vertical="|",
|
|
359
365
|
box_horizontal="-",
|
|
@@ -34,9 +34,13 @@ class TuiDaemonSession:
|
|
|
34
34
|
|
|
35
35
|
def __init__(self, cfg: Any) -> None:
|
|
36
36
|
self._cfg = cfg
|
|
37
|
-
|
|
37
|
+
ws_url = websocket_url_from_config(cfg)
|
|
38
|
+
self._client = WebSocketClient(url=ws_url)
|
|
39
|
+
self._rpc_client = WebSocketClient(url=ws_url)
|
|
38
40
|
self._thread_id: str | None = None
|
|
39
41
|
self._read_lock = asyncio.Lock()
|
|
42
|
+
self._rpc_lock = asyncio.Lock()
|
|
43
|
+
self._rpc_connected = False
|
|
40
44
|
self._streaming = False
|
|
41
45
|
|
|
42
46
|
@property
|
|
@@ -73,6 +77,8 @@ class TuiDaemonSession:
|
|
|
73
77
|
async def close(self) -> None:
|
|
74
78
|
"""Close the daemon websocket."""
|
|
75
79
|
await self._client.close()
|
|
80
|
+
await self._rpc_client.close()
|
|
81
|
+
self._rpc_connected = False
|
|
76
82
|
|
|
77
83
|
async def detach(self) -> None:
|
|
78
84
|
"""Detach this client from the daemon."""
|
|
@@ -183,8 +189,9 @@ class TuiDaemonSession:
|
|
|
183
189
|
thread_id = str(config.get("configurable", {}).get("thread_id", "")).strip()
|
|
184
190
|
if not thread_id:
|
|
185
191
|
return DaemonStateSnapshot(values={})
|
|
186
|
-
async with self.
|
|
187
|
-
|
|
192
|
+
async with self._rpc_lock:
|
|
193
|
+
await self._ensure_rpc_connected()
|
|
194
|
+
response = await self._rpc_client.request_response(
|
|
188
195
|
{"type": "thread_state", "thread_id": thread_id},
|
|
189
196
|
response_type="thread_state_response",
|
|
190
197
|
)
|
|
@@ -231,8 +238,9 @@ class TuiDaemonSession:
|
|
|
231
238
|
if include_events:
|
|
232
239
|
payload["include_events"] = True
|
|
233
240
|
|
|
234
|
-
async with self.
|
|
235
|
-
|
|
241
|
+
async with self._rpc_lock:
|
|
242
|
+
await self._ensure_rpc_connected()
|
|
243
|
+
response = await self._rpc_client.request_response(
|
|
236
244
|
payload,
|
|
237
245
|
response_type="thread_messages_response",
|
|
238
246
|
timeout=10.0,
|
|
@@ -256,8 +264,9 @@ class TuiDaemonSession:
|
|
|
256
264
|
thread_id = str(config.get("configurable", {}).get("thread_id", "")).strip()
|
|
257
265
|
if not thread_id:
|
|
258
266
|
return
|
|
259
|
-
async with self.
|
|
260
|
-
await self.
|
|
267
|
+
async with self._rpc_lock:
|
|
268
|
+
await self._ensure_rpc_connected()
|
|
269
|
+
await self._rpc_client.request_response(
|
|
261
270
|
{
|
|
262
271
|
"type": "thread_update_state",
|
|
263
272
|
"thread_id": thread_id,
|
|
@@ -269,8 +278,9 @@ class TuiDaemonSession:
|
|
|
269
278
|
|
|
270
279
|
async def list_skills(self) -> list[dict[str, Any]]:
|
|
271
280
|
"""Return skill rows from the daemon catalog (no filesystem paths)."""
|
|
272
|
-
async with self.
|
|
273
|
-
|
|
281
|
+
async with self._rpc_lock:
|
|
282
|
+
await self._ensure_rpc_connected()
|
|
283
|
+
response = await self._rpc_client.list_skills(timeout=15.0)
|
|
274
284
|
skills = response.get("skills", [])
|
|
275
285
|
if not isinstance(skills, list):
|
|
276
286
|
return []
|
|
@@ -278,10 +288,19 @@ class TuiDaemonSession:
|
|
|
278
288
|
|
|
279
289
|
async def list_models(self) -> dict[str, Any]:
|
|
280
290
|
"""Return daemon ``models_list_response`` (models + default_model from server config)."""
|
|
281
|
-
async with self.
|
|
282
|
-
|
|
291
|
+
async with self._rpc_lock:
|
|
292
|
+
await self._ensure_rpc_connected()
|
|
293
|
+
return await self._rpc_client.list_models(timeout=15.0)
|
|
283
294
|
|
|
284
295
|
async def invoke_skill(self, skill: str, args: str = "") -> dict[str, Any]:
|
|
285
296
|
"""Resolve ``SKILL.md`` on the daemon and receive UI echo before the turn streams."""
|
|
286
|
-
async with self.
|
|
287
|
-
|
|
297
|
+
async with self._rpc_lock:
|
|
298
|
+
await self._ensure_rpc_connected()
|
|
299
|
+
return await self._rpc_client.invoke_skill(skill, args, timeout=120.0)
|
|
300
|
+
|
|
301
|
+
async def _ensure_rpc_connected(self) -> None:
|
|
302
|
+
"""Ensure dedicated RPC client is connected."""
|
|
303
|
+
if self._rpc_connected:
|
|
304
|
+
return
|
|
305
|
+
await connect_websocket_with_retries(self._rpc_client)
|
|
306
|
+
self._rpc_connected = True
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Shared message-display filtering for live and recovered TUI rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from soothe_sdk.langchain_wire import envelope_langchain_message_dict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_stream_message(message: Any) -> Any:
|
|
11
|
+
"""Best-effort conversion of wire dict payloads to LangChain message objects."""
|
|
12
|
+
if not isinstance(message, dict):
|
|
13
|
+
return message
|
|
14
|
+
try:
|
|
15
|
+
from langchain_core.messages import messages_from_dict
|
|
16
|
+
|
|
17
|
+
wrapped = envelope_langchain_message_dict(message)
|
|
18
|
+
restored = messages_from_dict([wrapped])
|
|
19
|
+
if restored:
|
|
20
|
+
return restored[0]
|
|
21
|
+
except Exception:
|
|
22
|
+
return message
|
|
23
|
+
return message
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_user_text_for_display(message: Any) -> str | None:
|
|
27
|
+
"""Return displayable user text, excluding internal system markers."""
|
|
28
|
+
from langchain_core.messages import HumanMessage
|
|
29
|
+
|
|
30
|
+
if not isinstance(message, HumanMessage):
|
|
31
|
+
return None
|
|
32
|
+
content = message.content if isinstance(message.content, str) else str(message.content)
|
|
33
|
+
text = content.strip()
|
|
34
|
+
if not text or text.startswith("[SYSTEM]"):
|
|
35
|
+
return None
|
|
36
|
+
return text
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_ai_text_for_display(message: Any) -> str:
|
|
40
|
+
"""Extract assistant-visible text from AI message payloads."""
|
|
41
|
+
try:
|
|
42
|
+
if hasattr(message, "text"):
|
|
43
|
+
extracted = str(message.text() or "").strip()
|
|
44
|
+
if extracted:
|
|
45
|
+
return extracted
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
content = getattr(message, "content", "")
|
|
50
|
+
if isinstance(content, str):
|
|
51
|
+
return content.strip()
|
|
52
|
+
|
|
53
|
+
if isinstance(content, list):
|
|
54
|
+
parts: list[str] = []
|
|
55
|
+
for block in content:
|
|
56
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
57
|
+
block_text = str(block.get("text", "")).strip()
|
|
58
|
+
if block_text:
|
|
59
|
+
parts.append(block_text)
|
|
60
|
+
elif isinstance(block, str):
|
|
61
|
+
block_text = block.strip()
|
|
62
|
+
if block_text:
|
|
63
|
+
parts.append(block_text)
|
|
64
|
+
return "".join(parts).strip()
|
|
65
|
+
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def extract_message_tool_calls(message: Any) -> list[dict[str, Any]]:
|
|
70
|
+
"""Extract tool call dicts from an AI message/chunk for card rendering."""
|
|
71
|
+
tool_calls = list(getattr(message, "tool_calls", None) or [])
|
|
72
|
+
tool_call_chunks = list(getattr(message, "tool_call_chunks", None) or [])
|
|
73
|
+
return [call for call in [*tool_call_chunks, *tool_calls] if isinstance(call, dict)]
|
|
@@ -24,6 +24,17 @@ DEFAULT_CONFIG_PATH = Path(SOOTHE_HOME) / "config" / "config.yml"
|
|
|
24
24
|
_ENV_PREFIX = "SOOTHE_"
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def _in_running_loop() -> bool:
|
|
28
|
+
"""Return whether current thread is already running an asyncio loop."""
|
|
29
|
+
import asyncio
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
asyncio.get_running_loop()
|
|
33
|
+
except RuntimeError:
|
|
34
|
+
return False
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
27
38
|
# Model configuration error (stub for now)
|
|
28
39
|
class ModelConfigError(Exception):
|
|
29
40
|
"""Error in model configuration."""
|
|
@@ -234,7 +245,7 @@ def get_credential_env_var(provider: str) -> str | None:
|
|
|
234
245
|
Per IG-174, fetches provider config from daemon via RPC.
|
|
235
246
|
Falls back to hardcoded env var mapping if daemon not reachable.
|
|
236
247
|
"""
|
|
237
|
-
if provider:
|
|
248
|
+
if provider and not _in_running_loop():
|
|
238
249
|
# Try to fetch from daemon
|
|
239
250
|
try:
|
|
240
251
|
import asyncio
|
|
@@ -518,29 +529,32 @@ def has_provider_credentials(provider: str) -> bool | None:
|
|
|
518
529
|
return bool(proj and proj.strip())
|
|
519
530
|
return None
|
|
520
531
|
|
|
521
|
-
# Try to fetch from daemon
|
|
522
|
-
|
|
523
|
-
|
|
532
|
+
# Try to fetch from daemon only when no event loop is active in this thread.
|
|
533
|
+
if not _in_running_loop():
|
|
534
|
+
try:
|
|
535
|
+
import asyncio
|
|
524
536
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
return None
|
|
530
|
-
if provider_data.get("api_key"):
|
|
531
|
-
try:
|
|
532
|
-
from soothe_sdk.utils import resolve_provider_env
|
|
533
|
-
|
|
534
|
-
v = resolve_provider_env(
|
|
535
|
-
provider_data["api_key"], provider_name=provider, field_name="api_key"
|
|
536
|
-
)
|
|
537
|
-
except Exception:
|
|
538
|
-
logger.debug("resolve api_key failed for provider %r", provider, exc_info=True)
|
|
537
|
+
provider_data = asyncio.run(_fetch_provider_config(provider))
|
|
538
|
+
if provider_data is not None:
|
|
539
|
+
provider_type = provider_data.get("provider_type", "")
|
|
540
|
+
if provider_type in IMPLICIT_AUTH_PROVIDERS or provider_type == "ollama":
|
|
539
541
|
return None
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
542
|
+
if provider_data.get("api_key"):
|
|
543
|
+
try:
|
|
544
|
+
from soothe_sdk.utils import resolve_provider_env
|
|
545
|
+
|
|
546
|
+
v = resolve_provider_env(
|
|
547
|
+
provider_data["api_key"], provider_name=provider, field_name="api_key"
|
|
548
|
+
)
|
|
549
|
+
except Exception:
|
|
550
|
+
logger.debug(
|
|
551
|
+
"resolve api_key failed for provider %r", provider, exc_info=True
|
|
552
|
+
)
|
|
553
|
+
return None
|
|
554
|
+
return bool(v and str(v).strip())
|
|
555
|
+
return False
|
|
556
|
+
except Exception:
|
|
557
|
+
logger.debug("Could not fetch provider config from daemon", exc_info=True)
|
|
544
558
|
|
|
545
559
|
# Fallback to environment variable check
|
|
546
560
|
env_name = PROVIDER_API_KEY_ENV.get(provider)
|
|
@@ -677,6 +677,8 @@ async def execute_task_textual(
|
|
|
677
677
|
agent: The LangGraph agent to execute
|
|
678
678
|
daemon_session: Optional daemon-backed session for direct websocket
|
|
679
679
|
streaming. When provided, this becomes the primary execution path.
|
|
680
|
+
When thread is already running and skip_daemon_send_turn=True,
|
|
681
|
+
starts event consumption loop for subscribed thread.
|
|
680
682
|
assistant_id: The agent identifier
|
|
681
683
|
session_state: Session state with auto_approve flag
|
|
682
684
|
adapter: The TextualUIAdapter for UI operations
|
|
@@ -696,7 +698,8 @@ async def execute_task_textual(
|
|
|
696
698
|
If `None`, a new instance is created internally.
|
|
697
699
|
skip_daemon_send_turn: When ``True`` with ``daemon_session`` set, skip
|
|
698
700
|
``send_turn`` and only consume chunks (daemon already queued the
|
|
699
|
-
prompt, e.g. after ``invoke_skill``).
|
|
701
|
+
prompt, e.g. after ``invoke_skill``). Also used for consuming
|
|
702
|
+
events from already-running threads.
|
|
700
703
|
|
|
701
704
|
Note:
|
|
702
705
|
Progress verbosity (``quiet`` … ``debug``) for tool UI and the stream
|
|
@@ -1754,6 +1757,7 @@ async def execute_task_textual(
|
|
|
1754
1757
|
adapter=adapter,
|
|
1755
1758
|
agent=agent,
|
|
1756
1759
|
config=config,
|
|
1760
|
+
daemon_session=daemon_session,
|
|
1757
1761
|
pending_text_by_namespace=pending_text_by_namespace,
|
|
1758
1762
|
captured_input_tokens=captured_input_tokens,
|
|
1759
1763
|
captured_output_tokens=captured_output_tokens,
|
|
@@ -1779,6 +1783,7 @@ async def _handle_interrupt_cleanup(
|
|
|
1779
1783
|
adapter: TextualUIAdapter,
|
|
1780
1784
|
agent: Any, # noqa: ANN401 # Dynamic agent graph type
|
|
1781
1785
|
config: RunnableConfig,
|
|
1786
|
+
daemon_session: Any = None, # noqa: ANN401 # Daemon-backed TUI session
|
|
1782
1787
|
pending_text_by_namespace: dict[tuple, str],
|
|
1783
1788
|
captured_input_tokens: int,
|
|
1784
1789
|
captured_output_tokens: int,
|
|
@@ -1791,6 +1796,8 @@ async def _handle_interrupt_cleanup(
|
|
|
1791
1796
|
adapter: UI adapter with display callbacks.
|
|
1792
1797
|
agent: The LangGraph agent.
|
|
1793
1798
|
config: Runnable config with `thread_id`.
|
|
1799
|
+
daemon_session: Optional daemon-backed session. When provided, sends
|
|
1800
|
+
detach message before disconnect so thread continues running.
|
|
1794
1801
|
pending_text_by_namespace: Accumulated text per namespace.
|
|
1795
1802
|
captured_input_tokens: Input tokens captured before interrupt.
|
|
1796
1803
|
captured_output_tokens: Output tokens captured before interrupt.
|
|
@@ -1860,6 +1867,16 @@ async def _handle_interrupt_cleanup(
|
|
|
1860
1867
|
approximate=approximate,
|
|
1861
1868
|
)
|
|
1862
1869
|
|
|
1870
|
+
# IG-228: Send detach message to daemon before disconnect (RFC-0013)
|
|
1871
|
+
# This signals the daemon to let the thread continue running in background
|
|
1872
|
+
# instead of cancelling it as an unexpected disconnect.
|
|
1873
|
+
if daemon_session is not None:
|
|
1874
|
+
try:
|
|
1875
|
+
await daemon_session.detach()
|
|
1876
|
+
logger.info("Sent detach message to daemon - thread will continue running")
|
|
1877
|
+
except Exception:
|
|
1878
|
+
logger.warning("Failed to send detach message during interrupt cleanup", exc_info=True)
|
|
1879
|
+
|
|
1863
1880
|
|
|
1864
1881
|
async def _persist_context_tokens(
|
|
1865
1882
|
agent: Any, # noqa: ANN401 # Dynamic agent graph type
|
|
@@ -164,7 +164,7 @@ def _strip_success_exit_line(text: str) -> str:
|
|
|
164
164
|
|
|
165
165
|
|
|
166
166
|
class UserMessage(_TimestampClickMixin, Static):
|
|
167
|
-
"""Widget displaying a user message."""
|
|
167
|
+
"""Widget displaying a user message with enhanced styling."""
|
|
168
168
|
|
|
169
169
|
can_select = True
|
|
170
170
|
"""Enable text selection for copy functionality."""
|
|
@@ -173,11 +173,24 @@ class UserMessage(_TimestampClickMixin, Static):
|
|
|
173
173
|
UserMessage {
|
|
174
174
|
height: auto;
|
|
175
175
|
padding: 0 1;
|
|
176
|
-
margin:
|
|
177
|
-
background:
|
|
176
|
+
margin: 1 0;
|
|
177
|
+
background: $surface;
|
|
178
178
|
border-left: wide $primary;
|
|
179
179
|
}
|
|
180
|
+
|
|
181
|
+
UserMessage.-mode-shell {
|
|
182
|
+
border-left: wide $mode-bash;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
UserMessage.-mode-command {
|
|
186
|
+
border-left: wide $mode-command;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
UserMessage:hover {
|
|
190
|
+
background: $surface-darken-1;
|
|
191
|
+
}
|
|
180
192
|
"""
|
|
193
|
+
"""Enhanced styling with role indicator, background tint, and mode-specific borders."""
|
|
181
194
|
|
|
182
195
|
def __init__(self, content: str, **kwargs: Any) -> None:
|
|
183
196
|
"""Initialize a user message.
|
|
@@ -198,15 +211,20 @@ class UserMessage(_TimestampClickMixin, Static):
|
|
|
198
211
|
self.add_class("-ascii")
|
|
199
212
|
|
|
200
213
|
def render(self) -> Content:
|
|
201
|
-
"""Render the styled user message.
|
|
214
|
+
"""Render the styled user message with role indicator.
|
|
202
215
|
|
|
203
216
|
Returns:
|
|
204
|
-
Styled Content with mode prefix and highlighted mentions.
|
|
217
|
+
Styled Content with role header, mode prefix, and highlighted mentions.
|
|
205
218
|
"""
|
|
206
219
|
colors = theme.get_theme_colors(self)
|
|
207
220
|
parts: list[str | tuple[str, str]] = []
|
|
208
221
|
content = self._content
|
|
209
222
|
|
|
223
|
+
# Add "Human" role indicator header
|
|
224
|
+
glyphs = get_glyphs()
|
|
225
|
+
role_icon = glyphs.user if not is_ascii_mode() else ">"
|
|
226
|
+
parts.append((f"{role_icon} Human ", f"bold {colors.primary}"))
|
|
227
|
+
|
|
210
228
|
# Use mode-specific prefix indicator when content starts with a
|
|
211
229
|
# mode trigger character (e.g. "!" for shell, "/" for commands).
|
|
212
230
|
# The display glyph may differ from the trigger (e.g. "$" for shell).
|
|
@@ -216,7 +234,8 @@ class UserMessage(_TimestampClickMixin, Static):
|
|
|
216
234
|
parts.append((f"{glyph} ", f"bold {_mode_color(mode, self)}"))
|
|
217
235
|
content = content[1:]
|
|
218
236
|
else:
|
|
219
|
-
|
|
237
|
+
# Add subtle separator for non-mode messages
|
|
238
|
+
parts.append(("│ ", f"dim {colors.muted}"))
|
|
220
239
|
|
|
221
240
|
# Highlight @mentions and /commands in the content
|
|
222
241
|
last_end = 0
|
|
@@ -571,7 +590,7 @@ class SkillMessage(Vertical):
|
|
|
571
590
|
|
|
572
591
|
|
|
573
592
|
class AssistantMessage(_TimestampClickMixin, Vertical):
|
|
574
|
-
"""Widget displaying an assistant message with markdown support.
|
|
593
|
+
"""Widget displaying an assistant message with markdown support and enhanced styling.
|
|
575
594
|
|
|
576
595
|
Uses MarkdownStream for smoother streaming instead of re-rendering
|
|
577
596
|
the full content on each update.
|
|
@@ -584,14 +603,26 @@ class AssistantMessage(_TimestampClickMixin, Vertical):
|
|
|
584
603
|
AssistantMessage {
|
|
585
604
|
height: auto;
|
|
586
605
|
padding: 0 1;
|
|
587
|
-
margin:
|
|
606
|
+
margin: 1 0;
|
|
607
|
+
background: $background-darken-1;
|
|
608
|
+
border-left: wide $secondary;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
AssistantMessage .assistant-header {
|
|
612
|
+
height: auto;
|
|
613
|
+
margin-bottom: 1;
|
|
588
614
|
}
|
|
589
615
|
|
|
590
616
|
AssistantMessage Markdown {
|
|
591
617
|
padding: 0;
|
|
592
618
|
margin: 0;
|
|
593
619
|
}
|
|
620
|
+
|
|
621
|
+
AssistantMessage:hover {
|
|
622
|
+
background: $background-darken-2;
|
|
623
|
+
}
|
|
594
624
|
"""
|
|
625
|
+
"""Enhanced styling with role indicator, secondary border, and background tint."""
|
|
595
626
|
|
|
596
627
|
def __init__(self, content: str = "", **kwargs: Any) -> None:
|
|
597
628
|
"""Initialize an assistant message.
|
|
@@ -609,8 +640,18 @@ class AssistantMessage(_TimestampClickMixin, Vertical):
|
|
|
609
640
|
"""Compose the assistant message layout.
|
|
610
641
|
|
|
611
642
|
Yields:
|
|
612
|
-
Markdown widget for
|
|
643
|
+
Header widget with role indicator and Markdown widget for content.
|
|
613
644
|
"""
|
|
645
|
+
colors = theme.get_theme_colors()
|
|
646
|
+
glyphs = get_glyphs()
|
|
647
|
+
is_ascii = is_ascii_mode()
|
|
648
|
+
role_icon = glyphs.assistant if not is_ascii else "◆"
|
|
649
|
+
|
|
650
|
+
# Add role header
|
|
651
|
+
yield Static(
|
|
652
|
+
Content.styled(f"{role_icon} AI ", f"bold {colors.secondary}"),
|
|
653
|
+
classes="assistant-header",
|
|
654
|
+
)
|
|
614
655
|
from textual.widgets import Markdown
|
|
615
656
|
|
|
616
657
|
yield Markdown("", id="assistant-content")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/goal_formatter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|