soothe-cli 0.7.6__tar.gz → 0.7.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/PKG-INFO +1 -1
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/execution/daemon.py +1 -1
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/policy/display_policy.py +3 -18
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/presentation/renderer_protocol.py +1 -1
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/presentation/subagent_task_display.py +10 -7
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/state/file_tracker.py +21 -24
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -3
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/transport/session.py +1 -1
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_app.py +0 -2
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_execution.py +31 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_history.py +2 -2
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_messages_mixin.py +27 -3
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/app.tcss +0 -22
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/command_registry.py +7 -2
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/commands/slash_commands.py +8 -2
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/commands/subagent_routing.py +5 -4
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/config.py +0 -45
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/file_change_notify.py +2 -2
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/file_change_renderers.py +4 -5
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/input.py +8 -5
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/preview_limits.py +2 -5
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/textual_adapter.py +106 -51
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/chat_input.py +46 -3
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/context_viewer.py +1 -1
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -22
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +181 -28
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +1 -1
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/diff_message.py +1 -20
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +27 -1
- soothe_cli-0.7.6/src/soothe_cli/runtime/wire/display_text.py +0 -15
- soothe_cli-0.7.6/src/soothe_cli/tui/app/_commands.py +0 -50
- soothe_cli-0.7.6/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -341
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/.gitignore +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/README.md +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/pyproject.toml +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/__init__.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/__init__.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/__init__.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/execution/__init__.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/execution/headless.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/execution/launcher.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/cli/main.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/config/__init__.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/config/cli_config.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/config/loader.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/config/logging_setup.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/__init__.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/headless/processor.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/presentation/engine.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/state/session_stats.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/state/step_router.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/state/transcript.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/turn/prepare.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/wire/message_text.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/wire/messages.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/__init__.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/_cli_context.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/_env_vars.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/_version.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/__init__.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_model.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_module_init.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_startup.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/app/_ui.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/binding.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/commands/__init__.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/commands/command_router.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/hooks.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/markdown_theme.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/media_utils.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/model_config.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/path_utils.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/project_utils.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/sessions.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/shell_color.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/skills/__init__.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/skills/invocation.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/skills/load.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/spinner_labels.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/theme.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/tips.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/tool_display.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/unicode_security.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/update_check.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/__init__.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/_links.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/diff.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/editor.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/history.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/loading.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/message_store.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/cognition_step.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/status.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
- {soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/tui/widgets/welcome.py +0 -0
|
@@ -280,7 +280,7 @@ async def _run_headless_session_once(
|
|
|
280
280
|
query_started = True
|
|
281
281
|
elif (state == "idle" and query_started) or state == "stopped":
|
|
282
282
|
loop_clock = asyncio.get_event_loop()
|
|
283
|
-
drain_deadline = loop_clock.time() +
|
|
283
|
+
drain_deadline = loop_clock.time() + 0.5
|
|
284
284
|
while loop_clock.time() < drain_deadline:
|
|
285
285
|
try:
|
|
286
286
|
nxt = await asyncio.wait_for(client.read_event(), timeout=0.25)
|
|
@@ -134,33 +134,18 @@ class DisplayPolicy:
|
|
|
134
134
|
"""Check if this is a plan-related event."""
|
|
135
135
|
return event_type.startswith("soothe.cognition.plan.")
|
|
136
136
|
|
|
137
|
-
def
|
|
138
|
-
"""Check if this is a
|
|
139
|
-
return event_type.startswith("soothe.subagent.
|
|
137
|
+
def is_deep_research_event(self, event_type: str) -> bool:
|
|
138
|
+
"""Check if this is a Deep Research subagent event."""
|
|
139
|
+
return event_type.startswith("soothe.subagent.deep_research.")
|
|
140
140
|
|
|
141
141
|
def is_internal_event(self, event_type: str) -> bool:
|
|
142
142
|
"""Check if this is an internal (never-shown) event."""
|
|
143
143
|
return event_type in INTERNAL_EVENT_TYPES or event_type.startswith("soothe.internal.")
|
|
144
144
|
|
|
145
145
|
|
|
146
|
-
# =============================================================================
|
|
147
|
-
# Factory Function
|
|
148
|
-
# =============================================================================
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def create_display_policy() -> DisplayPolicy:
|
|
152
|
-
"""Create the shared display policy instance (single fixed UX tier)."""
|
|
153
|
-
return DisplayPolicy()
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
# =============================================================================
|
|
157
|
-
# Exports
|
|
158
|
-
# =============================================================================
|
|
159
|
-
|
|
160
146
|
__all__ = [
|
|
161
147
|
"INTERNAL_EVENT_TYPES",
|
|
162
148
|
"SKIP_EVENT_TYPES",
|
|
163
149
|
"DisplayPolicy",
|
|
164
150
|
"VerbosityTier",
|
|
165
|
-
"create_display_policy",
|
|
166
151
|
]
|
{soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/presentation/renderer_protocol.py
RENAMED
|
@@ -141,7 +141,7 @@ class RendererProtocol(Protocol):
|
|
|
141
141
|
Catch-all for events not covered by specific callbacks.
|
|
142
142
|
|
|
143
143
|
Args:
|
|
144
|
-
event_type: Full event type string (e.g., ``soothe.subagent.
|
|
144
|
+
event_type: Full event type string (e.g., ``soothe.subagent.deep_research.started``).
|
|
145
145
|
data: Event payload.
|
|
146
146
|
namespace: Subagent namespace tuple (empty for main agent).
|
|
147
147
|
task_scope: When subgraph streams are bound to a Task tool call (IG-334).
|
{soothe_cli-0.7.6 → soothe_cli-0.7.8}/src/soothe_cli/runtime/presentation/subagent_task_display.py
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Format subagent Task-scope assistant blobs for CLI/TUI.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Deep Research emits structured JSON in subgraph assistant streams. Clients show
|
|
4
4
|
user-facing summaries only; internal planning JSON is suppressed.
|
|
5
5
|
"""
|
|
6
6
|
|
|
@@ -11,8 +11,8 @@ from collections.abc import Iterator
|
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def
|
|
15
|
-
"""True when ``obj`` is
|
|
14
|
+
def _is_deep_research_internal_json_object(obj: dict[str, Any]) -> bool:
|
|
15
|
+
"""True when ``obj`` is Deep Research engine scratch (not a user-facing report)."""
|
|
16
16
|
keys = set(obj.keys())
|
|
17
17
|
if "sub_questions" in keys:
|
|
18
18
|
return True
|
|
@@ -89,21 +89,24 @@ def format_subagent_task_assistant_for_display(
|
|
|
89
89
|
|
|
90
90
|
Args:
|
|
91
91
|
raw: Full assistant text from a subgraph namespace.
|
|
92
|
-
subagent_type: Built-in subagent id when known (``
|
|
92
|
+
subagent_type: Built-in subagent id when known (``deep_research``, …).
|
|
93
93
|
|
|
94
94
|
Returns:
|
|
95
95
|
Scrubbed one-line text, or ``""`` when only internal payloads remain.
|
|
96
96
|
"""
|
|
97
97
|
agent = (subagent_type or "").strip().lower()
|
|
98
98
|
text = raw
|
|
99
|
-
if agent
|
|
99
|
+
if agent in ("deep_research", "academic_research"):
|
|
100
100
|
stripped = text.strip()
|
|
101
101
|
internal_present = any(
|
|
102
|
-
|
|
102
|
+
_is_deep_research_internal_json_object(obj)
|
|
103
|
+
for obj in _iter_embedded_json_objects(stripped)
|
|
103
104
|
)
|
|
104
105
|
if internal_present:
|
|
105
106
|
return ""
|
|
106
|
-
text = _strip_concatenated_json_objects(
|
|
107
|
+
text = _strip_concatenated_json_objects(
|
|
108
|
+
text, predicate=_is_deep_research_internal_json_object
|
|
109
|
+
)
|
|
107
110
|
return (text or "").strip()
|
|
108
111
|
|
|
109
112
|
|
|
@@ -19,16 +19,6 @@ FileOpStatus = Literal["pending", "success", "error"]
|
|
|
19
19
|
FILE_CHANGE_TOOLS: frozenset[str] = get_file_write_tool_names()
|
|
20
20
|
"""Filesystem tools that produce before/after diffs in the TUI chat."""
|
|
21
21
|
|
|
22
|
-
_FILE_CHANGE_PREVIEW_LABELS: dict[str, str] = {
|
|
23
|
-
"write_file": "Writing",
|
|
24
|
-
"edit_file": "Editing",
|
|
25
|
-
"edit_file_lines": "Editing",
|
|
26
|
-
"insert_lines": "Inserting",
|
|
27
|
-
"delete_lines": "Deleting",
|
|
28
|
-
"apply_diff": "Patching",
|
|
29
|
-
"delete_file": "Deleting",
|
|
30
|
-
}
|
|
31
|
-
|
|
32
22
|
|
|
33
23
|
def _safe_read(path: Path) -> str | None:
|
|
34
24
|
"""Read file content, returning None on failure.
|
|
@@ -439,21 +429,28 @@ def track_file_operation(
|
|
|
439
429
|
tracker.start_operation(tool_name, args, tcid)
|
|
440
430
|
|
|
441
431
|
|
|
442
|
-
def
|
|
443
|
-
"""
|
|
444
|
-
|
|
432
|
+
def file_change_label(tool_name: str, *, is_new_file: bool = False) -> str:
|
|
433
|
+
"""Single-word past-tense prefix for a file-change card header."""
|
|
434
|
+
if tool_name in ("delete_file", "delete_lines"):
|
|
435
|
+
return "Deleted"
|
|
436
|
+
if tool_name == "write_file":
|
|
437
|
+
return "Created" if is_new_file else "Written"
|
|
438
|
+
if tool_name in ("edit_file", "edit_file_lines"):
|
|
439
|
+
return "Edited"
|
|
440
|
+
if tool_name == "insert_lines":
|
|
441
|
+
return "Inserted"
|
|
442
|
+
if tool_name == "apply_diff":
|
|
443
|
+
return "Patched"
|
|
444
|
+
return "Changed"
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def file_change_label_from_preview_data(tool_name: str, data: dict[str, Any]) -> str:
|
|
448
|
+
"""Derive a preview header label from renderer-built widget data."""
|
|
449
|
+
is_new = bool(data.get("is_new_file")) if tool_name == "write_file" else False
|
|
450
|
+
return file_change_label(tool_name, is_new_file=is_new)
|
|
445
451
|
|
|
446
452
|
|
|
447
453
|
def file_change_action_label(record: FileOperationRecord) -> str:
|
|
448
454
|
"""Human-readable label for a completed file operation (chat diff header)."""
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if record.tool_name == "write_file" and not (record.before_content or ""):
|
|
452
|
-
return "Created"
|
|
453
|
-
if record.tool_name == "write_file":
|
|
454
|
-
return "Written"
|
|
455
|
-
if record.tool_name in ("edit_file", "edit_file_lines", "delete_lines", "apply_diff"):
|
|
456
|
-
return "Updated"
|
|
457
|
-
if record.tool_name == "insert_lines":
|
|
458
|
-
return "Inserted"
|
|
459
|
-
return "Changed"
|
|
455
|
+
is_new = record.tool_name == "write_file" and not (record.before_content or "")
|
|
456
|
+
return file_change_label(record.tool_name, is_new_file=is_new)
|
|
@@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
|
|
|
26
26
|
|
|
27
27
|
# Match headless daemon client: brief read window after ``idle`` so stream events
|
|
28
28
|
# that arrive slightly after status are not dropped (``cli/execution/daemon.py``).
|
|
29
|
-
_POST_IDLE_DRAIN_DEADLINE_S =
|
|
29
|
+
_POST_IDLE_DRAIN_DEADLINE_S = 0.5
|
|
30
30
|
|
|
31
31
|
# Align with ``bootstrap_loop_session`` daemon-ready wait (RFC-450 §8.2).
|
|
32
32
|
_RPC_HANDSHAKE_TIMEOUT_S = 20.0
|
|
@@ -23,7 +23,6 @@ from textual.message import Message
|
|
|
23
23
|
|
|
24
24
|
from soothe_cli.runtime.state.session_stats import SessionStats
|
|
25
25
|
from soothe_cli.tui import theme
|
|
26
|
-
from soothe_cli.tui.app._commands import _CommandsMixin
|
|
27
26
|
from soothe_cli.tui.app._execution import _ExecutionMixin
|
|
28
27
|
from soothe_cli.tui.app._history import _HistoryMixin
|
|
29
28
|
from soothe_cli.tui.app._messages_mixin import _MessagesMixin
|
|
@@ -55,7 +54,6 @@ class SootheApp(
|
|
|
55
54
|
App,
|
|
56
55
|
_StartupMixin,
|
|
57
56
|
_HistoryMixin,
|
|
58
|
-
_CommandsMixin,
|
|
59
57
|
_ModelMixin,
|
|
60
58
|
_ExecutionMixin,
|
|
61
59
|
_UIMixin,
|
|
@@ -450,6 +450,37 @@ class _ExecutionMixin:
|
|
|
450
450
|
"""
|
|
451
451
|
return f"{prefix}: {loop_id}"
|
|
452
452
|
|
|
453
|
+
async def _get_conversation_token_count(self) -> int | None:
|
|
454
|
+
"""Return the approximate conversation-only token count.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
Token count as an integer, or `None` if state is unavailable.
|
|
458
|
+
"""
|
|
459
|
+
if not self._lc_loop_id:
|
|
460
|
+
return None
|
|
461
|
+
try:
|
|
462
|
+
from langchain_core.messages import messages_from_dict
|
|
463
|
+
from langchain_core.messages.utils import count_tokens_approximately
|
|
464
|
+
|
|
465
|
+
if self._daemon_session is None:
|
|
466
|
+
return None
|
|
467
|
+
snap = await self._daemon_session.aget_loop_state(self._lc_loop_id)
|
|
468
|
+
vals = getattr(snap, "values", None)
|
|
469
|
+
if not isinstance(vals, dict):
|
|
470
|
+
return None
|
|
471
|
+
raw = vals.get("messages")
|
|
472
|
+
if not isinstance(raw, list) or not raw:
|
|
473
|
+
return None
|
|
474
|
+
if isinstance(raw[0], dict):
|
|
475
|
+
messages = messages_from_dict(raw)
|
|
476
|
+
else:
|
|
477
|
+
messages = raw
|
|
478
|
+
|
|
479
|
+
return count_tokens_approximately(messages)
|
|
480
|
+
except Exception: # best-effort for /tokens display
|
|
481
|
+
logger.debug("Failed to retrieve conversation token count", exc_info=True)
|
|
482
|
+
return None
|
|
483
|
+
|
|
453
484
|
async def _handle_command(self, command: str) -> None:
|
|
454
485
|
"""Handle a slash command.
|
|
455
486
|
|
|
@@ -24,7 +24,7 @@ from soothe_sdk.display.text_extract import (
|
|
|
24
24
|
normalize_stream_message,
|
|
25
25
|
)
|
|
26
26
|
from soothe_sdk.display.transcript_types import MessageData
|
|
27
|
-
from soothe_sdk.ux.loop_stream import
|
|
27
|
+
from soothe_sdk.ux.loop_stream import is_stream_terminal
|
|
28
28
|
from textual.content import Content
|
|
29
29
|
|
|
30
30
|
from soothe_cli.tui.app._module_init import _LoopHistoryPayload
|
|
@@ -339,7 +339,7 @@ class _HistoryMixin:
|
|
|
339
339
|
|
|
340
340
|
if isinstance(message, (AIMessage, AIMessageChunk)):
|
|
341
341
|
extracted = extract_ai_text_for_display(message)
|
|
342
|
-
is_terminal =
|
|
342
|
+
is_terminal = is_stream_terminal(message)
|
|
343
343
|
if extracted:
|
|
344
344
|
# Deduplicate immediate replayed AI chunks after reconnect/resubscribe.
|
|
345
345
|
if last_ai_chunk_by_ns.get(ns_key) == extracted:
|
|
@@ -399,6 +399,23 @@ class _MessagesMixin:
|
|
|
399
399
|
)
|
|
400
400
|
)
|
|
401
401
|
|
|
402
|
+
_INTERRUPT_UI_MESSAGE = "Stream cancelled"
|
|
403
|
+
|
|
404
|
+
async def _tear_down_interrupt_ui(self, message: str | None = None) -> None:
|
|
405
|
+
"""Immediately stop in-flight step/goal UI when the user interrupts a turn.
|
|
406
|
+
|
|
407
|
+
Daemon cancel and worker teardown can take several seconds; without an
|
|
408
|
+
eager UI pass the thinking spinner and goal-tree running rows stay live.
|
|
409
|
+
"""
|
|
410
|
+
label = message or self._INTERRUPT_UI_MESSAGE
|
|
411
|
+
adapter = getattr(self, "_ui_adapter", None)
|
|
412
|
+
if adapter is not None:
|
|
413
|
+
if adapter._tool_to_step or adapter._tool_display_by_call_id:
|
|
414
|
+
adapter.finalize_pending_tools_with_error(label)
|
|
415
|
+
if adapter._current_step_messages or adapter._goal_tree_message is not None:
|
|
416
|
+
adapter.finalize_pending_steps_with_error(label)
|
|
417
|
+
await self._set_spinner(None)
|
|
418
|
+
|
|
402
419
|
def _cancel_worker(self, worker: Worker[None] | None, *, discard_queue: bool = True) -> None:
|
|
403
420
|
"""Cancel an active worker, optionally discarding the pending message queue.
|
|
404
421
|
|
|
@@ -414,15 +431,17 @@ class _MessagesMixin:
|
|
|
414
431
|
worker.cancel()
|
|
415
432
|
|
|
416
433
|
async def _interrupt_daemon_agent_turn(self, *, discard_queue: bool = True) -> None:
|
|
417
|
-
"""
|
|
434
|
+
"""Stop in-flight UI, send daemon ``/cancel``, then cancel the local worker.
|
|
418
435
|
|
|
419
|
-
|
|
420
|
-
|
|
436
|
+
UI teardown runs first so Ctrl+C does not leave the thinking spinner or
|
|
437
|
+
goal-tree running rows active while the daemon winds down (which can take
|
|
438
|
+
several seconds on long execute steps).
|
|
421
439
|
|
|
422
440
|
Args:
|
|
423
441
|
discard_queue: When ``False``, preserve queued user goals so they
|
|
424
442
|
run after the cancelled turn finishes cleanup.
|
|
425
443
|
"""
|
|
444
|
+
await self._tear_down_interrupt_ui()
|
|
426
445
|
session = self._daemon_session
|
|
427
446
|
worker = self._agent_worker
|
|
428
447
|
if session is not None:
|
|
@@ -483,6 +502,11 @@ class _MessagesMixin:
|
|
|
483
502
|
group="daemon-interrupt",
|
|
484
503
|
)
|
|
485
504
|
else:
|
|
505
|
+
self.run_worker(
|
|
506
|
+
self._tear_down_interrupt_ui(),
|
|
507
|
+
exclusive=False,
|
|
508
|
+
group="interrupt-ui",
|
|
509
|
+
)
|
|
486
510
|
self._cancel_worker(self._agent_worker, discard_queue=False)
|
|
487
511
|
self._quit_pending = False
|
|
488
512
|
return
|
|
@@ -102,12 +102,6 @@ Screen {
|
|
|
102
102
|
margin: 0;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
.file-change-preview Markdown {
|
|
106
|
-
margin: 0;
|
|
107
|
-
padding: 0;
|
|
108
|
-
height: auto;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
105
|
.file-change-preview-section-label {
|
|
112
106
|
height: auto;
|
|
113
107
|
margin: 0;
|
|
@@ -126,22 +120,6 @@ Screen {
|
|
|
126
120
|
}
|
|
127
121
|
|
|
128
122
|
/* Diff styling — previews and DiffMessage (fg + bg + padding) */
|
|
129
|
-
.diff-removed {
|
|
130
|
-
height: auto;
|
|
131
|
-
color: $text-error;
|
|
132
|
-
background: $error-muted;
|
|
133
|
-
padding: 0;
|
|
134
|
-
margin: 0;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
.diff-added {
|
|
138
|
-
height: auto;
|
|
139
|
-
color: $text-success;
|
|
140
|
-
background: $success-muted;
|
|
141
|
-
padding: 0;
|
|
142
|
-
margin: 0;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
123
|
.diff-context {
|
|
146
124
|
height: auto;
|
|
147
125
|
color: $text-muted;
|
|
@@ -114,8 +114,13 @@ COMMANDS: tuple[SlashCommand, ...] = (
|
|
|
114
114
|
hidden_keywords="continue history sessions loops",
|
|
115
115
|
),
|
|
116
116
|
SlashCommand(
|
|
117
|
-
name="/
|
|
118
|
-
description="Route prompt to
|
|
117
|
+
name="/deep_research",
|
|
118
|
+
description="Route prompt to deep_research subagent (usage: /deep_research <query>)",
|
|
119
|
+
bypass_tier=BypassTier.QUEUED,
|
|
120
|
+
),
|
|
121
|
+
SlashCommand(
|
|
122
|
+
name="/academic_research",
|
|
123
|
+
description="Route prompt to academic_research subagent (usage: /academic_research <query>)",
|
|
119
124
|
bypass_tier=BypassTier.QUEUED,
|
|
120
125
|
),
|
|
121
126
|
SlashCommand(
|
|
@@ -274,10 +274,16 @@ COMMANDS: dict[str, dict[str, Any]] = {
|
|
|
274
274
|
},
|
|
275
275
|
# Daemon routing commands (3)
|
|
276
276
|
"/plan": {"location": "daemon", "type": "routing", "description": "Trigger plan mode"},
|
|
277
|
-
"/
|
|
277
|
+
"/deep_research": {
|
|
278
278
|
"location": "daemon",
|
|
279
279
|
"type": "routing",
|
|
280
|
-
"description": "Route query to
|
|
280
|
+
"description": "Route query to Deep Research subagent",
|
|
281
|
+
"requires_query": True,
|
|
282
|
+
},
|
|
283
|
+
"/academic_research": {
|
|
284
|
+
"location": "daemon",
|
|
285
|
+
"type": "routing",
|
|
286
|
+
"description": "Route query to Academic Research subagent",
|
|
281
287
|
"requires_query": True,
|
|
282
288
|
},
|
|
283
289
|
}
|
|
@@ -5,12 +5,13 @@ from __future__ import annotations
|
|
|
5
5
|
# Display names for known soothe core subagents (IG-517).
|
|
6
6
|
SUBAGENT_DISPLAY_NAMES: dict[str, str] = {
|
|
7
7
|
"planner": "Planner",
|
|
8
|
-
"
|
|
8
|
+
"deep_research": "Deep Research",
|
|
9
|
+
"academic_research": "Academic Research",
|
|
9
10
|
"browser_use": "Browser",
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
# Lowercase ids matched after ``/`` for preferred_subagent routing (core only).
|
|
13
|
-
SUBAGENT_SLASH_ROUTE_IDS: tuple[str, ...] = ("
|
|
14
|
+
SUBAGENT_SLASH_ROUTE_IDS: tuple[str, ...] = ("deep_research", "academic_research")
|
|
14
15
|
|
|
15
16
|
BUILTIN_SUBAGENT_NAMES: list[str] = list(SUBAGENT_SLASH_ROUTE_IDS)
|
|
16
17
|
|
|
@@ -33,7 +34,7 @@ def get_subagent_display_name(technical_name: str) -> str:
|
|
|
33
34
|
def parse_subagent_from_input(user_input: str) -> tuple[str | None, str]:
|
|
34
35
|
"""Parse subagent subcommand from user input.
|
|
35
36
|
|
|
36
|
-
Detects subagent routing commands (e.g. ``/
|
|
37
|
+
Detects subagent routing commands (e.g. ``/deep_research``)
|
|
37
38
|
and extracts the subagent name along with the cleaned input text.
|
|
38
39
|
|
|
39
40
|
Args:
|
|
@@ -45,7 +46,7 @@ def parse_subagent_from_input(user_input: str) -> tuple[str | None, str]:
|
|
|
45
46
|
The subcommand is removed from ``cleaned_text``.
|
|
46
47
|
|
|
47
48
|
Examples:
|
|
48
|
-
``"/
|
|
49
|
+
``"/deep_research check this"`` -> ``("deep_research", "check this")``
|
|
49
50
|
``"hello world"`` -> ``(None, "hello world")``
|
|
50
51
|
"""
|
|
51
52
|
first_match: tuple[int, str] | None = None
|
|
@@ -1242,51 +1242,6 @@ class Settings:
|
|
|
1242
1242
|
return None
|
|
1243
1243
|
return self.project_root / ".agents" / "skills"
|
|
1244
1244
|
|
|
1245
|
-
@staticmethod
|
|
1246
|
-
def get_user_claude_skills_dir() -> Path:
|
|
1247
|
-
"""Get user-level `~/.claude/skills/` directory (experimental).
|
|
1248
|
-
|
|
1249
|
-
Convenience bridge for cross-tool skill sharing with Claude Code.
|
|
1250
|
-
This is experimental and may be removed.
|
|
1251
|
-
|
|
1252
|
-
Returns:
|
|
1253
|
-
Path to `~/.claude/skills/`
|
|
1254
|
-
"""
|
|
1255
|
-
return Path.home() / ".claude" / "skills"
|
|
1256
|
-
|
|
1257
|
-
def get_project_claude_skills_dir(self) -> Path | None:
|
|
1258
|
-
"""Get project-level `.claude/skills/` directory (experimental).
|
|
1259
|
-
|
|
1260
|
-
Convenience bridge for cross-tool skill sharing with Claude Code.
|
|
1261
|
-
This is experimental and may be removed.
|
|
1262
|
-
|
|
1263
|
-
Returns:
|
|
1264
|
-
Path to `{project_root}/.claude/skills/`, or `None` if not in a project.
|
|
1265
|
-
"""
|
|
1266
|
-
if not self.project_root:
|
|
1267
|
-
return None
|
|
1268
|
-
return self.project_root / ".claude" / "skills"
|
|
1269
|
-
|
|
1270
|
-
@staticmethod
|
|
1271
|
-
def get_built_in_skills_dir() -> Path:
|
|
1272
|
-
"""Get the directory containing built-in skills that ship with the CLI.
|
|
1273
|
-
|
|
1274
|
-
Returns:
|
|
1275
|
-
Path to the `built_in_skills/` directory within the package.
|
|
1276
|
-
"""
|
|
1277
|
-
return Path(__file__).parent / "built_in_skills"
|
|
1278
|
-
|
|
1279
|
-
def get_extra_skills_dirs(self) -> list[Path]:
|
|
1280
|
-
"""Get user-configured extra skill directories.
|
|
1281
|
-
|
|
1282
|
-
Set via `SOOTHE_EXTRA_SKILLS_DIRS` (colon-separated paths) or
|
|
1283
|
-
`[skills].extra_allowed_dirs` in `SOOTHE_HOME/config/config.yml`.
|
|
1284
|
-
|
|
1285
|
-
Returns:
|
|
1286
|
-
List of extra skill directory paths, or empty list if not configured.
|
|
1287
|
-
"""
|
|
1288
|
-
return self.extra_skills_dirs or []
|
|
1289
|
-
|
|
1290
1245
|
|
|
1291
1246
|
class SessionState:
|
|
1292
1247
|
"""Mutable session state shared across the app, adapter, and agent."""
|
|
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
8
8
|
|
|
9
9
|
from soothe_cli.runtime.state.file_tracker import (
|
|
10
10
|
FILE_CHANGE_TOOLS,
|
|
11
|
-
|
|
11
|
+
file_change_label_from_preview_data,
|
|
12
12
|
parse_insert_line_arg,
|
|
13
13
|
parse_line_range_args,
|
|
14
14
|
)
|
|
@@ -89,7 +89,7 @@ async def mount_file_change_preview(
|
|
|
89
89
|
if built is None:
|
|
90
90
|
return
|
|
91
91
|
widget_cls, data = built
|
|
92
|
-
label =
|
|
92
|
+
label = file_change_label_from_preview_data(tool_name, data)
|
|
93
93
|
try:
|
|
94
94
|
widget = widget_cls(data, action_label=label)
|
|
95
95
|
widget.id = textual_widget_id("file-preview", tcid)
|
|
@@ -21,7 +21,6 @@ from soothe_cli.tui.widgets.file_change_preview import (
|
|
|
21
21
|
EditFileLinesPreviewWidget,
|
|
22
22
|
EditFilePreviewWidget,
|
|
23
23
|
FileChangePreviewWidget,
|
|
24
|
-
GenericFilePreviewWidget,
|
|
25
24
|
InsertLinesPreviewWidget,
|
|
26
25
|
WriteFilePreviewWidget,
|
|
27
26
|
unified_diff_body_lines,
|
|
@@ -84,7 +83,7 @@ def build_file_change_preview(
|
|
|
84
83
|
if tool_name == "edit_file_lines":
|
|
85
84
|
line_range = parse_line_range_args(args)
|
|
86
85
|
if line_range is None:
|
|
87
|
-
return
|
|
86
|
+
return None
|
|
88
87
|
start_line, end_line = line_range
|
|
89
88
|
new_string = str(args.get("new_content") or "")
|
|
90
89
|
before = ""
|
|
@@ -104,7 +103,7 @@ def build_file_change_preview(
|
|
|
104
103
|
if tool_name == "insert_lines":
|
|
105
104
|
line = parse_insert_line_arg(args)
|
|
106
105
|
if line is None:
|
|
107
|
-
return
|
|
106
|
+
return None
|
|
108
107
|
insert_content = str(args.get("content") or "")
|
|
109
108
|
before = ""
|
|
110
109
|
physical = resolve_physical_path(path_str, assistant_id)
|
|
@@ -122,7 +121,7 @@ def build_file_change_preview(
|
|
|
122
121
|
if tool_name == "delete_lines":
|
|
123
122
|
line_range = parse_line_range_args(args)
|
|
124
123
|
if line_range is None:
|
|
125
|
-
return
|
|
124
|
+
return None
|
|
126
125
|
start_line, end_line = line_range
|
|
127
126
|
before = ""
|
|
128
127
|
physical = resolve_physical_path(path_str, assistant_id)
|
|
@@ -172,7 +171,7 @@ def build_file_change_preview(
|
|
|
172
171
|
"total_lines": len(lines),
|
|
173
172
|
}
|
|
174
173
|
|
|
175
|
-
return
|
|
174
|
+
return None
|
|
176
175
|
|
|
177
176
|
|
|
178
177
|
def update_preview_data_from_record(data: dict[str, Any], record: FileOperationRecord) -> None:
|
|
@@ -839,21 +839,24 @@ def compose_paste_into_input(
|
|
|
839
839
|
return f"{existing_text[:start]}{pasted_text}{existing_text[end:]}"
|
|
840
840
|
|
|
841
841
|
|
|
842
|
-
def abbreviate_pasted_input_display(text: str) -> str:
|
|
843
|
-
"""Build a compact
|
|
842
|
+
def abbreviate_pasted_input_display(text: str, *, paste_index: int = 1) -> str:
|
|
843
|
+
"""Build a compact paste token for large pasted payloads.
|
|
844
844
|
|
|
845
845
|
The full ``text`` is retained separately for submission; this string is
|
|
846
846
|
only for on-screen display in the input widget.
|
|
847
847
|
|
|
848
848
|
Args:
|
|
849
849
|
text: Full pasted content.
|
|
850
|
+
paste_index: 1-based counter for multiple pastes in the same input.
|
|
850
851
|
|
|
851
852
|
Returns:
|
|
852
|
-
Abbreviated display
|
|
853
|
+
Abbreviated display token (e.g. ``[Pasted text #1 +35 lines]``).
|
|
853
854
|
"""
|
|
854
855
|
lines = text.splitlines()
|
|
855
856
|
if not lines and text:
|
|
856
857
|
lines = [text]
|
|
857
858
|
n_lines = len(lines) if lines else 1
|
|
858
|
-
|
|
859
|
-
|
|
859
|
+
if n_lines > 1:
|
|
860
|
+
extra = n_lines - 1
|
|
861
|
+
return f"[Pasted text #{paste_index} +{extra} lines]"
|
|
862
|
+
return f"[Pasted text #{paste_index}]"
|
|
@@ -48,8 +48,5 @@ APPROVAL_WARNING_TEXT_TRUNCATE_CHARS: Final[int] = 220
|
|
|
48
48
|
# --- Unified diff snippets in chat (`file_ops`, DiffMessage) ---
|
|
49
49
|
APPROVAL_DIFF_MAX_LINES: Final[int] = 15
|
|
50
50
|
|
|
51
|
-
# ---
|
|
52
|
-
|
|
53
|
-
AUTOPILOT_FINDING_LINE_PREVIEW_CHARS: Final[int] = 80
|
|
54
|
-
AUTOPILOT_FINDINGS_VISIBLE_COUNT: Final[int] = 20
|
|
55
|
-
AUTOPILOT_GRAPH_EDGE_PREVIEW_COUNT: Final[int] = 3
|
|
51
|
+
# --- Plan quick-view overlay (Ctrl+T goal tree step rows) ---
|
|
52
|
+
PLAN_QUICK_VIEW_STEP_LINE_MAX_CHARS: Final[int] = 76
|