zjcode 0.0.1__py3-none-any.whl
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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,3846 @@
|
|
|
1
|
+
"""Message widgets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import textwrap
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from time import time
|
|
14
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
|
15
|
+
|
|
16
|
+
from textual import on
|
|
17
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
18
|
+
from textual.content import Content
|
|
19
|
+
from textual.css.query import NoMatches, WrongType
|
|
20
|
+
from textual.events import Click
|
|
21
|
+
from textual.geometry import Offset
|
|
22
|
+
from textual.message import Message
|
|
23
|
+
from textual.message_pump import NoActiveAppError
|
|
24
|
+
from textual.reactive import var
|
|
25
|
+
from textual.selection import Selection
|
|
26
|
+
from textual.widgets import Static
|
|
27
|
+
|
|
28
|
+
from deepagents_code import theme
|
|
29
|
+
from deepagents_code.config import (
|
|
30
|
+
MODE_DISPLAY_GLYPHS,
|
|
31
|
+
detect_mode_prefix,
|
|
32
|
+
get_glyphs,
|
|
33
|
+
is_ascii_mode,
|
|
34
|
+
)
|
|
35
|
+
from deepagents_code.file_ops import is_sensitive_file_path
|
|
36
|
+
from deepagents_code.formatting import format_duration
|
|
37
|
+
from deepagents_code.input import EMAIL_PREFIX_PATTERN, INPUT_HIGHLIGHT_PATTERN
|
|
38
|
+
from deepagents_code.tool_display import (
|
|
39
|
+
EXECUTE_HEADER_MAX_LENGTH,
|
|
40
|
+
JS_EVAL_HEADER_MAX_LENGTH,
|
|
41
|
+
format_tool_display,
|
|
42
|
+
)
|
|
43
|
+
from deepagents_code.tui.widgets._js_eval_display import (
|
|
44
|
+
JsEvalBlock,
|
|
45
|
+
JsEvalError,
|
|
46
|
+
JsEvalResult,
|
|
47
|
+
JsEvalStdout,
|
|
48
|
+
parse_js_eval_blocks,
|
|
49
|
+
)
|
|
50
|
+
from deepagents_code.tui.widgets._links import (
|
|
51
|
+
event_targets_link,
|
|
52
|
+
open_checked_url_async,
|
|
53
|
+
open_style_link,
|
|
54
|
+
)
|
|
55
|
+
from deepagents_code.tui.widgets.diff import compose_diff_lines
|
|
56
|
+
from deepagents_code.unicode_security import render_with_unicode_markers
|
|
57
|
+
|
|
58
|
+
if TYPE_CHECKING:
|
|
59
|
+
from rich.console import (
|
|
60
|
+
Console as RichConsole,
|
|
61
|
+
ConsoleOptions,
|
|
62
|
+
RenderResult,
|
|
63
|
+
)
|
|
64
|
+
from textual.app import ComposeResult
|
|
65
|
+
from textual.events import MouseMove
|
|
66
|
+
from textual.timer import Timer
|
|
67
|
+
from textual.widget import Widget
|
|
68
|
+
from textual.widgets import Markdown
|
|
69
|
+
from textual.widgets._markdown import MarkdownStream
|
|
70
|
+
|
|
71
|
+
from deepagents_code.input import MediaTracker
|
|
72
|
+
|
|
73
|
+
logger = logging.getLogger(__name__)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _mode_color(mode: str | None, widget_or_app: object | None = None) -> str:
|
|
77
|
+
"""Return the hex color string for a mode, falling back to primary.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
mode: Mode name (e.g. `'shell'`, `'command'`) or `None`.
|
|
81
|
+
widget_or_app: Textual widget or `App` for theme-aware lookup.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Color string from the active theme's `ThemeColors`.
|
|
85
|
+
"""
|
|
86
|
+
colors = theme.get_theme_colors(widget_or_app)
|
|
87
|
+
if not mode:
|
|
88
|
+
return colors.primary
|
|
89
|
+
if mode == "shell_incognito":
|
|
90
|
+
return colors.mode_incognito
|
|
91
|
+
if mode == "shell":
|
|
92
|
+
return colors.mode_bash
|
|
93
|
+
if mode == "command":
|
|
94
|
+
return colors.mode_command
|
|
95
|
+
logger.warning("Missing color for mode '%s'; falling back to primary.", mode)
|
|
96
|
+
return colors.primary
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class FormattedOutput:
|
|
101
|
+
"""Result of formatting tool output for display."""
|
|
102
|
+
|
|
103
|
+
content: Content
|
|
104
|
+
"""Styled `Content` for the formatted output."""
|
|
105
|
+
|
|
106
|
+
truncation: str | None = None
|
|
107
|
+
"""Description of truncated content (e.g., "10 more lines"), or None if no
|
|
108
|
+
truncation occurred."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Maximum number of tool arguments to display inline
|
|
112
|
+
_MAX_INLINE_ARGS = 3
|
|
113
|
+
|
|
114
|
+
# Truncation limits for display
|
|
115
|
+
_MAX_TODO_CONTENT_LEN = 70
|
|
116
|
+
_DEFAULT_TODO_WRAP_WIDTH = 60
|
|
117
|
+
_TODO_WRAP_GUARD_COLUMNS = 4
|
|
118
|
+
_MAX_WEB_CONTENT_LEN = 100
|
|
119
|
+
|
|
120
|
+
# User message display truncation — when content exceeds this many characters,
|
|
121
|
+
# only the head and tail are rendered with an elision marker in between.
|
|
122
|
+
# This keeps very large pastes from flooding the conversation scrollback.
|
|
123
|
+
_USER_MSG_MAX_DISPLAY_CHARS = 10_000
|
|
124
|
+
_USER_MSG_TRUNCATE_HEAD_CHARS = 2_500
|
|
125
|
+
_USER_MSG_TRUNCATE_TAIL_CHARS = 2_500
|
|
126
|
+
|
|
127
|
+
# Tools that have their key info already in the header (no need for args line)
|
|
128
|
+
_TOOLS_WITH_HEADER_INFO: set[str] = {
|
|
129
|
+
# Filesystem tools
|
|
130
|
+
"ls",
|
|
131
|
+
"read_file",
|
|
132
|
+
"write_file",
|
|
133
|
+
"edit_file",
|
|
134
|
+
"delete",
|
|
135
|
+
"glob",
|
|
136
|
+
"grep",
|
|
137
|
+
"execute", # sandbox shell
|
|
138
|
+
"js_eval", # JS interpreter
|
|
139
|
+
# Web tools
|
|
140
|
+
"web_search",
|
|
141
|
+
"fetch_url",
|
|
142
|
+
"ask_user",
|
|
143
|
+
# Agent tools
|
|
144
|
+
"task",
|
|
145
|
+
"write_todos",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# Tools whose key info (file path / search pattern) is already in the header, so
|
|
150
|
+
# their output body is collapsed entirely by default — an expand affordance
|
|
151
|
+
# replaces the inline preview. `read_file` echoes the file; grep/glob echo the
|
|
152
|
+
# matches for a pattern the header already names.
|
|
153
|
+
_COLLAPSE_OUTPUT_BY_DEFAULT: set[str] = {
|
|
154
|
+
"read_file",
|
|
155
|
+
"grep",
|
|
156
|
+
"glob",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# Long-running tools whose completed status row reports how long they ran
|
|
161
|
+
# ("Took <duration>") when a run was timed, instead of being hidden. `execute`
|
|
162
|
+
# shells and `task` subagent dispatches can both run for a while, so the elapsed
|
|
163
|
+
# time is useful.
|
|
164
|
+
_TIMED_SUCCESS_TOOLS: set[str] = {
|
|
165
|
+
"execute",
|
|
166
|
+
"task",
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
_SUCCESS_EXIT_RE = re.compile(r"\n?\[Command succeeded with exit code 0\]\s*$")
|
|
171
|
+
"""Strip the SDK's `[Command succeeded with exit code 0]` trailer from tool output."""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
_READ_FILE_GUTTER_RE = re.compile(r"^ *(\d+(?:\.\d+)?)(?: |\t)(.*)$")
|
|
175
|
+
"""Match a `read_file` gutter row into (marker, source).
|
|
176
|
+
|
|
177
|
+
The marker is a bare `N` or `N.M` (the latter a wrapped-line continuation) —
|
|
178
|
+
both sides of the dot required, so a stray `.5` head is not a gutter. The
|
|
179
|
+
separator is exactly two spaces (current format) or a single tab (legacy
|
|
180
|
+
`cat -n`). Only the separator is consumed and leading padding is spaces-only, so
|
|
181
|
+
source indentation — including leading tabs — after the gutter stays put. Kept in
|
|
182
|
+
sync with the separator emitted by deepagents' `format_content_with_line_numbers`
|
|
183
|
+
(the authoritative producer). See `ToolCallMessage._compact_line_gutter`.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _strip_success_exit_line(text: str) -> str:
|
|
188
|
+
"""Remove the `[Command succeeded with exit code 0]` trailer.
|
|
189
|
+
|
|
190
|
+
Non-zero exit codes are left intact (they come through `set_error`).
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
text: Raw tool output string.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Text with the success exit-code trailer removed, if present.
|
|
197
|
+
"""
|
|
198
|
+
return _SUCCESS_EXIT_RE.sub("", text)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# Visual width of the prompt prefix (glyph + trailing space, e.g. "> ", "$ ").
|
|
202
|
+
# Glyphs are single characters, so the prefix is always two columns wide.
|
|
203
|
+
_PROMPT_PREFIX_WIDTH = 2
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _strip_prompt_prefix(
|
|
207
|
+
result: tuple[str, str] | None,
|
|
208
|
+
selection: Selection,
|
|
209
|
+
) -> tuple[str, str] | None:
|
|
210
|
+
"""Drop the leading prompt prefix glyph from a selected range.
|
|
211
|
+
|
|
212
|
+
The prefix is only rendered on the first row, so it is stripped only when
|
|
213
|
+
the selection begins there. This keeps triple-click / select-all copies to
|
|
214
|
+
the message body instead of the decorative `"> "` (or mode glyph) prefix.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
result: The `(text, ending)` tuple returned by `Static.get_selection`.
|
|
218
|
+
selection: The active selection geometry.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The selection with the prefix removed from row 0, or `result` unchanged.
|
|
222
|
+
"""
|
|
223
|
+
if result is None:
|
|
224
|
+
return None
|
|
225
|
+
text, ending = result
|
|
226
|
+
start = selection.start
|
|
227
|
+
if start is not None and start.y != 0:
|
|
228
|
+
return result
|
|
229
|
+
start_x = 0 if start is None else start.x
|
|
230
|
+
prefix_chars = max(0, _PROMPT_PREFIX_WIDTH - start_x)
|
|
231
|
+
return text[prefix_chars:], ending
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _select_prompt_body(widget: Static) -> None:
|
|
235
|
+
"""Select the user message body without its decorative prompt glyph.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
widget: User message widget whose body should be selected.
|
|
239
|
+
"""
|
|
240
|
+
widget.screen.selections = { # ty: ignore[invalid-assignment] # Textual reactive descriptor assignment updates selection watchers; `set_reactive` would skip them.
|
|
241
|
+
widget: Selection(Offset(_PROMPT_PREFIX_WIDTH, 0), None),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class SessionSeparator(Static):
|
|
246
|
+
"""Widget displaying a session separator between different sessions."""
|
|
247
|
+
|
|
248
|
+
DEFAULT_CSS = """
|
|
249
|
+
SessionSeparator {
|
|
250
|
+
height: auto;
|
|
251
|
+
padding: 1 0;
|
|
252
|
+
margin: 2 0;
|
|
253
|
+
width: 100%;
|
|
254
|
+
color: $primary;
|
|
255
|
+
text-align: center;
|
|
256
|
+
background: transparent;
|
|
257
|
+
}
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
261
|
+
"""Initialize a session separator.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
**kwargs: Additional arguments passed to parent.
|
|
265
|
+
"""
|
|
266
|
+
super().__init__(
|
|
267
|
+
"---\n✨ ✨ ✨ ✨ ✨ ✨ 【 NEW SESSION 】 ✨ ✨ ✨ ✨ ✨ ✨\n---",
|
|
268
|
+
**kwargs,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _truncate_for_display(text: str) -> str:
|
|
273
|
+
"""Truncate very long user message text for display in the conversation.
|
|
274
|
+
|
|
275
|
+
Keeps the first and last portions and replaces the middle with an elision
|
|
276
|
+
marker showing the number of newlines in the hidden region. This mirrors
|
|
277
|
+
Claude Code's `UserPromptMessage` head+tail truncation for rendering
|
|
278
|
+
performance.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
text: Full message content.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Truncated text with an elision marker, or the original text when
|
|
285
|
+
it does not exceed the display threshold.
|
|
286
|
+
"""
|
|
287
|
+
if len(text) <= _USER_MSG_MAX_DISPLAY_CHARS:
|
|
288
|
+
return text
|
|
289
|
+
head = text[:_USER_MSG_TRUNCATE_HEAD_CHARS]
|
|
290
|
+
tail = text[-_USER_MSG_TRUNCATE_TAIL_CHARS:]
|
|
291
|
+
hidden_text = text[_USER_MSG_TRUNCATE_HEAD_CHARS:-_USER_MSG_TRUNCATE_TAIL_CHARS]
|
|
292
|
+
hidden_lines = hidden_text.count("\n")
|
|
293
|
+
return f"{head}\n… +{hidden_lines} lines …\n{tail}"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class UserMessage(Static):
|
|
297
|
+
"""Widget displaying a user message."""
|
|
298
|
+
|
|
299
|
+
DEFAULT_CSS = """
|
|
300
|
+
UserMessage {
|
|
301
|
+
height: auto;
|
|
302
|
+
padding: 0 1;
|
|
303
|
+
margin: 0 0 1 0;
|
|
304
|
+
background: transparent;
|
|
305
|
+
border-left: wide $primary;
|
|
306
|
+
pointer: text;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
UserMessage.-cancelled {
|
|
310
|
+
opacity: 0.6;
|
|
311
|
+
}
|
|
312
|
+
"""
|
|
313
|
+
"""`-cancelled` dims a prompt whose turn was interrupted by the user."""
|
|
314
|
+
|
|
315
|
+
def __init__(
|
|
316
|
+
self,
|
|
317
|
+
content: str,
|
|
318
|
+
*,
|
|
319
|
+
media_snapshot: MediaTracker | None = None,
|
|
320
|
+
**kwargs: Any,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Initialize a user message.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
content: The message content
|
|
326
|
+
media_snapshot: Optional media tracker state captured at submission.
|
|
327
|
+
**kwargs: Additional arguments passed to parent
|
|
328
|
+
"""
|
|
329
|
+
super().__init__(**kwargs)
|
|
330
|
+
self._content = content
|
|
331
|
+
self._media_snapshot = media_snapshot
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def raw_text(self) -> str:
|
|
335
|
+
"""The original, untruncated message text as the user submitted it.
|
|
336
|
+
|
|
337
|
+
Named `raw_text` rather than `content` to avoid shadowing Textual's
|
|
338
|
+
read/write `Static.content` property (backed by a mangled attribute);
|
|
339
|
+
overriding it getter-only would make `self.content = ...` raise.
|
|
340
|
+
"""
|
|
341
|
+
return self._content
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def media_snapshot(self) -> MediaTracker | None:
|
|
345
|
+
"""Media tracker state captured when the message was submitted."""
|
|
346
|
+
return self._media_snapshot
|
|
347
|
+
|
|
348
|
+
def set_cancelled(self) -> None:
|
|
349
|
+
"""Dim the message to mark its turn as interrupted by the user."""
|
|
350
|
+
self.add_class("-cancelled")
|
|
351
|
+
|
|
352
|
+
def get_selection(self, selection: Selection) -> tuple[str, str] | None:
|
|
353
|
+
"""Return selected text, preferring the full content over the render.
|
|
354
|
+
|
|
355
|
+
`render()` truncates long messages, so for a full-message selection
|
|
356
|
+
(select-all / select-to-end, where `selection.end` is `None`) the text
|
|
357
|
+
is extracted from the untruncated content so copy yields the complete
|
|
358
|
+
original. A partial selection is extracted from the base (on-screen)
|
|
359
|
+
render so its offsets stay aligned with what the user highlighted.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
selection: The active selection geometry.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
The `(text, ending)` selection with the prefix removed, or `None`.
|
|
366
|
+
"""
|
|
367
|
+
if selection.end is not None:
|
|
368
|
+
return _strip_prompt_prefix(super().get_selection(selection), selection)
|
|
369
|
+
text = str(self._build_full_render())
|
|
370
|
+
return _strip_prompt_prefix((selection.extract(text), "\n"), selection)
|
|
371
|
+
|
|
372
|
+
def _prefix_and_body(self) -> tuple[tuple[str, str], str]:
|
|
373
|
+
"""Compute the styled mode prefix and the body with its trigger stripped.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
A `(prefix, body)` pair where `prefix` is a `(text, style)` tuple and
|
|
377
|
+
`body` is the content with any mode-trigger prefix removed.
|
|
378
|
+
"""
|
|
379
|
+
colors = theme.get_theme_colors(self)
|
|
380
|
+
content = self._content
|
|
381
|
+
mode_match = detect_mode_prefix(content)
|
|
382
|
+
if mode_match:
|
|
383
|
+
prefix_text, mode = mode_match
|
|
384
|
+
glyph = MODE_DISPLAY_GLYPHS.get(mode, prefix_text[0])
|
|
385
|
+
return (
|
|
386
|
+
(f"{glyph} ", f"bold {_mode_color(mode, self)}"),
|
|
387
|
+
content[len(prefix_text) :],
|
|
388
|
+
)
|
|
389
|
+
return ("> ", f"bold {colors.primary}"), content
|
|
390
|
+
|
|
391
|
+
def _build_full_render(self) -> Content:
|
|
392
|
+
"""Build a Content from the full content without display truncation.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Content with the mode prefix glyph and the full message body.
|
|
396
|
+
"""
|
|
397
|
+
prefix, body = self._prefix_and_body()
|
|
398
|
+
return Content.assemble(prefix, body)
|
|
399
|
+
|
|
400
|
+
def text_select_all(self) -> None:
|
|
401
|
+
"""Select the message body without the prompt prefix glyph."""
|
|
402
|
+
_select_prompt_body(self)
|
|
403
|
+
|
|
404
|
+
def on_mount(self) -> None:
|
|
405
|
+
"""Add CSS classes for mode-specific border and ASCII border type."""
|
|
406
|
+
mode_match = detect_mode_prefix(self._content)
|
|
407
|
+
if mode_match:
|
|
408
|
+
_prefix, mode = mode_match
|
|
409
|
+
self.add_class(f"-mode-{mode.replace('_', '-')}")
|
|
410
|
+
if is_ascii_mode():
|
|
411
|
+
self.add_class("-ascii")
|
|
412
|
+
|
|
413
|
+
def render(self) -> Content:
|
|
414
|
+
"""Render the styled user message.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Styled Content with mode prefix and highlighted mentions.
|
|
418
|
+
"""
|
|
419
|
+
colors = theme.get_theme_colors(self)
|
|
420
|
+
|
|
421
|
+
# Use mode-specific prefix indicator when content starts with a
|
|
422
|
+
# mode trigger character (e.g. "!" for shell, "/" for commands).
|
|
423
|
+
# The display glyph may differ from the trigger (e.g. "$" for shell).
|
|
424
|
+
prefix, content = self._prefix_and_body()
|
|
425
|
+
parts: list[str | tuple[str, str]] = [prefix]
|
|
426
|
+
|
|
427
|
+
# Truncate very long content for display so large pastes don't flood
|
|
428
|
+
# the conversation. The full text is still available for copy/select.
|
|
429
|
+
content = _truncate_for_display(content)
|
|
430
|
+
|
|
431
|
+
# Highlight @mentions and /commands in the content
|
|
432
|
+
last_end = 0
|
|
433
|
+
for match in INPUT_HIGHLIGHT_PATTERN.finditer(content):
|
|
434
|
+
start, end = match.span()
|
|
435
|
+
token = match.group()
|
|
436
|
+
|
|
437
|
+
# Skip @mentions that look like email addresses
|
|
438
|
+
if token.startswith("@") and start > 0:
|
|
439
|
+
char_before = content[start - 1]
|
|
440
|
+
if EMAIL_PREFIX_PATTERN.match(char_before):
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
# Add text before the match (unstyled)
|
|
444
|
+
if start > last_end:
|
|
445
|
+
parts.append(content[last_end:start])
|
|
446
|
+
|
|
447
|
+
# The regex only matches tokens starting with / or @
|
|
448
|
+
if token.startswith("/") and start == 0:
|
|
449
|
+
# /command at start
|
|
450
|
+
parts.append((token, f"bold {colors.warning}"))
|
|
451
|
+
elif token.startswith("@"):
|
|
452
|
+
# @file mention
|
|
453
|
+
parts.append((token, f"bold {colors.primary}"))
|
|
454
|
+
last_end = end
|
|
455
|
+
|
|
456
|
+
# Add remaining text after last match
|
|
457
|
+
if last_end < len(content):
|
|
458
|
+
parts.append(content[last_end:])
|
|
459
|
+
|
|
460
|
+
return Content.assemble(*parts)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class QueuedUserMessage(Static):
|
|
464
|
+
"""Widget displaying a queued (pending) user message in grey.
|
|
465
|
+
|
|
466
|
+
This is an ephemeral widget that gets removed when the message is dequeued.
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
DEFAULT_CSS = """
|
|
470
|
+
QueuedUserMessage {
|
|
471
|
+
height: auto;
|
|
472
|
+
padding: 0 1;
|
|
473
|
+
margin: 0 0 1 0;
|
|
474
|
+
background: transparent;
|
|
475
|
+
border-left: wide $panel;
|
|
476
|
+
opacity: 0.6;
|
|
477
|
+
pointer: text;
|
|
478
|
+
}
|
|
479
|
+
"""
|
|
480
|
+
"""Dimmed border + reduced opacity to distinguish queued messages from sent ones."""
|
|
481
|
+
|
|
482
|
+
def __init__(self, content: str, **kwargs: Any) -> None:
|
|
483
|
+
"""Initialize a queued user message.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
content: The message content
|
|
487
|
+
**kwargs: Additional arguments passed to parent
|
|
488
|
+
"""
|
|
489
|
+
super().__init__(**kwargs)
|
|
490
|
+
self._content = content
|
|
491
|
+
|
|
492
|
+
def on_mount(self) -> None:
|
|
493
|
+
"""Add ASCII border class when in ASCII mode."""
|
|
494
|
+
if is_ascii_mode():
|
|
495
|
+
self.add_class("-ascii")
|
|
496
|
+
|
|
497
|
+
def get_selection(self, selection: Selection) -> tuple[str, str] | None:
|
|
498
|
+
"""Return selected text, preferring the full content over the render.
|
|
499
|
+
|
|
500
|
+
See `UserMessage.get_selection`: full-message selections extract from
|
|
501
|
+
the untruncated content, partial selections defer to the on-screen
|
|
502
|
+
render so offsets stay aligned.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
selection: The active selection geometry.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
The `(text, ending)` selection with the prefix removed, or `None`.
|
|
509
|
+
"""
|
|
510
|
+
if selection.end is not None:
|
|
511
|
+
return _strip_prompt_prefix(super().get_selection(selection), selection)
|
|
512
|
+
text = str(self._build_full_render())
|
|
513
|
+
return _strip_prompt_prefix((selection.extract(text), "\n"), selection)
|
|
514
|
+
|
|
515
|
+
def _prefix_and_body(self) -> tuple[tuple[str, str], str]:
|
|
516
|
+
"""Compute the muted mode prefix and body with its trigger stripped.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
A `(prefix, body)` pair where `prefix` is a `(text, style)` tuple.
|
|
520
|
+
"""
|
|
521
|
+
colors = theme.get_theme_colors(self)
|
|
522
|
+
content = self._content
|
|
523
|
+
mode_match = detect_mode_prefix(content)
|
|
524
|
+
if mode_match:
|
|
525
|
+
prefix_text, mode = mode_match
|
|
526
|
+
glyph = MODE_DISPLAY_GLYPHS.get(mode, prefix_text[0])
|
|
527
|
+
return (f"{glyph} ", f"bold {colors.muted}"), content[len(prefix_text) :]
|
|
528
|
+
return ("> ", f"bold {colors.muted}"), content
|
|
529
|
+
|
|
530
|
+
def _build_full_render(self) -> Content:
|
|
531
|
+
"""Build a Content from the full content without display truncation.
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
Content with the mode prefix glyph and the full message body.
|
|
535
|
+
"""
|
|
536
|
+
prefix, body = self._prefix_and_body()
|
|
537
|
+
return Content.assemble(prefix, body)
|
|
538
|
+
|
|
539
|
+
def text_select_all(self) -> None:
|
|
540
|
+
"""Select the message body without the prompt prefix glyph."""
|
|
541
|
+
_select_prompt_body(self)
|
|
542
|
+
|
|
543
|
+
def render(self) -> Content:
|
|
544
|
+
"""Render the queued user message (greyed out).
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
Styled Content with dimmed prefix and body.
|
|
548
|
+
"""
|
|
549
|
+
colors = theme.get_theme_colors(self)
|
|
550
|
+
prefix, content = self._prefix_and_body()
|
|
551
|
+
content = _truncate_for_display(content)
|
|
552
|
+
return Content.assemble(prefix, (content, colors.muted))
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _strip_frontmatter(text: str) -> str:
|
|
556
|
+
"""Remove YAML frontmatter delimited by `---` markers.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
text: Raw `SKILL.md` content.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
Body text with frontmatter removed and leading whitespace stripped.
|
|
563
|
+
"""
|
|
564
|
+
stripped = text.lstrip()
|
|
565
|
+
if not stripped.startswith("---"):
|
|
566
|
+
return text
|
|
567
|
+
# Find closing --- (skip the opening line)
|
|
568
|
+
end = stripped.find("\n---", 3)
|
|
569
|
+
if end == -1:
|
|
570
|
+
return text
|
|
571
|
+
# Skip past the closing --- and its trailing newline
|
|
572
|
+
after = end + 4 # len("\n---")
|
|
573
|
+
return stripped[after:].lstrip("\n")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
class _SkillToggle(Static):
|
|
577
|
+
"""Clickable header/hint area for toggling skill body expansion.
|
|
578
|
+
|
|
579
|
+
Referenced by name in `SkillMessage._on_toggle_click`'s `@on(Click)`
|
|
580
|
+
CSS selector — rename with care.
|
|
581
|
+
"""
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
class SkillMessage(Vertical):
|
|
585
|
+
"""Widget displaying a skill invocation with collapsible body.
|
|
586
|
+
|
|
587
|
+
Shows skill name, source badge, description, and user args as a compact
|
|
588
|
+
header. The full SKILL.md body (frontmatter stripped) is hidden behind a
|
|
589
|
+
preview/expand toggle (click or Ctrl+O). The expanded view renders
|
|
590
|
+
markdown via Rich's `Markdown` inside a single `Static` widget.
|
|
591
|
+
|
|
592
|
+
Visibility is driven by a CSS class (`-expanded`) toggled via a Textual
|
|
593
|
+
reactive `var`. Click handlers are scoped to the header and hint widgets
|
|
594
|
+
(`_SkillToggle`) so clicks on the rendered markdown body do not trigger
|
|
595
|
+
expansion toggles (preserving text selection, for instance).
|
|
596
|
+
"""
|
|
597
|
+
|
|
598
|
+
DEFAULT_CSS = """
|
|
599
|
+
SkillMessage {
|
|
600
|
+
height: auto;
|
|
601
|
+
padding: 0 1;
|
|
602
|
+
margin: 0 0 1 0;
|
|
603
|
+
background: transparent;
|
|
604
|
+
border-left: wide $skill;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
SkillMessage .skill-header {
|
|
608
|
+
height: auto;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
SkillMessage .skill-description {
|
|
612
|
+
color: $text-muted;
|
|
613
|
+
margin-left: 3;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
SkillMessage .skill-args {
|
|
617
|
+
margin-left: 3;
|
|
618
|
+
margin-top: 0;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
SkillMessage #skill-md {
|
|
622
|
+
margin-left: 3;
|
|
623
|
+
margin-top: 0;
|
|
624
|
+
padding: 0;
|
|
625
|
+
display: none;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
SkillMessage .skill-hint {
|
|
629
|
+
margin-left: 3;
|
|
630
|
+
color: $text-muted;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
SkillMessage.-expanded #skill-md {
|
|
634
|
+
display: block;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
SkillMessage:hover {
|
|
638
|
+
border-left: wide $skill-hover;
|
|
639
|
+
}
|
|
640
|
+
"""
|
|
641
|
+
|
|
642
|
+
_PREVIEW_LINES = 4
|
|
643
|
+
_PREVIEW_CHARS = 300
|
|
644
|
+
|
|
645
|
+
_expanded: var[bool] = var(False, toggle_class="-expanded")
|
|
646
|
+
|
|
647
|
+
def __init__(
|
|
648
|
+
self,
|
|
649
|
+
skill_name: str,
|
|
650
|
+
description: str = "",
|
|
651
|
+
source: str = "",
|
|
652
|
+
body: str = "",
|
|
653
|
+
args: str = "",
|
|
654
|
+
**kwargs: Any,
|
|
655
|
+
) -> None:
|
|
656
|
+
"""Initialize a skill message.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
skill_name: Skill identifier.
|
|
660
|
+
description: Short description of the skill.
|
|
661
|
+
source: Origin label (e.g., `'built-in'`, `'user'`).
|
|
662
|
+
body: Full SKILL.md content (frontmatter included).
|
|
663
|
+
args: User-provided arguments.
|
|
664
|
+
**kwargs: Additional arguments passed to parent.
|
|
665
|
+
"""
|
|
666
|
+
super().__init__(**kwargs)
|
|
667
|
+
self._skill_name = skill_name
|
|
668
|
+
self._description = description
|
|
669
|
+
self._source = source
|
|
670
|
+
self._body = body
|
|
671
|
+
self._stripped_body = _strip_frontmatter(body)
|
|
672
|
+
self._args = args
|
|
673
|
+
self._md_widget: Static | None = None
|
|
674
|
+
self._hint_widget: _SkillToggle | None = None
|
|
675
|
+
self._deferred_expanded: bool = False
|
|
676
|
+
self._md_rendered: bool = False
|
|
677
|
+
|
|
678
|
+
def compose(self) -> ComposeResult:
|
|
679
|
+
"""Compose the skill message layout.
|
|
680
|
+
|
|
681
|
+
Yields:
|
|
682
|
+
Widgets for header, description, args, and collapsible body.
|
|
683
|
+
"""
|
|
684
|
+
colors = theme.get_theme_colors()
|
|
685
|
+
source_tag = f" [{self._source}]" if self._source else ""
|
|
686
|
+
yield _SkillToggle(
|
|
687
|
+
Content.styled(
|
|
688
|
+
f"/ skill:{self._skill_name}{source_tag}",
|
|
689
|
+
f"bold {colors.skill}",
|
|
690
|
+
),
|
|
691
|
+
classes="skill-header",
|
|
692
|
+
)
|
|
693
|
+
if self._description:
|
|
694
|
+
yield _SkillToggle(
|
|
695
|
+
Content.styled(self._description, "dim"),
|
|
696
|
+
classes="skill-description",
|
|
697
|
+
)
|
|
698
|
+
if self._args:
|
|
699
|
+
yield Static(
|
|
700
|
+
Content.assemble(
|
|
701
|
+
("User request: ", "bold"),
|
|
702
|
+
self._args,
|
|
703
|
+
),
|
|
704
|
+
classes="skill-args",
|
|
705
|
+
)
|
|
706
|
+
yield Static("", id="skill-md")
|
|
707
|
+
yield _SkillToggle("", classes="skill-hint", id="skill-hint")
|
|
708
|
+
|
|
709
|
+
def on_mount(self) -> None:
|
|
710
|
+
"""Cache widget references, render initial state.
|
|
711
|
+
|
|
712
|
+
Ordering matters: widget refs must be cached before `_prepare_body`
|
|
713
|
+
or `_deferred_expanded` assignment, because either may set
|
|
714
|
+
`_expanded` which fires `watch__expanded` synchronously.
|
|
715
|
+
"""
|
|
716
|
+
if is_ascii_mode():
|
|
717
|
+
colors = theme.get_theme_colors(self)
|
|
718
|
+
self.styles.border_left = ("ascii", colors.skill)
|
|
719
|
+
|
|
720
|
+
self._md_widget = self.query_one("#skill-md", Static)
|
|
721
|
+
self._hint_widget = self.query_one("#skill-hint", _SkillToggle)
|
|
722
|
+
|
|
723
|
+
body = self._stripped_body.strip()
|
|
724
|
+
if body:
|
|
725
|
+
self._prepare_body(body)
|
|
726
|
+
|
|
727
|
+
if self._deferred_expanded:
|
|
728
|
+
self._expanded = self._deferred_expanded
|
|
729
|
+
self._deferred_expanded = False
|
|
730
|
+
|
|
731
|
+
def _prepare_body(self, body: str) -> None:
|
|
732
|
+
"""Set initial hint text. Full body render is deferred to first expand.
|
|
733
|
+
|
|
734
|
+
Args:
|
|
735
|
+
body: Stripped markdown body text.
|
|
736
|
+
"""
|
|
737
|
+
lines = body.split("\n")
|
|
738
|
+
total_lines = len(lines)
|
|
739
|
+
needs_truncation = (
|
|
740
|
+
total_lines > self._PREVIEW_LINES or len(body) > self._PREVIEW_CHARS
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
if needs_truncation:
|
|
744
|
+
remaining = total_lines - self._PREVIEW_LINES
|
|
745
|
+
ellipsis = get_glyphs().ellipsis
|
|
746
|
+
if self._hint_widget:
|
|
747
|
+
self._hint_widget.update(
|
|
748
|
+
Content.styled(
|
|
749
|
+
f"{ellipsis} {remaining} more lines"
|
|
750
|
+
" — click or Ctrl+O to expand",
|
|
751
|
+
"dim",
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
else:
|
|
755
|
+
# Short body — show fully rendered, no preview needed.
|
|
756
|
+
self._ensure_md_rendered(body)
|
|
757
|
+
self._expanded = True
|
|
758
|
+
|
|
759
|
+
def _ensure_md_rendered(self, body: str) -> None:
|
|
760
|
+
"""Render markdown into the Static widget on first call, then no-op.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
body: Stripped markdown body text.
|
|
764
|
+
"""
|
|
765
|
+
if self._md_rendered or not self._md_widget:
|
|
766
|
+
return
|
|
767
|
+
try:
|
|
768
|
+
from rich.markdown import Markdown as RichMarkdown
|
|
769
|
+
|
|
770
|
+
self._md_widget.update(RichMarkdown(body))
|
|
771
|
+
except Exception:
|
|
772
|
+
logger.warning(
|
|
773
|
+
"Failed to render skill body as markdown; falling back to plain text",
|
|
774
|
+
exc_info=True,
|
|
775
|
+
)
|
|
776
|
+
self._md_widget.update(body)
|
|
777
|
+
self._md_rendered = True
|
|
778
|
+
|
|
779
|
+
def toggle_body(self) -> None:
|
|
780
|
+
"""Toggle between preview and full body display."""
|
|
781
|
+
if not self._stripped_body.strip():
|
|
782
|
+
return
|
|
783
|
+
self._expanded = not self._expanded
|
|
784
|
+
|
|
785
|
+
def watch__expanded(self, expanded: bool) -> None:
|
|
786
|
+
"""Lazy-render markdown on first expand; update hint text."""
|
|
787
|
+
body = self._stripped_body.strip()
|
|
788
|
+
if not body:
|
|
789
|
+
return
|
|
790
|
+
|
|
791
|
+
if expanded:
|
|
792
|
+
self._ensure_md_rendered(body)
|
|
793
|
+
|
|
794
|
+
if not self._hint_widget:
|
|
795
|
+
return
|
|
796
|
+
|
|
797
|
+
lines = body.split("\n")
|
|
798
|
+
total_lines = len(lines)
|
|
799
|
+
needs_truncation = (
|
|
800
|
+
total_lines > self._PREVIEW_LINES or len(body) > self._PREVIEW_CHARS
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
if not needs_truncation:
|
|
804
|
+
# Short body — always fully visible, no hint needed.
|
|
805
|
+
self._hint_widget.display = False
|
|
806
|
+
return
|
|
807
|
+
|
|
808
|
+
if expanded:
|
|
809
|
+
self._hint_widget.update(
|
|
810
|
+
Content.styled("click or Ctrl+O to collapse", "dim italic")
|
|
811
|
+
)
|
|
812
|
+
else:
|
|
813
|
+
remaining = total_lines - self._PREVIEW_LINES
|
|
814
|
+
ellipsis = get_glyphs().ellipsis
|
|
815
|
+
self._hint_widget.update(
|
|
816
|
+
Content.styled(
|
|
817
|
+
f"{ellipsis} {remaining} more lines — click or Ctrl+O to expand",
|
|
818
|
+
"dim",
|
|
819
|
+
)
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
@on(Click, "_SkillToggle")
|
|
823
|
+
def _on_toggle_click(self, event: Click) -> None:
|
|
824
|
+
"""Toggle expansion when header or hint is clicked."""
|
|
825
|
+
event.stop()
|
|
826
|
+
if self._stripped_body.strip():
|
|
827
|
+
self.toggle_body()
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
class CopyTurnButton(Static):
|
|
831
|
+
"""Clickable button that copies the preceding Q&A turn to the clipboard.
|
|
832
|
+
|
|
833
|
+
Rendered as a small `[ Copy ]` label at the bottom-left of an
|
|
834
|
+
`AssistantMessage`. Hidden while the assistant is still streaming and
|
|
835
|
+
revealed once `stop_stream` finalizes the content.
|
|
836
|
+
"""
|
|
837
|
+
|
|
838
|
+
DEFAULT_CSS = """
|
|
839
|
+
CopyTurnButton {
|
|
840
|
+
display: none;
|
|
841
|
+
height: 1;
|
|
842
|
+
width: auto;
|
|
843
|
+
max-width: 12;
|
|
844
|
+
padding: 0 1;
|
|
845
|
+
margin: 0 0 0 0;
|
|
846
|
+
color: $text-muted;
|
|
847
|
+
text-style: dim;
|
|
848
|
+
background: transparent;
|
|
849
|
+
pointer: pointer;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
CopyTurnButton.-visible {
|
|
853
|
+
display: block;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
CopyTurnButton:hover {
|
|
857
|
+
color: $primary;
|
|
858
|
+
text-style: bold;
|
|
859
|
+
}
|
|
860
|
+
"""
|
|
861
|
+
|
|
862
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
863
|
+
"""Initialize the copy button.
|
|
864
|
+
|
|
865
|
+
Args:
|
|
866
|
+
**kwargs: Additional arguments passed to parent.
|
|
867
|
+
"""
|
|
868
|
+
glyphs = get_glyphs()
|
|
869
|
+
super().__init__(f"{glyphs.arrow_up} Copy", **kwargs)
|
|
870
|
+
|
|
871
|
+
@on(Click)
|
|
872
|
+
def _handle_click(self, event: Click) -> None:
|
|
873
|
+
"""Copy the Q&A turn (user question + assistant answer) to clipboard.
|
|
874
|
+
|
|
875
|
+
Args:
|
|
876
|
+
event: The click event.
|
|
877
|
+
"""
|
|
878
|
+
event.stop()
|
|
879
|
+
assistant = self.parent
|
|
880
|
+
if assistant is None:
|
|
881
|
+
return
|
|
882
|
+
|
|
883
|
+
# Walk siblings backwards to find the nearest preceding UserMessage.
|
|
884
|
+
messages_container = assistant.parent
|
|
885
|
+
if messages_container is None:
|
|
886
|
+
return
|
|
887
|
+
|
|
888
|
+
user_content: str | None = None
|
|
889
|
+
for sibling in reversed(messages_container.children):
|
|
890
|
+
if sibling is assistant:
|
|
891
|
+
continue
|
|
892
|
+
if isinstance(sibling, UserMessage):
|
|
893
|
+
user_content = sibling._content
|
|
894
|
+
break
|
|
895
|
+
|
|
896
|
+
assistant_content = ""
|
|
897
|
+
if isinstance(assistant, AssistantMessage):
|
|
898
|
+
assistant_content = assistant._content
|
|
899
|
+
|
|
900
|
+
parts: list[str] = []
|
|
901
|
+
if user_content:
|
|
902
|
+
parts.append(user_content)
|
|
903
|
+
if assistant_content:
|
|
904
|
+
parts.append(assistant_content)
|
|
905
|
+
|
|
906
|
+
if not parts:
|
|
907
|
+
return
|
|
908
|
+
|
|
909
|
+
text = "\n\n".join(parts)
|
|
910
|
+
|
|
911
|
+
from deepagents_code.clipboard import copy_text_with_feedback
|
|
912
|
+
|
|
913
|
+
copy_text_with_feedback(
|
|
914
|
+
self.app,
|
|
915
|
+
text,
|
|
916
|
+
failure_noun="message",
|
|
917
|
+
success_message="Q&A copied to clipboard",
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
class AssistantMessage(Vertical):
|
|
922
|
+
"""Widget displaying an assistant message with markdown support.
|
|
923
|
+
|
|
924
|
+
Uses MarkdownStream for smoother streaming instead of re-rendering
|
|
925
|
+
the full content on each update. Once a stream finishes, the message
|
|
926
|
+
is re-rendered from the complete source via `Markdown.update()` to
|
|
927
|
+
work around Textualize/textual#6518: `MarkdownFence._update_from_block`
|
|
928
|
+
refreshes the visible `Label` but leaves `_highlighted_code` pinned to
|
|
929
|
+
the first chunk, so any later recompose (click, focus change, theme
|
|
930
|
+
update) re-yields the stale value and wrapped fenced-code bodies vanish.
|
|
931
|
+
A full re-parse rebuilds every fence with correct internal state.
|
|
932
|
+
|
|
933
|
+
Streamed tokens are coalesced in `_pending_append` and flushed to the
|
|
934
|
+
`MarkdownStream` on a throttled timer (`_STREAM_FLUSH_INTERVAL`). Writing
|
|
935
|
+
every token immediately forced a markdown re-parse per chunk on the UI
|
|
936
|
+
event loop, which starved keyboard input while the model streamed.
|
|
937
|
+
Batching the writes keeps the event loop free so typing stays responsive.
|
|
938
|
+
"""
|
|
939
|
+
|
|
940
|
+
_STREAM_FLUSH_INTERVAL: ClassVar[float] = 0.1
|
|
941
|
+
"""Seconds between coalesced flushes of streamed text to the markdown widget."""
|
|
942
|
+
|
|
943
|
+
DEFAULT_CSS = """
|
|
944
|
+
AssistantMessage {
|
|
945
|
+
height: auto;
|
|
946
|
+
padding: 0 1;
|
|
947
|
+
margin: 0 0 1 0;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
AssistantMessage Markdown {
|
|
951
|
+
padding: 0;
|
|
952
|
+
margin: 0;
|
|
953
|
+
pointer: text;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/* Markdown blocks carry a bottom margin for inter-block spacing; drop it
|
|
957
|
+
on the final block so the message has no trailing blank row. */
|
|
958
|
+
AssistantMessage Markdown > *:last-child {
|
|
959
|
+
margin-bottom: 0;
|
|
960
|
+
}
|
|
961
|
+
"""
|
|
962
|
+
|
|
963
|
+
def __init__(self, content: str = "", **kwargs: Any) -> None:
|
|
964
|
+
"""Initialize an assistant message.
|
|
965
|
+
|
|
966
|
+
Args:
|
|
967
|
+
content: Initial markdown content
|
|
968
|
+
**kwargs: Additional arguments passed to parent
|
|
969
|
+
"""
|
|
970
|
+
super().__init__(**kwargs)
|
|
971
|
+
self._content_parts: list[str] = [content] if content else []
|
|
972
|
+
self._markdown: Markdown | None = None
|
|
973
|
+
self._stream: MarkdownStream | None = None
|
|
974
|
+
self._pending_append = ""
|
|
975
|
+
self._flush_timer: Timer | None = None
|
|
976
|
+
self._stream_finalized: bool = bool(content)
|
|
977
|
+
|
|
978
|
+
@property
|
|
979
|
+
def _content(self) -> str:
|
|
980
|
+
"""Full message text, materialized from streamed chunks on access."""
|
|
981
|
+
if len(self._content_parts) > 1:
|
|
982
|
+
self._content_parts = ["".join(self._content_parts)]
|
|
983
|
+
return self._content_parts[0] if self._content_parts else ""
|
|
984
|
+
|
|
985
|
+
@_content.setter
|
|
986
|
+
def _content(self, value: str) -> None:
|
|
987
|
+
self._content_parts = [value] if value else []
|
|
988
|
+
|
|
989
|
+
def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual widget method convention
|
|
990
|
+
"""Compose the assistant message layout.
|
|
991
|
+
|
|
992
|
+
Yields:
|
|
993
|
+
Markdown widget for rendering assistant content, and a copy
|
|
994
|
+
button shown after streaming completes.
|
|
995
|
+
"""
|
|
996
|
+
from textual.widgets import Markdown
|
|
997
|
+
|
|
998
|
+
yield Markdown("", id="assistant-content", open_links=False)
|
|
999
|
+
yield CopyTurnButton(id="copy-turn-btn")
|
|
1000
|
+
|
|
1001
|
+
def on_mount(self) -> None:
|
|
1002
|
+
"""Store reference to markdown widget and show copy button if finalized."""
|
|
1003
|
+
from textual.widgets import Markdown
|
|
1004
|
+
|
|
1005
|
+
self._markdown = self.query_one("#assistant-content", Markdown)
|
|
1006
|
+
self._show_copy_button()
|
|
1007
|
+
|
|
1008
|
+
def _show_copy_button(self) -> None:
|
|
1009
|
+
"""Reveal the copy-turn button if the message content is finalized."""
|
|
1010
|
+
if not self._stream_finalized:
|
|
1011
|
+
return
|
|
1012
|
+
with contextlib.suppress(NoMatches, WrongType):
|
|
1013
|
+
self.query_one("#copy-turn-btn", CopyTurnButton).add_class("-visible")
|
|
1014
|
+
|
|
1015
|
+
def on_mouse_move(self, event: MouseMove) -> None:
|
|
1016
|
+
"""Show a pointer cursor over markdown links, text cursor elsewhere.
|
|
1017
|
+
|
|
1018
|
+
The pointer is set on the inner `Markdown` widget because it carries a
|
|
1019
|
+
non-default (`text`) pointer in CSS, so the screen resolves its shape
|
|
1020
|
+
before reaching this container.
|
|
1021
|
+
"""
|
|
1022
|
+
if self._markdown is not None:
|
|
1023
|
+
self._markdown.styles.pointer = (
|
|
1024
|
+
"pointer" if event_targets_link(event) else "text"
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
def on_leave(self) -> None:
|
|
1028
|
+
"""Reset the markdown pointer shape when the mouse leaves the message."""
|
|
1029
|
+
if self._markdown is not None:
|
|
1030
|
+
self._markdown.styles.pointer = "text"
|
|
1031
|
+
|
|
1032
|
+
async def on_markdown_link_clicked(self, event: Markdown.LinkClicked) -> None:
|
|
1033
|
+
"""Open Markdown links with the same toast feedback as style links."""
|
|
1034
|
+
event.stop()
|
|
1035
|
+
await open_checked_url_async(event.href, app=self.app, notify_on_success=True)
|
|
1036
|
+
|
|
1037
|
+
def _get_markdown(self) -> Markdown:
|
|
1038
|
+
"""Get the markdown widget, querying if not cached.
|
|
1039
|
+
|
|
1040
|
+
Returns:
|
|
1041
|
+
The Markdown widget for this message.
|
|
1042
|
+
"""
|
|
1043
|
+
if self._markdown is None:
|
|
1044
|
+
from textual.widgets import Markdown
|
|
1045
|
+
|
|
1046
|
+
self._markdown = self.query_one("#assistant-content", Markdown)
|
|
1047
|
+
return self._markdown
|
|
1048
|
+
|
|
1049
|
+
def _ensure_stream(self) -> MarkdownStream:
|
|
1050
|
+
"""Ensure the markdown stream is initialized.
|
|
1051
|
+
|
|
1052
|
+
Returns:
|
|
1053
|
+
The MarkdownStream instance for streaming content.
|
|
1054
|
+
"""
|
|
1055
|
+
if self._stream is None:
|
|
1056
|
+
from textual.widgets import Markdown
|
|
1057
|
+
|
|
1058
|
+
self._stream = Markdown.get_stream(self._get_markdown())
|
|
1059
|
+
return self._stream
|
|
1060
|
+
|
|
1061
|
+
async def append_content(self, text: str) -> None:
|
|
1062
|
+
"""Append streamed content, coalescing writes onto a throttled timer.
|
|
1063
|
+
|
|
1064
|
+
Tokens are buffered in `_pending_append` and written to the
|
|
1065
|
+
`MarkdownStream` at most once per `_STREAM_FLUSH_INTERVAL` so the UI
|
|
1066
|
+
event loop stays free to process keypresses while the model streams.
|
|
1067
|
+
|
|
1068
|
+
Args:
|
|
1069
|
+
text: Text to append
|
|
1070
|
+
"""
|
|
1071
|
+
if not text:
|
|
1072
|
+
return
|
|
1073
|
+
self._content_parts.append(text)
|
|
1074
|
+
self._pending_append += text
|
|
1075
|
+
if self._flush_timer is None:
|
|
1076
|
+
self._flush_timer = self.set_interval(
|
|
1077
|
+
self._STREAM_FLUSH_INTERVAL, self._flush_pending_append
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
async def _flush_pending_append(self) -> None:
|
|
1081
|
+
"""Write any buffered streamed text to the markdown stream.
|
|
1082
|
+
|
|
1083
|
+
Runs from a Textual timer callback, where an unhandled exception
|
|
1084
|
+
escalates to `App._handle_exception` and tears down the whole REPL.
|
|
1085
|
+
On a transient write failure the buffer is restored (re-prepended
|
|
1086
|
+
ahead of any text that arrived in the meantime) so the next tick
|
|
1087
|
+
retries instead of silently dropping the fragment.
|
|
1088
|
+
"""
|
|
1089
|
+
if not self._pending_append:
|
|
1090
|
+
return
|
|
1091
|
+
pending = self._pending_append
|
|
1092
|
+
self._pending_append = ""
|
|
1093
|
+
try:
|
|
1094
|
+
stream = self._ensure_stream()
|
|
1095
|
+
await stream.write(pending)
|
|
1096
|
+
except Exception: # a render hiccup must not crash the app
|
|
1097
|
+
self._pending_append = pending + self._pending_append
|
|
1098
|
+
logger.exception("Failed to flush streamed markdown fragment")
|
|
1099
|
+
|
|
1100
|
+
def _stop_flush_timer(self) -> None:
|
|
1101
|
+
"""Cancel the coalescing flush timer if it is running."""
|
|
1102
|
+
if self._flush_timer is not None:
|
|
1103
|
+
self._flush_timer.stop()
|
|
1104
|
+
self._flush_timer = None
|
|
1105
|
+
|
|
1106
|
+
async def write_initial_content(self) -> None:
|
|
1107
|
+
"""Write initial content if provided at construction time."""
|
|
1108
|
+
if self._content:
|
|
1109
|
+
await self._get_markdown().update(self._content)
|
|
1110
|
+
self._show_copy_button()
|
|
1111
|
+
|
|
1112
|
+
async def stop_stream(self) -> None:
|
|
1113
|
+
"""Stop the streaming and finalize the content."""
|
|
1114
|
+
self._stop_flush_timer()
|
|
1115
|
+
await self._flush_pending_append()
|
|
1116
|
+
if self._stream is not None:
|
|
1117
|
+
await self._stream.stop()
|
|
1118
|
+
self._stream = None
|
|
1119
|
+
await self._get_markdown().update(self._content)
|
|
1120
|
+
self._stream_finalized = True
|
|
1121
|
+
self._show_copy_button()
|
|
1122
|
+
|
|
1123
|
+
async def set_content(self, content: str) -> None:
|
|
1124
|
+
"""Set the full message content.
|
|
1125
|
+
|
|
1126
|
+
Cancels any active stream and renders the new content with a
|
|
1127
|
+
single `Markdown.update()` (avoiding a redundant intermediate
|
|
1128
|
+
update of the in-flight content).
|
|
1129
|
+
|
|
1130
|
+
Args:
|
|
1131
|
+
content: The markdown content to display
|
|
1132
|
+
"""
|
|
1133
|
+
self._stop_flush_timer()
|
|
1134
|
+
self._pending_append = ""
|
|
1135
|
+
if self._stream is not None:
|
|
1136
|
+
await self._stream.stop()
|
|
1137
|
+
self._stream = None
|
|
1138
|
+
self._content = content
|
|
1139
|
+
if self._markdown:
|
|
1140
|
+
await self._markdown.update(content)
|
|
1141
|
+
self._stream_finalized = True
|
|
1142
|
+
self._show_copy_button()
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
_ToolStatus = Literal["pending", "running", "success", "error", "rejected", "skipped"]
|
|
1146
|
+
"""The full set of lifecycle states a tool call can hold.
|
|
1147
|
+
|
|
1148
|
+
Kept as a closed `Literal` so `ty` flags typos at the assignment sites and so
|
|
1149
|
+
the grouping predicates (`is_success`/`is_failed`/`is_pending`) partition a
|
|
1150
|
+
known universe.
|
|
1151
|
+
"""
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
class ToolCallMessage(Vertical):
|
|
1155
|
+
"""Widget displaying a tool call with collapsible output.
|
|
1156
|
+
|
|
1157
|
+
Tool outputs are shown as a 3-line preview by default.
|
|
1158
|
+
Press Ctrl+O to expand/collapse the full output.
|
|
1159
|
+
Shows an animated "Running..." indicator while the tool is executing.
|
|
1160
|
+
"""
|
|
1161
|
+
|
|
1162
|
+
DEFAULT_CSS = """
|
|
1163
|
+
ToolCallMessage {
|
|
1164
|
+
height: auto;
|
|
1165
|
+
padding: 0 1;
|
|
1166
|
+
margin: 0 0 1 0;
|
|
1167
|
+
background: transparent;
|
|
1168
|
+
border-left: wide $tool;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
ToolCallMessage .tool-header {
|
|
1172
|
+
height: auto;
|
|
1173
|
+
color: $tool;
|
|
1174
|
+
text-style: bold;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
ToolCallMessage .tool-task-desc {
|
|
1178
|
+
color: $text-muted;
|
|
1179
|
+
margin-left: 3;
|
|
1180
|
+
text-style: italic;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
ToolCallMessage .tool-args {
|
|
1184
|
+
color: $text-muted;
|
|
1185
|
+
margin-left: 3;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
ToolCallMessage .tool-status {
|
|
1189
|
+
margin-left: 3;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
ToolCallMessage .tool-status.pending {
|
|
1193
|
+
color: $warning;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
ToolCallMessage .tool-status.success {
|
|
1197
|
+
color: $success;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
ToolCallMessage .tool-status.error {
|
|
1201
|
+
color: $error;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
ToolCallMessage .tool-status.rejected {
|
|
1205
|
+
color: $warning;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
ToolCallMessage .tool-reject-reason {
|
|
1209
|
+
margin-left: 3;
|
|
1210
|
+
margin-top: 0;
|
|
1211
|
+
height: auto;
|
|
1212
|
+
color: $text-muted;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
ToolCallMessage .tool-output-row {
|
|
1216
|
+
layout: horizontal;
|
|
1217
|
+
height: auto;
|
|
1218
|
+
width: 1fr;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/* Fixed gutter holds the output glyph so soft-wrapped content lines stay
|
|
1222
|
+
aligned to a single hanging indent instead of falling under the glyph. */
|
|
1223
|
+
ToolCallMessage .tool-output-gutter {
|
|
1224
|
+
width: 2;
|
|
1225
|
+
height: 1;
|
|
1226
|
+
color: $text-muted;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
ToolCallMessage .tool-output {
|
|
1230
|
+
margin-left: 0;
|
|
1231
|
+
margin-top: 0;
|
|
1232
|
+
padding: 0;
|
|
1233
|
+
height: auto;
|
|
1234
|
+
width: 1fr;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
ToolCallMessage .tool-output-preview {
|
|
1238
|
+
margin-left: 0;
|
|
1239
|
+
margin-top: 0;
|
|
1240
|
+
width: 1fr;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
ToolCallMessage .tool-output-hint {
|
|
1244
|
+
margin-left: 0;
|
|
1245
|
+
color: $text-muted;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
ToolCallMessage:hover {
|
|
1249
|
+
border-left: wide $tool-hover;
|
|
1250
|
+
}
|
|
1251
|
+
"""
|
|
1252
|
+
"""Left border tracks tool lifecycle; hover brightens for interactivity."""
|
|
1253
|
+
|
|
1254
|
+
_PREVIEW_LINES = 6
|
|
1255
|
+
"""Maximum number of lines to show in preview mode."""
|
|
1256
|
+
|
|
1257
|
+
_PREVIEW_CHARS = 400
|
|
1258
|
+
"""Maximum number of characters to show in preview mode."""
|
|
1259
|
+
|
|
1260
|
+
_JS_EVAL_INLINE_RESULT_MAX = 80
|
|
1261
|
+
"""Maximum single-line `js_eval` result length rendered inline.
|
|
1262
|
+
|
|
1263
|
+
Inline rendering uses `result: value` rather than a standalone labeled block.
|
|
1264
|
+
"""
|
|
1265
|
+
|
|
1266
|
+
_RUNNING_TIMER_THRESHOLD_SECS = 10
|
|
1267
|
+
"""Seconds a tool must run before the elapsed-time counter appears.
|
|
1268
|
+
|
|
1269
|
+
Short tool calls finish well under this threshold, so the timer would only
|
|
1270
|
+
flicker on briefly; suppressing it until the tool is genuinely slow keeps
|
|
1271
|
+
the "Running..." row quiet for the common case.
|
|
1272
|
+
"""
|
|
1273
|
+
|
|
1274
|
+
def __init__(
|
|
1275
|
+
self,
|
|
1276
|
+
tool_name: str,
|
|
1277
|
+
args: dict[str, Any] | None = None,
|
|
1278
|
+
**kwargs: Any,
|
|
1279
|
+
) -> None:
|
|
1280
|
+
"""Initialize a tool call message.
|
|
1281
|
+
|
|
1282
|
+
Args:
|
|
1283
|
+
tool_name: Name of the tool being called
|
|
1284
|
+
args: Tool arguments (optional)
|
|
1285
|
+
**kwargs: Additional arguments passed to parent
|
|
1286
|
+
"""
|
|
1287
|
+
super().__init__(**kwargs)
|
|
1288
|
+
self._tool_name = tool_name
|
|
1289
|
+
self._args = args or {}
|
|
1290
|
+
self._status: _ToolStatus = "pending" # Waiting for approval or auto-approve
|
|
1291
|
+
self._output: str = ""
|
|
1292
|
+
self._expanded: bool = False
|
|
1293
|
+
self._args_expanded: bool = False
|
|
1294
|
+
# User-provided reason attached to a HITL reject decision (if any).
|
|
1295
|
+
self._reject_reason: str | None = None
|
|
1296
|
+
# Widget references (set in on_mount)
|
|
1297
|
+
self._status_widget: Static | None = None
|
|
1298
|
+
self._header_widget: Static | None = None
|
|
1299
|
+
self._args_widget: Static | None = None
|
|
1300
|
+
self._args_hint_widget: Static | None = None
|
|
1301
|
+
self._preview_widget: Static | None = None
|
|
1302
|
+
self._preview_row: Horizontal | None = None
|
|
1303
|
+
self._hint_widget: Static | None = None
|
|
1304
|
+
self._full_widget: Static | None = None
|
|
1305
|
+
self._full_row: Horizontal | None = None
|
|
1306
|
+
self._reject_reason_widget: Static | None = None
|
|
1307
|
+
# Animation state
|
|
1308
|
+
self._spinner_position = 0
|
|
1309
|
+
self._start_time: float | None = None
|
|
1310
|
+
self._duration: float | None = None
|
|
1311
|
+
self._animation_timer: Timer | None = None
|
|
1312
|
+
# Deferred state for hydration (set by MessageData.to_widget)
|
|
1313
|
+
self._deferred_status: str | None = None
|
|
1314
|
+
self._deferred_output: str | None = None
|
|
1315
|
+
self._deferred_duration: float | None = None
|
|
1316
|
+
self._deferred_expanded: bool = False
|
|
1317
|
+
self._deferred_reject_reason: str | None = None
|
|
1318
|
+
# Whether the widget is currently hidden because an approval prompt
|
|
1319
|
+
# is rendering the same content (see `set_awaiting_approval`).
|
|
1320
|
+
self._awaiting_approval: bool = False
|
|
1321
|
+
|
|
1322
|
+
def compose(self) -> ComposeResult:
|
|
1323
|
+
"""Compose the tool call message layout.
|
|
1324
|
+
|
|
1325
|
+
Yields:
|
|
1326
|
+
Widgets for header, arguments, status, and output display.
|
|
1327
|
+
"""
|
|
1328
|
+
tool_label = format_tool_display(self._tool_name, self._args)
|
|
1329
|
+
yield Static(tool_label, markup=False, classes="tool-header", id="tool-header")
|
|
1330
|
+
# Task: dedicated description line (dim, truncated)
|
|
1331
|
+
if self._tool_name == "task":
|
|
1332
|
+
desc = self._args.get("description", "")
|
|
1333
|
+
if desc:
|
|
1334
|
+
max_len = 120
|
|
1335
|
+
suffix = "..." if len(desc) > max_len else ""
|
|
1336
|
+
truncated = desc[:max_len].rstrip() + suffix
|
|
1337
|
+
yield Static(
|
|
1338
|
+
Content.styled(truncated, "dim"),
|
|
1339
|
+
classes="tool-task-desc",
|
|
1340
|
+
)
|
|
1341
|
+
# Only show args for tools where header doesn't capture the key info
|
|
1342
|
+
elif self._tool_name not in _TOOLS_WITH_HEADER_INFO:
|
|
1343
|
+
args = self._filtered_args()
|
|
1344
|
+
if args:
|
|
1345
|
+
args_str = ", ".join(
|
|
1346
|
+
f"{k}={v!r}" for k, v in list(args.items())[:_MAX_INLINE_ARGS]
|
|
1347
|
+
)
|
|
1348
|
+
if len(args) > _MAX_INLINE_ARGS:
|
|
1349
|
+
args_str += ", ..."
|
|
1350
|
+
yield Static(
|
|
1351
|
+
Content.from_markup("[dim]($args)[/dim]", args=args_str),
|
|
1352
|
+
classes="tool-args",
|
|
1353
|
+
)
|
|
1354
|
+
# Collapsed argument detail for tools whose args are too noisy inline.
|
|
1355
|
+
# Mounted for every tool but only populated when `has_expandable_args` is True.
|
|
1356
|
+
yield Static("", classes="tool-args", id="args-full")
|
|
1357
|
+
yield Static("", classes="tool-output-hint", id="args-hint")
|
|
1358
|
+
# Status - shows running animation while pending, then final status
|
|
1359
|
+
yield Static("", classes="tool-status", id="status")
|
|
1360
|
+
# Optional HITL reject reason (only shown when user rejected with a message)
|
|
1361
|
+
yield Static("", classes="tool-reject-reason", id="reject-reason")
|
|
1362
|
+
# Output area - hidden initially, shown when output is set. The glyph
|
|
1363
|
+
# lives in a fixed-width gutter so wrapped content aligns to a single
|
|
1364
|
+
# hanging indent rather than wrapping back under the glyph.
|
|
1365
|
+
output_prefix = get_glyphs().output_prefix
|
|
1366
|
+
yield Horizontal(
|
|
1367
|
+
Static(output_prefix, classes="tool-output-gutter"),
|
|
1368
|
+
Static("", classes="tool-output-preview", id="output-preview"),
|
|
1369
|
+
classes="tool-output-row",
|
|
1370
|
+
id="output-preview-row",
|
|
1371
|
+
)
|
|
1372
|
+
yield Horizontal(
|
|
1373
|
+
Static(output_prefix, classes="tool-output-gutter"),
|
|
1374
|
+
Static("", classes="tool-output", id="output-full"),
|
|
1375
|
+
classes="tool-output-row",
|
|
1376
|
+
id="output-full-row",
|
|
1377
|
+
)
|
|
1378
|
+
yield Static("", classes="tool-output-hint", id="output-hint")
|
|
1379
|
+
|
|
1380
|
+
def on_mount(self) -> None:
|
|
1381
|
+
"""Cache widget references and hide all status/output areas initially."""
|
|
1382
|
+
if is_ascii_mode():
|
|
1383
|
+
self.add_class("-ascii")
|
|
1384
|
+
|
|
1385
|
+
self._status_widget = self.query_one("#status", Static)
|
|
1386
|
+
self._header_widget = self.query_one("#tool-header", Static)
|
|
1387
|
+
self._args_widget = self.query_one("#args-full", Static)
|
|
1388
|
+
self._args_hint_widget = self.query_one("#args-hint", Static)
|
|
1389
|
+
self._preview_widget = self.query_one("#output-preview", Static)
|
|
1390
|
+
self._preview_row = self.query_one("#output-preview-row", Horizontal)
|
|
1391
|
+
self._hint_widget = self.query_one("#output-hint", Static)
|
|
1392
|
+
self._full_widget = self.query_one("#output-full", Static)
|
|
1393
|
+
self._full_row = self.query_one("#output-full-row", Horizontal)
|
|
1394
|
+
self._reject_reason_widget = self.query_one("#reject-reason", Static)
|
|
1395
|
+
# Hide everything initially - status only shown when running or on error/reject
|
|
1396
|
+
self._status_widget.display = False
|
|
1397
|
+
self._args_widget.display = False
|
|
1398
|
+
self._args_hint_widget.display = False
|
|
1399
|
+
self._preview_row.display = False
|
|
1400
|
+
self._hint_widget.display = False
|
|
1401
|
+
self._full_row.display = False
|
|
1402
|
+
self._reject_reason_widget.display = False
|
|
1403
|
+
self._update_args_display()
|
|
1404
|
+
|
|
1405
|
+
# Restore deferred state if this widget was hydrated from data
|
|
1406
|
+
self._restore_deferred_state()
|
|
1407
|
+
|
|
1408
|
+
def _restore_deferred_state(self) -> None:
|
|
1409
|
+
"""Restore state from deferred values (used when hydrating from data)."""
|
|
1410
|
+
if self._deferred_status is None:
|
|
1411
|
+
return
|
|
1412
|
+
|
|
1413
|
+
status = self._deferred_status
|
|
1414
|
+
output = self._deferred_output or ""
|
|
1415
|
+
duration = self._deferred_duration
|
|
1416
|
+
self._expanded = self._deferred_expanded
|
|
1417
|
+
if self._deferred_reject_reason:
|
|
1418
|
+
self._reject_reason = self._deferred_reject_reason
|
|
1419
|
+
|
|
1420
|
+
# Clear deferred values
|
|
1421
|
+
self._deferred_status = None
|
|
1422
|
+
self._deferred_output = None
|
|
1423
|
+
self._deferred_duration = None
|
|
1424
|
+
self._deferred_expanded = False
|
|
1425
|
+
self._deferred_reject_reason = None
|
|
1426
|
+
|
|
1427
|
+
# Restore based on status (don't restart animations for running tools)
|
|
1428
|
+
colors = theme.get_theme_colors(self)
|
|
1429
|
+
match status:
|
|
1430
|
+
case "success":
|
|
1431
|
+
self._status = "success"
|
|
1432
|
+
self._output = output
|
|
1433
|
+
self._duration = duration
|
|
1434
|
+
if self._tool_name in _TIMED_SUCCESS_TOOLS and duration is not None:
|
|
1435
|
+
self._show_timed_success_status(duration)
|
|
1436
|
+
else:
|
|
1437
|
+
self._show_success_status()
|
|
1438
|
+
self._update_output_display()
|
|
1439
|
+
case "error":
|
|
1440
|
+
self._status = "error"
|
|
1441
|
+
self._output = output
|
|
1442
|
+
if self._status_widget:
|
|
1443
|
+
self._status_widget.add_class("error")
|
|
1444
|
+
error_icon = get_glyphs().error
|
|
1445
|
+
self._status_widget.update(
|
|
1446
|
+
Content.styled(f"{error_icon} Error", colors.error)
|
|
1447
|
+
)
|
|
1448
|
+
self._status_widget.display = True
|
|
1449
|
+
self._update_output_display()
|
|
1450
|
+
case "rejected":
|
|
1451
|
+
self._status = "rejected"
|
|
1452
|
+
if self._status_widget:
|
|
1453
|
+
self._status_widget.add_class("rejected")
|
|
1454
|
+
error_icon = get_glyphs().error
|
|
1455
|
+
self._status_widget.update(
|
|
1456
|
+
Content.styled(f"{error_icon} Rejected", colors.warning)
|
|
1457
|
+
)
|
|
1458
|
+
self._status_widget.display = True
|
|
1459
|
+
self._update_reject_reason_display()
|
|
1460
|
+
case "skipped":
|
|
1461
|
+
self._status = "skipped"
|
|
1462
|
+
if self._status_widget:
|
|
1463
|
+
self._status_widget.add_class("rejected")
|
|
1464
|
+
self._status_widget.update(Content.styled("- Skipped", "dim"))
|
|
1465
|
+
self._status_widget.display = True
|
|
1466
|
+
case "running":
|
|
1467
|
+
# For running tools, show static "Running..." without animation
|
|
1468
|
+
# (animations shouldn't be restored for archived tools)
|
|
1469
|
+
self._status = "running"
|
|
1470
|
+
if self._status_widget:
|
|
1471
|
+
self._status_widget.add_class("pending")
|
|
1472
|
+
frame = get_glyphs().spinner_frames[0]
|
|
1473
|
+
self._status_widget.update(
|
|
1474
|
+
Content.styled(f"{frame} Running...", colors.warning)
|
|
1475
|
+
)
|
|
1476
|
+
self._status_widget.display = True
|
|
1477
|
+
case _:
|
|
1478
|
+
# pending or unknown - leave as default
|
|
1479
|
+
pass
|
|
1480
|
+
|
|
1481
|
+
def set_running(self) -> None:
|
|
1482
|
+
"""Mark the tool as running (approved and executing).
|
|
1483
|
+
|
|
1484
|
+
Call this when approval is granted to start the running animation.
|
|
1485
|
+
"""
|
|
1486
|
+
if self._status == "running":
|
|
1487
|
+
return # Already running
|
|
1488
|
+
|
|
1489
|
+
self._status = "running"
|
|
1490
|
+
self._duration = None
|
|
1491
|
+
self._start_time = time()
|
|
1492
|
+
if self._status_widget:
|
|
1493
|
+
self._status_widget.add_class("pending")
|
|
1494
|
+
self._status_widget.display = True
|
|
1495
|
+
self._update_running_animation()
|
|
1496
|
+
self._animation_timer = self.set_interval(0.1, self._update_running_animation)
|
|
1497
|
+
|
|
1498
|
+
def _update_running_animation(self) -> None:
|
|
1499
|
+
"""Update the running spinner animation."""
|
|
1500
|
+
if self._status != "running" or self._status_widget is None:
|
|
1501
|
+
return
|
|
1502
|
+
|
|
1503
|
+
spinner_frames = get_glyphs().spinner_frames
|
|
1504
|
+
frame = spinner_frames[self._spinner_position]
|
|
1505
|
+
self._spinner_position = (self._spinner_position + 1) % len(spinner_frames)
|
|
1506
|
+
|
|
1507
|
+
elapsed = ""
|
|
1508
|
+
if self._start_time is not None:
|
|
1509
|
+
elapsed_secs = int(time() - self._start_time)
|
|
1510
|
+
if elapsed_secs >= self._RUNNING_TIMER_THRESHOLD_SECS:
|
|
1511
|
+
elapsed = f" ({format_duration(elapsed_secs)})"
|
|
1512
|
+
|
|
1513
|
+
text = f"{frame} Running...{elapsed}"
|
|
1514
|
+
self._status_widget.update(
|
|
1515
|
+
Content.styled(text, theme.get_theme_colors(self).warning)
|
|
1516
|
+
)
|
|
1517
|
+
|
|
1518
|
+
def pause_running(self) -> None:
|
|
1519
|
+
"""Pause the running spinner while the tool awaits a user decision.
|
|
1520
|
+
|
|
1521
|
+
Reverts the row to its pending appearance (status hidden) and stops the
|
|
1522
|
+
animation so a tool blocked on HITL approval or `ask_user` input does
|
|
1523
|
+
not misleadingly display "Running...". Resume with `set_running`, which
|
|
1524
|
+
restarts the elapsed timer from the moment execution actually begins.
|
|
1525
|
+
"""
|
|
1526
|
+
if self._status != "running":
|
|
1527
|
+
return
|
|
1528
|
+
self._stop_animation()
|
|
1529
|
+
self._status = "pending"
|
|
1530
|
+
self._start_time = None
|
|
1531
|
+
if self._status_widget:
|
|
1532
|
+
self._status_widget.remove_class("pending")
|
|
1533
|
+
self._status_widget.display = False
|
|
1534
|
+
|
|
1535
|
+
def _stop_animation(self) -> None:
|
|
1536
|
+
"""Stop the running animation."""
|
|
1537
|
+
if self._animation_timer is not None:
|
|
1538
|
+
self._animation_timer.stop()
|
|
1539
|
+
self._animation_timer = None
|
|
1540
|
+
|
|
1541
|
+
def set_success(self, result: str = "") -> None:
|
|
1542
|
+
"""Mark the tool call as successful.
|
|
1543
|
+
|
|
1544
|
+
For long-running tools (`execute`, `task`) that actually ran (a start
|
|
1545
|
+
time was recorded via `set_running`), the elapsed run time is shown via
|
|
1546
|
+
`_show_timed_success_status`; every other case routes through
|
|
1547
|
+
`_show_success_status`.
|
|
1548
|
+
|
|
1549
|
+
Args:
|
|
1550
|
+
result: Tool output/result to display
|
|
1551
|
+
"""
|
|
1552
|
+
if self._status in {"rejected", "skipped"}:
|
|
1553
|
+
# A rejected tool (or one skipped due to a sibling rejection) never
|
|
1554
|
+
# legitimately becomes successful. A resumed turn can still stream a
|
|
1555
|
+
# synthetic ToolMessage for such a tool (see the reasoned-reject path
|
|
1556
|
+
# in `textual_adapter`); ignore it so the row keeps its terminal
|
|
1557
|
+
# rejected/skipped state instead of flipping.
|
|
1558
|
+
return
|
|
1559
|
+
elapsed = time() - self._start_time if self._start_time is not None else None
|
|
1560
|
+
self._stop_animation()
|
|
1561
|
+
self._status = "success"
|
|
1562
|
+
self._duration = (
|
|
1563
|
+
elapsed
|
|
1564
|
+
if self._tool_name in _TIMED_SUCCESS_TOOLS and elapsed is not None
|
|
1565
|
+
else None
|
|
1566
|
+
)
|
|
1567
|
+
# Strip redundant success trailer — the UI already conveys success
|
|
1568
|
+
self._output = _strip_success_exit_line(result)
|
|
1569
|
+
# Only successful `edit_file` is force-expanded so the applied diff is
|
|
1570
|
+
# visible. `read_file` / `grep` / `glob` (in `_COLLAPSE_OUTPUT_BY_DEFAULT`)
|
|
1571
|
+
# stay collapsed by default — the header already carries the file path
|
|
1572
|
+
# or pattern, and hiding the body keeps the log compact. Users can
|
|
1573
|
+
# Ctrl+O to expand.
|
|
1574
|
+
if self._tool_name == "edit_file" and not self._is_search_no_result_output(
|
|
1575
|
+
self._output
|
|
1576
|
+
):
|
|
1577
|
+
self._expanded = True
|
|
1578
|
+
if self._duration is not None:
|
|
1579
|
+
self._show_timed_success_status(self._duration)
|
|
1580
|
+
else:
|
|
1581
|
+
self._show_success_status()
|
|
1582
|
+
self._update_output_display()
|
|
1583
|
+
|
|
1584
|
+
def _show_timed_success_status(self, duration: float) -> None:
|
|
1585
|
+
"""Render the preserved duration for a completed timed tool call.
|
|
1586
|
+
|
|
1587
|
+
Args:
|
|
1588
|
+
duration: Elapsed tool run time in seconds.
|
|
1589
|
+
"""
|
|
1590
|
+
if self._status_widget is None:
|
|
1591
|
+
return
|
|
1592
|
+
self._status_widget.remove_class("pending")
|
|
1593
|
+
self._status_widget.update(
|
|
1594
|
+
Content.styled(f"Took {format_duration(duration)}", "dim")
|
|
1595
|
+
)
|
|
1596
|
+
self._status_widget.display = True
|
|
1597
|
+
|
|
1598
|
+
def _show_success_status(self) -> None:
|
|
1599
|
+
"""Render the status marker for a completed successful call.
|
|
1600
|
+
|
|
1601
|
+
When the call produces visible output it speaks for itself and the
|
|
1602
|
+
status stays hidden; otherwise show a "Success!" marker so a completed
|
|
1603
|
+
call (e.g. `edit_file`) isn't left without any outcome indicator.
|
|
1604
|
+
"""
|
|
1605
|
+
if self._status_widget is None:
|
|
1606
|
+
return
|
|
1607
|
+
self._status_widget.remove_class("pending")
|
|
1608
|
+
if (
|
|
1609
|
+
self._tool_name != "edit_file"
|
|
1610
|
+
and self._format_output(
|
|
1611
|
+
self._output, is_preview=False
|
|
1612
|
+
).content.plain.strip()
|
|
1613
|
+
):
|
|
1614
|
+
self._status_widget.remove_class("success")
|
|
1615
|
+
self._status_widget.display = False
|
|
1616
|
+
return
|
|
1617
|
+
glyph = get_glyphs().checkmark
|
|
1618
|
+
colors = theme.get_theme_colors(self)
|
|
1619
|
+
self._status_widget.add_class("success")
|
|
1620
|
+
self._status_widget.update(Content.styled(f"{glyph} Success!", colors.success))
|
|
1621
|
+
self._status_widget.display = True
|
|
1622
|
+
|
|
1623
|
+
def set_error(self, error: str) -> None:
|
|
1624
|
+
"""Mark the tool call as failed.
|
|
1625
|
+
|
|
1626
|
+
Args:
|
|
1627
|
+
error: Error message
|
|
1628
|
+
"""
|
|
1629
|
+
if self._status in {"rejected", "skipped"}:
|
|
1630
|
+
# A rejected/skipped tool never legitimately errors. A resumed turn
|
|
1631
|
+
# can stream a synthetic error ToolMessage for a reasoned-reject tool
|
|
1632
|
+
# (see `textual_adapter`); ignore it so the row keeps its rejected
|
|
1633
|
+
# state rather than flipping to "Error" (which also left the stale
|
|
1634
|
+
# `rejected` CSS class alongside `error`).
|
|
1635
|
+
return
|
|
1636
|
+
self._stop_animation()
|
|
1637
|
+
self._status = "error"
|
|
1638
|
+
# For shell commands, prepend the full command so users can see what failed
|
|
1639
|
+
command = self._args.get("command") if self._tool_name == "execute" else None
|
|
1640
|
+
if command and isinstance(command, str) and command.strip():
|
|
1641
|
+
self._output = f"$ {command}\n\n{error}"
|
|
1642
|
+
else:
|
|
1643
|
+
self._output = error
|
|
1644
|
+
if self._status_widget:
|
|
1645
|
+
self._status_widget.remove_class("pending")
|
|
1646
|
+
self._status_widget.add_class("error")
|
|
1647
|
+
error_icon = get_glyphs().error
|
|
1648
|
+
colors = theme.get_theme_colors(self)
|
|
1649
|
+
self._status_widget.update(
|
|
1650
|
+
Content.styled(f"{error_icon} Error", colors.error)
|
|
1651
|
+
)
|
|
1652
|
+
self._status_widget.display = True
|
|
1653
|
+
# Always show full error - errors should be visible
|
|
1654
|
+
self._expanded = True
|
|
1655
|
+
self._update_output_display()
|
|
1656
|
+
|
|
1657
|
+
def set_rejected(self, *, reason: str | None = None) -> None:
|
|
1658
|
+
"""Mark the tool call as rejected by user.
|
|
1659
|
+
|
|
1660
|
+
Args:
|
|
1661
|
+
reason: Optional free-text reason supplied via the HITL reject
|
|
1662
|
+
widget; rendered as a dim line beneath the status.
|
|
1663
|
+
"""
|
|
1664
|
+
self._stop_animation()
|
|
1665
|
+
self._status = "rejected"
|
|
1666
|
+
if reason and reason.strip():
|
|
1667
|
+
self._reject_reason = reason.strip()
|
|
1668
|
+
if self._status_widget:
|
|
1669
|
+
self._status_widget.remove_class("pending")
|
|
1670
|
+
self._status_widget.add_class("rejected")
|
|
1671
|
+
error_icon = get_glyphs().error
|
|
1672
|
+
text = f"{error_icon} Rejected"
|
|
1673
|
+
colors = theme.get_theme_colors(self)
|
|
1674
|
+
self._status_widget.update(Content.styled(text, colors.warning))
|
|
1675
|
+
self._status_widget.display = True
|
|
1676
|
+
self._update_reject_reason_display()
|
|
1677
|
+
|
|
1678
|
+
def _update_reject_reason_display(self) -> None:
|
|
1679
|
+
"""Render the rejection reason line if a reason is set."""
|
|
1680
|
+
if self._reject_reason_widget is None:
|
|
1681
|
+
return
|
|
1682
|
+
if self._reject_reason:
|
|
1683
|
+
self._reject_reason_widget.update(
|
|
1684
|
+
Content.from_markup(
|
|
1685
|
+
"[dim italic]Reason: $reason[/dim italic]",
|
|
1686
|
+
reason=self._reject_reason,
|
|
1687
|
+
)
|
|
1688
|
+
)
|
|
1689
|
+
self._reject_reason_widget.display = True
|
|
1690
|
+
else:
|
|
1691
|
+
self._reject_reason_widget.display = False
|
|
1692
|
+
|
|
1693
|
+
def set_skipped(self) -> None:
|
|
1694
|
+
"""Mark the tool call as skipped (due to another rejection)."""
|
|
1695
|
+
self._stop_animation()
|
|
1696
|
+
self._status = "skipped"
|
|
1697
|
+
if self._status_widget:
|
|
1698
|
+
self._status_widget.remove_class("pending")
|
|
1699
|
+
self._status_widget.add_class("rejected") # Use same styling as rejected
|
|
1700
|
+
self._status_widget.update(Content.styled("- Skipped", "dim"))
|
|
1701
|
+
self._status_widget.display = True
|
|
1702
|
+
|
|
1703
|
+
def set_awaiting_approval(self) -> None:
|
|
1704
|
+
"""Hide the tool call while an approval prompt mirrors its content.
|
|
1705
|
+
|
|
1706
|
+
Used to avoid showing the same shell command in both the streamed tool
|
|
1707
|
+
call header and the HITL approval dialog at the same time. The widget
|
|
1708
|
+
is restored via `clear_awaiting_approval` once the user decides.
|
|
1709
|
+
"""
|
|
1710
|
+
self._awaiting_approval = True
|
|
1711
|
+
self.display = False
|
|
1712
|
+
|
|
1713
|
+
def clear_awaiting_approval(self) -> None:
|
|
1714
|
+
"""Restore the tool call after `set_awaiting_approval`.
|
|
1715
|
+
|
|
1716
|
+
No-op if `set_awaiting_approval` was not previously called, so the
|
|
1717
|
+
method is safe to call unconditionally from a `finally` block.
|
|
1718
|
+
"""
|
|
1719
|
+
if not self._awaiting_approval:
|
|
1720
|
+
return
|
|
1721
|
+
self._awaiting_approval = False
|
|
1722
|
+
self.display = True
|
|
1723
|
+
|
|
1724
|
+
def toggle_output(self) -> None:
|
|
1725
|
+
"""Toggle expansion of the tool's preview/full output."""
|
|
1726
|
+
if not self._output:
|
|
1727
|
+
return
|
|
1728
|
+
# No-op in both directions when nothing is hidden: the collapsed and
|
|
1729
|
+
# expanded forms are identical, so toggling only flickers the hint.
|
|
1730
|
+
# This also covers force-expanded errors (see `set_error`).
|
|
1731
|
+
if not self._has_expandable_output():
|
|
1732
|
+
return
|
|
1733
|
+
self._expanded = not self._expanded
|
|
1734
|
+
self._update_output_display()
|
|
1735
|
+
|
|
1736
|
+
def toggle_args(self) -> None:
|
|
1737
|
+
"""Toggle display of collapsed tool arguments."""
|
|
1738
|
+
if not self.has_expandable_args:
|
|
1739
|
+
return
|
|
1740
|
+
self._args_expanded = not self._args_expanded
|
|
1741
|
+
self._update_args_display()
|
|
1742
|
+
|
|
1743
|
+
def on_click(self, event: Click) -> None:
|
|
1744
|
+
"""Toggle output/argument expansion.
|
|
1745
|
+
|
|
1746
|
+
A click on the header/args region (the truncated command or code line
|
|
1747
|
+
and its hint) toggles the collapsible args/code block directly, so an
|
|
1748
|
+
`execute` command or `js_eval` program can be expanded even when the
|
|
1749
|
+
output below it is *also* expandable. Otherwise prefer toggling output,
|
|
1750
|
+
falling through to the args/code block only when the output can't
|
|
1751
|
+
expand — `js_eval` commonly has a short, unexpandable result sitting
|
|
1752
|
+
below a multi-line, collapsible code block, and the old
|
|
1753
|
+
"output wins whenever it exists" rule left that code block stuck.
|
|
1754
|
+
"""
|
|
1755
|
+
event.stop() # Prevent click from bubbling up and scrolling
|
|
1756
|
+
if self.has_expandable_args and self._click_targets_args_region(event.widget):
|
|
1757
|
+
self.toggle_args()
|
|
1758
|
+
elif self._output and self.has_expandable_output:
|
|
1759
|
+
self.toggle_output()
|
|
1760
|
+
elif self.has_expandable_args:
|
|
1761
|
+
self.toggle_args()
|
|
1762
|
+
|
|
1763
|
+
def _click_targets_args_region(self, widget: object) -> bool:
|
|
1764
|
+
"""Whether a click landed on the header/args block (not the output).
|
|
1765
|
+
|
|
1766
|
+
Walks up from the clicked widget to `self`, matching the cached
|
|
1767
|
+
header, collapsed-args, and args-hint widgets. The walk is bounded so a
|
|
1768
|
+
mock or detached node (which never reaches `self` via `.parent`) returns
|
|
1769
|
+
`False` instead of looping, preserving the generic "prefer output"
|
|
1770
|
+
routing for those cases.
|
|
1771
|
+
|
|
1772
|
+
Returns:
|
|
1773
|
+
`True` if the click landed on the header/args region.
|
|
1774
|
+
"""
|
|
1775
|
+
targets = tuple(
|
|
1776
|
+
target
|
|
1777
|
+
for target in (
|
|
1778
|
+
self._header_widget,
|
|
1779
|
+
self._args_widget,
|
|
1780
|
+
self._args_hint_widget,
|
|
1781
|
+
)
|
|
1782
|
+
if target is not None
|
|
1783
|
+
)
|
|
1784
|
+
if not targets:
|
|
1785
|
+
# A click can only arrive post-mount, where these refs are always
|
|
1786
|
+
# cached, so an empty tuple means a regression nulled them out. Log
|
|
1787
|
+
# it rather than silently routing every click to output (mirrors
|
|
1788
|
+
# `_update_args_display`).
|
|
1789
|
+
logger.debug("_click_targets_args_region: header/args refs not cached")
|
|
1790
|
+
return False
|
|
1791
|
+
# The header/args/hint widgets are direct children of `self` (a click on
|
|
1792
|
+
# rendered text reports a descendant, so the real match depth is 0-1).
|
|
1793
|
+
# 8 is generous headroom that also bounds the walk for a detached or mock
|
|
1794
|
+
# node, whose `.parent` chain never reaches `self`.
|
|
1795
|
+
node = widget
|
|
1796
|
+
for _ in range(8):
|
|
1797
|
+
if node is None or node is self:
|
|
1798
|
+
return False
|
|
1799
|
+
if any(node is target for target in targets):
|
|
1800
|
+
return True
|
|
1801
|
+
node = getattr(node, "parent", None)
|
|
1802
|
+
return False
|
|
1803
|
+
|
|
1804
|
+
def _format_output(
|
|
1805
|
+
self, output: str, *, is_preview: bool = False
|
|
1806
|
+
) -> FormattedOutput:
|
|
1807
|
+
"""Format tool output based on tool type for nicer display.
|
|
1808
|
+
|
|
1809
|
+
Args:
|
|
1810
|
+
output: Raw output string
|
|
1811
|
+
is_preview: Whether this is for preview (truncated) display
|
|
1812
|
+
|
|
1813
|
+
Returns:
|
|
1814
|
+
FormattedOutput with content and optional truncation info.
|
|
1815
|
+
"""
|
|
1816
|
+
# Trim surrounding blank lines and trailing whitespace, but preserve the
|
|
1817
|
+
# command's own leading indentation on the first content line. A bare
|
|
1818
|
+
# `strip()` would lstrip the first line only — continuation lines keep
|
|
1819
|
+
# their indent — so output that indents every row (e.g. `git branch -r`,
|
|
1820
|
+
# which prefixes each branch with two spaces) renders with line 0 flush
|
|
1821
|
+
# and the rest indented beside the fixed glyph gutter.
|
|
1822
|
+
output = output.rstrip().lstrip("\n")
|
|
1823
|
+
if not output:
|
|
1824
|
+
return FormattedOutput(content=Content(""))
|
|
1825
|
+
|
|
1826
|
+
# Tool-specific formatting using dispatch table
|
|
1827
|
+
formatters = {
|
|
1828
|
+
"write_todos": self._format_todos_output,
|
|
1829
|
+
"ls": self._format_ls_output,
|
|
1830
|
+
"read_file": self._format_file_output,
|
|
1831
|
+
"write_file": self._format_file_output,
|
|
1832
|
+
"edit_file": self._format_edit_file_output,
|
|
1833
|
+
"grep": self._format_search_output,
|
|
1834
|
+
"glob": self._format_search_output,
|
|
1835
|
+
"execute": self._format_shell_output,
|
|
1836
|
+
"js_eval": self._format_js_eval_output,
|
|
1837
|
+
"web_search": self._format_web_output,
|
|
1838
|
+
"fetch_url": self._format_web_output,
|
|
1839
|
+
"task": self._format_task_output,
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
formatter = formatters.get(self._tool_name)
|
|
1843
|
+
if formatter:
|
|
1844
|
+
return formatter(output, is_preview=is_preview)
|
|
1845
|
+
|
|
1846
|
+
if is_preview:
|
|
1847
|
+
# Fallback for unknown tools: use generic truncation
|
|
1848
|
+
lines = output.split("\n")
|
|
1849
|
+
if len(lines) > self._PREVIEW_LINES:
|
|
1850
|
+
return self._format_lines_output(lines, is_preview=True)
|
|
1851
|
+
if len(output) > self._PREVIEW_CHARS:
|
|
1852
|
+
truncated = output[: self._PREVIEW_CHARS]
|
|
1853
|
+
truncation = f"{len(output) - self._PREVIEW_CHARS} more chars"
|
|
1854
|
+
return FormattedOutput(
|
|
1855
|
+
content=Content(truncated), truncation=truncation
|
|
1856
|
+
)
|
|
1857
|
+
|
|
1858
|
+
# Default: plain text (Content treats input as literal)
|
|
1859
|
+
return FormattedOutput(content=Content(output))
|
|
1860
|
+
|
|
1861
|
+
@property
|
|
1862
|
+
def has_expandable_output(self) -> bool:
|
|
1863
|
+
"""Whether collapsed output has hidden content worth a toggle.
|
|
1864
|
+
|
|
1865
|
+
Public wrapper around `_has_expandable_output` so toggle routing (click
|
|
1866
|
+
and Ctrl+O) can tell "has output" apart from "has output that can
|
|
1867
|
+
actually expand/collapse". `js_eval` results are frequently short and
|
|
1868
|
+
unexpandable while the code block above them *is* collapsible, so the
|
|
1869
|
+
routing must fall through to args when output cannot toggle.
|
|
1870
|
+
"""
|
|
1871
|
+
return self._has_expandable_output()
|
|
1872
|
+
|
|
1873
|
+
def _is_search_no_result_output(self, output: str) -> bool:
|
|
1874
|
+
"""Return whether search output is a terminal no-result message.
|
|
1875
|
+
|
|
1876
|
+
These sentinels must match the empty-result strings the SDK emits
|
|
1877
|
+
(`format_grep_matches` in `deepagents.backends.utils` and
|
|
1878
|
+
`_format_file_paths` in `deepagents.middleware.filesystem`). If those
|
|
1879
|
+
change, this silently stops matching and empty searches revert to
|
|
1880
|
+
collapsing behind an expand affordance rather than rendering inline.
|
|
1881
|
+
"""
|
|
1882
|
+
if self._tool_name == "grep":
|
|
1883
|
+
return output.strip() == "No matches found"
|
|
1884
|
+
if self._tool_name == "glob":
|
|
1885
|
+
return output.strip() == "No files found"
|
|
1886
|
+
return False
|
|
1887
|
+
|
|
1888
|
+
def _has_expandable_output(self) -> bool:
|
|
1889
|
+
"""Return whether collapsed output has hidden content to expand."""
|
|
1890
|
+
output = self._output.strip()
|
|
1891
|
+
if not output or self._is_search_no_result_output(output):
|
|
1892
|
+
return False
|
|
1893
|
+
|
|
1894
|
+
# Tools in `_COLLAPSE_OUTPUT_BY_DEFAULT` (read_file, grep, glob) collapse
|
|
1895
|
+
# their body entirely by default (the header already carries the file
|
|
1896
|
+
# path / search pattern), so any result with something to show is
|
|
1897
|
+
# expandable regardless of size. The exception is a search that finds
|
|
1898
|
+
# nothing: grep/glob return the terminal "No matches found" / "No files
|
|
1899
|
+
# found" message, caught by `_is_search_no_result_output` above so it
|
|
1900
|
+
# renders inline (see `_update_output_display`) instead of hiding a
|
|
1901
|
+
# "nothing found" result behind an expand click. Beyond that, confirm the
|
|
1902
|
+
# formatted output is non-empty rather than trusting the raw string —
|
|
1903
|
+
# output that formats to blank (all whitespace, or a serialized empty
|
|
1904
|
+
# collection like `[]`) has nothing to reveal. Successful `edit_file`
|
|
1905
|
+
# similarly hides its redundant success line in the collapsed view while
|
|
1906
|
+
# keeping the raw output expandable. This mirrors the empty-output guard
|
|
1907
|
+
# in `_update_output_display`, which suppresses any body that would
|
|
1908
|
+
# render blank before the collapse branch is reached — the two must move
|
|
1909
|
+
# together if that assumption changes. Errors are excluded because
|
|
1910
|
+
# `set_error` force-expands every error; treating a short error as
|
|
1911
|
+
# always-expandable would offer a collapse that hides it entirely.
|
|
1912
|
+
if self._tool_name in _COLLAPSE_OUTPUT_BY_DEFAULT and self._status != "error":
|
|
1913
|
+
formatted = self._format_output(output, is_preview=False)
|
|
1914
|
+
return bool(formatted.content.plain.strip())
|
|
1915
|
+
if self._tool_name == "edit_file" and self._status == "success":
|
|
1916
|
+
return True
|
|
1917
|
+
|
|
1918
|
+
if self._tool_name == "write_todos":
|
|
1919
|
+
return self._format_output(output, is_preview=True).truncation is not None
|
|
1920
|
+
|
|
1921
|
+
lines = output.split("\n")
|
|
1922
|
+
if len(lines) > self._PREVIEW_LINES or len(output) > self._PREVIEW_CHARS:
|
|
1923
|
+
# The outer size threshold is necessary but not sufficient: only
|
|
1924
|
+
# treat output as expandable if the formatter actually hides
|
|
1925
|
+
# content. Some formatters cap by line count alone (task and the
|
|
1926
|
+
# web fallback, via `_format_task_output` / `_format_lines_output`),
|
|
1927
|
+
# so a long single line crosses the char threshold yet renders in
|
|
1928
|
+
# full with nothing hidden.
|
|
1929
|
+
return self._format_output(output, is_preview=True).truncation is not None
|
|
1930
|
+
|
|
1931
|
+
return False
|
|
1932
|
+
|
|
1933
|
+
def _format_todos_output(
|
|
1934
|
+
self, output: str, *, is_preview: bool = False
|
|
1935
|
+
) -> FormattedOutput:
|
|
1936
|
+
"""Format write_todos output as a checklist.
|
|
1937
|
+
|
|
1938
|
+
Returns:
|
|
1939
|
+
FormattedOutput with checklist content and optional truncation info.
|
|
1940
|
+
"""
|
|
1941
|
+
items = self._parse_todo_items(output)
|
|
1942
|
+
if items is None:
|
|
1943
|
+
return FormattedOutput(content=Content(output))
|
|
1944
|
+
|
|
1945
|
+
if not items:
|
|
1946
|
+
return FormattedOutput(content=Content.styled(" No todos", "dim"))
|
|
1947
|
+
|
|
1948
|
+
lines: list[Content] = []
|
|
1949
|
+
max_items = 4 if is_preview else len(items)
|
|
1950
|
+
|
|
1951
|
+
# Build stats header
|
|
1952
|
+
stats = self._build_todo_stats(items)
|
|
1953
|
+
if stats:
|
|
1954
|
+
lines.extend([Content.assemble(" ", stats), Content("")])
|
|
1955
|
+
|
|
1956
|
+
# Format each item
|
|
1957
|
+
lines.extend(
|
|
1958
|
+
self._format_single_todo(item, is_preview=is_preview)
|
|
1959
|
+
for item in items[:max_items]
|
|
1960
|
+
)
|
|
1961
|
+
|
|
1962
|
+
truncation = None
|
|
1963
|
+
if is_preview:
|
|
1964
|
+
hidden_items = len(items) - max_items
|
|
1965
|
+
if hidden_items > 0:
|
|
1966
|
+
truncation = f"{hidden_items} more"
|
|
1967
|
+
elif any(
|
|
1968
|
+
len(self._todo_text(item)) > _MAX_TODO_CONTENT_LEN
|
|
1969
|
+
for item in items[:max_items]
|
|
1970
|
+
):
|
|
1971
|
+
truncation = "full todo text"
|
|
1972
|
+
|
|
1973
|
+
return FormattedOutput(content=Content("\n").join(lines), truncation=truncation)
|
|
1974
|
+
|
|
1975
|
+
@staticmethod
|
|
1976
|
+
def _todo_text(item: dict | str) -> str:
|
|
1977
|
+
"""Return display text for a todo item.
|
|
1978
|
+
|
|
1979
|
+
Args:
|
|
1980
|
+
item: Todo item dictionary or plain string.
|
|
1981
|
+
|
|
1982
|
+
Returns:
|
|
1983
|
+
Todo content text.
|
|
1984
|
+
"""
|
|
1985
|
+
if isinstance(item, dict):
|
|
1986
|
+
return str(item.get("content", str(item)))
|
|
1987
|
+
return str(item)
|
|
1988
|
+
|
|
1989
|
+
def _parse_todo_items(self, output: str) -> list | None: # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
1990
|
+
"""Parse todo items from output.
|
|
1991
|
+
|
|
1992
|
+
Returns:
|
|
1993
|
+
List of todo items, or None if parsing fails.
|
|
1994
|
+
"""
|
|
1995
|
+
list_match = re.search(r"\[(\{.*\})\]", output.replace("\n", " "), re.DOTALL)
|
|
1996
|
+
if list_match:
|
|
1997
|
+
try:
|
|
1998
|
+
return ast.literal_eval("[" + list_match.group(1) + "]")
|
|
1999
|
+
except (ValueError, SyntaxError):
|
|
2000
|
+
return None
|
|
2001
|
+
try:
|
|
2002
|
+
items = ast.literal_eval(output)
|
|
2003
|
+
return items if isinstance(items, list) else None
|
|
2004
|
+
except (ValueError, SyntaxError):
|
|
2005
|
+
return None
|
|
2006
|
+
|
|
2007
|
+
def _build_todo_stats(self, items: list) -> Content:
|
|
2008
|
+
"""Build stats content for todo list.
|
|
2009
|
+
|
|
2010
|
+
Returns:
|
|
2011
|
+
Styled `Content` showing active, pending, and completed counts.
|
|
2012
|
+
"""
|
|
2013
|
+
colors = theme.get_theme_colors(self)
|
|
2014
|
+
completed = sum(
|
|
2015
|
+
1 for i in items if isinstance(i, dict) and i.get("status") == "completed"
|
|
2016
|
+
)
|
|
2017
|
+
active = sum(
|
|
2018
|
+
1 for i in items if isinstance(i, dict) and i.get("status") == "in_progress"
|
|
2019
|
+
)
|
|
2020
|
+
pending = len(items) - completed - active
|
|
2021
|
+
|
|
2022
|
+
parts: list[Content] = []
|
|
2023
|
+
if active:
|
|
2024
|
+
parts.append(Content.styled(f"{active} active", colors.warning))
|
|
2025
|
+
if pending:
|
|
2026
|
+
parts.append(Content.styled(f"{pending} pending", "dim"))
|
|
2027
|
+
if completed:
|
|
2028
|
+
parts.append(Content.styled(f"{completed} done", colors.success))
|
|
2029
|
+
return Content.styled(" | ", "dim").join(parts) if parts else Content("")
|
|
2030
|
+
|
|
2031
|
+
def _todo_content_width(self, indent_width: int) -> int:
|
|
2032
|
+
"""Return the todo content wrap width for the current widget size.
|
|
2033
|
+
|
|
2034
|
+
Args:
|
|
2035
|
+
indent_width: Display width before todo content starts.
|
|
2036
|
+
|
|
2037
|
+
Returns:
|
|
2038
|
+
Width available for todo content wrapping.
|
|
2039
|
+
"""
|
|
2040
|
+
display_width = 0
|
|
2041
|
+
for widget in (self._full_widget, self._preview_widget):
|
|
2042
|
+
if widget and widget.is_mounted and widget.size.width > 0:
|
|
2043
|
+
display_width = widget.size.width
|
|
2044
|
+
break
|
|
2045
|
+
|
|
2046
|
+
if not display_width and self.is_mounted and self.size.width > 0:
|
|
2047
|
+
display_width = self.size.width
|
|
2048
|
+
|
|
2049
|
+
if not display_width:
|
|
2050
|
+
try:
|
|
2051
|
+
display_width = self.app.size.width
|
|
2052
|
+
except NoActiveAppError:
|
|
2053
|
+
display_width = _DEFAULT_TODO_WRAP_WIDTH
|
|
2054
|
+
|
|
2055
|
+
# The content widgets measured above live inside the gutter row, so
|
|
2056
|
+
# their width already excludes the output glyph column; the guard
|
|
2057
|
+
# columns absorb the gutter offset for the self/app fallback width.
|
|
2058
|
+
available = display_width - indent_width - _TODO_WRAP_GUARD_COLUMNS
|
|
2059
|
+
return max(20, available)
|
|
2060
|
+
|
|
2061
|
+
def _format_todo_line(
|
|
2062
|
+
self,
|
|
2063
|
+
prefix: Content,
|
|
2064
|
+
text: str,
|
|
2065
|
+
*,
|
|
2066
|
+
is_preview: bool,
|
|
2067
|
+
text_style: str | None = None,
|
|
2068
|
+
) -> Content:
|
|
2069
|
+
"""Format a todo row, wrapping expanded content under the text column.
|
|
2070
|
+
|
|
2071
|
+
Args:
|
|
2072
|
+
prefix: Styled status prefix before todo content.
|
|
2073
|
+
text: Todo text to render.
|
|
2074
|
+
is_preview: Whether the compact preview is being rendered.
|
|
2075
|
+
text_style: Optional style for todo content.
|
|
2076
|
+
|
|
2077
|
+
Returns:
|
|
2078
|
+
Styled `Content` for one todo row.
|
|
2079
|
+
"""
|
|
2080
|
+
if is_preview and len(text) > _MAX_TODO_CONTENT_LEN:
|
|
2081
|
+
text = text[: _MAX_TODO_CONTENT_LEN - 3] + "..."
|
|
2082
|
+
|
|
2083
|
+
if is_preview:
|
|
2084
|
+
content = Content.styled(text, text_style) if text_style else Content(text)
|
|
2085
|
+
return Content.assemble(prefix, content)
|
|
2086
|
+
|
|
2087
|
+
indent = " " * len(prefix.plain)
|
|
2088
|
+
wrapped = textwrap.wrap(
|
|
2089
|
+
text,
|
|
2090
|
+
width=self._todo_content_width(len(prefix.plain)),
|
|
2091
|
+
break_long_words=True,
|
|
2092
|
+
break_on_hyphens=False,
|
|
2093
|
+
) or [""]
|
|
2094
|
+
parts: list[Content] = [prefix]
|
|
2095
|
+
for index, line in enumerate(wrapped):
|
|
2096
|
+
if index:
|
|
2097
|
+
parts.append(Content("\n" + indent))
|
|
2098
|
+
content = Content.styled(line, text_style) if text_style else Content(line)
|
|
2099
|
+
parts.append(content)
|
|
2100
|
+
return Content.assemble(*parts)
|
|
2101
|
+
|
|
2102
|
+
def _format_single_todo(self, item: dict | str, *, is_preview: bool) -> Content:
|
|
2103
|
+
"""Format a single todo item.
|
|
2104
|
+
|
|
2105
|
+
Args:
|
|
2106
|
+
item: Todo item dictionary or plain string.
|
|
2107
|
+
is_preview: Whether the compact preview is being rendered.
|
|
2108
|
+
|
|
2109
|
+
Returns:
|
|
2110
|
+
Styled `Content` with checkbox and status styling.
|
|
2111
|
+
"""
|
|
2112
|
+
colors = theme.get_theme_colors(self)
|
|
2113
|
+
if isinstance(item, dict):
|
|
2114
|
+
text = self._todo_text(item)
|
|
2115
|
+
status = item.get("status", "pending")
|
|
2116
|
+
else:
|
|
2117
|
+
text = self._todo_text(item)
|
|
2118
|
+
status = "pending"
|
|
2119
|
+
|
|
2120
|
+
glyphs = get_glyphs()
|
|
2121
|
+
if status == "completed":
|
|
2122
|
+
return self._format_todo_line(
|
|
2123
|
+
Content.styled(f" {glyphs.checkmark} done ", colors.success),
|
|
2124
|
+
text,
|
|
2125
|
+
is_preview=is_preview,
|
|
2126
|
+
text_style="dim",
|
|
2127
|
+
)
|
|
2128
|
+
if status == "in_progress":
|
|
2129
|
+
return self._format_todo_line(
|
|
2130
|
+
Content.styled(f" {glyphs.circle_filled} active ", colors.warning),
|
|
2131
|
+
text,
|
|
2132
|
+
is_preview=is_preview,
|
|
2133
|
+
)
|
|
2134
|
+
return self._format_todo_line(
|
|
2135
|
+
Content.styled(f" {glyphs.circle_empty} todo ", "dim"),
|
|
2136
|
+
text,
|
|
2137
|
+
is_preview=is_preview,
|
|
2138
|
+
)
|
|
2139
|
+
|
|
2140
|
+
def _format_ls_output( # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
2141
|
+
self, output: str, *, is_preview: bool = False
|
|
2142
|
+
) -> FormattedOutput:
|
|
2143
|
+
"""Format ls output as a clean directory listing.
|
|
2144
|
+
|
|
2145
|
+
Returns:
|
|
2146
|
+
FormattedOutput with directory listing and optional truncation info.
|
|
2147
|
+
"""
|
|
2148
|
+
# Try to parse as a Python list (common format)
|
|
2149
|
+
try:
|
|
2150
|
+
items = ast.literal_eval(output)
|
|
2151
|
+
if isinstance(items, list):
|
|
2152
|
+
lines: list[Content] = []
|
|
2153
|
+
max_items = 5 if is_preview else len(items)
|
|
2154
|
+
for item in items[:max_items]:
|
|
2155
|
+
path = Path(str(item))
|
|
2156
|
+
name = path.name
|
|
2157
|
+
if path.suffix in {".py", ".pyx"}:
|
|
2158
|
+
lines.append(Content.styled(f" {name}", theme.FILE_PYTHON))
|
|
2159
|
+
elif path.suffix in {".json", ".yaml", ".yml", ".toml"}:
|
|
2160
|
+
lines.append(Content.styled(f" {name}", theme.FILE_CONFIG))
|
|
2161
|
+
elif not path.suffix:
|
|
2162
|
+
lines.append(Content.styled(f" {name}/", theme.FILE_DIR))
|
|
2163
|
+
else:
|
|
2164
|
+
lines.append(Content(f" {name}"))
|
|
2165
|
+
|
|
2166
|
+
truncation = None
|
|
2167
|
+
if is_preview and len(items) > max_items:
|
|
2168
|
+
truncation = f"{len(items) - max_items} more"
|
|
2169
|
+
|
|
2170
|
+
return FormattedOutput(
|
|
2171
|
+
content=Content("\n").join(lines), truncation=truncation
|
|
2172
|
+
)
|
|
2173
|
+
except (ValueError, SyntaxError):
|
|
2174
|
+
pass
|
|
2175
|
+
|
|
2176
|
+
# Fallback: plain text
|
|
2177
|
+
return FormattedOutput(content=Content(output))
|
|
2178
|
+
|
|
2179
|
+
@staticmethod
|
|
2180
|
+
def _compact_line_gutter(output: str) -> str:
|
|
2181
|
+
r"""Tighten `read_file`'s line-number gutter for display.
|
|
2182
|
+
|
|
2183
|
+
`read_file` prefixes each row with a right-justified line marker — `N`,
|
|
2184
|
+
or `N.M` for a wrapped-line continuation — then two spaces, then the
|
|
2185
|
+
original source content. (Output from deepagents versions predating the
|
|
2186
|
+
gutter disambiguation in #4561 used the older `cat -n` gutter — a wide
|
|
2187
|
+
right-justified number and a tab — which may still surface from cached or
|
|
2188
|
+
persisted transcripts.) The model needs the raw gutter for edits, but the
|
|
2189
|
+
TUI re-justifies markers to the widest marker actually present, then two
|
|
2190
|
+
spaces, mirroring how grep/glob results sit flush left. Source
|
|
2191
|
+
indentation after the gutter is preserved untouched.
|
|
2192
|
+
|
|
2193
|
+
The gutter shape is `_READ_FILE_GUTTER_RE`. Lines that don't match a
|
|
2194
|
+
gutter shape (e.g. test fixtures or non-numbered output) are passed
|
|
2195
|
+
through unchanged.
|
|
2196
|
+
|
|
2197
|
+
Returns:
|
|
2198
|
+
The output with compacted gutters, or the original string if no
|
|
2199
|
+
line-numbered content was found.
|
|
2200
|
+
"""
|
|
2201
|
+
lines = output.split("\n")
|
|
2202
|
+
parsed: list[tuple[str, str] | None] = []
|
|
2203
|
+
width = 0
|
|
2204
|
+
for line in lines:
|
|
2205
|
+
match = _READ_FILE_GUTTER_RE.match(line)
|
|
2206
|
+
if match:
|
|
2207
|
+
marker, source = match.groups()
|
|
2208
|
+
parsed.append((marker, source))
|
|
2209
|
+
width = max(width, len(marker))
|
|
2210
|
+
else:
|
|
2211
|
+
parsed.append(None)
|
|
2212
|
+
|
|
2213
|
+
if width == 0:
|
|
2214
|
+
return output
|
|
2215
|
+
|
|
2216
|
+
compacted: list[str] = []
|
|
2217
|
+
for line, row in zip(lines, parsed, strict=True):
|
|
2218
|
+
if row is None:
|
|
2219
|
+
compacted.append(line)
|
|
2220
|
+
else:
|
|
2221
|
+
marker, source = row
|
|
2222
|
+
compacted.append(f"{marker:>{width}} {source}")
|
|
2223
|
+
return "\n".join(compacted)
|
|
2224
|
+
|
|
2225
|
+
def _format_edit_file_output(
|
|
2226
|
+
self, output: str, *, is_preview: bool = False
|
|
2227
|
+
) -> FormattedOutput:
|
|
2228
|
+
"""Render edit_file output, hiding success only in the preview.
|
|
2229
|
+
|
|
2230
|
+
On success the collapsed status glyph and the diff already convey the
|
|
2231
|
+
outcome, so the "Successfully replaced ..." line is hidden by default.
|
|
2232
|
+
The full rendering still shows the raw tool output so clicking the row
|
|
2233
|
+
can recover the original message. Errors still render in both modes.
|
|
2234
|
+
|
|
2235
|
+
Returns:
|
|
2236
|
+
Empty preview on success, otherwise the file formatter.
|
|
2237
|
+
"""
|
|
2238
|
+
if self._status == "success" and is_preview:
|
|
2239
|
+
return FormattedOutput(content=Content(""))
|
|
2240
|
+
return self._format_file_output(output, is_preview=is_preview)
|
|
2241
|
+
|
|
2242
|
+
def _format_file_output(
|
|
2243
|
+
self, output: str, *, is_preview: bool = False
|
|
2244
|
+
) -> FormattedOutput:
|
|
2245
|
+
"""Format file read/write output.
|
|
2246
|
+
|
|
2247
|
+
Preview mode caps both line count and total characters so that files
|
|
2248
|
+
with very long lines (minified HTML/JS/CSS) don't wrap and overflow
|
|
2249
|
+
the widget.
|
|
2250
|
+
|
|
2251
|
+
Returns:
|
|
2252
|
+
FormattedOutput with file content and optional truncation info.
|
|
2253
|
+
"""
|
|
2254
|
+
output = self._compact_line_gutter(output)
|
|
2255
|
+
lines = output.split("\n")
|
|
2256
|
+
# Files conventionally end in "\n"; the trailing empty element isn't a
|
|
2257
|
+
# real line and would inflate truncation counts.
|
|
2258
|
+
had_trailing_newline = bool(lines) and not lines[-1]
|
|
2259
|
+
if had_trailing_newline:
|
|
2260
|
+
lines = lines[:-1]
|
|
2261
|
+
max_lines = 4 if is_preview else len(lines)
|
|
2262
|
+
char_budget = self._PREVIEW_CHARS if is_preview else None
|
|
2263
|
+
|
|
2264
|
+
shown, chars_used, char_truncated = self._truncate_to_budget(
|
|
2265
|
+
lines, max_lines=max_lines, char_budget=char_budget
|
|
2266
|
+
)
|
|
2267
|
+
parts = [Content(line) for line in shown]
|
|
2268
|
+
content = Content("\n").join(parts) if parts else Content("")
|
|
2269
|
+
|
|
2270
|
+
truncation = self._build_truncation_hint(
|
|
2271
|
+
output=output,
|
|
2272
|
+
lines=lines,
|
|
2273
|
+
parts_count=len(parts),
|
|
2274
|
+
chars_used=chars_used,
|
|
2275
|
+
char_truncated=char_truncated,
|
|
2276
|
+
had_trailing_newline=had_trailing_newline,
|
|
2277
|
+
is_preview=is_preview,
|
|
2278
|
+
)
|
|
2279
|
+
|
|
2280
|
+
return FormattedOutput(content=content, truncation=truncation)
|
|
2281
|
+
|
|
2282
|
+
@staticmethod
|
|
2283
|
+
def _truncate_to_budget(
|
|
2284
|
+
lines: list[str], *, max_lines: int, char_budget: int | None
|
|
2285
|
+
) -> tuple[list[str], int, bool]:
|
|
2286
|
+
"""Apply line- and character-count caps to a list of display lines.
|
|
2287
|
+
|
|
2288
|
+
Shared by the file, shell, and search formatters so preview truncation
|
|
2289
|
+
stays identical across tool outputs. When `char_budget` is `None` (the
|
|
2290
|
+
expanded, non-preview view) only the line cap applies.
|
|
2291
|
+
|
|
2292
|
+
Args:
|
|
2293
|
+
lines: Candidate display lines, already cleaned by the caller.
|
|
2294
|
+
max_lines: Maximum number of lines to emit.
|
|
2295
|
+
char_budget: Maximum characters to emit across all lines, counting
|
|
2296
|
+
the newline separators between them, or `None` for no cap.
|
|
2297
|
+
|
|
2298
|
+
Returns:
|
|
2299
|
+
The lines to show, the characters consumed (including separators),
|
|
2300
|
+
and whether the character budget forced truncation.
|
|
2301
|
+
"""
|
|
2302
|
+
shown: list[str] = []
|
|
2303
|
+
chars_used = 0
|
|
2304
|
+
char_truncated = False
|
|
2305
|
+
for line in lines[:max_lines]:
|
|
2306
|
+
display_line = line
|
|
2307
|
+
if char_budget is not None:
|
|
2308
|
+
separator_cost = 1 if shown else 0
|
|
2309
|
+
remaining = char_budget - chars_used - separator_cost
|
|
2310
|
+
if remaining <= 0:
|
|
2311
|
+
char_truncated = True
|
|
2312
|
+
break
|
|
2313
|
+
if len(line) > remaining:
|
|
2314
|
+
display_line = line[:remaining]
|
|
2315
|
+
char_truncated = True
|
|
2316
|
+
chars_used += separator_cost + len(display_line)
|
|
2317
|
+
shown.append(display_line)
|
|
2318
|
+
if char_truncated:
|
|
2319
|
+
break
|
|
2320
|
+
return shown, chars_used, char_truncated
|
|
2321
|
+
|
|
2322
|
+
@staticmethod
|
|
2323
|
+
def _build_truncation_hint(
|
|
2324
|
+
*,
|
|
2325
|
+
output: str,
|
|
2326
|
+
lines: list[str],
|
|
2327
|
+
parts_count: int,
|
|
2328
|
+
chars_used: int,
|
|
2329
|
+
char_truncated: bool,
|
|
2330
|
+
had_trailing_newline: bool,
|
|
2331
|
+
is_preview: bool,
|
|
2332
|
+
line_unit: Literal["files", "lines"] = "lines",
|
|
2333
|
+
) -> str | None:
|
|
2334
|
+
"""Compose the truncation hint, preferring line counts over char counts.
|
|
2335
|
+
|
|
2336
|
+
When both the line cap and the char cap were hit, hidden-line count is
|
|
2337
|
+
the more useful signal for the user — char counts dominate the hint
|
|
2338
|
+
for big files where what they really want to know is "how many more
|
|
2339
|
+
lines am I missing?". `line_unit` names the hidden-row noun ("lines"
|
|
2340
|
+
for text output, "files" for glob path lists).
|
|
2341
|
+
|
|
2342
|
+
Returns:
|
|
2343
|
+
Hint string for the UI, or `None` if nothing was truncated.
|
|
2344
|
+
"""
|
|
2345
|
+
if not is_preview:
|
|
2346
|
+
return None
|
|
2347
|
+
hidden_lines = len(lines) - parts_count
|
|
2348
|
+
if hidden_lines > 0:
|
|
2349
|
+
return f"{hidden_lines} more {line_unit}"
|
|
2350
|
+
if char_truncated:
|
|
2351
|
+
effective_output_len = len(output) - (1 if had_trailing_newline else 0)
|
|
2352
|
+
hidden_chars = effective_output_len - chars_used
|
|
2353
|
+
return f"{hidden_chars} more chars"
|
|
2354
|
+
return None
|
|
2355
|
+
|
|
2356
|
+
def _format_search_output(
|
|
2357
|
+
self, output: str, *, is_preview: bool = False
|
|
2358
|
+
) -> FormattedOutput:
|
|
2359
|
+
"""Format grep/glob search output.
|
|
2360
|
+
|
|
2361
|
+
Returns:
|
|
2362
|
+
FormattedOutput with search results and optional truncation info.
|
|
2363
|
+
"""
|
|
2364
|
+
# Try to parse as a Python list (glob returns list of paths). The
|
|
2365
|
+
# except is scoped to detection only — formatting runs outside it so a
|
|
2366
|
+
# bug in `_format_search_lines` can't silently reroute to the fallback.
|
|
2367
|
+
try:
|
|
2368
|
+
items = ast.literal_eval(output.strip())
|
|
2369
|
+
except (ValueError, SyntaxError):
|
|
2370
|
+
items = None
|
|
2371
|
+
|
|
2372
|
+
if isinstance(items, list):
|
|
2373
|
+
paths: list[str] = []
|
|
2374
|
+
for item in items:
|
|
2375
|
+
path = Path(str(item))
|
|
2376
|
+
try:
|
|
2377
|
+
display = str(path.relative_to(Path.cwd()))
|
|
2378
|
+
except ValueError:
|
|
2379
|
+
display = path.name
|
|
2380
|
+
paths.append(display)
|
|
2381
|
+
return self._format_search_lines(
|
|
2382
|
+
paths, is_preview=is_preview, line_unit="files"
|
|
2383
|
+
)
|
|
2384
|
+
|
|
2385
|
+
# Fallback: line-based output (grep results)
|
|
2386
|
+
lines = [
|
|
2387
|
+
raw_line.strip() for raw_line in output.split("\n") if raw_line.strip()
|
|
2388
|
+
]
|
|
2389
|
+
return self._format_search_lines(
|
|
2390
|
+
lines, is_preview=is_preview, line_unit="lines"
|
|
2391
|
+
)
|
|
2392
|
+
|
|
2393
|
+
def _format_search_lines(
|
|
2394
|
+
self,
|
|
2395
|
+
lines: list[str],
|
|
2396
|
+
*,
|
|
2397
|
+
is_preview: bool,
|
|
2398
|
+
line_unit: Literal["files", "lines"],
|
|
2399
|
+
) -> FormattedOutput:
|
|
2400
|
+
"""Format search result rows with line and character preview caps.
|
|
2401
|
+
|
|
2402
|
+
`line_unit` names the hidden-row noun for the hint — "files" for glob
|
|
2403
|
+
path lists, "lines" for grep matches.
|
|
2404
|
+
|
|
2405
|
+
Returns:
|
|
2406
|
+
FormattedOutput with search rows and optional truncation info.
|
|
2407
|
+
"""
|
|
2408
|
+
# Search rows are denser than file/shell output, so the preview shows
|
|
2409
|
+
# one extra row (5) before truncating.
|
|
2410
|
+
max_lines = 5 if is_preview else len(lines)
|
|
2411
|
+
char_budget = self._PREVIEW_CHARS if is_preview else None
|
|
2412
|
+
|
|
2413
|
+
shown, chars_used, char_truncated = self._truncate_to_budget(
|
|
2414
|
+
lines, max_lines=max_lines, char_budget=char_budget
|
|
2415
|
+
)
|
|
2416
|
+
parts = [Content(line) for line in shown]
|
|
2417
|
+
content = Content("\n").join(parts) if parts else Content("")
|
|
2418
|
+
|
|
2419
|
+
# The cleaned `lines` carry no trailing-newline element, so the joined
|
|
2420
|
+
# length is the full preview-able content length.
|
|
2421
|
+
truncation = self._build_truncation_hint(
|
|
2422
|
+
output="\n".join(lines),
|
|
2423
|
+
lines=lines,
|
|
2424
|
+
parts_count=len(parts),
|
|
2425
|
+
chars_used=chars_used,
|
|
2426
|
+
char_truncated=char_truncated,
|
|
2427
|
+
had_trailing_newline=False,
|
|
2428
|
+
is_preview=is_preview,
|
|
2429
|
+
line_unit=line_unit,
|
|
2430
|
+
)
|
|
2431
|
+
|
|
2432
|
+
return FormattedOutput(content=content, truncation=truncation)
|
|
2433
|
+
|
|
2434
|
+
def _format_shell_output(
|
|
2435
|
+
self, output: str, *, is_preview: bool = False
|
|
2436
|
+
) -> FormattedOutput:
|
|
2437
|
+
"""Format shell command output.
|
|
2438
|
+
|
|
2439
|
+
Returns:
|
|
2440
|
+
FormattedOutput with shell output and optional truncation info.
|
|
2441
|
+
"""
|
|
2442
|
+
lines = output.split("\n")
|
|
2443
|
+
had_trailing_newline = bool(lines) and not lines[-1]
|
|
2444
|
+
if had_trailing_newline:
|
|
2445
|
+
lines = lines[:-1]
|
|
2446
|
+
max_lines = 4 if is_preview else len(lines)
|
|
2447
|
+
char_budget = self._PREVIEW_CHARS if is_preview else None
|
|
2448
|
+
|
|
2449
|
+
shown, chars_used, char_truncated = self._truncate_to_budget(
|
|
2450
|
+
lines, max_lines=max_lines, char_budget=char_budget
|
|
2451
|
+
)
|
|
2452
|
+
# Dim the leading `$ command` echo; only the first row can carry it.
|
|
2453
|
+
parts = [
|
|
2454
|
+
Content.styled(line, "dim")
|
|
2455
|
+
if index == 0 and line.startswith("$ ")
|
|
2456
|
+
else Content(line)
|
|
2457
|
+
for index, line in enumerate(shown)
|
|
2458
|
+
]
|
|
2459
|
+
content = Content("\n").join(parts) if parts else Content("")
|
|
2460
|
+
|
|
2461
|
+
truncation = self._build_truncation_hint(
|
|
2462
|
+
output=output,
|
|
2463
|
+
lines=lines,
|
|
2464
|
+
parts_count=len(parts),
|
|
2465
|
+
chars_used=chars_used,
|
|
2466
|
+
char_truncated=char_truncated,
|
|
2467
|
+
had_trailing_newline=had_trailing_newline,
|
|
2468
|
+
is_preview=is_preview,
|
|
2469
|
+
)
|
|
2470
|
+
|
|
2471
|
+
return FormattedOutput(content=content, truncation=truncation)
|
|
2472
|
+
|
|
2473
|
+
def _format_js_eval_output(
|
|
2474
|
+
self, output: str, *, is_preview: bool = False
|
|
2475
|
+
) -> FormattedOutput:
|
|
2476
|
+
"""Format `js_eval` (JS interpreter) output.
|
|
2477
|
+
|
|
2478
|
+
Unwraps the REPL's `<stdout>` / `<result>` / `<error>` envelope into
|
|
2479
|
+
labeled, styled sections instead of dumping the raw XML-escaped blob.
|
|
2480
|
+
|
|
2481
|
+
Returns:
|
|
2482
|
+
FormattedOutput with the formatted REPL output and optional
|
|
2483
|
+
truncation info.
|
|
2484
|
+
"""
|
|
2485
|
+
blocks = parse_js_eval_blocks(output)
|
|
2486
|
+
if blocks is None:
|
|
2487
|
+
# Unexpected shape — fall back to plain line rendering.
|
|
2488
|
+
return self._format_lines_output(output.split("\n"), is_preview=is_preview)
|
|
2489
|
+
|
|
2490
|
+
colors = theme.get_theme_colors(self)
|
|
2491
|
+
|
|
2492
|
+
# Common case: a single short scalar result with no stdout. Rendering a
|
|
2493
|
+
# standalone "result" header above a one-word value reads as a
|
|
2494
|
+
# misplaced badge, so collapse it to an inline `result: value` line.
|
|
2495
|
+
if len(blocks) == 1:
|
|
2496
|
+
block = blocks[0]
|
|
2497
|
+
if (
|
|
2498
|
+
isinstance(block, JsEvalResult)
|
|
2499
|
+
and not block.kind
|
|
2500
|
+
and "\n" not in block.body
|
|
2501
|
+
and len(block.body) <= self._JS_EVAL_INLINE_RESULT_MAX
|
|
2502
|
+
):
|
|
2503
|
+
content = Content.assemble(
|
|
2504
|
+
Content.styled("result: ", colors.success),
|
|
2505
|
+
Content(block.body),
|
|
2506
|
+
)
|
|
2507
|
+
return FormattedOutput(content=content)
|
|
2508
|
+
lines: list[Content] = []
|
|
2509
|
+
total_lines = 0
|
|
2510
|
+
max_lines = self._PREVIEW_LINES if is_preview else None
|
|
2511
|
+
# Char budget mirrors the other formatters so a single very long body
|
|
2512
|
+
# line (e.g. a 10k-char result) is clipped instead of flooding the
|
|
2513
|
+
# collapsed preview. `None` outside preview means no char cap.
|
|
2514
|
+
remaining_chars = self._PREVIEW_CHARS if is_preview else None
|
|
2515
|
+
# Chars hidden when a single over-budget body line is clipped. Only
|
|
2516
|
+
# meaningful for the hint when no whole lines were dropped (line counts
|
|
2517
|
+
# take precedence below, matching `_build_truncation_hint`).
|
|
2518
|
+
clipped_chars = 0
|
|
2519
|
+
|
|
2520
|
+
def add_section(label: Content, body: str) -> None:
|
|
2521
|
+
nonlocal total_lines, remaining_chars, clipped_chars
|
|
2522
|
+
if max_lines is not None and total_lines >= max_lines:
|
|
2523
|
+
return
|
|
2524
|
+
if remaining_chars is not None and remaining_chars <= 0:
|
|
2525
|
+
return
|
|
2526
|
+
lines.append(label)
|
|
2527
|
+
total_lines += 1
|
|
2528
|
+
body_lines = body.split("\n") if body else [""]
|
|
2529
|
+
for body_line in body_lines:
|
|
2530
|
+
if max_lines is not None and total_lines >= max_lines:
|
|
2531
|
+
break
|
|
2532
|
+
if remaining_chars is not None:
|
|
2533
|
+
if remaining_chars <= 0:
|
|
2534
|
+
break
|
|
2535
|
+
if len(body_line) > remaining_chars:
|
|
2536
|
+
# Clip the over-budget line and stop adding more.
|
|
2537
|
+
lines.append(Content(f" {body_line[:remaining_chars]}"))
|
|
2538
|
+
total_lines += 1
|
|
2539
|
+
clipped_chars = len(body_line) - remaining_chars
|
|
2540
|
+
remaining_chars = 0
|
|
2541
|
+
break
|
|
2542
|
+
remaining_chars -= len(body_line)
|
|
2543
|
+
lines.append(Content(f" {body_line}"))
|
|
2544
|
+
total_lines += 1
|
|
2545
|
+
|
|
2546
|
+
for block in blocks:
|
|
2547
|
+
if isinstance(block, JsEvalStdout):
|
|
2548
|
+
add_section(Content.styled("stdout", "dim"), block.body)
|
|
2549
|
+
elif isinstance(block, JsEvalError):
|
|
2550
|
+
header = f"error ({block.error_type})" if block.error_type else "error"
|
|
2551
|
+
add_section(Content.styled(header, colors.error), block.body)
|
|
2552
|
+
else: # JsEvalResult
|
|
2553
|
+
label = "result (handle)" if block.kind else "result"
|
|
2554
|
+
add_section(Content.styled(label, colors.success), block.body)
|
|
2555
|
+
|
|
2556
|
+
content = Content("\n").join(lines) if lines else Content("")
|
|
2557
|
+
truncation = self._build_js_eval_truncation_hint(
|
|
2558
|
+
blocks=blocks,
|
|
2559
|
+
shown_lines=total_lines,
|
|
2560
|
+
clipped_chars=clipped_chars,
|
|
2561
|
+
is_preview=is_preview,
|
|
2562
|
+
)
|
|
2563
|
+
return FormattedOutput(content=content, truncation=truncation)
|
|
2564
|
+
|
|
2565
|
+
@staticmethod
|
|
2566
|
+
def _build_js_eval_truncation_hint(
|
|
2567
|
+
*,
|
|
2568
|
+
blocks: list[JsEvalBlock],
|
|
2569
|
+
shown_lines: int,
|
|
2570
|
+
clipped_chars: int,
|
|
2571
|
+
is_preview: bool,
|
|
2572
|
+
) -> str | None:
|
|
2573
|
+
"""Quantify how much `js_eval` preview content was hidden.
|
|
2574
|
+
|
|
2575
|
+
Prefers a hidden-line count over a hidden-char count (mirroring
|
|
2576
|
+
`_build_truncation_hint`): when whole sections were dropped, "N more
|
|
2577
|
+
lines" is the more useful signal; a lone clipped body line reports the
|
|
2578
|
+
chars it lost.
|
|
2579
|
+
|
|
2580
|
+
Args:
|
|
2581
|
+
blocks: The parsed blocks, used to compute the full (untruncated)
|
|
2582
|
+
display-line count.
|
|
2583
|
+
shown_lines: Display lines actually emitted into the preview.
|
|
2584
|
+
clipped_chars: Chars dropped from a single clipped body line, if any.
|
|
2585
|
+
is_preview: Whether this is preview rendering; full renders never
|
|
2586
|
+
truncate.
|
|
2587
|
+
|
|
2588
|
+
Returns:
|
|
2589
|
+
A hint string for the UI, or `None` when nothing was hidden.
|
|
2590
|
+
"""
|
|
2591
|
+
if not is_preview:
|
|
2592
|
+
return None
|
|
2593
|
+
# Each block renders as one label line plus its body lines; an empty
|
|
2594
|
+
# body still occupies one (blank) line.
|
|
2595
|
+
full_lines = sum(
|
|
2596
|
+
1 + (len(block.body.split("\n")) if block.body else 1) for block in blocks
|
|
2597
|
+
)
|
|
2598
|
+
hidden_lines = full_lines - shown_lines
|
|
2599
|
+
if hidden_lines > 0:
|
|
2600
|
+
return f"{hidden_lines} more lines"
|
|
2601
|
+
if clipped_chars > 0:
|
|
2602
|
+
return f"{clipped_chars} more chars"
|
|
2603
|
+
return None
|
|
2604
|
+
|
|
2605
|
+
def _format_web_output(
|
|
2606
|
+
self, output: str, *, is_preview: bool = False
|
|
2607
|
+
) -> FormattedOutput:
|
|
2608
|
+
"""Format web_search/fetch_url output.
|
|
2609
|
+
|
|
2610
|
+
Returns:
|
|
2611
|
+
FormattedOutput with web response and optional truncation info.
|
|
2612
|
+
"""
|
|
2613
|
+
data = self._try_parse_web_data(output)
|
|
2614
|
+
if isinstance(data, dict):
|
|
2615
|
+
return self._format_web_dict(data, is_preview=is_preview)
|
|
2616
|
+
|
|
2617
|
+
# Fallback: plain text
|
|
2618
|
+
return self._format_lines_output(output.split("\n"), is_preview=is_preview)
|
|
2619
|
+
|
|
2620
|
+
@staticmethod
|
|
2621
|
+
def _try_parse_web_data(output: str) -> dict | None:
|
|
2622
|
+
"""Try to parse web output as JSON or dict.
|
|
2623
|
+
|
|
2624
|
+
Returns:
|
|
2625
|
+
Parsed dict if successful, None otherwise.
|
|
2626
|
+
"""
|
|
2627
|
+
try:
|
|
2628
|
+
if output.strip().startswith("{"):
|
|
2629
|
+
return json.loads(output)
|
|
2630
|
+
return ast.literal_eval(output)
|
|
2631
|
+
except (ValueError, SyntaxError, json.JSONDecodeError):
|
|
2632
|
+
return None
|
|
2633
|
+
|
|
2634
|
+
def _format_web_dict(self, data: dict, *, is_preview: bool) -> FormattedOutput:
|
|
2635
|
+
"""Format a parsed web response dict.
|
|
2636
|
+
|
|
2637
|
+
Returns:
|
|
2638
|
+
FormattedOutput with web response content and optional truncation info.
|
|
2639
|
+
"""
|
|
2640
|
+
# Handle web_search results
|
|
2641
|
+
if "results" in data:
|
|
2642
|
+
return self._format_web_search_results(
|
|
2643
|
+
data.get("results", []), is_preview=is_preview
|
|
2644
|
+
)
|
|
2645
|
+
|
|
2646
|
+
# Handle fetch_url response
|
|
2647
|
+
if "markdown_content" in data:
|
|
2648
|
+
lines = data["markdown_content"].split("\n")
|
|
2649
|
+
return self._format_lines_output(lines, is_preview=is_preview)
|
|
2650
|
+
|
|
2651
|
+
# Generic dict - show key fields
|
|
2652
|
+
parts: list[Content] = []
|
|
2653
|
+
max_keys = 3 if is_preview else len(data)
|
|
2654
|
+
for k, v in list(data.items())[:max_keys]:
|
|
2655
|
+
v_str = str(v)
|
|
2656
|
+
if is_preview and len(v_str) > _MAX_WEB_CONTENT_LEN:
|
|
2657
|
+
v_str = v_str[:_MAX_WEB_CONTENT_LEN] + "..."
|
|
2658
|
+
parts.append(Content(f" {k}: {v_str}"))
|
|
2659
|
+
truncation = None
|
|
2660
|
+
if is_preview and len(data) > max_keys:
|
|
2661
|
+
truncation = f"{len(data) - max_keys} more"
|
|
2662
|
+
return FormattedOutput(
|
|
2663
|
+
content=Content("\n").join(parts) if parts else Content(""),
|
|
2664
|
+
truncation=truncation,
|
|
2665
|
+
)
|
|
2666
|
+
|
|
2667
|
+
def _format_web_search_results( # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
2668
|
+
self, results: list, *, is_preview: bool
|
|
2669
|
+
) -> FormattedOutput:
|
|
2670
|
+
"""Format web search results.
|
|
2671
|
+
|
|
2672
|
+
Returns:
|
|
2673
|
+
FormattedOutput with search results and optional truncation info.
|
|
2674
|
+
"""
|
|
2675
|
+
if not results:
|
|
2676
|
+
return FormattedOutput(content=Content.styled("No results", "dim"))
|
|
2677
|
+
parts: list[Content] = []
|
|
2678
|
+
max_results = 3 if is_preview else len(results)
|
|
2679
|
+
for r in results[:max_results]:
|
|
2680
|
+
title = r.get("title", "")
|
|
2681
|
+
url = r.get("url", "")
|
|
2682
|
+
parts.extend(
|
|
2683
|
+
[
|
|
2684
|
+
Content.styled(f" {title}", "bold"),
|
|
2685
|
+
Content.styled(f" {url}", "dim"),
|
|
2686
|
+
]
|
|
2687
|
+
)
|
|
2688
|
+
truncation = None
|
|
2689
|
+
if is_preview and len(results) > max_results:
|
|
2690
|
+
truncation = f"{len(results) - max_results} more results"
|
|
2691
|
+
return FormattedOutput(content=Content("\n").join(parts), truncation=truncation)
|
|
2692
|
+
|
|
2693
|
+
def _format_lines_output( # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
2694
|
+
self, lines: list[str], *, is_preview: bool
|
|
2695
|
+
) -> FormattedOutput:
|
|
2696
|
+
"""Format a list of lines with optional preview truncation.
|
|
2697
|
+
|
|
2698
|
+
Returns:
|
|
2699
|
+
FormattedOutput with lines content and optional truncation info.
|
|
2700
|
+
"""
|
|
2701
|
+
max_lines = 4 if is_preview else len(lines)
|
|
2702
|
+
parts = [Content(line) for line in lines[:max_lines]]
|
|
2703
|
+
content = Content("\n").join(parts) if parts else Content("")
|
|
2704
|
+
truncation = None
|
|
2705
|
+
if is_preview and len(lines) > max_lines:
|
|
2706
|
+
truncation = f"{len(lines) - max_lines} more lines"
|
|
2707
|
+
return FormattedOutput(content=content, truncation=truncation)
|
|
2708
|
+
|
|
2709
|
+
def _format_task_output( # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
2710
|
+
self, output: str, *, is_preview: bool = False
|
|
2711
|
+
) -> FormattedOutput:
|
|
2712
|
+
"""Format task (subagent) output.
|
|
2713
|
+
|
|
2714
|
+
Returns:
|
|
2715
|
+
FormattedOutput with task output and optional truncation info.
|
|
2716
|
+
"""
|
|
2717
|
+
lines = output.split("\n")
|
|
2718
|
+
max_lines = 4 if is_preview else len(lines)
|
|
2719
|
+
|
|
2720
|
+
parts = [Content(line) for line in lines[:max_lines]]
|
|
2721
|
+
content = Content("\n").join(parts) if parts else Content("")
|
|
2722
|
+
|
|
2723
|
+
truncation = None
|
|
2724
|
+
if is_preview and len(lines) > max_lines:
|
|
2725
|
+
truncation = f"{len(lines) - max_lines} more lines"
|
|
2726
|
+
|
|
2727
|
+
return FormattedOutput(content=content, truncation=truncation)
|
|
2728
|
+
|
|
2729
|
+
def _update_output_display(self) -> None:
|
|
2730
|
+
"""Update the output display based on expanded state."""
|
|
2731
|
+
# Guard: all widgets must be initialized before updating display state
|
|
2732
|
+
if (
|
|
2733
|
+
not self._output
|
|
2734
|
+
or not self._preview_widget
|
|
2735
|
+
or not self._preview_row
|
|
2736
|
+
or not self._full_widget
|
|
2737
|
+
or not self._full_row
|
|
2738
|
+
or not self._hint_widget
|
|
2739
|
+
):
|
|
2740
|
+
return
|
|
2741
|
+
|
|
2742
|
+
output_stripped = self._output.strip()
|
|
2743
|
+
lines = output_stripped.split("\n")
|
|
2744
|
+
total_lines = len(lines)
|
|
2745
|
+
total_chars = len(output_stripped)
|
|
2746
|
+
|
|
2747
|
+
# Truncate if too many lines OR too many characters
|
|
2748
|
+
needs_truncation = (
|
|
2749
|
+
total_lines > self._PREVIEW_LINES or total_chars > self._PREVIEW_CHARS
|
|
2750
|
+
)
|
|
2751
|
+
|
|
2752
|
+
# Some output is a non-empty raw string that the formatter renders as no
|
|
2753
|
+
# visible content — all whitespace, or a serialized empty collection like
|
|
2754
|
+
# `[]`. The raw `_output` is truthy, so the early-return guard at the top
|
|
2755
|
+
# of this method doesn't catch it, but rendering it would show an empty
|
|
2756
|
+
# box with a misleading expand affordance. Treat it like empty output and
|
|
2757
|
+
# render nothing. (A search that found nothing is not this case: grep/glob
|
|
2758
|
+
# return a human-readable "No matches found" / "No files found" that
|
|
2759
|
+
# formats non-empty and renders inline; see the collapse branch below.)
|
|
2760
|
+
# This also subsumes the all-whitespace case, so the collapsed branch
|
|
2761
|
+
# below no longer needs its own empty guard.
|
|
2762
|
+
#
|
|
2763
|
+
# This fires for errors too, but never hides one: a real error body is
|
|
2764
|
+
# human-readable text that formats non-empty (and execute errors keep
|
|
2765
|
+
# the `$ command` echo), so it only triggers on a body that has nothing
|
|
2766
|
+
# to render anyway. The "error" status badge stays visible regardless.
|
|
2767
|
+
full = self._format_output(self._output, is_preview=False)
|
|
2768
|
+
if not full.content.plain.strip():
|
|
2769
|
+
self._preview_row.display = False
|
|
2770
|
+
self._full_row.display = False
|
|
2771
|
+
self._hint_widget.display = False
|
|
2772
|
+
return
|
|
2773
|
+
|
|
2774
|
+
if self._expanded:
|
|
2775
|
+
# Show full output with formatting
|
|
2776
|
+
self._preview_row.display = False
|
|
2777
|
+
self._full_widget.update(full.content)
|
|
2778
|
+
self._full_row.display = True
|
|
2779
|
+
# Only offer a collapse affordance when collapsing would actually
|
|
2780
|
+
# hide something. Errors are force-expanded (see `set_error`), so a
|
|
2781
|
+
# short single-line error has no smaller collapsed form — showing
|
|
2782
|
+
# "click to collapse" there is misleading.
|
|
2783
|
+
if self._has_expandable_output():
|
|
2784
|
+
self._hint_widget.update(
|
|
2785
|
+
Content.styled(
|
|
2786
|
+
f"{self._output_hint_keys()} to collapse", "dim italic"
|
|
2787
|
+
)
|
|
2788
|
+
)
|
|
2789
|
+
self._hint_widget.display = True
|
|
2790
|
+
else:
|
|
2791
|
+
self._hint_widget.display = False
|
|
2792
|
+
else:
|
|
2793
|
+
# Show collapsed preview
|
|
2794
|
+
self._full_row.display = False
|
|
2795
|
+
# `read_file` echoes the file the agent read, grep/glob echo the
|
|
2796
|
+
# matches for a pattern the header already names, and `edit_file`
|
|
2797
|
+
# success output repeats the status/diff — so the body is noise by
|
|
2798
|
+
# default. Collapse it entirely (no preview) while keeping the
|
|
2799
|
+
# original output expandable for when the user does want to see it.
|
|
2800
|
+
# A grep/glob that found nothing is excluded: its terminal "No
|
|
2801
|
+
# matches/files found" message is the whole result, so it renders
|
|
2802
|
+
# inline rather than hiding behind an expand click.
|
|
2803
|
+
if not self._is_search_no_result_output(self._output) and (
|
|
2804
|
+
self._tool_name in _COLLAPSE_OUTPUT_BY_DEFAULT
|
|
2805
|
+
or (self._tool_name == "edit_file" and self._status == "success")
|
|
2806
|
+
):
|
|
2807
|
+
self._preview_row.display = False
|
|
2808
|
+
ellipsis = get_glyphs().ellipsis
|
|
2809
|
+
self._hint_widget.update(
|
|
2810
|
+
Content.styled(
|
|
2811
|
+
f"{ellipsis} {self._output_hint_keys()} to expand", "dim"
|
|
2812
|
+
)
|
|
2813
|
+
)
|
|
2814
|
+
self._hint_widget.display = True
|
|
2815
|
+
return
|
|
2816
|
+
# Truncate the preview only when the output is large enough to
|
|
2817
|
+
# warrant it; `write_todos` always uses its compact per-item preview
|
|
2818
|
+
# regardless of size.
|
|
2819
|
+
is_preview = needs_truncation or self._tool_name == "write_todos"
|
|
2820
|
+
# Pass the raw output, not `output_stripped`: `_format_output`
|
|
2821
|
+
# normalizes whitespace while preserving the first line's leading
|
|
2822
|
+
# indentation. Pre-stripping here flattens that indent on line 0 only,
|
|
2823
|
+
# misaligning uniformly indented output (e.g. `git branch -r`). The
|
|
2824
|
+
# expanded branch above already passes raw `self._output`.
|
|
2825
|
+
result = self._format_output(self._output, is_preview=is_preview)
|
|
2826
|
+
self._preview_widget.update(result.content)
|
|
2827
|
+
self._preview_row.display = True
|
|
2828
|
+
|
|
2829
|
+
# Offer expansion only when the formatter actually hid content.
|
|
2830
|
+
# The raw size threshold can trip without anything being hidden, and
|
|
2831
|
+
# promising an expansion that reveals nothing is misleading.
|
|
2832
|
+
if result.truncation:
|
|
2833
|
+
ellipsis = get_glyphs().ellipsis
|
|
2834
|
+
self._hint_widget.update(
|
|
2835
|
+
Content.styled(
|
|
2836
|
+
f"{ellipsis} {result.truncation} — "
|
|
2837
|
+
f"{self._output_hint_keys()} to expand",
|
|
2838
|
+
"dim",
|
|
2839
|
+
)
|
|
2840
|
+
)
|
|
2841
|
+
self._hint_widget.display = True
|
|
2842
|
+
else:
|
|
2843
|
+
self._hint_widget.display = False
|
|
2844
|
+
|
|
2845
|
+
def _output_hint_keys(self) -> str:
|
|
2846
|
+
"""Affordances to advertise in the output expand/collapse hint.
|
|
2847
|
+
|
|
2848
|
+
Ctrl+O routes to the collapsible command/code block whenever this row
|
|
2849
|
+
has one (see `action_toggle_tool_output`), so the output hint only
|
|
2850
|
+
advertises Ctrl+O when Ctrl+O would actually toggle the *output*. When a
|
|
2851
|
+
command/code block is present the output is reachable by clicking its
|
|
2852
|
+
own region instead.
|
|
2853
|
+
|
|
2854
|
+
Returns:
|
|
2855
|
+
`"click"` when an expandable command/code block owns Ctrl+O,
|
|
2856
|
+
otherwise `"click or Ctrl+O"`.
|
|
2857
|
+
"""
|
|
2858
|
+
return "click" if self.has_expandable_args else "click or Ctrl+O"
|
|
2859
|
+
|
|
2860
|
+
@property
|
|
2861
|
+
def has_output(self) -> bool:
|
|
2862
|
+
"""Check if this tool message has output to display.
|
|
2863
|
+
|
|
2864
|
+
Returns:
|
|
2865
|
+
True if there is output content, False otherwise.
|
|
2866
|
+
"""
|
|
2867
|
+
return bool(self._output)
|
|
2868
|
+
|
|
2869
|
+
@property
|
|
2870
|
+
def tool_name(self) -> str:
|
|
2871
|
+
"""Public read-only accessor for the underlying tool name."""
|
|
2872
|
+
return self._tool_name
|
|
2873
|
+
|
|
2874
|
+
@property
|
|
2875
|
+
def args(self) -> dict[str, Any]:
|
|
2876
|
+
"""Public read-only accessor for the parsed tool-call arguments.
|
|
2877
|
+
|
|
2878
|
+
Returns a shallow copy so a consumer (e.g. a hook payload built from
|
|
2879
|
+
`args`) cannot rebind the widget's top-level keys by reference. Nested
|
|
2880
|
+
mutable values are shared, not deep-copied, so callers must treat them as
|
|
2881
|
+
read-only and must not deep-mutate a returned nested value.
|
|
2882
|
+
"""
|
|
2883
|
+
return dict(self._args)
|
|
2884
|
+
|
|
2885
|
+
@property
|
|
2886
|
+
def is_success(self) -> bool:
|
|
2887
|
+
"""Whether the tool completed successfully."""
|
|
2888
|
+
return self._status == "success"
|
|
2889
|
+
|
|
2890
|
+
@property
|
|
2891
|
+
def is_failed(self) -> bool:
|
|
2892
|
+
"""Whether the tool did not succeed and should stay visible.
|
|
2893
|
+
|
|
2894
|
+
Covers errored, rejected, and skipped tools. `skipped` is included so a
|
|
2895
|
+
reject-cascade (one tool rejected, the rest skipped) keeps the skipped
|
|
2896
|
+
rows visible and out of the group's success count, matching how
|
|
2897
|
+
`_regroup_completed_tools` treats a hydrated transcript.
|
|
2898
|
+
"""
|
|
2899
|
+
return self._status in {"error", "rejected", "skipped"}
|
|
2900
|
+
|
|
2901
|
+
@property
|
|
2902
|
+
def is_pending(self) -> bool:
|
|
2903
|
+
"""Whether the tool has not finished (awaiting approval or running)."""
|
|
2904
|
+
return self._status in {"pending", "running"}
|
|
2905
|
+
|
|
2906
|
+
@property
|
|
2907
|
+
def has_expandable_args(self) -> bool:
|
|
2908
|
+
"""Whether the tool's args are large enough to deserve a collapsible block.
|
|
2909
|
+
|
|
2910
|
+
- `ask_user`: its `questions` payload is too noisy to render inline.
|
|
2911
|
+
- `js_eval`: the header shows only the first code line (truncated at
|
|
2912
|
+
`JS_EVAL_HEADER_MAX_LENGTH`), so the full program is offered as a
|
|
2913
|
+
collapsible block whenever it spans more than one non-blank line *or*
|
|
2914
|
+
a single line is long enough to be truncated in the header.
|
|
2915
|
+
- `execute`: the header truncates the shell command at
|
|
2916
|
+
`EXECUTE_HEADER_MAX_LENGTH`, so the full command is offered as a
|
|
2917
|
+
collapsible block when the command, after stripping surrounding
|
|
2918
|
+
whitespace, is longer than `EXECUTE_HEADER_MAX_LENGTH`.
|
|
2919
|
+
"""
|
|
2920
|
+
if self._tool_name == "ask_user":
|
|
2921
|
+
return bool(self._args)
|
|
2922
|
+
if self._tool_name == "js_eval":
|
|
2923
|
+
code = self._args.get("code")
|
|
2924
|
+
if isinstance(code, str) and code.strip():
|
|
2925
|
+
non_blank = sum(1 for line in code.splitlines() if line.strip())
|
|
2926
|
+
return non_blank > 1 or len(code.strip()) > JS_EVAL_HEADER_MAX_LENGTH
|
|
2927
|
+
if self._tool_name == "execute":
|
|
2928
|
+
command = self._args.get("command")
|
|
2929
|
+
if isinstance(command, str) and command.strip():
|
|
2930
|
+
return len(command.strip()) > EXECUTE_HEADER_MAX_LENGTH
|
|
2931
|
+
return False
|
|
2932
|
+
|
|
2933
|
+
def _format_code_detail(self) -> Content:
|
|
2934
|
+
"""Render the `js_eval` program for the collapsible code block.
|
|
2935
|
+
|
|
2936
|
+
The code is shown verbatim and left-aligned (its own indentation is the
|
|
2937
|
+
only indentation), as plain uncolored `Content`. Blank lines of
|
|
2938
|
+
top/bottom padding add breathing room between the `js_eval` header above
|
|
2939
|
+
and the "show/hide code" hint below.
|
|
2940
|
+
|
|
2941
|
+
Returns:
|
|
2942
|
+
A plain `Content` renderable with a blank line of padding on
|
|
2943
|
+
top and bottom.
|
|
2944
|
+
"""
|
|
2945
|
+
code = self._args.get("code")
|
|
2946
|
+
code_str = code.strip("\n") if isinstance(code, str) else str(code)
|
|
2947
|
+
code_str = render_with_unicode_markers(code_str)
|
|
2948
|
+
|
|
2949
|
+
# Blank lines of top/bottom padding separate the block from the header
|
|
2950
|
+
# line above and the "show/hide code" hint below.
|
|
2951
|
+
return Content("\n").join((Content(""), Content(code_str), Content("")))
|
|
2952
|
+
|
|
2953
|
+
def _format_command_detail(self) -> Content:
|
|
2954
|
+
"""Render the full `execute` command for the collapsible block.
|
|
2955
|
+
|
|
2956
|
+
The command is shown verbatim and left-aligned, as plain uncolored
|
|
2957
|
+
`Content`, mirroring `_format_code_detail`. Hidden/deceptive Unicode is
|
|
2958
|
+
rendered as visible markers so a truncated header can't conceal it.
|
|
2959
|
+
|
|
2960
|
+
Returns:
|
|
2961
|
+
A plain `Content` renderable with a blank line of padding on
|
|
2962
|
+
top and bottom.
|
|
2963
|
+
"""
|
|
2964
|
+
command = self._args.get("command")
|
|
2965
|
+
command_str = command.strip("\n") if isinstance(command, str) else str(command)
|
|
2966
|
+
command_str = render_with_unicode_markers(command_str)
|
|
2967
|
+
return Content("\n").join((Content(""), Content(command_str), Content("")))
|
|
2968
|
+
|
|
2969
|
+
def _format_args_detail(self) -> Content:
|
|
2970
|
+
"""Render tool arguments as an indented `Content` block.
|
|
2971
|
+
|
|
2972
|
+
Renders JSON-pretty-printed args, falling back to `str(self._args)`
|
|
2973
|
+
(with a visible marker) when JSON serialization fails — `default=str`
|
|
2974
|
+
already handles most non-serializable values, so reaching the fallback
|
|
2975
|
+
indicates a deeper issue worth logging. `js_eval` code is handled
|
|
2976
|
+
separately by `_format_code_detail`.
|
|
2977
|
+
|
|
2978
|
+
Returns:
|
|
2979
|
+
Indented `Content` containing JSON-pretty-printed arguments, or a
|
|
2980
|
+
marked fallback rendering on serialization failure.
|
|
2981
|
+
"""
|
|
2982
|
+
try:
|
|
2983
|
+
text = json.dumps(self._args, ensure_ascii=False, indent=2, default=str)
|
|
2984
|
+
except (TypeError, ValueError) as exc:
|
|
2985
|
+
logger.warning(
|
|
2986
|
+
"ask_user args not JSON-serializable; using repr fallback: %r", exc
|
|
2987
|
+
)
|
|
2988
|
+
text = f"# (fallback rendering)\n{self._args!s}"
|
|
2989
|
+
lines = Content(text).split("\n")
|
|
2990
|
+
return Content("\n").join(Content.assemble(" ", line) for line in lines)
|
|
2991
|
+
|
|
2992
|
+
def _update_args_display(self) -> None:
|
|
2993
|
+
"""Update the collapsed/expanded argument display."""
|
|
2994
|
+
if self._args_widget is None or self._args_hint_widget is None:
|
|
2995
|
+
# Toggle invoked before on_mount cached the refs; log so a regression
|
|
2996
|
+
# that nulls them out post-mount doesn't appear as a silent no-op.
|
|
2997
|
+
logger.debug("_update_args_display called before widget refs are cached")
|
|
2998
|
+
return
|
|
2999
|
+
|
|
3000
|
+
if not self.has_expandable_args:
|
|
3001
|
+
self._args_widget.display = False
|
|
3002
|
+
self._args_hint_widget.display = False
|
|
3003
|
+
return
|
|
3004
|
+
|
|
3005
|
+
if self._tool_name == "js_eval":
|
|
3006
|
+
noun, detail_fn = "code", self._format_code_detail
|
|
3007
|
+
elif self._tool_name == "execute":
|
|
3008
|
+
noun, detail_fn = "command", self._format_command_detail
|
|
3009
|
+
else:
|
|
3010
|
+
noun, detail_fn = "arguments", self._format_args_detail
|
|
3011
|
+
if self._args_expanded:
|
|
3012
|
+
self._args_widget.update(detail_fn())
|
|
3013
|
+
self._args_widget.display = True
|
|
3014
|
+
self._args_hint_widget.update(
|
|
3015
|
+
Content.styled(f"click or Ctrl+O to hide {noun}", "dim italic")
|
|
3016
|
+
)
|
|
3017
|
+
else:
|
|
3018
|
+
self._args_widget.display = False
|
|
3019
|
+
self._args_hint_widget.update(
|
|
3020
|
+
Content.styled(f"click or Ctrl+O to show {noun}", "dim italic")
|
|
3021
|
+
)
|
|
3022
|
+
self._args_hint_widget.display = True
|
|
3023
|
+
|
|
3024
|
+
def _filtered_args(self) -> dict[str, Any]:
|
|
3025
|
+
"""Filter large tool args for display.
|
|
3026
|
+
|
|
3027
|
+
Returns:
|
|
3028
|
+
Filtered args dict with only display-relevant keys for write/edit tools.
|
|
3029
|
+
"""
|
|
3030
|
+
if self._tool_name not in {"write_file", "edit_file"}:
|
|
3031
|
+
return self._args
|
|
3032
|
+
|
|
3033
|
+
filtered: dict[str, Any] = {}
|
|
3034
|
+
for key in ("file_path", "path", "replace_all"):
|
|
3035
|
+
if key in self._args:
|
|
3036
|
+
filtered[key] = self._args[key]
|
|
3037
|
+
return filtered
|
|
3038
|
+
|
|
3039
|
+
|
|
3040
|
+
# Maps a tool name to the summary category it aggregates under. grep/glob share
|
|
3041
|
+
# "search" so a mixed run folds into a single "Searched for N patterns" segment.
|
|
3042
|
+
_TOOL_SUMMARY_CATEGORY: dict[str, str] = {
|
|
3043
|
+
"read_file": "read",
|
|
3044
|
+
"write_file": "write",
|
|
3045
|
+
"edit_file": "edit",
|
|
3046
|
+
"delete": "delete",
|
|
3047
|
+
"ls": "ls",
|
|
3048
|
+
"grep": "search",
|
|
3049
|
+
"glob": "search",
|
|
3050
|
+
"execute": "shell",
|
|
3051
|
+
"js_eval": "js",
|
|
3052
|
+
"web_search": "web_search",
|
|
3053
|
+
"fetch_url": "fetch",
|
|
3054
|
+
"task": "task",
|
|
3055
|
+
"write_todos": "todos",
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
# category -> (present verb, past verb, singular noun, plural noun).
|
|
3059
|
+
_TOOL_SUMMARY_PHRASES: dict[str, tuple[str, str, str, str]] = {
|
|
3060
|
+
"read": ("Reading", "Read", "file", "files"),
|
|
3061
|
+
"write": ("Writing", "Wrote", "file", "files"),
|
|
3062
|
+
"edit": ("Editing", "Edited", "file", "files"),
|
|
3063
|
+
"delete": ("Deleting", "Deleted", "file", "files"),
|
|
3064
|
+
"ls": ("Listing", "Listed", "directory", "directories"),
|
|
3065
|
+
"search": ("Searching for", "Searched for", "pattern", "patterns"),
|
|
3066
|
+
"shell": ("Running", "Ran", "shell command", "shell commands"),
|
|
3067
|
+
"js": ("Running", "Ran", "JS evaluation", "JS evaluations"),
|
|
3068
|
+
"fetch": ("Fetching", "Fetched", "URL", "URLs"),
|
|
3069
|
+
"task": ("Running", "Ran", "agent", "agents"),
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
_Tense = Literal["present", "past"]
|
|
3073
|
+
|
|
3074
|
+
|
|
3075
|
+
def _summary_segment(category: str, count: int, tool_name: str, tense: _Tense) -> str:
|
|
3076
|
+
"""Phrase a single count segment, e.g. "Read 2 files" / "Reading 2 files".
|
|
3077
|
+
|
|
3078
|
+
Args:
|
|
3079
|
+
category: The summary category the tools were bucketed into.
|
|
3080
|
+
count: How many tools fell into this category.
|
|
3081
|
+
tool_name: A representative raw tool name, used to phrase categories
|
|
3082
|
+
that have no dedicated entry in `_TOOL_SUMMARY_PHRASES`.
|
|
3083
|
+
tense: Whether to phrase the segment in the present or past tense.
|
|
3084
|
+
|
|
3085
|
+
Returns:
|
|
3086
|
+
The phrased segment for this category, count, and tense.
|
|
3087
|
+
"""
|
|
3088
|
+
if category == "web_search":
|
|
3089
|
+
base = "Searching the web" if tense == "present" else "Searched the web"
|
|
3090
|
+
return base if count == 1 else f"{base} {count} times"
|
|
3091
|
+
if category == "todos":
|
|
3092
|
+
return "Updating todos" if tense == "present" else "Updated todos"
|
|
3093
|
+
phrase = _TOOL_SUMMARY_PHRASES.get(category)
|
|
3094
|
+
if phrase is None:
|
|
3095
|
+
present, past = "Running", "Ran"
|
|
3096
|
+
singular, plural = f"{tool_name} call", f"{tool_name} calls"
|
|
3097
|
+
else:
|
|
3098
|
+
present, past, singular, plural = phrase
|
|
3099
|
+
verb = present if tense == "present" else past
|
|
3100
|
+
noun = singular if count == 1 else plural
|
|
3101
|
+
return f"{verb} {count} {noun}"
|
|
3102
|
+
|
|
3103
|
+
|
|
3104
|
+
def summarize_tool_group(tool_names: list[str], *, tense: _Tense = "past") -> str:
|
|
3105
|
+
"""Build a one-line summary of a run of tool calls.
|
|
3106
|
+
|
|
3107
|
+
Aggregates by category in first-appearance order and lowercases the lead
|
|
3108
|
+
word of every segment after the first, e.g.
|
|
3109
|
+
`["read_file", "read_file", "execute"]` -> "Read 2 files, ran 1 shell command".
|
|
3110
|
+
|
|
3111
|
+
Args:
|
|
3112
|
+
tool_names: Raw tool names for the run, in call order.
|
|
3113
|
+
tense: Whether to phrase the summary in the present or past tense.
|
|
3114
|
+
|
|
3115
|
+
Returns:
|
|
3116
|
+
The aggregated one-line summary string in the requested tense.
|
|
3117
|
+
"""
|
|
3118
|
+
counts: dict[str, int] = {}
|
|
3119
|
+
order: list[str] = []
|
|
3120
|
+
rep_name: dict[str, str] = {}
|
|
3121
|
+
for name in tool_names:
|
|
3122
|
+
category = _TOOL_SUMMARY_CATEGORY.get(name, name)
|
|
3123
|
+
if category not in counts:
|
|
3124
|
+
counts[category] = 0
|
|
3125
|
+
order.append(category)
|
|
3126
|
+
rep_name[category] = name
|
|
3127
|
+
counts[category] += 1
|
|
3128
|
+
|
|
3129
|
+
segments = [
|
|
3130
|
+
_summary_segment(cat, counts[cat], rep_name[cat], tense) for cat in order
|
|
3131
|
+
]
|
|
3132
|
+
if not segments:
|
|
3133
|
+
return "Running tools" if tense == "present" else "Ran tools"
|
|
3134
|
+
first, *rest = segments
|
|
3135
|
+
lowered = [f"{seg[0].lower()}{seg[1:]}" if seg else seg for seg in rest]
|
|
3136
|
+
return ", ".join([first, *lowered])
|
|
3137
|
+
|
|
3138
|
+
|
|
3139
|
+
class ToolGroupSummary(Static):
|
|
3140
|
+
"""Collapsed one-line stand-in for an assistant step's tool calls.
|
|
3141
|
+
|
|
3142
|
+
Tools are hidden from the moment they start; this single line shows live
|
|
3143
|
+
progress ("Running 1 shell command…") and flips to the past tense
|
|
3144
|
+
("Ran 1 shell command") once every tool finishes. Clicking the line or
|
|
3145
|
+
pressing Ctrl+O expands the underlying tool rows (and their diffs).
|
|
3146
|
+
|
|
3147
|
+
Two modes:
|
|
3148
|
+
|
|
3149
|
+
- **live** (streaming): created empty, members added via `add_member` as
|
|
3150
|
+
they mount, a spinner timer animates the line and re-renders present/past
|
|
3151
|
+
tense, and failed tools are ejected back into view so errors stay visible.
|
|
3152
|
+
- **finalized** (`live=False`, used for hydration/resume): a fixed set of
|
|
3153
|
+
completed tools rendered straight to the past tense with no timer.
|
|
3154
|
+
|
|
3155
|
+
Purely presentational — never tracked by the message store; it is re-derived
|
|
3156
|
+
from the mounted tool widgets on each stream boundary and on hydration.
|
|
3157
|
+
"""
|
|
3158
|
+
|
|
3159
|
+
DEFAULT_CSS = """
|
|
3160
|
+
ToolGroupSummary {
|
|
3161
|
+
height: auto;
|
|
3162
|
+
padding: 0 1;
|
|
3163
|
+
margin: 0 0 1 0;
|
|
3164
|
+
color: $text-muted;
|
|
3165
|
+
pointer: pointer;
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
ToolGroupSummary:hover {
|
|
3169
|
+
color: $text;
|
|
3170
|
+
}
|
|
3171
|
+
"""
|
|
3172
|
+
|
|
3173
|
+
_SPINNER_INTERVAL: ClassVar[float] = 0.1
|
|
3174
|
+
|
|
3175
|
+
_collapsed: var[bool] = var(False)
|
|
3176
|
+
"""User preference: default the group to expanded so every tool call in
|
|
3177
|
+
the run (`read_file(...)`, `grep(...)`, ...) is visible from the moment
|
|
3178
|
+
it mounts. The per-tool result body still honors its own collapse rules
|
|
3179
|
+
(`_COLLAPSE_OUTPUT_BY_DEFAULT`), so verbose output stays folded. Click the
|
|
3180
|
+
header or press Ctrl+O to re-collapse the whole group.
|
|
3181
|
+
"""
|
|
3182
|
+
|
|
3183
|
+
def __init__(
|
|
3184
|
+
self,
|
|
3185
|
+
tools: list[ToolCallMessage] | None = None,
|
|
3186
|
+
collapsible: list[Widget] | None = None,
|
|
3187
|
+
*,
|
|
3188
|
+
live: bool = False,
|
|
3189
|
+
**kwargs: Any,
|
|
3190
|
+
) -> None:
|
|
3191
|
+
"""Initialize the summary.
|
|
3192
|
+
|
|
3193
|
+
Args:
|
|
3194
|
+
tools: Tool widgets the summary aggregates (drives its text). May be
|
|
3195
|
+
empty for a live group that grows via `add_member`.
|
|
3196
|
+
collapsible: Every widget hidden/shown with the group, including the
|
|
3197
|
+
tool widgets and any interleaved diff previews.
|
|
3198
|
+
live: When True, animate progress and accept new members until
|
|
3199
|
+
`close`. When False, render a finalized past-tense summary.
|
|
3200
|
+
**kwargs: Additional arguments passed to `Static`.
|
|
3201
|
+
"""
|
|
3202
|
+
super().__init__("", **kwargs)
|
|
3203
|
+
self._tools = list(tools or [])
|
|
3204
|
+
self._collapsible = list(collapsible or [])
|
|
3205
|
+
self._finalized = not live
|
|
3206
|
+
self._spinner_pos = 0
|
|
3207
|
+
self._timer: Timer | None = None
|
|
3208
|
+
# Cached summary phrasing, rebuilt only when membership changes (not on
|
|
3209
|
+
# every spinner tick). None means "recompute on next render".
|
|
3210
|
+
self._present_text: str | None = None
|
|
3211
|
+
self._past_text: str | None = None
|
|
3212
|
+
|
|
3213
|
+
def on_mount(self) -> None:
|
|
3214
|
+
"""Apply initial visibility, render, and arm the spinner if live."""
|
|
3215
|
+
self._apply_visibility()
|
|
3216
|
+
self._render_line()
|
|
3217
|
+
self._sync_timer()
|
|
3218
|
+
|
|
3219
|
+
def add_member(self, tool: ToolCallMessage, *extra: Widget) -> None:
|
|
3220
|
+
"""Add a tool (and any associated widgets) to a live group."""
|
|
3221
|
+
tool.add_class("-grouped")
|
|
3222
|
+
self._tools.append(tool)
|
|
3223
|
+
self._collapsible.append(tool)
|
|
3224
|
+
for widget in extra:
|
|
3225
|
+
widget.add_class("-grouped")
|
|
3226
|
+
self._collapsible.append(widget)
|
|
3227
|
+
self._present_text = self._past_text = None
|
|
3228
|
+
self._apply_visibility()
|
|
3229
|
+
self._render_line()
|
|
3230
|
+
self._sync_timer()
|
|
3231
|
+
|
|
3232
|
+
def add_collapsible(self, widget: Widget) -> None:
|
|
3233
|
+
"""Attach a non-tool widget (e.g. a diff) to be folded with the group."""
|
|
3234
|
+
widget.add_class("-grouped")
|
|
3235
|
+
self._collapsible.append(widget)
|
|
3236
|
+
if widget.is_attached:
|
|
3237
|
+
widget.display = not self._collapsed
|
|
3238
|
+
|
|
3239
|
+
def close(self) -> None:
|
|
3240
|
+
"""Mark the group complete; no further members will join."""
|
|
3241
|
+
self._finalized = True
|
|
3242
|
+
self._evict_failed()
|
|
3243
|
+
self._stop_timer()
|
|
3244
|
+
if not self.is_attached:
|
|
3245
|
+
return
|
|
3246
|
+
if self._tools:
|
|
3247
|
+
self._render_line()
|
|
3248
|
+
else:
|
|
3249
|
+
# Every tool failed and was ejected — nothing left to summarize.
|
|
3250
|
+
self.remove()
|
|
3251
|
+
|
|
3252
|
+
@property
|
|
3253
|
+
def has_attached_members(self) -> bool:
|
|
3254
|
+
"""Whether any collapsed widget is still attached to the DOM."""
|
|
3255
|
+
return any(widget.is_attached for widget in self._collapsible)
|
|
3256
|
+
|
|
3257
|
+
def toggle(self) -> None:
|
|
3258
|
+
"""Toggle between collapsed and expanded."""
|
|
3259
|
+
self._collapsed = not self._collapsed
|
|
3260
|
+
|
|
3261
|
+
def watch__collapsed(self, _collapsed: bool) -> None:
|
|
3262
|
+
"""Re-render and re-apply member visibility when the state changes.
|
|
3263
|
+
|
|
3264
|
+
Coalesced into one repaint so expanding a multi-tool group reveals every
|
|
3265
|
+
row at once instead of bouncing the transcript per member.
|
|
3266
|
+
"""
|
|
3267
|
+
if not self.is_attached:
|
|
3268
|
+
self._apply_visibility()
|
|
3269
|
+
self._render_line()
|
|
3270
|
+
return
|
|
3271
|
+
with self.app.batch_update():
|
|
3272
|
+
self._apply_visibility()
|
|
3273
|
+
self._render_line()
|
|
3274
|
+
|
|
3275
|
+
def on_click(self, event: Click) -> None:
|
|
3276
|
+
"""Toggle the group on click."""
|
|
3277
|
+
event.stop()
|
|
3278
|
+
self.toggle()
|
|
3279
|
+
|
|
3280
|
+
def _in_progress(self) -> bool:
|
|
3281
|
+
"""Whether any member tool is still pending or running.
|
|
3282
|
+
|
|
3283
|
+
Returns:
|
|
3284
|
+
True if at least one member tool has not finished.
|
|
3285
|
+
"""
|
|
3286
|
+
return any(tool.is_pending for tool in self._tools)
|
|
3287
|
+
|
|
3288
|
+
def _evict_failed(self) -> None:
|
|
3289
|
+
"""Un-fold errored/rejected/skipped tools so non-successes stay visible."""
|
|
3290
|
+
failed = [t for t in self._tools if t.is_failed]
|
|
3291
|
+
if not failed:
|
|
3292
|
+
return
|
|
3293
|
+
for tool in failed:
|
|
3294
|
+
self._tools.remove(tool)
|
|
3295
|
+
if tool in self._collapsible:
|
|
3296
|
+
self._collapsible.remove(tool)
|
|
3297
|
+
tool.remove_class("-grouped")
|
|
3298
|
+
if tool.is_attached:
|
|
3299
|
+
tool.display = True
|
|
3300
|
+
self._present_text = self._past_text = None
|
|
3301
|
+
|
|
3302
|
+
def _sync_timer(self) -> None:
|
|
3303
|
+
"""Run the spinner timer only while live members are in progress."""
|
|
3304
|
+
if not self._finalized and self._in_progress():
|
|
3305
|
+
if self._timer is None:
|
|
3306
|
+
self._timer = self.set_interval(self._SPINNER_INTERVAL, self._tick)
|
|
3307
|
+
else:
|
|
3308
|
+
self._stop_timer()
|
|
3309
|
+
|
|
3310
|
+
def _stop_timer(self) -> None:
|
|
3311
|
+
if self._timer is not None:
|
|
3312
|
+
self._timer.stop()
|
|
3313
|
+
self._timer = None
|
|
3314
|
+
|
|
3315
|
+
def _tick(self) -> None:
|
|
3316
|
+
"""Advance the spinner, eject failures, and flip to past tense when done."""
|
|
3317
|
+
try:
|
|
3318
|
+
self._spinner_pos += 1
|
|
3319
|
+
before = len(self._tools)
|
|
3320
|
+
self._evict_failed()
|
|
3321
|
+
evicted = len(self._tools) != before
|
|
3322
|
+
if self._collapsed:
|
|
3323
|
+
# Re-assert hidden state in case a member was shown externally
|
|
3324
|
+
# (e.g. ToolCallMessage.clear_awaiting_approval after HITL).
|
|
3325
|
+
self._apply_visibility()
|
|
3326
|
+
if not self._tools:
|
|
3327
|
+
self._stop_timer()
|
|
3328
|
+
if self.is_attached:
|
|
3329
|
+
self.remove()
|
|
3330
|
+
return
|
|
3331
|
+
in_progress = self._in_progress()
|
|
3332
|
+
if not in_progress:
|
|
3333
|
+
self._stop_timer()
|
|
3334
|
+
# A bare spinner advance keeps the line height; only relayout when
|
|
3335
|
+
# membership changed (eviction) or the line flips to past tense.
|
|
3336
|
+
self._render_line(
|
|
3337
|
+
in_progress=in_progress, layout=evicted or not in_progress
|
|
3338
|
+
)
|
|
3339
|
+
except Exception:
|
|
3340
|
+
# Fires ~10x/second, so an unhandled raise would propagate out of the
|
|
3341
|
+
# interval callback and can crash the app repeatedly. The group is
|
|
3342
|
+
# purely presentational; stop animating and log rather than take the
|
|
3343
|
+
# transcript down.
|
|
3344
|
+
logger.exception("ToolGroupSummary spinner tick failed; stopping timer")
|
|
3345
|
+
self._stop_timer()
|
|
3346
|
+
|
|
3347
|
+
def _apply_visibility(self) -> None:
|
|
3348
|
+
"""Show or hide every folded widget per the collapsed state."""
|
|
3349
|
+
visible = not self._collapsed
|
|
3350
|
+
for widget in self._collapsible:
|
|
3351
|
+
if widget.is_attached and widget.display != visible:
|
|
3352
|
+
widget.display = visible
|
|
3353
|
+
|
|
3354
|
+
def _render_line(
|
|
3355
|
+
self, *, in_progress: bool | None = None, layout: bool = True
|
|
3356
|
+
) -> None:
|
|
3357
|
+
"""Refresh the summary line for the current tense and collapsed state.
|
|
3358
|
+
|
|
3359
|
+
Args:
|
|
3360
|
+
in_progress: Pre-computed progress state to avoid re-scanning members
|
|
3361
|
+
on the spinner hot path; recomputed when omitted.
|
|
3362
|
+
layout: Whether the update may change the line's height. The spinner
|
|
3363
|
+
hot path passes False so a bare glyph swap doesn't relayout the
|
|
3364
|
+
whole transcript 10x/second.
|
|
3365
|
+
"""
|
|
3366
|
+
if not self.is_attached:
|
|
3367
|
+
return
|
|
3368
|
+
if not self._tools:
|
|
3369
|
+
self.update(Content(""), layout=layout)
|
|
3370
|
+
return
|
|
3371
|
+
glyphs = get_glyphs()
|
|
3372
|
+
if in_progress is None:
|
|
3373
|
+
in_progress = self._in_progress()
|
|
3374
|
+
if not self._finalized and in_progress:
|
|
3375
|
+
if self._present_text is None:
|
|
3376
|
+
self._present_text = summarize_tool_group(
|
|
3377
|
+
[tool.tool_name for tool in self._tools], tense="present"
|
|
3378
|
+
)
|
|
3379
|
+
frames = glyphs.spinner_frames
|
|
3380
|
+
spinner = frames[self._spinner_pos % len(frames)]
|
|
3381
|
+
self.update(
|
|
3382
|
+
Content(f"{spinner} {self._present_text}{glyphs.ellipsis}"),
|
|
3383
|
+
layout=layout,
|
|
3384
|
+
)
|
|
3385
|
+
else:
|
|
3386
|
+
mark = (
|
|
3387
|
+
glyphs.disclosure_collapsed
|
|
3388
|
+
if self._collapsed
|
|
3389
|
+
else glyphs.disclosure_expanded
|
|
3390
|
+
)
|
|
3391
|
+
if self._past_text is None:
|
|
3392
|
+
self._past_text = summarize_tool_group(
|
|
3393
|
+
[tool.tool_name for tool in self._tools], tense="past"
|
|
3394
|
+
)
|
|
3395
|
+
self.update(Content(f"{mark} {self._past_text}"), layout=layout)
|
|
3396
|
+
|
|
3397
|
+
|
|
3398
|
+
class DiffMessage(Static):
|
|
3399
|
+
"""Widget displaying a diff with syntax highlighting."""
|
|
3400
|
+
|
|
3401
|
+
DEFAULT_CSS = """
|
|
3402
|
+
DiffMessage {
|
|
3403
|
+
height: auto;
|
|
3404
|
+
padding: 1;
|
|
3405
|
+
margin: 0 0 1 0;
|
|
3406
|
+
background: $surface;
|
|
3407
|
+
border: solid $primary;
|
|
3408
|
+
pointer: text;
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
DiffMessage .diff-header {
|
|
3412
|
+
text-style: bold;
|
|
3413
|
+
margin-bottom: 1;
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
DiffMessage .diff-add {
|
|
3417
|
+
color: $text-success;
|
|
3418
|
+
background: $success-muted;
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
DiffMessage .diff-remove {
|
|
3422
|
+
color: $text-error;
|
|
3423
|
+
background: $error-muted;
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
DiffMessage .diff-context {
|
|
3427
|
+
color: $text-muted;
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
DiffMessage .diff-hunk {
|
|
3431
|
+
color: $secondary;
|
|
3432
|
+
text-style: bold;
|
|
3433
|
+
}
|
|
3434
|
+
"""
|
|
3435
|
+
"""Diff syntax coloring per theme: additions, removals, muted context."""
|
|
3436
|
+
|
|
3437
|
+
def __init__(
|
|
3438
|
+
self,
|
|
3439
|
+
diff_content: str,
|
|
3440
|
+
file_path: str = "",
|
|
3441
|
+
*,
|
|
3442
|
+
tool_name: str | None = None,
|
|
3443
|
+
**kwargs: Any,
|
|
3444
|
+
) -> None:
|
|
3445
|
+
"""Initialize a diff message.
|
|
3446
|
+
|
|
3447
|
+
Args:
|
|
3448
|
+
diff_content: The unified diff content
|
|
3449
|
+
file_path: Path to the file being modified
|
|
3450
|
+
tool_name: Name of the file tool that produced the diff
|
|
3451
|
+
**kwargs: Additional arguments passed to parent
|
|
3452
|
+
"""
|
|
3453
|
+
super().__init__(**kwargs)
|
|
3454
|
+
self._diff_content = diff_content
|
|
3455
|
+
self._file_path = file_path
|
|
3456
|
+
self._tool_name = tool_name
|
|
3457
|
+
|
|
3458
|
+
def compose(self) -> ComposeResult:
|
|
3459
|
+
"""Compose the diff message layout.
|
|
3460
|
+
|
|
3461
|
+
Yields:
|
|
3462
|
+
Widgets displaying the diff header and formatted content.
|
|
3463
|
+
"""
|
|
3464
|
+
if self._file_path:
|
|
3465
|
+
yield Static(
|
|
3466
|
+
Content.from_markup("[bold]File: $path[/bold]", path=self._file_path),
|
|
3467
|
+
classes="diff-header",
|
|
3468
|
+
)
|
|
3469
|
+
|
|
3470
|
+
# Never render the contents of credential files (e.g. `.env`) — the diff
|
|
3471
|
+
# would leak secrets into the terminal UI and scrollback.
|
|
3472
|
+
if is_sensitive_file_path(self._file_path):
|
|
3473
|
+
yield Static(
|
|
3474
|
+
Content.styled("Diff hidden — file may contain credentials", "dim")
|
|
3475
|
+
)
|
|
3476
|
+
else:
|
|
3477
|
+
# Render the diff with per-line Statics (CSS-driven backgrounds)
|
|
3478
|
+
yield from compose_diff_lines(self._diff_content, max_lines=100)
|
|
3479
|
+
|
|
3480
|
+
def on_mount(self) -> None:
|
|
3481
|
+
"""Set border style based on charset mode."""
|
|
3482
|
+
if is_ascii_mode():
|
|
3483
|
+
colors = theme.get_theme_colors(self)
|
|
3484
|
+
self.styles.border = ("ascii", colors.primary)
|
|
3485
|
+
|
|
3486
|
+
|
|
3487
|
+
class ErrorMessage(Static):
|
|
3488
|
+
"""Widget displaying an error message."""
|
|
3489
|
+
|
|
3490
|
+
DEFAULT_CSS = """
|
|
3491
|
+
ErrorMessage {
|
|
3492
|
+
height: auto;
|
|
3493
|
+
padding: 1;
|
|
3494
|
+
margin: 0 0 1 0;
|
|
3495
|
+
background: $error-muted;
|
|
3496
|
+
color: white;
|
|
3497
|
+
border-left: wide $error;
|
|
3498
|
+
pointer: text;
|
|
3499
|
+
}
|
|
3500
|
+
"""
|
|
3501
|
+
"""Tinted background + left border to visually separate errors from output."""
|
|
3502
|
+
|
|
3503
|
+
def __init__(self, error: str | Content, **kwargs: Any) -> None:
|
|
3504
|
+
"""Initialize an error message.
|
|
3505
|
+
|
|
3506
|
+
Args:
|
|
3507
|
+
error: Plain string, or `Content` for pre-styled bodies
|
|
3508
|
+
(e.g. with `link`-styled spans).
|
|
3509
|
+
**kwargs: Additional arguments passed to parent.
|
|
3510
|
+
"""
|
|
3511
|
+
self._content = error
|
|
3512
|
+
super().__init__(**kwargs)
|
|
3513
|
+
|
|
3514
|
+
def render(self) -> Content:
|
|
3515
|
+
"""Render with theme-aware colors.
|
|
3516
|
+
|
|
3517
|
+
Returns:
|
|
3518
|
+
Styled error content; spans on a `Content` body are preserved.
|
|
3519
|
+
"""
|
|
3520
|
+
colors = theme.get_theme_colors(self)
|
|
3521
|
+
return Content.assemble(
|
|
3522
|
+
Content.styled("Error: ", f"bold {colors.error}"),
|
|
3523
|
+
self._content,
|
|
3524
|
+
)
|
|
3525
|
+
|
|
3526
|
+
def on_mount(self) -> None:
|
|
3527
|
+
"""Set border style based on charset mode."""
|
|
3528
|
+
if is_ascii_mode():
|
|
3529
|
+
colors = theme.get_theme_colors(self)
|
|
3530
|
+
self.styles.border_left = ("ascii", colors.error)
|
|
3531
|
+
|
|
3532
|
+
def on_click(self, event: Click) -> None: # noqa: PLR6301 # Textual event handler
|
|
3533
|
+
"""Open clicked URLs."""
|
|
3534
|
+
if event.style.link:
|
|
3535
|
+
open_style_link(event)
|
|
3536
|
+
|
|
3537
|
+
|
|
3538
|
+
class _RubricResultToggle(Static):
|
|
3539
|
+
"""Clickable summary or hint for a rubric result."""
|
|
3540
|
+
|
|
3541
|
+
|
|
3542
|
+
class RubricResultMessage(Vertical):
|
|
3543
|
+
"""Compact grader result with complete, scrollable details on demand."""
|
|
3544
|
+
|
|
3545
|
+
class ExpansionChanged(Message):
|
|
3546
|
+
"""Posted when the grader-details expansion state changes."""
|
|
3547
|
+
|
|
3548
|
+
def __init__(self, widget: RubricResultMessage, expanded: bool) -> None:
|
|
3549
|
+
"""Initialize an expansion-state message.
|
|
3550
|
+
|
|
3551
|
+
Args:
|
|
3552
|
+
widget: The rubric result whose expansion state changed.
|
|
3553
|
+
expanded: Whether the grader details are now expanded.
|
|
3554
|
+
"""
|
|
3555
|
+
super().__init__()
|
|
3556
|
+
self.widget = widget
|
|
3557
|
+
self.expanded = expanded
|
|
3558
|
+
|
|
3559
|
+
DEFAULT_CSS = """
|
|
3560
|
+
RubricResultMessage {
|
|
3561
|
+
height: auto;
|
|
3562
|
+
padding: 0 1;
|
|
3563
|
+
margin: 0 0 1 0;
|
|
3564
|
+
color: $text-muted;
|
|
3565
|
+
border-left: wide $warning;
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
RubricResultMessage .rubric-result-summary {
|
|
3569
|
+
height: auto;
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
RubricResultMessage .rubric-result-details-scroll {
|
|
3573
|
+
display: none;
|
|
3574
|
+
height: auto;
|
|
3575
|
+
max-height: 16;
|
|
3576
|
+
margin: 1 0 0 2;
|
|
3577
|
+
overflow-y: auto;
|
|
3578
|
+
scrollbar-size-vertical: 1;
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
RubricResultMessage .rubric-result-details {
|
|
3582
|
+
height: auto;
|
|
3583
|
+
padding: 0;
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
RubricResultMessage .rubric-result-hint {
|
|
3587
|
+
height: auto;
|
|
3588
|
+
margin-left: 2;
|
|
3589
|
+
color: $text-muted;
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
RubricResultMessage.-expanded .rubric-result-details-scroll {
|
|
3593
|
+
display: block;
|
|
3594
|
+
}
|
|
3595
|
+
"""
|
|
3596
|
+
|
|
3597
|
+
_expanded: var[bool] = var(False, toggle_class="-expanded")
|
|
3598
|
+
|
|
3599
|
+
def __init__(
|
|
3600
|
+
self,
|
|
3601
|
+
summary: str,
|
|
3602
|
+
details: str,
|
|
3603
|
+
**kwargs: Any,
|
|
3604
|
+
) -> None:
|
|
3605
|
+
"""Initialize a grader result.
|
|
3606
|
+
|
|
3607
|
+
Args:
|
|
3608
|
+
summary: Concise default transcript line.
|
|
3609
|
+
details: Complete user-facing grader explanation and criteria gaps.
|
|
3610
|
+
**kwargs: Additional arguments passed to `Vertical`.
|
|
3611
|
+
"""
|
|
3612
|
+
super().__init__(**kwargs)
|
|
3613
|
+
self._summary = summary
|
|
3614
|
+
self._details = details
|
|
3615
|
+
self._hint_widget: _RubricResultToggle | None = None
|
|
3616
|
+
self._deferred_expanded = False
|
|
3617
|
+
# Last expansion value published to the message store. Deduping against it
|
|
3618
|
+
# keeps the reactive's initialization watcher and the deferred restore from
|
|
3619
|
+
# re-emitting a value the store already holds.
|
|
3620
|
+
self._published_expanded = False
|
|
3621
|
+
|
|
3622
|
+
def compose(self) -> ComposeResult:
|
|
3623
|
+
"""Compose the compact summary, details viewport, and expansion hint.
|
|
3624
|
+
|
|
3625
|
+
Yields:
|
|
3626
|
+
Summary, scrollable details, and toggle hint widgets.
|
|
3627
|
+
"""
|
|
3628
|
+
yield _RubricResultToggle(
|
|
3629
|
+
Content.styled(self._summary, "dim italic"),
|
|
3630
|
+
classes="rubric-result-summary",
|
|
3631
|
+
)
|
|
3632
|
+
with VerticalScroll(classes="rubric-result-details-scroll"):
|
|
3633
|
+
yield Static(
|
|
3634
|
+
Content(self._details),
|
|
3635
|
+
classes="rubric-result-details",
|
|
3636
|
+
)
|
|
3637
|
+
yield _RubricResultToggle("", classes="rubric-result-hint")
|
|
3638
|
+
|
|
3639
|
+
def on_mount(self) -> None:
|
|
3640
|
+
"""Initialize the expansion hint and restore deferred state."""
|
|
3641
|
+
self._hint_widget = self.query_one(
|
|
3642
|
+
".rubric-result-hint",
|
|
3643
|
+
_RubricResultToggle,
|
|
3644
|
+
)
|
|
3645
|
+
if is_ascii_mode():
|
|
3646
|
+
colors = theme.get_theme_colors(self)
|
|
3647
|
+
self.styles.border_left = ("ascii", colors.warning)
|
|
3648
|
+
if not self._details:
|
|
3649
|
+
self._hint_widget.display = False
|
|
3650
|
+
return
|
|
3651
|
+
# The store already holds the restored state, so record it as published
|
|
3652
|
+
# first; the assignment below then dedupes instead of re-emitting it.
|
|
3653
|
+
self._published_expanded = self._deferred_expanded
|
|
3654
|
+
if self._deferred_expanded:
|
|
3655
|
+
self._expanded = True
|
|
3656
|
+
self._deferred_expanded = False
|
|
3657
|
+
self._update_hint()
|
|
3658
|
+
|
|
3659
|
+
def toggle_details(self) -> None:
|
|
3660
|
+
"""Toggle the complete grader details."""
|
|
3661
|
+
if self._details:
|
|
3662
|
+
self._expanded = not self._expanded
|
|
3663
|
+
|
|
3664
|
+
def watch__expanded(self, expanded: bool) -> None:
|
|
3665
|
+
"""Refresh the hint and publish user-driven expansion for virtualization."""
|
|
3666
|
+
self._update_hint()
|
|
3667
|
+
# Publish only genuine changes: dedupe against the store's known value to
|
|
3668
|
+
# drop the reactive's initialization watcher and the deferred restore, and
|
|
3669
|
+
# require `is_attached` so `_expanded` set in pre-mount test setup does not
|
|
3670
|
+
# `post_message` on a detached widget (NoActiveAppError).
|
|
3671
|
+
if self.is_attached and expanded != self._published_expanded:
|
|
3672
|
+
self._published_expanded = expanded
|
|
3673
|
+
self.post_message(self.ExpansionChanged(self, expanded))
|
|
3674
|
+
|
|
3675
|
+
def _update_hint(self) -> None:
|
|
3676
|
+
"""Render the current expansion hint."""
|
|
3677
|
+
if self._hint_widget is None or not self._details:
|
|
3678
|
+
return
|
|
3679
|
+
action = "hide" if self._expanded else "show"
|
|
3680
|
+
self._hint_widget.update(
|
|
3681
|
+
Content.styled(f"click or Ctrl+O to {action} details", "dim italic")
|
|
3682
|
+
)
|
|
3683
|
+
|
|
3684
|
+
@on(Click, "_RubricResultToggle")
|
|
3685
|
+
def _on_toggle_click(self, event: Click) -> None:
|
|
3686
|
+
"""Toggle details from the summary or hint."""
|
|
3687
|
+
event.stop()
|
|
3688
|
+
self.toggle_details()
|
|
3689
|
+
|
|
3690
|
+
|
|
3691
|
+
class _MutedRichMarkdown:
|
|
3692
|
+
"""Render Rich markdown to match `AppMessage`'s muted-italic base.
|
|
3693
|
+
|
|
3694
|
+
Plain `AppMessage` strings render as `dim italic` via `Content.styled`
|
|
3695
|
+
plus the widget's CSS. Rich's default markdown theme paints h2-h4
|
|
3696
|
+
magenta and table headers/borders cyan, and doesn't apply `dim` to
|
|
3697
|
+
paragraphs, so markdown blocks look visually distinct. This wrapper:
|
|
3698
|
+
|
|
3699
|
+
- Applies a `rich.theme.Theme` while rendering that strips the stock
|
|
3700
|
+
colors while keeping structural emphasis (bold/underline/italic), and
|
|
3701
|
+
- Layers `dim` over the whole document via `rich.styled.Styled` so
|
|
3702
|
+
body text matches the `dim italic` baseline used elsewhere.
|
|
3703
|
+
"""
|
|
3704
|
+
|
|
3705
|
+
_THEME_OVERRIDES: ClassVar[dict[str, str]] = {
|
|
3706
|
+
"markdown.h1": "bold underline",
|
|
3707
|
+
"markdown.h2": "bold underline",
|
|
3708
|
+
"markdown.h3": "bold",
|
|
3709
|
+
"markdown.h4": "italic",
|
|
3710
|
+
"markdown.table.header": "bold",
|
|
3711
|
+
"markdown.table.border": "",
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
def __init__(self, markup: str) -> None:
|
|
3715
|
+
from rich.markdown import Markdown as RichMarkdown
|
|
3716
|
+
|
|
3717
|
+
self._markdown = RichMarkdown(markup)
|
|
3718
|
+
self._markup = markup
|
|
3719
|
+
|
|
3720
|
+
def __rich_console__( # noqa: PLW3201 # Rich renderable protocol
|
|
3721
|
+
self, console: RichConsole, options: ConsoleOptions
|
|
3722
|
+
) -> RenderResult:
|
|
3723
|
+
from rich.styled import Styled
|
|
3724
|
+
from rich.theme import Theme
|
|
3725
|
+
|
|
3726
|
+
theme = Theme(self._THEME_OVERRIDES, inherit=True)
|
|
3727
|
+
try:
|
|
3728
|
+
with console.use_theme(theme):
|
|
3729
|
+
yield from Styled(self._markdown, "dim").__rich_console__(
|
|
3730
|
+
console, options
|
|
3731
|
+
)
|
|
3732
|
+
except Exception:
|
|
3733
|
+
# Rich markdown or theme application blew up on malformed input.
|
|
3734
|
+
# Fall back to the raw source so the chat view keeps rendering.
|
|
3735
|
+
logger.warning(
|
|
3736
|
+
"Rich markdown rendering failed; falling back to plain text",
|
|
3737
|
+
exc_info=True,
|
|
3738
|
+
)
|
|
3739
|
+
yield from Styled(self._markup, "dim italic").__rich_console__(
|
|
3740
|
+
console, options
|
|
3741
|
+
)
|
|
3742
|
+
|
|
3743
|
+
|
|
3744
|
+
class AppMessage(Static):
|
|
3745
|
+
"""Widget displaying an app message."""
|
|
3746
|
+
|
|
3747
|
+
# Disable Textual's auto_links to prevent a flicker cycle: Style.__add__
|
|
3748
|
+
# calls .copy() for linked styles, generating a fresh random _link_id on
|
|
3749
|
+
# each render. This means highlight_link_id never stabilizes, causing an
|
|
3750
|
+
# infinite hover-refresh loop.
|
|
3751
|
+
auto_links = False
|
|
3752
|
+
|
|
3753
|
+
DEFAULT_CSS = """
|
|
3754
|
+
AppMessage {
|
|
3755
|
+
height: auto;
|
|
3756
|
+
padding: 0 1;
|
|
3757
|
+
margin: 0 0 1 0;
|
|
3758
|
+
color: $text-muted;
|
|
3759
|
+
text-style: italic;
|
|
3760
|
+
pointer: text;
|
|
3761
|
+
}
|
|
3762
|
+
"""
|
|
3763
|
+
|
|
3764
|
+
def __init__(
|
|
3765
|
+
self,
|
|
3766
|
+
message: str | Content,
|
|
3767
|
+
*,
|
|
3768
|
+
markdown: bool = False,
|
|
3769
|
+
**kwargs: Any,
|
|
3770
|
+
) -> None:
|
|
3771
|
+
"""Initialize a system message.
|
|
3772
|
+
|
|
3773
|
+
Args:
|
|
3774
|
+
message: The system message as a string or pre-styled `Content`.
|
|
3775
|
+
markdown: When `True`, render `message` as markdown via Rich's
|
|
3776
|
+
markdown renderer (tables, headings, bold, etc.).
|
|
3777
|
+
|
|
3778
|
+
Requires a string message — `Content` objects already carry
|
|
3779
|
+
their own structure.
|
|
3780
|
+
**kwargs: Additional arguments passed to parent.
|
|
3781
|
+
|
|
3782
|
+
Raises:
|
|
3783
|
+
TypeError: If `markdown=True` is combined with a non-string
|
|
3784
|
+
`message`.
|
|
3785
|
+
"""
|
|
3786
|
+
self._content = message
|
|
3787
|
+
self._is_markdown = markdown
|
|
3788
|
+
if markdown:
|
|
3789
|
+
if not isinstance(message, str):
|
|
3790
|
+
msg = "AppMessage(markdown=True) requires a string message"
|
|
3791
|
+
raise TypeError(msg)
|
|
3792
|
+
rendered = _MutedRichMarkdown(message)
|
|
3793
|
+
elif isinstance(message, Content):
|
|
3794
|
+
rendered = message
|
|
3795
|
+
else:
|
|
3796
|
+
rendered = Content.styled(message, "dim italic")
|
|
3797
|
+
super().__init__(rendered, **kwargs)
|
|
3798
|
+
|
|
3799
|
+
def on_click(self, event: Click) -> None: # noqa: PLR6301 # Textual event handler
|
|
3800
|
+
"""Open style-embedded hyperlinks on single click."""
|
|
3801
|
+
open_style_link(event)
|
|
3802
|
+
|
|
3803
|
+
|
|
3804
|
+
class SummarizationMessage(AppMessage):
|
|
3805
|
+
"""Widget displaying a summarization completion notification."""
|
|
3806
|
+
|
|
3807
|
+
DEFAULT_CSS = """
|
|
3808
|
+
SummarizationMessage {
|
|
3809
|
+
height: auto;
|
|
3810
|
+
padding: 0 1;
|
|
3811
|
+
margin: 0 0 1 0;
|
|
3812
|
+
color: $primary;
|
|
3813
|
+
background: $surface;
|
|
3814
|
+
border-left: wide $primary;
|
|
3815
|
+
text-style: bold;
|
|
3816
|
+
pointer: text;
|
|
3817
|
+
}
|
|
3818
|
+
"""
|
|
3819
|
+
|
|
3820
|
+
def __init__(self, message: str | Content | None = None, **kwargs: Any) -> None:
|
|
3821
|
+
"""Initialize a summarization notification message.
|
|
3822
|
+
|
|
3823
|
+
Args:
|
|
3824
|
+
message: Optional message override used when rehydrating from the
|
|
3825
|
+
message store.
|
|
3826
|
+
|
|
3827
|
+
Defaults to the standard summary notification.
|
|
3828
|
+
**kwargs: Additional arguments passed to parent.
|
|
3829
|
+
"""
|
|
3830
|
+
self._raw_message = message
|
|
3831
|
+
# Pass the default text to AppMessage for _content serialization;
|
|
3832
|
+
# render() supplies theme-aware styling at display time.
|
|
3833
|
+
super().__init__(message or "✓ Conversation offloaded", **kwargs)
|
|
3834
|
+
|
|
3835
|
+
def render(self) -> Content:
|
|
3836
|
+
"""Render with theme-aware colors.
|
|
3837
|
+
|
|
3838
|
+
Returns:
|
|
3839
|
+
Styled summarization content with theme-appropriate color.
|
|
3840
|
+
"""
|
|
3841
|
+
colors = theme.get_theme_colors(self)
|
|
3842
|
+
if self._raw_message is None:
|
|
3843
|
+
return Content.styled("✓ Conversation offloaded", f"bold {colors.primary}")
|
|
3844
|
+
if isinstance(self._raw_message, Content):
|
|
3845
|
+
return self._raw_message
|
|
3846
|
+
return Content.styled(self._raw_message, f"bold {colors.primary}")
|