soothe-cli 1.0.4__tar.gz → 1.0.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-1.0.4 → soothe_cli-1.0.6}/.gitignore +0 -1
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/PKG-INFO +1 -1
- soothe_cli-1.0.6/src/soothe_cli/display/card.py +158 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_app.py +15 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_clarification.py +5 -8
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_execution.py +3 -16
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_messages_mixin.py +192 -28
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_model.py +1 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_startup.py +1 -2
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_ui.py +18 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/app.tcss +12 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/file_change_preview.py +1 -1
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -10
- soothe_cli-1.0.6/src/soothe_cli/tui/widgets/messages/_helpers.py +155 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/app.py +1 -1
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/assistant.py +2 -4
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +4 -4
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +1 -1
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/cognition_step.py +7 -5
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +29 -29
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/error.py +1 -1
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/skill.py +1 -1
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/structured_ask_user.py +33 -21
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +5 -5
- soothe_cli-1.0.6/src/soothe_cli/tui/widgets/queued_goals_bar.py +384 -0
- soothe_cli-1.0.4/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -318
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/README.md +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/pyproject.toml +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/_cli_context.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/_env_vars.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/_version.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/card_wire.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/execution/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/execution/daemon.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/execution/headless.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/execution/launcher.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/main.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/soothecli.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/commands/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/commands/binding.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/commands/command_registry.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/commands/command_router.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/commands/slash_commands.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/commands/subagent_routing.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/config/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/config/cli_config.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/config/loader.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/config/logging_setup.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/markdown_theme.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/media_utils.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/mermaid_render.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/path_utils.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/preview_limits.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/shell_color.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/spinner_labels.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/theme.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/tips.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/tool_display.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/unicode_security.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/loops/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/loops/sessions.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/model_config.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/project_utils.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/headless/processor.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/engine.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/id_format.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/step_id_format.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/state/session_stats.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/state/step_router.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/token_events_debug.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/token_usage.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/transport/session.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/turn/prepare.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/wire/message_text.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/wire/messages.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/_console_impl.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/bootstrap.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/core.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/glyphs.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/provider.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/shell_allow.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/skills_dirs.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/stream_config.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_entrypoints.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_history.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_model_params.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_terminal.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_theme_prefs.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_types.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/composer_mode.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/file_change_notify.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/file_change_renderers.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/input.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/skills/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/skills/invocation.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/skills/load.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/textual_adapter.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/__init__.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/_links.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/context_data.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/context_viewer.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/diff.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/editor.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/history.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/loading.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/message_store.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/pinned_goal_bar.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/router_profile_selector.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/status.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/welcome.py +0 -0
- {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/update_check.py +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Card prefix glyph, header assembly, and body gutter alignment shared by TUI card widgets.
|
|
2
|
+
|
|
3
|
+
These helpers encode card *appearance* (prefix tone, glyph prefix, gutter
|
|
4
|
+
alignment, header assembly). They are intentionally free of Textual widget
|
|
5
|
+
lifecycle concerns; refresh throttling and animation visibility stay in the
|
|
6
|
+
TUI layer (``tui/widgets/messages/_helpers.py``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from textual.content import Content
|
|
12
|
+
|
|
13
|
+
from soothe_cli.display import theme
|
|
14
|
+
from soothe_cli.display.preview_limits import STEP_TASK_CARD_COLLAPSE_LINE_THRESHOLD
|
|
15
|
+
from soothe_cli.display.tool_display import display_width
|
|
16
|
+
from soothe_cli.settings import get_glyphs
|
|
17
|
+
|
|
18
|
+
# Preview limits imported from preview_limits module
|
|
19
|
+
_STEP_TOOL_PREVIEW_ROWS = STEP_TASK_CARD_COLLAPSE_LINE_THRESHOLD
|
|
20
|
+
"""Collapsed step/task activity preview shows this many rows."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _card_dot_tone(
|
|
24
|
+
status: str,
|
|
25
|
+
colors: theme.ThemeColors,
|
|
26
|
+
*,
|
|
27
|
+
accent: str | None = None,
|
|
28
|
+
spinner_position: int = 0,
|
|
29
|
+
animate_running: bool = False,
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Return a Textual style string for a card prefix dot from lifecycle status."""
|
|
32
|
+
phase = (status or "pending").strip().lower()
|
|
33
|
+
if phase in ("success", "done", "completed"):
|
|
34
|
+
return colors.card_success
|
|
35
|
+
if phase in ("error", "failed", "interrupted"):
|
|
36
|
+
return colors.card_error
|
|
37
|
+
if phase == "running":
|
|
38
|
+
# Yellow flash on every other spinner tick; dim amber in between.
|
|
39
|
+
if animate_running and spinner_position % 2:
|
|
40
|
+
return colors.card_running_muted
|
|
41
|
+
return colors.card_running
|
|
42
|
+
if phase in ("queued", "pending", "continue", "replan"):
|
|
43
|
+
return colors.muted
|
|
44
|
+
if accent:
|
|
45
|
+
return accent
|
|
46
|
+
return colors.muted
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _card_dot_prefix_content(
|
|
50
|
+
widget: object,
|
|
51
|
+
status: str,
|
|
52
|
+
*,
|
|
53
|
+
accent: str | None = None,
|
|
54
|
+
glyph_override: str | None = None,
|
|
55
|
+
spinner_position: int = 0,
|
|
56
|
+
animate_running: bool = False,
|
|
57
|
+
) -> Content:
|
|
58
|
+
"""Build a stateful colored card-prefix glyph."""
|
|
59
|
+
try:
|
|
60
|
+
colors = theme.get_theme_colors(widget)
|
|
61
|
+
except Exception: # noqa: BLE001
|
|
62
|
+
colors = theme.DARK_COLORS
|
|
63
|
+
glyph = glyph_override or get_glyphs().tool_prefix
|
|
64
|
+
tone = _card_dot_tone(
|
|
65
|
+
status,
|
|
66
|
+
colors,
|
|
67
|
+
accent=accent,
|
|
68
|
+
spinner_position=spinner_position,
|
|
69
|
+
animate_running=animate_running,
|
|
70
|
+
)
|
|
71
|
+
return Content.styled(f"{glyph} ", tone)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _card_prefix_width(glyph_override: str | None = None) -> int:
|
|
75
|
+
"""Display width of the card header prefix (the dot/glyph plus one space).
|
|
76
|
+
|
|
77
|
+
Every body line and tree-branch line must left-align at the right side of
|
|
78
|
+
this prefix, so card content never extends left of the header text. In
|
|
79
|
+
ASCII mode the prefix is ``"[*] "`` (4 cols) while the tree gutter glyph
|
|
80
|
+
``"L"`` is only 1 col; the body gutter is padded to close that gap.
|
|
81
|
+
"""
|
|
82
|
+
glyph = glyph_override or get_glyphs().tool_prefix
|
|
83
|
+
return display_width(glyph) + 1 # +1 for the trailing space
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _card_body_gutter(glyph_override: str | None = None) -> str:
|
|
87
|
+
"""Body/tree-branch gutter aligned to the right of the card prefix dot.
|
|
88
|
+
|
|
89
|
+
The tree glyph (``⎿`` / ``L``) stays at the left edge of the prefix column;
|
|
90
|
+
trailing spaces pad the gutter so its display width equals the header
|
|
91
|
+
prefix width (``glyph + " "``). That makes every card line and tree branch
|
|
92
|
+
start at the right side of the dot space.
|
|
93
|
+
"""
|
|
94
|
+
g = get_glyphs()
|
|
95
|
+
prefix_width = _card_prefix_width(glyph_override)
|
|
96
|
+
glyph_w = display_width(g.output_prefix)
|
|
97
|
+
pad = max(0, prefix_width - glyph_w)
|
|
98
|
+
return f"{g.output_prefix}{' ' * pad}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _card_item_indent(
|
|
102
|
+
extra_indent: int = 0,
|
|
103
|
+
*,
|
|
104
|
+
glyph_override: str | None = None,
|
|
105
|
+
) -> str:
|
|
106
|
+
"""Plain-space indent for activity item rows (no tree glyph).
|
|
107
|
+
|
|
108
|
+
Item rows under a section (tool rows, todo items, notes, "+N more") sit at
|
|
109
|
+
the card header prefix width plus ``extra_indent`` columns — aligned with
|
|
110
|
+
the right side of the dot space so they never extend left of the title
|
|
111
|
+
text, and indented under the section's ``⎿`` label.
|
|
112
|
+
"""
|
|
113
|
+
prefix_width = _card_prefix_width(glyph_override)
|
|
114
|
+
return f"{' ' * max(0, prefix_width + extra_indent)}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _assemble_card_header(
|
|
118
|
+
widget: object,
|
|
119
|
+
body_part: str,
|
|
120
|
+
*,
|
|
121
|
+
status: str = "running",
|
|
122
|
+
accent: str | None = None,
|
|
123
|
+
glyph_override: str | None = None,
|
|
124
|
+
spinner_position: int = 0,
|
|
125
|
+
animate_running: bool = False,
|
|
126
|
+
) -> Content:
|
|
127
|
+
"""Build a card title: stateful prefix glyph plus foreground body (no bold).
|
|
128
|
+
|
|
129
|
+
Used for Goal, Plan, Step, and tool (including Task) headers. The glyph color
|
|
130
|
+
reflects lifecycle status; body lines below use the tree gutter returned by
|
|
131
|
+
:func:`_card_body_gutter`, which aligns to the right of the card prefix dot.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
widget: Mounted widget (or any object accepted by ``get_theme_colors``).
|
|
135
|
+
body_part: Header text (goal, step description, etc.).
|
|
136
|
+
status: Card lifecycle phase for glyph color/shape.
|
|
137
|
+
accent: Optional override tone for the glyph (e.g. skill/error accent).
|
|
138
|
+
spinner_position: Toggles gray flash while ``animate_running`` is true.
|
|
139
|
+
animate_running: Flash the glyph gray while running (step/task/assistant cards).
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Assembled ``Content`` for a ``Static`` header.
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
colors = theme.get_theme_colors(widget)
|
|
146
|
+
except Exception: # noqa: BLE001
|
|
147
|
+
colors = theme.DARK_COLORS
|
|
148
|
+
return Content.assemble(
|
|
149
|
+
_card_dot_prefix_content(
|
|
150
|
+
widget,
|
|
151
|
+
status,
|
|
152
|
+
accent=accent,
|
|
153
|
+
glyph_override=glyph_override,
|
|
154
|
+
spinner_position=spinner_position,
|
|
155
|
+
animate_running=animate_running,
|
|
156
|
+
),
|
|
157
|
+
Content.styled(body_part, colors.foreground),
|
|
158
|
+
)
|
|
@@ -48,6 +48,7 @@ from soothe_cli.tui.widgets.messages import (
|
|
|
48
48
|
)
|
|
49
49
|
from soothe_cli.tui.widgets.pinned_goal_bar import PinnedGoalBar
|
|
50
50
|
from soothe_cli.tui.widgets.plan_quick_view_overlay import PlanQuickViewOverlay
|
|
51
|
+
from soothe_cli.tui.widgets.queued_goals_bar import QueuedGoalsBar
|
|
51
52
|
from soothe_cli.tui.widgets.status import StatusBar
|
|
52
53
|
from soothe_cli.tui.widgets.welcome import WelcomeBanner
|
|
53
54
|
|
|
@@ -133,6 +134,13 @@ class SootheApp(
|
|
|
133
134
|
show=False,
|
|
134
135
|
priority=True,
|
|
135
136
|
),
|
|
137
|
+
Binding(
|
|
138
|
+
"ctrl+q",
|
|
139
|
+
"focus_queued_goals",
|
|
140
|
+
"Focus Queued Goals",
|
|
141
|
+
show=False,
|
|
142
|
+
priority=True,
|
|
143
|
+
),
|
|
136
144
|
]
|
|
137
145
|
"""App-level keybindings for dismiss, quit, and navigation."""
|
|
138
146
|
|
|
@@ -496,6 +504,13 @@ class SootheApp(
|
|
|
496
504
|
default_visible=plan_visible,
|
|
497
505
|
)
|
|
498
506
|
with Container(id="bottom-app-container"):
|
|
507
|
+
# Queued goals bar — inside the docked bottom container, above
|
|
508
|
+
# the thinking row and chat input. Hidden when the queue is
|
|
509
|
+
# empty; shows all pending queued goals as selectable rows.
|
|
510
|
+
# Placed inside #bottom-app-container (rather than as a separate
|
|
511
|
+
# docked sibling) because Textual does not stack multiple
|
|
512
|
+
# docked-to-same-edge widgets — they overlap instead.
|
|
513
|
+
yield QueuedGoalsBar(id="queued-goals")
|
|
499
514
|
with Container(id="thinking-status"):
|
|
500
515
|
yield TipRow(id="tip-row")
|
|
501
516
|
yield ChatInput(
|
|
@@ -7,19 +7,16 @@ keep the execution mixin focused on agent execution and message routing.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
_PLAN_REVIEW_ACTIONS = frozenset({"Approve", "Reject", "Refine"})
|
|
10
|
-
_TOOL_APPROVAL_ACTIONS = frozenset({"Approve", "Reject"
|
|
10
|
+
_TOOL_APPROVAL_ACTIONS = frozenset({"Approve", "Reject"})
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def clarification_wire_content(answers: list[str], *, origin_node: str = "") -> str:
|
|
14
14
|
"""Human-readable turn content for a clarification submit (not a new goal).
|
|
15
15
|
|
|
16
|
-
Option-selector actions use a stable
|
|
17
|
-
|
|
18
|
-
Plan review →
|
|
19
|
-
|
|
20
|
-
instructions in `answers[1]`). When `origin_node` is empty (legacy
|
|
21
|
-
callers) the action label alone selects the prefix, preserving the safety
|
|
22
|
-
net for any selector-origin submit.
|
|
16
|
+
Option-selector actions use a stable ``<prefix>: …`` header so a dropped
|
|
17
|
+
``clarification_answer`` flag cannot turn a bare action into Pass1 TASK.
|
|
18
|
+
Plan review → ``Plan review:`` (Refine carries refinement text in
|
|
19
|
+
``answers[1]``). Tool approval → ``Tool approval:``.
|
|
23
20
|
"""
|
|
24
21
|
non_empty = [a for a in answers if str(a).strip()]
|
|
25
22
|
if not non_empty:
|
|
@@ -189,10 +189,7 @@ class _ExecutionMixin:
|
|
|
189
189
|
await self._process_message(value, mode)
|
|
190
190
|
return
|
|
191
191
|
self._pending_messages.append(QueuedMessage(text=value, mode=mode))
|
|
192
|
-
queued_widget = QueuedUserMessage(value)
|
|
193
|
-
self._queued_widgets.append(queued_widget)
|
|
194
192
|
self._refresh_queued_goal_tips()
|
|
195
|
-
await self._mount_message(queued_widget)
|
|
196
193
|
return
|
|
197
194
|
|
|
198
195
|
await self._process_message(value, mode)
|
|
@@ -576,14 +573,12 @@ class _ExecutionMixin:
|
|
|
576
573
|
if self._agent_running or self._shell_running:
|
|
577
574
|
queued_widget = QueuedUserMessage(command)
|
|
578
575
|
self._queued_widgets.append(queued_widget)
|
|
579
|
-
self._refresh_queued_goal_tips()
|
|
580
576
|
await self._mount_message(queued_widget)
|
|
581
577
|
|
|
582
578
|
async def _mount_output() -> None:
|
|
583
579
|
# Remove the ephemeral queued widget, then mount real output.
|
|
584
580
|
if queued_widget in self._queued_widgets:
|
|
585
581
|
self._queued_widgets.remove(queued_widget)
|
|
586
|
-
self._refresh_queued_goal_tips()
|
|
587
582
|
with suppress(Exception):
|
|
588
583
|
await queued_widget.remove()
|
|
589
584
|
await self._mount_message(UserMessage(command))
|
|
@@ -677,6 +672,7 @@ class _ExecutionMixin:
|
|
|
677
672
|
elif cmd == "/clear":
|
|
678
673
|
self._pending_messages.clear()
|
|
679
674
|
self._queued_widgets.clear()
|
|
675
|
+
self._refresh_queued_goal_tips()
|
|
680
676
|
await self._clear_messages()
|
|
681
677
|
self._reset_loop_token_usage(None)
|
|
682
678
|
# Clear status message (e.g., "Interrupted" from previous session)
|
|
@@ -1165,12 +1161,7 @@ class _ExecutionMixin:
|
|
|
1165
1161
|
self._processing_pending = True
|
|
1166
1162
|
try:
|
|
1167
1163
|
msg = self._pending_messages.popleft()
|
|
1168
|
-
|
|
1169
|
-
# Remove the ephemeral queued-message widget
|
|
1170
|
-
if self._queued_widgets:
|
|
1171
|
-
widget = self._queued_widgets.popleft()
|
|
1172
|
-
await widget.remove()
|
|
1173
|
-
self._refresh_queued_goal_tips()
|
|
1164
|
+
self._refresh_queued_goal_tips()
|
|
1174
1165
|
|
|
1175
1166
|
await self._process_message(msg.text, msg.mode)
|
|
1176
1167
|
except Exception:
|
|
@@ -1301,11 +1292,7 @@ class _ExecutionMixin:
|
|
|
1301
1292
|
if self._pending_messages:
|
|
1302
1293
|
msg = self._pending_messages.popleft()
|
|
1303
1294
|
prompt = msg.text
|
|
1304
|
-
|
|
1305
|
-
widget = self._queued_widgets.popleft()
|
|
1306
|
-
with suppress(Exception):
|
|
1307
|
-
await widget.remove()
|
|
1308
|
-
self._refresh_queued_goal_tips()
|
|
1295
|
+
self._refresh_queued_goal_tips()
|
|
1309
1296
|
if msg.mode != "normal":
|
|
1310
1297
|
# Shell/command still need their normal handlers.
|
|
1311
1298
|
await self._process_message(msg.text, msg.mode)
|
|
@@ -38,6 +38,7 @@ from soothe_cli.tui.widgets.messages import (
|
|
|
38
38
|
SkillMessage,
|
|
39
39
|
SummarizationMessage,
|
|
40
40
|
)
|
|
41
|
+
from soothe_cli.tui.widgets.queued_goals_bar import QueuedGoalsBar
|
|
41
42
|
|
|
42
43
|
logger = logging.getLogger(__name__)
|
|
43
44
|
|
|
@@ -66,17 +67,40 @@ class _MessagesMixin:
|
|
|
66
67
|
"""Message widget lifecycle, store management, queue, interrupt/quit, toggles, editor, and events."""
|
|
67
68
|
|
|
68
69
|
def _refresh_queued_goal_tips(self) -> None:
|
|
69
|
-
"""
|
|
70
|
-
|
|
70
|
+
"""Sync the queued goals bar with the current queue state.
|
|
71
|
+
|
|
72
|
+
Feeds the current ``_pending_messages`` snapshot to the
|
|
73
|
+
``QueuedGoalsBar`` so it re-renders all rows. Shows queue
|
|
74
|
+
interaction tips on the bar when the tail goal is a normal-mode
|
|
75
|
+
goal and the agent is running.
|
|
76
|
+
"""
|
|
77
|
+
self._sync_queued_goals_bar()
|
|
78
|
+
if not self._pending_messages:
|
|
71
79
|
return
|
|
72
|
-
|
|
80
|
+
show_tips = False
|
|
73
81
|
if self._pending_messages and self._pending_messages[-1].mode == "normal":
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
82
|
+
show_tips = True
|
|
83
|
+
try:
|
|
84
|
+
bar = self.query_one("#queued-goals", QueuedGoalsBar)
|
|
85
|
+
except Exception:
|
|
86
|
+
# NoMatches, ScreenStackError, or no app/screen in test stubs.
|
|
87
|
+
return
|
|
88
|
+
with suppress(Exception):
|
|
89
|
+
bar.set_show_tips(show_tips)
|
|
90
|
+
|
|
91
|
+
def _sync_queued_goals_bar(self) -> None:
|
|
92
|
+
"""Push the current ``_pending_messages`` snapshot to the bar widget.
|
|
93
|
+
|
|
94
|
+
Safe to call when the bar is not yet mounted (e.g. during startup
|
|
95
|
+
or in unit tests using stubs) — the call is silently skipped.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
bar = self.query_one("#queued-goals", QueuedGoalsBar)
|
|
99
|
+
except Exception:
|
|
100
|
+
# NoMatches, ScreenStackError, or no app/screen in test stubs.
|
|
101
|
+
return
|
|
102
|
+
goals = list(self._pending_messages)
|
|
103
|
+
bar.set_goals(goals)
|
|
80
104
|
|
|
81
105
|
def _has_pending_chat_input(self) -> bool:
|
|
82
106
|
"""Return whether chat input has draft content that should be preserved."""
|
|
@@ -369,7 +393,11 @@ class _MessagesMixin:
|
|
|
369
393
|
return False
|
|
370
394
|
|
|
371
395
|
def _assistant_card_already_visible(self, card: Any) -> bool:
|
|
372
|
-
"""True when stream already mounted this assistant body
|
|
396
|
+
"""True when the stream already mounted this assistant body.
|
|
397
|
+
|
|
398
|
+
Also suppresses the raw plan ASSISTANT card when a plan-review
|
|
399
|
+
`StructuredAskUserWidget` with overlapping body is mounted.
|
|
400
|
+
"""
|
|
373
401
|
from soothe_sdk.display.transcript_types import MessageType
|
|
374
402
|
|
|
375
403
|
adapter = getattr(self, "_ui_adapter", None)
|
|
@@ -388,6 +416,54 @@ class _MessagesMixin:
|
|
|
388
416
|
return True
|
|
389
417
|
# Only inspect the latest assistant card.
|
|
390
418
|
break
|
|
419
|
+
# Plan-mode: suppress ASSISTANT card when a plan-review widget already
|
|
420
|
+
# shows the same plan body.
|
|
421
|
+
if self._plan_review_widget_shows_text(text):
|
|
422
|
+
return True
|
|
423
|
+
return False
|
|
424
|
+
|
|
425
|
+
@staticmethod
|
|
426
|
+
def _text_overlaps_plan_body(text: str, body: str) -> bool:
|
|
427
|
+
"""True when `text` and `body` overlap (exact, prefix, substring, or 200-char prefix).
|
|
428
|
+
|
|
429
|
+
Substring checks require the shorter string to be at least 50 chars
|
|
430
|
+
to avoid false positives.
|
|
431
|
+
"""
|
|
432
|
+
if not text or not body:
|
|
433
|
+
return False
|
|
434
|
+
if text == body or text.startswith(body) or body.startswith(text):
|
|
435
|
+
return True
|
|
436
|
+
# Substring containment: handles preface trimming (e.g. "Here is the
|
|
437
|
+
# plan.\n\n## Plan:..." card vs "## Plan:..." widget body). Require the
|
|
438
|
+
# shorter string to be at least 50 chars to avoid false positives.
|
|
439
|
+
shorter = body if len(body) <= len(text) else text
|
|
440
|
+
longer = text if shorter is body else body
|
|
441
|
+
if len(shorter) >= 50 and shorter in longer:
|
|
442
|
+
return True
|
|
443
|
+
# Handle truncation: compare a significant prefix of the shorter text.
|
|
444
|
+
prefix_len = min(200, len(text), len(body))
|
|
445
|
+
if prefix_len >= 50 and text[:prefix_len] == body[:prefix_len]:
|
|
446
|
+
return True
|
|
447
|
+
return False
|
|
448
|
+
|
|
449
|
+
def _plan_review_widget_shows_text(self, text: str) -> bool:
|
|
450
|
+
"""True when a mounted plan-review widget's body overlaps `text`.
|
|
451
|
+
|
|
452
|
+
Only plan-review widgets (`_is_plan_review`) are considered, so
|
|
453
|
+
non-plan ASSISTANT cards are never suppressed.
|
|
454
|
+
"""
|
|
455
|
+
adapter = getattr(self, "_ui_adapter", None)
|
|
456
|
+
if adapter is None:
|
|
457
|
+
return False
|
|
458
|
+
by_step = getattr(adapter, "_clarification_input_by_step", None) or {}
|
|
459
|
+
for widget in by_step.values():
|
|
460
|
+
if not getattr(widget, "_is_plan_review", False):
|
|
461
|
+
continue
|
|
462
|
+
body = str(getattr(widget, "_body_markdown", "") or "").strip()
|
|
463
|
+
if not body:
|
|
464
|
+
continue
|
|
465
|
+
if self._text_overlaps_plan_body(text, body):
|
|
466
|
+
return True
|
|
391
467
|
return False
|
|
392
468
|
|
|
393
469
|
def _cognition_reason_already_visible(self, card: Any) -> bool:
|
|
@@ -624,18 +700,15 @@ class _MessagesMixin:
|
|
|
624
700
|
await self._set_spinner(None)
|
|
625
701
|
|
|
626
702
|
def _pop_last_queued_entry(self) -> Any | None:
|
|
627
|
-
"""Pop the latest queued message
|
|
703
|
+
"""Pop the latest queued message, if available.
|
|
704
|
+
|
|
705
|
+
Removes the tail entry from ``_pending_messages`` and refreshes
|
|
706
|
+
the ``QueuedGoalsBar`` so the row disappears.
|
|
707
|
+
"""
|
|
628
708
|
if not self._pending_messages:
|
|
629
709
|
return None
|
|
630
710
|
msg = self._pending_messages.pop()
|
|
631
|
-
|
|
632
|
-
widget = self._queued_widgets.pop()
|
|
633
|
-
widget.remove()
|
|
634
|
-
self._refresh_queued_goal_tips()
|
|
635
|
-
else:
|
|
636
|
-
logger.warning(
|
|
637
|
-
"Queued-widget deque empty while pending-messages was not; widget/message tracking may be out of sync"
|
|
638
|
-
)
|
|
711
|
+
self._refresh_queued_goal_tips()
|
|
639
712
|
return msg
|
|
640
713
|
|
|
641
714
|
def _restore_last_queued_goal_to_input(self) -> bool:
|
|
@@ -661,11 +734,10 @@ class _MessagesMixin:
|
|
|
661
734
|
return True
|
|
662
735
|
|
|
663
736
|
def _discard_queue(self) -> None:
|
|
664
|
-
"""Clear pending messages, deferred actions, and queued
|
|
737
|
+
"""Clear pending messages, deferred actions, and queued goals bar."""
|
|
665
738
|
self._pending_messages.clear()
|
|
666
|
-
for w in self._queued_widgets:
|
|
667
|
-
w.remove()
|
|
668
739
|
self._queued_widgets.clear()
|
|
740
|
+
self._refresh_queued_goal_tips()
|
|
669
741
|
self._deferred_actions.clear()
|
|
670
742
|
|
|
671
743
|
def _cancel_last_queued_message(self) -> bool:
|
|
@@ -687,6 +759,86 @@ class _MessagesMixin:
|
|
|
687
759
|
return False
|
|
688
760
|
return self._restore_last_queued_goal_to_input()
|
|
689
761
|
|
|
762
|
+
def cancel_queued_goal_at_index(self, index: int) -> bool:
|
|
763
|
+
"""Cancel the queued goal at ``index`` without restoring it to input.
|
|
764
|
+
|
|
765
|
+
Args:
|
|
766
|
+
index: Zero-based index into ``_pending_messages`` (0 = head/oldest).
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
``True`` when a goal was cancelled, ``False`` when the index is
|
|
770
|
+
out of range.
|
|
771
|
+
"""
|
|
772
|
+
if index < 0 or index >= len(self._pending_messages):
|
|
773
|
+
return False
|
|
774
|
+
# deque doesn't support del[] — convert to list, remove, rebuild.
|
|
775
|
+
items = list(self._pending_messages)
|
|
776
|
+
msg = items.pop(index)
|
|
777
|
+
self._pending_messages.clear()
|
|
778
|
+
self._pending_messages.extend(items)
|
|
779
|
+
self._refresh_queued_goal_tips()
|
|
780
|
+
self.notify(f"Cancelled queued goal: {msg.text[:60]}", timeout=2)
|
|
781
|
+
return True
|
|
782
|
+
|
|
783
|
+
def edit_queued_goal_at_index(self, index: int) -> bool:
|
|
784
|
+
"""Move the queued goal at ``index`` into the chat input for editing.
|
|
785
|
+
|
|
786
|
+
Only normal-mode goals can be edited; shell/command goals are skipped
|
|
787
|
+
to avoid clobbering the input with a mode prefix.
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
index: Zero-based index into ``_pending_messages`` (0 = head/oldest).
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
``True`` when the goal was moved to input, ``False`` otherwise.
|
|
794
|
+
"""
|
|
795
|
+
if index < 0 or index >= len(self._pending_messages):
|
|
796
|
+
return False
|
|
797
|
+
if self._has_pending_chat_input():
|
|
798
|
+
return False
|
|
799
|
+
items = list(self._pending_messages)
|
|
800
|
+
msg = items[index]
|
|
801
|
+
if msg.mode != "normal":
|
|
802
|
+
return False
|
|
803
|
+
items.pop(index)
|
|
804
|
+
self._pending_messages.clear()
|
|
805
|
+
self._pending_messages.extend(items)
|
|
806
|
+
if self._chat_input:
|
|
807
|
+
self._chat_input.value = msg.text
|
|
808
|
+
self._refresh_queued_goal_tips()
|
|
809
|
+
self.notify("Queued goal moved to input", timeout=2)
|
|
810
|
+
return True
|
|
811
|
+
|
|
812
|
+
def submit_queued_goal_at_index(self, index: int) -> bool:
|
|
813
|
+
"""Submit the queued goal at ``index`` for immediate execution.
|
|
814
|
+
|
|
815
|
+
Pops the goal from the queue and processes it right away, bypassing
|
|
816
|
+
the normal FIFO order. Only valid when no agent/worker is already
|
|
817
|
+
running, since submitting while busy would just re-queue the goal.
|
|
818
|
+
|
|
819
|
+
Args:
|
|
820
|
+
index: Zero-based index into ``_pending_messages`` (0 = head/oldest).
|
|
821
|
+
|
|
822
|
+
Returns:
|
|
823
|
+
``True`` when the goal was submitted, ``False`` when the index is
|
|
824
|
+
out of range or an agent is already running.
|
|
825
|
+
"""
|
|
826
|
+
if index < 0 or index >= len(self._pending_messages):
|
|
827
|
+
return False
|
|
828
|
+
if self._agent_running or self._shell_running or self._processing_pending:
|
|
829
|
+
self.notify("Agent is busy — goal stays queued", timeout=2)
|
|
830
|
+
return False
|
|
831
|
+
items = list(self._pending_messages)
|
|
832
|
+
msg = items.pop(index)
|
|
833
|
+
self._pending_messages.clear()
|
|
834
|
+
self._pending_messages.extend(items)
|
|
835
|
+
self._refresh_queued_goal_tips()
|
|
836
|
+
import asyncio
|
|
837
|
+
|
|
838
|
+
asyncio.ensure_future(self._process_message(msg.text, msg.mode))
|
|
839
|
+
self.notify(f"Submitting: {msg.text[:60]}", timeout=2)
|
|
840
|
+
return True
|
|
841
|
+
|
|
690
842
|
def _defer_action(self, action: DeferredAction) -> None:
|
|
691
843
|
"""Queue a deferred action, replacing any existing action of the same kind.
|
|
692
844
|
|
|
@@ -891,13 +1043,25 @@ class _MessagesMixin:
|
|
|
891
1043
|
|
|
892
1044
|
# Close completion popup or exit slash/shell command mode
|
|
893
1045
|
if self._chat_input:
|
|
894
|
-
# When queue has pending goals and input is idle, Esc cancels
|
|
895
|
-
#
|
|
1046
|
+
# When queue has pending goals and input is idle, Esc cancels a
|
|
1047
|
+
# queued normal goal (without interrupting current work). If the
|
|
1048
|
+
# QueuedGoalsBar is focused, cancel the selected goal; otherwise
|
|
1049
|
+
# cancel the latest (tail) goal.
|
|
896
1050
|
if not self._has_pending_chat_input():
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1051
|
+
cancelled = False
|
|
1052
|
+
try:
|
|
1053
|
+
bar = self.query_one("#queued-goals", QueuedGoalsBar)
|
|
1054
|
+
except Exception:
|
|
1055
|
+
# NoMatches, ScreenStackError, or no app/screen in test stubs.
|
|
1056
|
+
bar = None
|
|
1057
|
+
if bar is not None and self.focused is bar:
|
|
1058
|
+
cancelled = bar.cancel_selected()
|
|
1059
|
+
if not cancelled:
|
|
1060
|
+
queue_tail = self._pending_messages[-1] if self._pending_messages else None
|
|
1061
|
+
if queue_tail is not None and queue_tail.mode == "normal":
|
|
1062
|
+
cancelled = self._cancel_last_queued_message()
|
|
1063
|
+
if cancelled:
|
|
1064
|
+
return
|
|
901
1065
|
if self._chat_input.dismiss_completion():
|
|
902
1066
|
return
|
|
903
1067
|
if self._chat_input.exit_mode():
|
|
@@ -486,6 +486,7 @@ class _ModelMixin:
|
|
|
486
486
|
# Clear conversation (similar to /clear, without creating a new loop)
|
|
487
487
|
self._pending_messages.clear()
|
|
488
488
|
self._queued_widgets.clear()
|
|
489
|
+
self._refresh_queued_goal_tips()
|
|
489
490
|
await self._clear_messages()
|
|
490
491
|
self._reset_loop_token_usage(None)
|
|
491
492
|
self._update_status("")
|
|
@@ -646,9 +646,8 @@ class _StartupMixin:
|
|
|
646
646
|
# Discard any messages queued while connecting
|
|
647
647
|
if self._pending_messages:
|
|
648
648
|
self._pending_messages.clear()
|
|
649
|
-
for w in self._queued_widgets:
|
|
650
|
-
w.remove()
|
|
651
649
|
self._queued_widgets.clear()
|
|
650
|
+
self._refresh_queued_goal_tips()
|
|
652
651
|
self._deferred_actions.clear()
|
|
653
652
|
|
|
654
653
|
@staticmethod
|
|
@@ -20,6 +20,7 @@ from soothe_cli.runtime.token_events_debug import TokenEventTrace
|
|
|
20
20
|
from soothe_cli.tui.widgets.loading import LoadingWidget
|
|
21
21
|
from soothe_cli.tui.widgets.messages import AssistantMessage, UserMessage
|
|
22
22
|
from soothe_cli.tui.widgets.pinned_goal_bar import PinnedGoalBar
|
|
23
|
+
from soothe_cli.tui.widgets.queued_goals_bar import QueuedGoalsBar
|
|
23
24
|
|
|
24
25
|
logger = logging.getLogger(__name__)
|
|
25
26
|
_monotonic = time.monotonic
|
|
@@ -520,6 +521,23 @@ class _UIMixin:
|
|
|
520
521
|
# scroll position.
|
|
521
522
|
self._sync_pinned_goal_visibility()
|
|
522
523
|
|
|
524
|
+
def action_focus_queued_goals(self) -> None:
|
|
525
|
+
"""Focus the queued goals bar (Ctrl+q).
|
|
526
|
+
|
|
527
|
+
When the bar has queued goals, moves keyboard focus to it so the
|
|
528
|
+
user can navigate with arrow keys, edit with Enter, or cancel
|
|
529
|
+
with Esc. When the queue is empty, this is a no-op.
|
|
530
|
+
"""
|
|
531
|
+
if self.screen.is_modal:
|
|
532
|
+
return
|
|
533
|
+
try:
|
|
534
|
+
bar = self.query_one("#queued-goals", QueuedGoalsBar)
|
|
535
|
+
except NoMatches:
|
|
536
|
+
return
|
|
537
|
+
if not bar.has_goals:
|
|
538
|
+
return
|
|
539
|
+
bar.activate()
|
|
540
|
+
|
|
523
541
|
def _on_chat_scroll_y_changed(self, _old: float, _new: float) -> None:
|
|
524
542
|
"""React to chat scroll position changes to show/hide the pinned bar.
|
|
525
543
|
|
|
@@ -32,6 +32,18 @@ Screen {
|
|
|
32
32
|
display: none;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/* Queued goals bar — inside #bottom-app-container, above the thinking row
|
|
36
|
+
and chat input. Hidden when the queue is empty; shows all pending queued
|
|
37
|
+
goals as selectable rows. Not docked separately because Textual overlaps
|
|
38
|
+
multiple docked-to-same-edge widgets. Focus styling (background, border,
|
|
39
|
+
color, text-style) is defined in QueuedGoalsBar.DEFAULT_CSS. */
|
|
40
|
+
#queued-goals {
|
|
41
|
+
height: auto;
|
|
42
|
+
max-height: 40vh;
|
|
43
|
+
width: 100%;
|
|
44
|
+
display: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
/* Chat area - main scrollable messages area */
|
|
36
48
|
#chat {
|
|
37
49
|
height: 1fr;
|
|
@@ -11,6 +11,7 @@ from textual.events import Click
|
|
|
11
11
|
from textual.widgets import Static
|
|
12
12
|
|
|
13
13
|
from soothe_cli.display import theme
|
|
14
|
+
from soothe_cli.display.card import _assemble_card_header
|
|
14
15
|
from soothe_cli.display.preview_limits import (
|
|
15
16
|
TOOL_APPROVAL_BODY_MAX_LINES,
|
|
16
17
|
TOOL_APPROVAL_DIFF_WIDGET_MAX_LINES,
|
|
@@ -19,7 +20,6 @@ from soothe_cli.display.preview_limits import (
|
|
|
19
20
|
from soothe_cli.settings import get_glyphs
|
|
20
21
|
from soothe_cli.tui.widgets.clipboard import screen_has_text_selection
|
|
21
22
|
from soothe_cli.tui.widgets.diff import DIFF_CODE_GAP, compose_diff_line_list
|
|
22
|
-
from soothe_cli.tui.widgets.messages._helpers import _assemble_card_header
|
|
23
23
|
|
|
24
24
|
if TYPE_CHECKING:
|
|
25
25
|
from textual.app import ComposeResult
|