soothe-cli 0.1.0__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 (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,1720 @@
1
+ """Message widgets for Soothe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import json
7
+ import logging
8
+ import re
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from time import time
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from textual import on
15
+ from textual.containers import Vertical
16
+ from textual.content import Content
17
+ from textual.events import Click
18
+ from textual.reactive import var
19
+ from textual.widgets import Static
20
+
21
+ from soothe_cli.tui import theme
22
+ from soothe_cli.tui.config import (
23
+ MODE_DISPLAY_GLYPHS,
24
+ PREFIX_TO_MODE,
25
+ get_glyphs,
26
+ is_ascii_mode,
27
+ )
28
+ from soothe_cli.tui.formatting import format_duration
29
+ from soothe_cli.tui.input import EMAIL_PREFIX_PATTERN, INPUT_HIGHLIGHT_PATTERN
30
+ from soothe_cli.tui.tool_display import format_tool_display
31
+ from soothe_cli.tui.widgets._links import open_style_link
32
+ from soothe_cli.tui.widgets.diff import compose_diff_lines
33
+
34
+ if TYPE_CHECKING:
35
+ from textual.app import ComposeResult
36
+ from textual.timer import Timer
37
+ from textual.widgets import Markdown
38
+ from textual.widgets._markdown import MarkdownStream
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ def _show_timestamp_toast(widget: Static | Vertical) -> None:
44
+ """Show a toast with the message's creation timestamp.
45
+
46
+ No-ops silently if the widget is not mounted or has no associated message
47
+ data in the store.
48
+
49
+ Args:
50
+ widget: The message widget whose timestamp to display.
51
+ """
52
+ from datetime import UTC, datetime
53
+
54
+ try:
55
+ app = widget.app
56
+ except Exception: # noqa: BLE001 # Textual raises when widget has no app
57
+ return
58
+ if not widget.id:
59
+ return
60
+ store = app._message_store # type: ignore[attr-defined]
61
+ data = store.get_message(widget.id)
62
+ if not data:
63
+ return
64
+ dt = datetime.fromtimestamp(data.timestamp, tz=UTC).astimezone()
65
+ label = f"{dt:%b} {dt.day}, {dt.hour % 12 or 12}:{dt:%M:%S} {dt:%p}"
66
+ app.notify(label, timeout=3)
67
+
68
+
69
+ class _TimestampClickMixin:
70
+ """Mixin that shows a timestamp toast on click.
71
+
72
+ Add to any message widget that should display its creation timestamp when
73
+ clicked. Widgets needing additional click behavior (e.g. `ToolCallMessage`,
74
+ `AppMessage`) should override `on_click` and call `_show_timestamp_toast`
75
+ directly instead.
76
+ """
77
+
78
+ def on_click(self, event: Click) -> None: # noqa: ARG002 # Textual event handler
79
+ """Show timestamp toast on click."""
80
+ _show_timestamp_toast(self) # type: ignore[arg-type]
81
+
82
+
83
+ def _mode_color(mode: str | None, widget_or_app: object | None = None) -> str:
84
+ """Return the hex color string for a mode, falling back to primary.
85
+
86
+ Args:
87
+ mode: Mode name (e.g. `'shell'`, `'command'`) or `None`.
88
+ widget_or_app: Textual widget or `App` for theme-aware lookup.
89
+
90
+ Returns:
91
+ Color string from the active theme's `ThemeColors`.
92
+ """
93
+ colors = theme.get_theme_colors(widget_or_app)
94
+ if not mode:
95
+ return colors.primary
96
+ if mode == "shell":
97
+ return colors.mode_bash
98
+ if mode == "command":
99
+ return colors.mode_command
100
+ logger.warning("Missing color for mode '%s'; falling back to primary.", mode)
101
+ return colors.primary
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class FormattedOutput:
106
+ """Result of formatting tool output for display."""
107
+
108
+ content: Content
109
+ """Styled `Content` for the formatted output."""
110
+
111
+ truncation: str | None = None
112
+ """Description of truncated content (e.g., "10 more lines"), or None if no
113
+ truncation occurred."""
114
+
115
+
116
+ # Maximum number of tool arguments to display inline
117
+ _MAX_INLINE_ARGS = 3
118
+
119
+ # Truncation limits for display
120
+ _MAX_TODO_CONTENT_LEN = 70
121
+ _MAX_WEB_CONTENT_LEN = 100
122
+
123
+ # Tools that have their key info already in the header (no need for args line)
124
+ _TOOLS_WITH_HEADER_INFO: set[str] = {
125
+ # Filesystem tools
126
+ "ls",
127
+ "read_file",
128
+ "write_file",
129
+ "edit_file",
130
+ "glob",
131
+ "grep",
132
+ "execute", # sandbox shell
133
+ # Shell tools
134
+ "shell", # local shell
135
+ # Web tools
136
+ "web_search",
137
+ "fetch_url",
138
+ # Agent tools
139
+ "task",
140
+ "write_todos",
141
+ }
142
+
143
+
144
+ _SUCCESS_EXIT_RE = re.compile(r"\n?\[Command succeeded with exit code 0\]\s*$")
145
+ """Strip the SDK's `[Command succeeded with exit code 0]` trailer from tool output."""
146
+
147
+
148
+ def _strip_success_exit_line(text: str) -> str:
149
+ """Remove the `[Command succeeded with exit code 0]` trailer.
150
+
151
+ Non-zero exit codes are left intact (they come through `set_error`).
152
+
153
+ Args:
154
+ text: Raw tool output string.
155
+
156
+ Returns:
157
+ Text with the success exit-code trailer removed, if present.
158
+ """
159
+ return _SUCCESS_EXIT_RE.sub("", text)
160
+
161
+
162
+ class UserMessage(_TimestampClickMixin, Static):
163
+ """Widget displaying a user message."""
164
+
165
+ DEFAULT_CSS = """
166
+ UserMessage {
167
+ height: auto;
168
+ padding: 0 1;
169
+ margin: 0 0 1 0;
170
+ background: transparent;
171
+ border-left: wide $primary;
172
+ }
173
+ """
174
+
175
+ def __init__(self, content: str, **kwargs: Any) -> None:
176
+ """Initialize a user message.
177
+
178
+ Args:
179
+ content: The message content
180
+ **kwargs: Additional arguments passed to parent
181
+ """
182
+ super().__init__(**kwargs)
183
+ self._content = content
184
+
185
+ def on_mount(self) -> None:
186
+ """Add CSS classes for mode-specific border and ASCII border type."""
187
+ mode = PREFIX_TO_MODE.get(self._content[:1]) if self._content else None
188
+ if mode:
189
+ self.add_class(f"-mode-{mode}")
190
+ if is_ascii_mode():
191
+ self.add_class("-ascii")
192
+
193
+ def render(self) -> Content:
194
+ """Render the styled user message.
195
+
196
+ Returns:
197
+ Styled Content with mode prefix and highlighted mentions.
198
+ """
199
+ colors = theme.get_theme_colors(self)
200
+ parts: list[str | tuple[str, str]] = []
201
+ content = self._content
202
+
203
+ # Use mode-specific prefix indicator when content starts with a
204
+ # mode trigger character (e.g. "!" for shell, "/" for commands).
205
+ # The display glyph may differ from the trigger (e.g. "$" for shell).
206
+ mode = PREFIX_TO_MODE.get(content[:1]) if content else None
207
+ if mode:
208
+ glyph = MODE_DISPLAY_GLYPHS.get(mode, content[0])
209
+ parts.append((f"{glyph} ", f"bold {_mode_color(mode, self)}"))
210
+ content = content[1:]
211
+ else:
212
+ parts.append(("> ", f"bold {colors.primary}"))
213
+
214
+ # Highlight @mentions and /commands in the content
215
+ last_end = 0
216
+ for match in INPUT_HIGHLIGHT_PATTERN.finditer(content):
217
+ start, end = match.span()
218
+ token = match.group()
219
+
220
+ # Skip @mentions that look like email addresses
221
+ if token.startswith("@") and start > 0:
222
+ char_before = content[start - 1]
223
+ if EMAIL_PREFIX_PATTERN.match(char_before):
224
+ continue
225
+
226
+ # Add text before the match (unstyled)
227
+ if start > last_end:
228
+ parts.append(content[last_end:start])
229
+
230
+ # The regex only matches tokens starting with / or @
231
+ if token.startswith("/") and start == 0:
232
+ # /command at start
233
+ parts.append((token, f"bold {colors.warning}"))
234
+ elif token.startswith("@"):
235
+ # @file mention
236
+ parts.append((token, f"bold {colors.primary}"))
237
+ last_end = end
238
+
239
+ # Add remaining text after last match
240
+ if last_end < len(content):
241
+ parts.append(content[last_end:])
242
+
243
+ return Content.assemble(*parts)
244
+
245
+
246
+ class QueuedUserMessage(Static):
247
+ """Widget displaying a queued (pending) user message in grey.
248
+
249
+ This is an ephemeral widget that gets removed when the message is dequeued.
250
+ """
251
+
252
+ DEFAULT_CSS = """
253
+ QueuedUserMessage {
254
+ height: auto;
255
+ padding: 0 1;
256
+ margin: 0 0 1 0;
257
+ background: transparent;
258
+ border-left: wide $panel;
259
+ opacity: 0.6;
260
+ }
261
+ """
262
+ """Dimmed border + reduced opacity to distinguish queued messages from sent ones."""
263
+
264
+ def __init__(self, content: str, **kwargs: Any) -> None:
265
+ """Initialize a queued user message.
266
+
267
+ Args:
268
+ content: The message content
269
+ **kwargs: Additional arguments passed to parent
270
+ """
271
+ super().__init__(**kwargs)
272
+ self._content = content
273
+
274
+ def on_mount(self) -> None:
275
+ """Add ASCII border class when in ASCII mode."""
276
+ if is_ascii_mode():
277
+ self.add_class("-ascii")
278
+
279
+ def render(self) -> Content:
280
+ """Render the queued user message (greyed out).
281
+
282
+ Returns:
283
+ Styled Content with dimmed prefix and body.
284
+ """
285
+ colors = theme.get_theme_colors(self)
286
+ content = self._content
287
+ mode = PREFIX_TO_MODE.get(content[:1]) if content else None
288
+ if mode:
289
+ glyph = MODE_DISPLAY_GLYPHS.get(mode, content[0])
290
+ prefix = (f"{glyph} ", f"bold {colors.muted}")
291
+ content = content[1:]
292
+ else:
293
+ prefix = ("> ", f"bold {colors.muted}")
294
+ return Content.assemble(prefix, (content, colors.muted))
295
+
296
+
297
+ def _strip_frontmatter(text: str) -> str:
298
+ """Remove YAML frontmatter delimited by `---` markers.
299
+
300
+ Args:
301
+ text: Raw `SKILL.md` content.
302
+
303
+ Returns:
304
+ Body text with frontmatter removed and leading whitespace stripped.
305
+ """
306
+ from soothe_cli.tui.skills.load import strip_skill_frontmatter
307
+
308
+ return strip_skill_frontmatter(text)
309
+
310
+
311
+ class _SkillToggle(Static):
312
+ """Clickable header/hint area for toggling skill body expansion.
313
+
314
+ Referenced by name in `SkillMessage._on_toggle_click`'s `@on(Click)`
315
+ CSS selector — rename with care.
316
+ """
317
+
318
+
319
+ class SkillMessage(Vertical):
320
+ """Widget displaying a skill invocation with collapsible body.
321
+
322
+ Shows skill name, source badge, description, and user args as a compact
323
+ header. The full SKILL.md body (frontmatter stripped) is hidden behind a
324
+ preview/expand toggle (click or Ctrl+O). The expanded view renders
325
+ markdown via Rich's `Markdown` inside a single `Static` widget.
326
+
327
+ Visibility is driven by a CSS class (`-expanded`) toggled via a Textual
328
+ reactive `var`. Click handlers are scoped to the header and hint widgets
329
+ (`_SkillToggle`) so clicks on the rendered markdown body do not trigger
330
+ expansion toggles (preserving text selection, for instance).
331
+ """
332
+
333
+ DEFAULT_CSS = """
334
+ SkillMessage {
335
+ height: auto;
336
+ padding: 0 1;
337
+ margin: 0 0 1 0;
338
+ background: transparent;
339
+ border-left: wide $skill;
340
+ }
341
+
342
+ SkillMessage .skill-header {
343
+ height: auto;
344
+ }
345
+
346
+ SkillMessage .skill-description {
347
+ color: $text-muted;
348
+ margin-left: 3;
349
+ }
350
+
351
+ SkillMessage .skill-args {
352
+ margin-left: 3;
353
+ margin-top: 0;
354
+ }
355
+
356
+ SkillMessage #skill-md {
357
+ margin-left: 3;
358
+ margin-top: 0;
359
+ padding: 0;
360
+ display: none;
361
+ }
362
+
363
+ SkillMessage .skill-hint {
364
+ margin-left: 3;
365
+ color: $text-muted;
366
+ }
367
+
368
+ SkillMessage.-expanded #skill-md {
369
+ display: block;
370
+ }
371
+
372
+ SkillMessage:hover {
373
+ border-left: wide $skill-hover;
374
+ }
375
+ """
376
+
377
+ _PREVIEW_LINES = 4
378
+ _PREVIEW_CHARS = 300
379
+
380
+ _expanded: var[bool] = var(False, toggle_class="-expanded")
381
+
382
+ def __init__(
383
+ self,
384
+ skill_name: str,
385
+ description: str = "",
386
+ source: str = "",
387
+ body: str = "",
388
+ args: str = "",
389
+ **kwargs: Any,
390
+ ) -> None:
391
+ """Initialize a skill message.
392
+
393
+ Args:
394
+ skill_name: Skill identifier.
395
+ description: Short description of the skill.
396
+ source: Origin label (e.g., `'built-in'`, `'user'`).
397
+ body: Full SKILL.md content (frontmatter included).
398
+ args: User-provided arguments.
399
+ **kwargs: Additional arguments passed to parent.
400
+ """
401
+ super().__init__(**kwargs)
402
+ self._skill_name = skill_name
403
+ self._description = description
404
+ self._source = source
405
+ self._body = body
406
+ self._stripped_body = _strip_frontmatter(body)
407
+ self._args = args
408
+ self._md_widget: Static | None = None
409
+ self._hint_widget: _SkillToggle | None = None
410
+ self._deferred_expanded: bool = False
411
+ self._md_rendered: bool = False
412
+
413
+ def compose(self) -> ComposeResult:
414
+ """Compose the skill message layout.
415
+
416
+ Yields:
417
+ Widgets for header, description, args, and collapsible body.
418
+ """
419
+ colors = theme.get_theme_colors()
420
+ source_tag = f" [{self._source}]" if self._source else ""
421
+ yield _SkillToggle(
422
+ Content.styled(
423
+ f"/ skill:{self._skill_name}{source_tag}",
424
+ f"bold {colors.skill}",
425
+ ),
426
+ classes="skill-header",
427
+ )
428
+ if self._description:
429
+ yield _SkillToggle(
430
+ Content.styled(self._description, "dim"),
431
+ classes="skill-description",
432
+ )
433
+ if self._args:
434
+ yield Static(
435
+ Content.assemble(
436
+ ("User request: ", "bold"),
437
+ self._args,
438
+ ),
439
+ classes="skill-args",
440
+ )
441
+ yield Static("", id="skill-md")
442
+ yield _SkillToggle("", classes="skill-hint", id="skill-hint")
443
+
444
+ def on_mount(self) -> None:
445
+ """Cache widget references, render initial state.
446
+
447
+ Ordering matters: widget refs must be cached before `_prepare_body`
448
+ or `_deferred_expanded` assignment, because either may set
449
+ `_expanded` which fires `watch__expanded` synchronously.
450
+ """
451
+ if is_ascii_mode():
452
+ colors = theme.get_theme_colors(self)
453
+ self.styles.border_left = ("ascii", colors.skill)
454
+
455
+ self._md_widget = self.query_one("#skill-md", Static)
456
+ self._hint_widget = self.query_one("#skill-hint", _SkillToggle)
457
+
458
+ body = self._stripped_body.strip()
459
+ if body:
460
+ self._prepare_body(body)
461
+
462
+ if self._deferred_expanded:
463
+ self._expanded = self._deferred_expanded
464
+ self._deferred_expanded = False
465
+
466
+ def _prepare_body(self, body: str) -> None:
467
+ """Set initial hint text. Full body render is deferred to first expand.
468
+
469
+ Args:
470
+ body: Stripped markdown body text.
471
+ """
472
+ lines = body.split("\n")
473
+ total_lines = len(lines)
474
+ needs_truncation = total_lines > self._PREVIEW_LINES or len(body) > self._PREVIEW_CHARS
475
+
476
+ if needs_truncation:
477
+ remaining = total_lines - self._PREVIEW_LINES
478
+ ellipsis = get_glyphs().ellipsis
479
+ if self._hint_widget:
480
+ self._hint_widget.update(
481
+ Content.styled(
482
+ f"{ellipsis} {remaining} more lines — click or Ctrl+O to expand",
483
+ "dim",
484
+ )
485
+ )
486
+ else:
487
+ # Short body — show fully rendered, no preview needed.
488
+ self._ensure_md_rendered(body)
489
+ self._expanded = True
490
+
491
+ def _ensure_md_rendered(self, body: str) -> None:
492
+ """Render markdown into the Static widget on first call, then no-op.
493
+
494
+ Args:
495
+ body: Stripped markdown body text.
496
+ """
497
+ if self._md_rendered or not self._md_widget:
498
+ return
499
+ try:
500
+ from rich.markdown import Markdown as RichMarkdown
501
+
502
+ self._md_widget.update(RichMarkdown(body))
503
+ except Exception:
504
+ logger.warning(
505
+ "Failed to render skill body as markdown; falling back to plain text",
506
+ exc_info=True,
507
+ )
508
+ self._md_widget.update(body)
509
+ self._md_rendered = True
510
+
511
+ def toggle_body(self) -> None:
512
+ """Toggle between preview and full body display."""
513
+ if not self._stripped_body.strip():
514
+ return
515
+ self._expanded = not self._expanded
516
+
517
+ def watch__expanded(self, expanded: bool) -> None:
518
+ """Lazy-render markdown on first expand; update hint text."""
519
+ body = self._stripped_body.strip()
520
+ if not body:
521
+ return
522
+
523
+ if expanded:
524
+ self._ensure_md_rendered(body)
525
+
526
+ if not self._hint_widget:
527
+ return
528
+
529
+ lines = body.split("\n")
530
+ total_lines = len(lines)
531
+ needs_truncation = total_lines > self._PREVIEW_LINES or len(body) > self._PREVIEW_CHARS
532
+
533
+ if not needs_truncation:
534
+ # Short body — always fully visible, no hint needed.
535
+ self._hint_widget.display = False
536
+ return
537
+
538
+ if expanded:
539
+ self._hint_widget.update(Content.styled("click or Ctrl+O to collapse", "dim italic"))
540
+ else:
541
+ remaining = total_lines - self._PREVIEW_LINES
542
+ ellipsis = get_glyphs().ellipsis
543
+ self._hint_widget.update(
544
+ Content.styled(
545
+ f"{ellipsis} {remaining} more lines — click or Ctrl+O to expand",
546
+ "dim",
547
+ )
548
+ )
549
+
550
+ @on(Click, "_SkillToggle")
551
+ def _on_toggle_click(self, event: Click) -> None:
552
+ """Toggle expansion when header or hint is clicked."""
553
+ event.stop()
554
+ if self._stripped_body.strip():
555
+ self.toggle_body()
556
+ else:
557
+ _show_timestamp_toast(self)
558
+
559
+
560
+ class AssistantMessage(_TimestampClickMixin, Vertical):
561
+ """Widget displaying an assistant message with markdown support.
562
+
563
+ Uses MarkdownStream for smoother streaming instead of re-rendering
564
+ the full content on each update.
565
+ """
566
+
567
+ DEFAULT_CSS = """
568
+ AssistantMessage {
569
+ height: auto;
570
+ padding: 0 1;
571
+ margin: 0 0 1 0;
572
+ }
573
+
574
+ AssistantMessage Markdown {
575
+ padding: 0;
576
+ margin: 0;
577
+ }
578
+ """
579
+
580
+ def __init__(self, content: str = "", **kwargs: Any) -> None:
581
+ """Initialize an assistant message.
582
+
583
+ Args:
584
+ content: Initial markdown content
585
+ **kwargs: Additional arguments passed to parent
586
+ """
587
+ super().__init__(**kwargs)
588
+ self._content = content
589
+ self._markdown: Markdown | None = None
590
+ self._stream: MarkdownStream | None = None
591
+
592
+ def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual widget method convention
593
+ """Compose the assistant message layout.
594
+
595
+ Yields:
596
+ Markdown widget for rendering assistant content.
597
+ """
598
+ from textual.widgets import Markdown
599
+
600
+ yield Markdown("", id="assistant-content")
601
+
602
+ def on_mount(self) -> None:
603
+ """Store reference to markdown widget."""
604
+ from textual.widgets import Markdown
605
+
606
+ self._markdown = self.query_one("#assistant-content", Markdown)
607
+
608
+ def _get_markdown(self) -> Markdown:
609
+ """Get the markdown widget, querying if not cached.
610
+
611
+ Returns:
612
+ The Markdown widget for this message.
613
+ """
614
+ if self._markdown is None:
615
+ from textual.widgets import Markdown
616
+
617
+ self._markdown = self.query_one("#assistant-content", Markdown)
618
+ return self._markdown
619
+
620
+ def _ensure_stream(self) -> MarkdownStream:
621
+ """Ensure the markdown stream is initialized.
622
+
623
+ Returns:
624
+ The MarkdownStream instance for streaming content.
625
+ """
626
+ if self._stream is None:
627
+ from textual.widgets import Markdown
628
+
629
+ self._stream = Markdown.get_stream(self._get_markdown())
630
+ return self._stream
631
+
632
+ async def append_content(self, text: str) -> None:
633
+ """Append content to the message (for streaming).
634
+
635
+ Uses MarkdownStream for smoother rendering instead of re-rendering
636
+ the full content on each chunk.
637
+
638
+ Args:
639
+ text: Text to append
640
+ """
641
+ if not text:
642
+ return
643
+ self._content += text
644
+ stream = self._ensure_stream()
645
+ await stream.write(text)
646
+
647
+ async def write_initial_content(self) -> None:
648
+ """Write initial content if provided at construction time."""
649
+ if self._content:
650
+ stream = self._ensure_stream()
651
+ await stream.write(self._content)
652
+
653
+ async def stop_stream(self) -> None:
654
+ """Stop the streaming and finalize the content."""
655
+ if self._stream is not None:
656
+ await self._stream.stop()
657
+ self._stream = None
658
+
659
+ async def set_content(self, content: str) -> None:
660
+ """Set the full message content.
661
+
662
+ This stops any active stream and sets content directly.
663
+
664
+ Args:
665
+ content: The markdown content to display
666
+ """
667
+ await self.stop_stream()
668
+ self._content = content
669
+ if self._markdown:
670
+ await self._markdown.update(content)
671
+
672
+
673
+ class ToolCallMessage(Vertical):
674
+ """Widget displaying a tool call with collapsible output.
675
+
676
+ Tool outputs are shown as a 3-line preview by default.
677
+ Press Ctrl+O to expand/collapse the full output.
678
+ Shows an animated "Running..." indicator while the tool is executing.
679
+ """
680
+
681
+ DEFAULT_CSS = """
682
+ ToolCallMessage {
683
+ height: auto;
684
+ padding: 0 1;
685
+ margin: 0 0 1 0;
686
+ background: transparent;
687
+ border-left: wide $tool;
688
+ }
689
+
690
+ ToolCallMessage .tool-header {
691
+ height: auto;
692
+ color: $tool;
693
+ text-style: bold;
694
+ }
695
+
696
+ ToolCallMessage .tool-task-desc {
697
+ color: $text-muted;
698
+ margin-left: 3;
699
+ text-style: italic;
700
+ }
701
+
702
+ ToolCallMessage .tool-args {
703
+ color: $text-muted;
704
+ margin-left: 3;
705
+ }
706
+
707
+ ToolCallMessage .tool-status {
708
+ margin-left: 3;
709
+ }
710
+
711
+ ToolCallMessage .tool-status.pending {
712
+ color: $warning;
713
+ }
714
+
715
+ ToolCallMessage .tool-status.success {
716
+ color: $success;
717
+ }
718
+
719
+ ToolCallMessage .tool-status.error {
720
+ color: $error;
721
+ }
722
+
723
+ ToolCallMessage .tool-status.rejected {
724
+ color: $warning;
725
+ }
726
+
727
+ ToolCallMessage .tool-output {
728
+ margin-left: 0;
729
+ margin-top: 0;
730
+ padding: 0;
731
+ height: auto;
732
+ }
733
+
734
+ ToolCallMessage .tool-output-preview {
735
+ margin-left: 0;
736
+ margin-top: 0;
737
+ }
738
+
739
+ ToolCallMessage .tool-output-hint {
740
+ margin-left: 0;
741
+ color: $text-muted;
742
+ }
743
+
744
+ ToolCallMessage:hover {
745
+ border-left: wide $tool-hover;
746
+ }
747
+ """
748
+ """Left border tracks tool lifecycle; hover brightens for interactivity."""
749
+
750
+ # Max lines/chars to show in preview mode
751
+ _PREVIEW_LINES = 6
752
+ _PREVIEW_CHARS = 400
753
+
754
+ def __init__(
755
+ self,
756
+ tool_name: str,
757
+ args: dict[str, Any] | None = None,
758
+ **kwargs: Any,
759
+ ) -> None:
760
+ """Initialize a tool call message.
761
+
762
+ Args:
763
+ tool_name: Name of the tool being called
764
+ args: Tool arguments (optional)
765
+ **kwargs: Additional arguments passed to parent
766
+ """
767
+ super().__init__(**kwargs)
768
+ self._tool_name = tool_name
769
+ self._args = args or {}
770
+ self._status = "pending" # Waiting for approval or auto-approve
771
+ self._output: str = ""
772
+ self._expanded: bool = False
773
+ # Widget references (set in on_mount)
774
+ self._status_widget: Static | None = None
775
+ self._preview_widget: Static | None = None
776
+ self._hint_widget: Static | None = None
777
+ self._full_widget: Static | None = None
778
+ # Animation state
779
+ self._spinner_position = 0
780
+ self._start_time: float | None = None
781
+ self._animation_timer: Timer | None = None
782
+ # Deferred state for hydration (set by MessageData.to_widget)
783
+ self._deferred_status: str | None = None
784
+ self._deferred_output: str | None = None
785
+ self._deferred_expanded: bool = False
786
+
787
+ def compose(self) -> ComposeResult:
788
+ """Compose the tool call message layout.
789
+
790
+ Yields:
791
+ Widgets for header, arguments, status, and output display.
792
+ """
793
+ tool_label = format_tool_display(self._tool_name, self._args)
794
+ yield Static(tool_label, markup=False, classes="tool-header")
795
+ # Task: dedicated description line (dim, truncated)
796
+ if self._tool_name == "task":
797
+ desc = self._args.get("description", "")
798
+ if desc:
799
+ max_len = 120
800
+ suffix = "..." if len(desc) > max_len else ""
801
+ truncated = desc[:max_len].rstrip() + suffix
802
+ yield Static(
803
+ Content.styled(truncated, "dim"),
804
+ classes="tool-task-desc",
805
+ )
806
+ # Only show args for tools where header doesn't capture the key info
807
+ elif self._tool_name not in _TOOLS_WITH_HEADER_INFO:
808
+ args = self._filtered_args()
809
+ if args:
810
+ args_str = ", ".join(f"{k}={v!r}" for k, v in list(args.items())[:_MAX_INLINE_ARGS])
811
+ if len(args) > _MAX_INLINE_ARGS:
812
+ args_str += ", ..."
813
+ yield Static(
814
+ Content.from_markup("[dim]($args)[/dim]", args=args_str),
815
+ classes="tool-args",
816
+ )
817
+ # Status - shows running animation while pending, then final status
818
+ yield Static("", classes="tool-status", id="status")
819
+ # Output area - hidden initially, shown when output is set
820
+ yield Static("", classes="tool-output-preview", id="output-preview")
821
+ yield Static("", classes="tool-output", id="output-full")
822
+ yield Static("", classes="tool-output-hint", id="output-hint")
823
+
824
+ def on_mount(self) -> None:
825
+ """Cache widget references and hide all status/output areas initially."""
826
+ if is_ascii_mode():
827
+ self.add_class("-ascii")
828
+
829
+ self._status_widget = self.query_one("#status", Static)
830
+ self._preview_widget = self.query_one("#output-preview", Static)
831
+ self._hint_widget = self.query_one("#output-hint", Static)
832
+ self._full_widget = self.query_one("#output-full", Static)
833
+ # Hide everything initially - status only shown when running or on error/reject
834
+ self._status_widget.display = False
835
+ self._preview_widget.display = False
836
+ self._hint_widget.display = False
837
+ self._full_widget.display = False
838
+
839
+ # Restore deferred state if this widget was hydrated from data
840
+ self._restore_deferred_state()
841
+
842
+ def _restore_deferred_state(self) -> None:
843
+ """Restore state from deferred values (used when hydrating from data)."""
844
+ if self._deferred_status is None:
845
+ return
846
+
847
+ status = self._deferred_status
848
+ output = self._deferred_output or ""
849
+ self._expanded = self._deferred_expanded
850
+
851
+ # Clear deferred values
852
+ self._deferred_status = None
853
+ self._deferred_output = None
854
+ self._deferred_expanded = False
855
+
856
+ # Restore based on status (don't restart animations for running tools)
857
+ colors = theme.get_theme_colors(self)
858
+ match status:
859
+ case "success":
860
+ self._status = "success"
861
+ self._output = output
862
+ self._update_output_display()
863
+ case "error":
864
+ self._status = "error"
865
+ self._output = output
866
+ if self._status_widget:
867
+ self._status_widget.add_class("error")
868
+ error_icon = get_glyphs().error
869
+ self._status_widget.update(Content.styled(f"{error_icon} Error", colors.error))
870
+ self._status_widget.display = True
871
+ self._update_output_display()
872
+ case "rejected":
873
+ self._status = "rejected"
874
+ if self._status_widget:
875
+ self._status_widget.add_class("rejected")
876
+ error_icon = get_glyphs().error
877
+ self._status_widget.update(
878
+ Content.styled(f"{error_icon} Rejected", colors.warning)
879
+ )
880
+ self._status_widget.display = True
881
+ case "skipped":
882
+ self._status = "skipped"
883
+ if self._status_widget:
884
+ self._status_widget.add_class("rejected")
885
+ self._status_widget.update(Content.styled("- Skipped", "dim"))
886
+ self._status_widget.display = True
887
+ case "running":
888
+ # For running tools, show static "Running..." without animation
889
+ # (animations shouldn't be restored for archived tools)
890
+ self._status = "running"
891
+ if self._status_widget:
892
+ self._status_widget.add_class("pending")
893
+ frame = get_glyphs().spinner_frames[0]
894
+ self._status_widget.update(
895
+ Content.styled(f"{frame} Running...", colors.warning)
896
+ )
897
+ self._status_widget.display = True
898
+ case _:
899
+ # pending or unknown - leave as default
900
+ pass
901
+
902
+ def set_running(self) -> None:
903
+ """Mark the tool as running (approved and executing).
904
+
905
+ Call this when approval is granted to start the running animation.
906
+ """
907
+ if self._status == "running":
908
+ return # Already running
909
+
910
+ self._status = "running"
911
+ self._start_time = time()
912
+ if self._status_widget:
913
+ self._status_widget.add_class("pending")
914
+ self._status_widget.display = True
915
+ self._update_running_animation()
916
+ self._animation_timer = self.set_interval(0.1, self._update_running_animation)
917
+
918
+ def _update_running_animation(self) -> None:
919
+ """Update the running spinner animation."""
920
+ if self._status != "running" or self._status_widget is None:
921
+ return
922
+
923
+ spinner_frames = get_glyphs().spinner_frames
924
+ frame = spinner_frames[self._spinner_position]
925
+ self._spinner_position = (self._spinner_position + 1) % len(spinner_frames)
926
+
927
+ elapsed = ""
928
+ if self._start_time is not None:
929
+ elapsed_secs = int(time() - self._start_time)
930
+ elapsed = f" ({format_duration(elapsed_secs)})"
931
+
932
+ text = f"{frame} Running...{elapsed}"
933
+ self._status_widget.update(Content.styled(text, theme.get_theme_colors(self).warning))
934
+
935
+ def _stop_animation(self) -> None:
936
+ """Stop the running animation."""
937
+ if self._animation_timer is not None:
938
+ self._animation_timer.stop()
939
+ self._animation_timer = None
940
+
941
+ def set_success(self, result: str = "") -> None:
942
+ """Mark the tool call as successful.
943
+
944
+ Args:
945
+ result: Tool output/result to display
946
+ """
947
+ self._stop_animation()
948
+ self._status = "success"
949
+ # Strip redundant success trailer — the UI already conveys success
950
+ self._output = _strip_success_exit_line(result)
951
+ if self._status_widget:
952
+ self._status_widget.remove_class("pending")
953
+ # Hide status on success - output speaks for itself
954
+ self._status_widget.display = False
955
+ self._update_output_display()
956
+
957
+ def set_error(self, error: str) -> None:
958
+ """Mark the tool call as failed.
959
+
960
+ Args:
961
+ error: Error message
962
+ """
963
+ self._stop_animation()
964
+ self._status = "error"
965
+ # For shell commands, prepend the full command so users can see what failed
966
+ command = (
967
+ self._args.get("command") if self._tool_name in {"shell", "bash", "execute"} else None
968
+ )
969
+ if command and isinstance(command, str) and command.strip():
970
+ self._output = f"$ {command}\n\n{error}"
971
+ else:
972
+ self._output = error
973
+ if self._status_widget:
974
+ self._status_widget.remove_class("pending")
975
+ self._status_widget.add_class("error")
976
+ error_icon = get_glyphs().error
977
+ colors = theme.get_theme_colors(self)
978
+ self._status_widget.update(Content.styled(f"{error_icon} Error", colors.error))
979
+ self._status_widget.display = True
980
+ # Always show full error - errors should be visible
981
+ self._expanded = True
982
+ self._update_output_display()
983
+
984
+ def set_rejected(self) -> None:
985
+ """Mark the tool call as rejected by user."""
986
+ self._stop_animation()
987
+ self._status = "rejected"
988
+ if self._status_widget:
989
+ self._status_widget.remove_class("pending")
990
+ self._status_widget.add_class("rejected")
991
+ error_icon = get_glyphs().error
992
+ text = f"{error_icon} Rejected"
993
+ colors = theme.get_theme_colors(self)
994
+ self._status_widget.update(Content.styled(text, colors.warning))
995
+ self._status_widget.display = True
996
+
997
+ def set_skipped(self) -> None:
998
+ """Mark the tool call as skipped (due to another rejection)."""
999
+ self._stop_animation()
1000
+ self._status = "skipped"
1001
+ if self._status_widget:
1002
+ self._status_widget.remove_class("pending")
1003
+ self._status_widget.add_class("rejected") # Use same styling as rejected
1004
+ self._status_widget.update(Content.styled("- Skipped", "dim"))
1005
+ self._status_widget.display = True
1006
+
1007
+ def toggle_output(self) -> None:
1008
+ """Toggle between preview and full output display."""
1009
+ if not self._output:
1010
+ return
1011
+ self._expanded = not self._expanded
1012
+ self._update_output_display()
1013
+
1014
+ def on_click(self, event: Click) -> None:
1015
+ """Toggle output expansion, or show timestamp if no output."""
1016
+ event.stop() # Prevent click from bubbling up and scrolling
1017
+ if self._output:
1018
+ self.toggle_output()
1019
+ else:
1020
+ _show_timestamp_toast(self)
1021
+
1022
+ def _format_output(self, output: str, *, is_preview: bool = False) -> FormattedOutput:
1023
+ """Format tool output based on tool type for nicer display.
1024
+
1025
+ Args:
1026
+ output: Raw output string
1027
+ is_preview: Whether this is for preview (truncated) display
1028
+
1029
+ Returns:
1030
+ FormattedOutput with content and optional truncation info.
1031
+ """
1032
+ output = output.strip()
1033
+ if not output:
1034
+ return FormattedOutput(content=Content(""))
1035
+
1036
+ # Tool-specific formatting using dispatch table
1037
+ formatters = {
1038
+ "write_todos": self._format_todos_output,
1039
+ "ls": self._format_ls_output,
1040
+ "read_file": self._format_file_output,
1041
+ "write_file": self._format_file_output,
1042
+ "edit_file": self._format_file_output,
1043
+ "grep": self._format_search_output,
1044
+ "glob": self._format_search_output,
1045
+ "shell": self._format_shell_output,
1046
+ "bash": self._format_shell_output,
1047
+ "execute": self._format_shell_output,
1048
+ "web_search": self._format_web_output,
1049
+ "fetch_url": self._format_web_output,
1050
+ "task": self._format_task_output,
1051
+ }
1052
+
1053
+ formatter = formatters.get(self._tool_name)
1054
+ if formatter:
1055
+ return formatter(output, is_preview=is_preview)
1056
+
1057
+ if is_preview:
1058
+ # Fallback for unknown tools: use generic truncation
1059
+ lines = output.split("\n")
1060
+ if len(lines) > self._PREVIEW_LINES:
1061
+ return self._format_lines_output(lines, is_preview=True)
1062
+ if len(output) > self._PREVIEW_CHARS:
1063
+ truncated = output[: self._PREVIEW_CHARS]
1064
+ truncation = f"{len(output) - self._PREVIEW_CHARS} more chars"
1065
+ return FormattedOutput(content=Content(truncated), truncation=truncation)
1066
+
1067
+ # Default: plain text (Content treats input as literal)
1068
+ return FormattedOutput(content=Content(output))
1069
+
1070
+ def _prefix_output(self, content: Content) -> Content: # noqa: PLR6301 # Grouped as method for widget cohesion
1071
+ """Prefix output with output marker and indent continuation lines.
1072
+
1073
+ Args:
1074
+ content: The styled output content to prefix and indent.
1075
+
1076
+ Returns:
1077
+ `Content` with output prefix on first line and indented
1078
+ continuation.
1079
+ """
1080
+ if not content.plain:
1081
+ return Content("")
1082
+ output_prefix = get_glyphs().output_prefix
1083
+ lines = content.split("\n")
1084
+ prefixed = [Content.assemble(f"{output_prefix} ", lines[0])]
1085
+ prefixed.extend(Content.assemble(" ", line) for line in lines[1:])
1086
+ return Content("\n").join(prefixed)
1087
+
1088
+ def _format_todos_output(self, output: str, *, is_preview: bool = False) -> FormattedOutput:
1089
+ """Format write_todos output as a checklist.
1090
+
1091
+ Returns:
1092
+ FormattedOutput with checklist content and optional truncation info.
1093
+ """
1094
+ items = self._parse_todo_items(output)
1095
+ if items is None:
1096
+ return FormattedOutput(content=Content(output))
1097
+
1098
+ if not items:
1099
+ return FormattedOutput(content=Content.styled(" No todos", "dim"))
1100
+
1101
+ lines: list[Content] = []
1102
+ max_items = 4 if is_preview else len(items)
1103
+
1104
+ # Build stats header
1105
+ stats = self._build_todo_stats(items)
1106
+ if stats:
1107
+ lines.extend([Content.assemble(" ", stats), Content("")])
1108
+
1109
+ # Format each item
1110
+ lines.extend(self._format_single_todo(item) for item in items[:max_items])
1111
+
1112
+ truncation = None
1113
+ if is_preview and len(items) > max_items:
1114
+ truncation = f"{len(items) - max_items} more"
1115
+
1116
+ return FormattedOutput(content=Content("\n").join(lines), truncation=truncation)
1117
+
1118
+ def _parse_todo_items(self, output: str) -> list | None: # noqa: PLR6301 # Grouped as method for widget cohesion
1119
+ """Parse todo items from output.
1120
+
1121
+ Returns:
1122
+ List of todo items, or None if parsing fails.
1123
+ """
1124
+ list_match = re.search(r"\[(\{.*\})\]", output.replace("\n", " "), re.DOTALL)
1125
+ if list_match:
1126
+ try:
1127
+ return ast.literal_eval("[" + list_match.group(1) + "]")
1128
+ except (ValueError, SyntaxError):
1129
+ return None
1130
+ try:
1131
+ items = ast.literal_eval(output)
1132
+ return items if isinstance(items, list) else None
1133
+ except (ValueError, SyntaxError):
1134
+ return None
1135
+
1136
+ def _build_todo_stats(self, items: list) -> Content:
1137
+ """Build stats content for todo list.
1138
+
1139
+ Returns:
1140
+ Styled `Content` showing active, pending, and completed counts.
1141
+ """
1142
+ colors = theme.get_theme_colors(self)
1143
+ completed = sum(1 for i in items if isinstance(i, dict) and i.get("status") == "completed")
1144
+ active = sum(1 for i in items if isinstance(i, dict) and i.get("status") == "in_progress")
1145
+ pending = len(items) - completed - active
1146
+
1147
+ parts: list[Content] = []
1148
+ if active:
1149
+ parts.append(Content.styled(f"{active} active", colors.warning))
1150
+ if pending:
1151
+ parts.append(Content.styled(f"{pending} pending", "dim"))
1152
+ if completed:
1153
+ parts.append(Content.styled(f"{completed} done", colors.success))
1154
+ return Content.styled(" | ", "dim").join(parts) if parts else Content("")
1155
+
1156
+ def _format_single_todo(self, item: dict | str) -> Content:
1157
+ """Format a single todo item.
1158
+
1159
+ Returns:
1160
+ Styled `Content` with checkbox and status styling.
1161
+ """
1162
+ colors = theme.get_theme_colors(self)
1163
+ if isinstance(item, dict):
1164
+ text = item.get("content", str(item))
1165
+ status = item.get("status", "pending")
1166
+ else:
1167
+ text = str(item)
1168
+ status = "pending"
1169
+
1170
+ if len(text) > _MAX_TODO_CONTENT_LEN:
1171
+ text = text[: _MAX_TODO_CONTENT_LEN - 3] + "..."
1172
+
1173
+ glyphs = get_glyphs()
1174
+ if status == "completed":
1175
+ return Content.assemble(
1176
+ Content.styled(f" {glyphs.checkmark} done", colors.success),
1177
+ Content.styled(f" {text}", "dim"),
1178
+ )
1179
+ if status == "in_progress":
1180
+ return Content.assemble(
1181
+ Content.styled(f" {glyphs.circle_filled} active", colors.warning),
1182
+ f" {text}",
1183
+ )
1184
+ return Content.assemble(
1185
+ Content.styled(f" {glyphs.circle_empty} todo", "dim"),
1186
+ f" {text}",
1187
+ )
1188
+
1189
+ def _format_ls_output( # noqa: PLR6301 # Grouped as method for widget cohesion
1190
+ self, output: str, *, is_preview: bool = False
1191
+ ) -> FormattedOutput:
1192
+ """Format ls output as a clean directory listing.
1193
+
1194
+ Returns:
1195
+ FormattedOutput with directory listing and optional truncation info.
1196
+ """
1197
+ # Try to parse as a Python list (common format)
1198
+ try:
1199
+ items = ast.literal_eval(output)
1200
+ if isinstance(items, list):
1201
+ lines: list[Content] = []
1202
+ max_items = 5 if is_preview else len(items)
1203
+ for item in items[:max_items]:
1204
+ path = Path(str(item))
1205
+ name = path.name
1206
+ if path.suffix in {".py", ".pyx"}:
1207
+ lines.append(Content.styled(f" {name}", theme.FILE_PYTHON))
1208
+ elif path.suffix in {".json", ".yaml", ".yml", ".yaml"}:
1209
+ lines.append(Content.styled(f" {name}", theme.FILE_CONFIG))
1210
+ elif not path.suffix:
1211
+ lines.append(Content.styled(f" {name}/", theme.FILE_DIR))
1212
+ else:
1213
+ lines.append(Content(f" {name}"))
1214
+
1215
+ truncation = None
1216
+ if is_preview and len(items) > max_items:
1217
+ truncation = f"{len(items) - max_items} more"
1218
+
1219
+ return FormattedOutput(content=Content("\n").join(lines), truncation=truncation)
1220
+ except (ValueError, SyntaxError):
1221
+ pass
1222
+
1223
+ # Fallback: plain text
1224
+ return FormattedOutput(content=Content(output))
1225
+
1226
+ def _format_file_output( # noqa: PLR6301 # Grouped as method for widget cohesion
1227
+ self, output: str, *, is_preview: bool = False
1228
+ ) -> FormattedOutput:
1229
+ """Format file read/write output.
1230
+
1231
+ Returns:
1232
+ FormattedOutput with file content and optional truncation info.
1233
+ """
1234
+ lines = output.split("\n")
1235
+ max_lines = 4 if is_preview else len(lines)
1236
+
1237
+ parts = [Content(line) for line in lines[:max_lines]]
1238
+ content = Content("\n").join(parts)
1239
+
1240
+ truncation = None
1241
+ if is_preview and len(lines) > max_lines:
1242
+ truncation = f"{len(lines) - max_lines} more lines"
1243
+
1244
+ return FormattedOutput(content=content, truncation=truncation)
1245
+
1246
+ def _format_search_output( # noqa: PLR6301 # Grouped as method for widget cohesion
1247
+ self, output: str, *, is_preview: bool = False
1248
+ ) -> FormattedOutput:
1249
+ """Format grep/glob search output.
1250
+
1251
+ Returns:
1252
+ FormattedOutput with search results and optional truncation info.
1253
+ """
1254
+ # Try to parse as a Python list (glob returns list of paths)
1255
+ try:
1256
+ items = ast.literal_eval(output.strip())
1257
+ if isinstance(items, list):
1258
+ parts: list[Content] = []
1259
+ max_items = 5 if is_preview else len(items)
1260
+ for item in items[:max_items]:
1261
+ path = Path(str(item))
1262
+ try:
1263
+ rel = path.relative_to(Path.cwd())
1264
+ display = str(rel)
1265
+ except ValueError:
1266
+ display = path.name
1267
+ parts.append(Content(f" {display}"))
1268
+
1269
+ truncation = None
1270
+ if is_preview and len(items) > max_items:
1271
+ truncation = f"{len(items) - max_items} more files"
1272
+
1273
+ return FormattedOutput(content=Content("\n").join(parts), truncation=truncation)
1274
+ except (ValueError, SyntaxError):
1275
+ pass
1276
+
1277
+ # Fallback: line-based output (grep results)
1278
+ lines = output.split("\n")
1279
+ max_lines = 5 if is_preview else len(lines)
1280
+
1281
+ parts = [
1282
+ Content(f" {raw_line.strip()}") for raw_line in lines[:max_lines] if raw_line.strip()
1283
+ ]
1284
+
1285
+ content = Content("\n").join(parts) if parts else Content("")
1286
+ truncation = None
1287
+ if is_preview and len(lines) > max_lines:
1288
+ truncation = f"{len(lines) - max_lines} more"
1289
+
1290
+ return FormattedOutput(content=content, truncation=truncation)
1291
+
1292
+ def _format_shell_output( # noqa: PLR6301 # Grouped as method for widget cohesion
1293
+ self, output: str, *, is_preview: bool = False
1294
+ ) -> FormattedOutput:
1295
+ """Format shell command output.
1296
+
1297
+ Returns:
1298
+ FormattedOutput with shell output and optional truncation info.
1299
+ """
1300
+ lines = output.split("\n")
1301
+ max_lines = 4 if is_preview else len(lines)
1302
+
1303
+ parts: list[Content] = []
1304
+ for i, line in enumerate(lines[:max_lines]):
1305
+ if i == 0 and line.startswith("$ "):
1306
+ parts.append(Content.styled(line, "dim"))
1307
+ else:
1308
+ parts.append(Content(line))
1309
+
1310
+ content = Content("\n").join(parts) if parts else Content("")
1311
+
1312
+ truncation = None
1313
+ if is_preview and len(lines) > max_lines:
1314
+ truncation = f"{len(lines) - max_lines} more lines"
1315
+
1316
+ return FormattedOutput(content=content, truncation=truncation)
1317
+
1318
+ def _format_web_output(self, output: str, *, is_preview: bool = False) -> FormattedOutput:
1319
+ """Format web_search/fetch_url output.
1320
+
1321
+ Returns:
1322
+ FormattedOutput with web response and optional truncation info.
1323
+ """
1324
+ data = self._try_parse_web_data(output)
1325
+ if isinstance(data, dict):
1326
+ return self._format_web_dict(data, is_preview=is_preview)
1327
+
1328
+ # Fallback: plain text
1329
+ return self._format_lines_output(output.split("\n"), is_preview=is_preview)
1330
+
1331
+ @staticmethod
1332
+ def _try_parse_web_data(output: str) -> dict | None:
1333
+ """Try to parse web output as JSON or dict.
1334
+
1335
+ Returns:
1336
+ Parsed dict if successful, None otherwise.
1337
+ """
1338
+ try:
1339
+ if output.strip().startswith("{"):
1340
+ return json.loads(output)
1341
+ return ast.literal_eval(output)
1342
+ except (ValueError, SyntaxError, json.JSONDecodeError):
1343
+ return None
1344
+
1345
+ def _format_web_dict(self, data: dict, *, is_preview: bool) -> FormattedOutput:
1346
+ """Format a parsed web response dict.
1347
+
1348
+ Returns:
1349
+ FormattedOutput with web response content and optional truncation info.
1350
+ """
1351
+ # Handle web_search results
1352
+ if "results" in data:
1353
+ return self._format_web_search_results(data.get("results", []), is_preview=is_preview)
1354
+
1355
+ # Handle fetch_url response
1356
+ if "markdown_content" in data:
1357
+ lines = data["markdown_content"].split("\n")
1358
+ return self._format_lines_output(lines, is_preview=is_preview)
1359
+
1360
+ # Generic dict - show key fields
1361
+ parts: list[Content] = []
1362
+ max_keys = 3 if is_preview else len(data)
1363
+ for k, v in list(data.items())[:max_keys]:
1364
+ v_str = str(v)
1365
+ if is_preview and len(v_str) > _MAX_WEB_CONTENT_LEN:
1366
+ v_str = v_str[:_MAX_WEB_CONTENT_LEN] + "..."
1367
+ parts.append(Content(f" {k}: {v_str}"))
1368
+ truncation = None
1369
+ if is_preview and len(data) > max_keys:
1370
+ truncation = f"{len(data) - max_keys} more"
1371
+ return FormattedOutput(
1372
+ content=Content("\n").join(parts) if parts else Content(""),
1373
+ truncation=truncation,
1374
+ )
1375
+
1376
+ def _format_web_search_results( # noqa: PLR6301 # Grouped as method for widget cohesion
1377
+ self, results: list, *, is_preview: bool
1378
+ ) -> FormattedOutput:
1379
+ """Format web search results.
1380
+
1381
+ Returns:
1382
+ FormattedOutput with search results and optional truncation info.
1383
+ """
1384
+ if not results:
1385
+ return FormattedOutput(content=Content.styled("No results", "dim"))
1386
+ parts: list[Content] = []
1387
+ max_results = 3 if is_preview else len(results)
1388
+ for r in results[:max_results]:
1389
+ title = r.get("title", "")
1390
+ url = r.get("url", "")
1391
+ parts.extend(
1392
+ [
1393
+ Content.styled(f" {title}", "bold"),
1394
+ Content.styled(f" {url}", "dim"),
1395
+ ]
1396
+ )
1397
+ truncation = None
1398
+ if is_preview and len(results) > max_results:
1399
+ truncation = f"{len(results) - max_results} more results"
1400
+ return FormattedOutput(content=Content("\n").join(parts), truncation=truncation)
1401
+
1402
+ def _format_lines_output( # noqa: PLR6301 # Grouped as method for widget cohesion
1403
+ self, lines: list[str], *, is_preview: bool
1404
+ ) -> FormattedOutput:
1405
+ """Format a list of lines with optional preview truncation.
1406
+
1407
+ Returns:
1408
+ FormattedOutput with lines content and optional truncation info.
1409
+ """
1410
+ max_lines = 4 if is_preview else len(lines)
1411
+ parts = [Content(line) for line in lines[:max_lines]]
1412
+ content = Content("\n").join(parts) if parts else Content("")
1413
+ truncation = None
1414
+ if is_preview and len(lines) > max_lines:
1415
+ truncation = f"{len(lines) - max_lines} more lines"
1416
+ return FormattedOutput(content=content, truncation=truncation)
1417
+
1418
+ def _format_task_output( # noqa: PLR6301 # Grouped as method for widget cohesion
1419
+ self, output: str, *, is_preview: bool = False
1420
+ ) -> FormattedOutput:
1421
+ """Format task (subagent) output.
1422
+
1423
+ Returns:
1424
+ FormattedOutput with task output and optional truncation info.
1425
+ """
1426
+ lines = output.split("\n")
1427
+ max_lines = 4 if is_preview else len(lines)
1428
+
1429
+ parts = [Content(line) for line in lines[:max_lines]]
1430
+ content = Content("\n").join(parts) if parts else Content("")
1431
+
1432
+ truncation = None
1433
+ if is_preview and len(lines) > max_lines:
1434
+ truncation = f"{len(lines) - max_lines} more lines"
1435
+
1436
+ return FormattedOutput(content=content, truncation=truncation)
1437
+
1438
+ def _update_output_display(self) -> None:
1439
+ """Update the output display based on expanded state."""
1440
+ # Guard: all widgets must be initialized before updating display state
1441
+ if (
1442
+ not self._output
1443
+ or not self._preview_widget
1444
+ or not self._full_widget
1445
+ or not self._hint_widget
1446
+ ):
1447
+ return
1448
+
1449
+ output_stripped = self._output.strip()
1450
+ lines = output_stripped.split("\n")
1451
+ total_lines = len(lines)
1452
+ total_chars = len(output_stripped)
1453
+
1454
+ # Truncate if too many lines OR too many characters
1455
+ needs_truncation = total_lines > self._PREVIEW_LINES or total_chars > self._PREVIEW_CHARS
1456
+
1457
+ if self._expanded:
1458
+ # Show full output with formatting
1459
+ self._preview_widget.display = False
1460
+ result = self._format_output(self._output, is_preview=False)
1461
+ prefixed = self._prefix_output(result.content)
1462
+ self._full_widget.update(prefixed)
1463
+ self._full_widget.display = True
1464
+ # Show collapse hint underneath
1465
+ self._hint_widget.update(Content.styled("click or Ctrl+O to collapse", "dim italic"))
1466
+ self._hint_widget.display = True
1467
+ else:
1468
+ # Show preview
1469
+ self._full_widget.display = False
1470
+ if needs_truncation:
1471
+ result = self._format_output(self._output, is_preview=True)
1472
+ prefixed = self._prefix_output(result.content)
1473
+ self._preview_widget.update(prefixed)
1474
+ self._preview_widget.display = True
1475
+
1476
+ # Build hint with truncation info if available
1477
+ if result.truncation:
1478
+ ellipsis = get_glyphs().ellipsis
1479
+ hint = Content.styled(
1480
+ f"{ellipsis} {result.truncation} — click or Ctrl+O to expand",
1481
+ "dim",
1482
+ )
1483
+ else:
1484
+ hint = Content.styled("click or Ctrl+O to expand", "dim italic")
1485
+ self._hint_widget.update(hint)
1486
+ self._hint_widget.display = True
1487
+ elif output_stripped:
1488
+ # Output fits in preview, show formatted
1489
+ result = self._format_output(output_stripped, is_preview=False)
1490
+ prefixed = self._prefix_output(result.content)
1491
+ self._preview_widget.update(prefixed)
1492
+ self._preview_widget.display = True
1493
+ self._hint_widget.display = False
1494
+ else:
1495
+ self._preview_widget.display = False
1496
+ self._hint_widget.display = False
1497
+
1498
+ @property
1499
+ def has_output(self) -> bool:
1500
+ """Check if this tool message has output to display.
1501
+
1502
+ Returns:
1503
+ True if there is output content, False otherwise.
1504
+ """
1505
+ return bool(self._output)
1506
+
1507
+ def _filtered_args(self) -> dict[str, Any]:
1508
+ """Filter large tool args for display.
1509
+
1510
+ Returns:
1511
+ Filtered args dict with only display-relevant keys for write/edit tools.
1512
+ """
1513
+ if self._tool_name not in {"write_file", "edit_file"}:
1514
+ return self._args
1515
+
1516
+ filtered: dict[str, Any] = {}
1517
+ for key in ("file_path", "path", "replace_all"):
1518
+ if key in self._args:
1519
+ filtered[key] = self._args[key]
1520
+ return filtered
1521
+
1522
+
1523
+ class DiffMessage(_TimestampClickMixin, Static):
1524
+ """Widget displaying a diff with syntax highlighting."""
1525
+
1526
+ DEFAULT_CSS = """
1527
+ DiffMessage {
1528
+ height: auto;
1529
+ padding: 1;
1530
+ margin: 0 0 1 0;
1531
+ background: $surface;
1532
+ border: solid $primary;
1533
+ }
1534
+
1535
+ DiffMessage .diff-header {
1536
+ text-style: bold;
1537
+ margin-bottom: 1;
1538
+ }
1539
+
1540
+ DiffMessage .diff-add {
1541
+ color: $text-success;
1542
+ background: $success-muted;
1543
+ }
1544
+
1545
+ DiffMessage .diff-remove {
1546
+ color: $text-error;
1547
+ background: $error-muted;
1548
+ }
1549
+
1550
+ DiffMessage .diff-context {
1551
+ color: $text-muted;
1552
+ }
1553
+
1554
+ DiffMessage .diff-hunk {
1555
+ color: $secondary;
1556
+ text-style: bold;
1557
+ }
1558
+ """
1559
+ """Diff syntax coloring per theme: additions, removals, muted context."""
1560
+
1561
+ def __init__(self, diff_content: str, file_path: str = "", **kwargs: Any) -> None:
1562
+ """Initialize a diff message.
1563
+
1564
+ Args:
1565
+ diff_content: The unified diff content
1566
+ file_path: Path to the file being modified
1567
+ **kwargs: Additional arguments passed to parent
1568
+ """
1569
+ super().__init__(**kwargs)
1570
+ self._diff_content = diff_content
1571
+ self._file_path = file_path
1572
+
1573
+ def compose(self) -> ComposeResult:
1574
+ """Compose the diff message layout.
1575
+
1576
+ Yields:
1577
+ Widgets displaying the diff header and formatted content.
1578
+ """
1579
+ if self._file_path:
1580
+ yield Static(
1581
+ Content.from_markup("[bold]File: $path[/bold]", path=self._file_path),
1582
+ classes="diff-header",
1583
+ )
1584
+
1585
+ # Render the diff with per-line Statics (CSS-driven backgrounds)
1586
+ yield from compose_diff_lines(self._diff_content, max_lines=100)
1587
+
1588
+ def on_mount(self) -> None:
1589
+ """Set border style based on charset mode."""
1590
+ if is_ascii_mode():
1591
+ colors = theme.get_theme_colors(self)
1592
+ self.styles.border = ("ascii", colors.primary)
1593
+
1594
+
1595
+ class ErrorMessage(_TimestampClickMixin, Static):
1596
+ """Widget displaying an error message."""
1597
+
1598
+ DEFAULT_CSS = """
1599
+ ErrorMessage {
1600
+ height: auto;
1601
+ padding: 1;
1602
+ margin: 0 0 1 0;
1603
+ background: $error-muted;
1604
+ color: white;
1605
+ border-left: wide $error;
1606
+ }
1607
+ """
1608
+ """Tinted background + left border to visually separate errors from output."""
1609
+
1610
+ def __init__(self, error: str, **kwargs: Any) -> None:
1611
+ """Initialize an error message.
1612
+
1613
+ Args:
1614
+ error: The error message
1615
+ **kwargs: Additional arguments passed to parent
1616
+ """
1617
+ # Store raw content for serialization
1618
+ self._content = error
1619
+ super().__init__(**kwargs)
1620
+
1621
+ def render(self) -> Content:
1622
+ """Render with theme-aware colors.
1623
+
1624
+ Returns:
1625
+ Styled error content with theme-appropriate color.
1626
+ """
1627
+ colors = theme.get_theme_colors(self)
1628
+ return Content.assemble(
1629
+ Content.styled("Error: ", f"bold {colors.error}"),
1630
+ self._content,
1631
+ )
1632
+
1633
+ def on_mount(self) -> None:
1634
+ """Set border style based on charset mode."""
1635
+ if is_ascii_mode():
1636
+ colors = theme.get_theme_colors(self)
1637
+ self.styles.border_left = ("ascii", colors.error)
1638
+
1639
+
1640
+ class AppMessage(Static):
1641
+ """Widget displaying an app message."""
1642
+
1643
+ # Disable Textual's auto_links to prevent a flicker cycle: Style.__add__
1644
+ # calls .copy() for linked styles, generating a fresh random _link_id on
1645
+ # each render. This means highlight_link_id never stabilizes, causing an
1646
+ # infinite hover-refresh loop.
1647
+ auto_links = False
1648
+
1649
+ DEFAULT_CSS = """
1650
+ AppMessage {
1651
+ height: auto;
1652
+ padding: 0 1;
1653
+ margin: 0 0 1 0;
1654
+ color: $text-muted;
1655
+ text-style: italic;
1656
+ }
1657
+ """
1658
+
1659
+ def __init__(self, message: str | Content, **kwargs: Any) -> None:
1660
+ """Initialize a system message.
1661
+
1662
+ Args:
1663
+ message: The system message as a string or pre-styled `Content`.
1664
+ **kwargs: Additional arguments passed to parent
1665
+ """
1666
+ # Store raw content for serialization
1667
+ self._content = message
1668
+ rendered = (
1669
+ message if isinstance(message, Content) else Content.styled(message, "dim italic")
1670
+ )
1671
+ super().__init__(rendered, **kwargs)
1672
+
1673
+ def on_click(self, event: Click) -> None:
1674
+ """Open style-embedded hyperlinks on single click and show timestamp."""
1675
+ open_style_link(event)
1676
+ _show_timestamp_toast(self)
1677
+
1678
+
1679
+ class SummarizationMessage(AppMessage):
1680
+ """Widget displaying a summarization completion notification."""
1681
+
1682
+ DEFAULT_CSS = """
1683
+ SummarizationMessage {
1684
+ height: auto;
1685
+ padding: 0 1;
1686
+ margin: 0 0 1 0;
1687
+ color: $primary;
1688
+ background: $surface;
1689
+ border-left: wide $primary;
1690
+ text-style: bold;
1691
+ }
1692
+ """
1693
+
1694
+ def __init__(self, message: str | Content | None = None, **kwargs: Any) -> None:
1695
+ """Initialize a summarization notification message.
1696
+
1697
+ Args:
1698
+ message: Optional message override used when rehydrating from the
1699
+ message store.
1700
+
1701
+ Defaults to the standard summary notification.
1702
+ **kwargs: Additional arguments passed to parent.
1703
+ """
1704
+ self._raw_message = message
1705
+ # Pass the default text to AppMessage for _content serialization;
1706
+ # render() supplies theme-aware styling at display time.
1707
+ super().__init__(message or "Conversation summarized", **kwargs)
1708
+
1709
+ def render(self) -> Content:
1710
+ """Render with theme-aware colors.
1711
+
1712
+ Returns:
1713
+ Styled summarization content with theme-appropriate color.
1714
+ """
1715
+ colors = theme.get_theme_colors(self)
1716
+ if self._raw_message is None:
1717
+ return Content.styled("Conversation summarized", f"bold {colors.primary}")
1718
+ if isinstance(self._raw_message, Content):
1719
+ return self._raw_message
1720
+ return Content.styled(self._raw_message, f"bold {colors.primary}")