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.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. 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}")