vtx-coding-agent 0.1.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 (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/ui/blocks.py ADDED
@@ -0,0 +1,735 @@
1
+ from collections.abc import Callable, Iterable
2
+ from dataclasses import dataclass
3
+ from typing import Literal
4
+
5
+ from rich.style import Style
6
+ from rich.text import Text
7
+ from textual import events
8
+ from textual.app import ComposeResult
9
+ from textual.message import Message
10
+ from textual.widgets import Label, Static
11
+
12
+ from vtx import config
13
+ from vtx.core.types import ImageContent
14
+ from vtx.diff_display import DIFF_BG_PAD_MARKER
15
+ from vtx.permissions import ApprovalResponse
16
+
17
+ from .formatting import (
18
+ find_stable_block_boundary,
19
+ format_bash_command,
20
+ format_markdown,
21
+ format_markdown_block,
22
+ markdown_render_width,
23
+ strip_markdown_for_collapsed_text,
24
+ )
25
+
26
+ _UPDATE_COMMAND = "uv tool upgrade vtx-coding-agent"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class LaunchWarning:
31
+ message: str
32
+ severity: Literal["warning", "error"] = "warning"
33
+
34
+
35
+ def stylize_badge_markers(text: Text, markers: Iterable[str]) -> None:
36
+ badge_style = f"{config.ui.colors.badge.label} bold"
37
+ plain = text.plain
38
+ for marker in markers:
39
+ search_start = 0
40
+ while True:
41
+ start = plain.find(marker, search_start)
42
+ if start == -1:
43
+ break
44
+ text.stylize(badge_style, start, start + len(marker))
45
+ search_start = start + len(marker)
46
+
47
+
48
+ class _StreamingMarkdownMixin:
49
+ """Block-cached markdown streaming.
50
+
51
+ The current unfinished line is buffered until a newline arrives. Completed text is
52
+ split at stable block boundaries (blank lines outside code fences). Closed blocks
53
+ are rendered once and cached, so each refresh only re-renders the open tail block,
54
+ coalesced into the next frame. `_flush_streaming` does one full render at the end,
55
+ so the final display never carries streaming artifacts.
56
+ """
57
+
58
+ _pending: str
59
+ _completed: str
60
+ _completed_display: Text
61
+ _committed_blocks: list[Text]
62
+ _committed_len: int
63
+ _committed_width: int
64
+ _stream_update_pending: bool
65
+ _stream_finalized: bool
66
+ # Provided by Textual's Static widget at runtime
67
+ call_after_refresh: Callable[[Callable[[], None]], object]
68
+
69
+ def _init_streaming(self) -> None:
70
+ self._pending = ""
71
+ self._completed = ""
72
+ self._completed_display = Text()
73
+ self._committed_blocks = []
74
+ self._committed_len = 0
75
+ self._committed_width = 0
76
+ self._stream_update_pending = False
77
+ self._stream_finalized = False
78
+
79
+ def _streaming_update_label(self, display: Text) -> None:
80
+ raise NotImplementedError
81
+
82
+ def _streaming_pending_style(self) -> str | None:
83
+ return None
84
+
85
+ def _refresh_completed_display(self) -> None:
86
+ width = markdown_render_width()
87
+ if width != self._committed_width: # cached renders are stale after a resize
88
+ self._committed_blocks = []
89
+ self._committed_len = 0
90
+ self._committed_width = width
91
+
92
+ boundary = find_stable_block_boundary(self._completed)
93
+ if boundary > self._committed_len:
94
+ block = format_markdown_block(self._completed[self._committed_len : boundary], width)
95
+ # Some source renders to nothing (HTML comments, link reference definitions).
96
+ # An empty entry here would add a stray blank gap to every later join.
97
+ if block.plain:
98
+ self._committed_blocks.append(block)
99
+ self._committed_len = boundary
100
+
101
+ tail = self._completed[self._committed_len :]
102
+ parts = [*self._committed_blocks]
103
+ if tail.strip():
104
+ tail_block = format_markdown_block(tail, width)
105
+ if tail_block.plain:
106
+ parts.append(tail_block)
107
+ self._completed_display = Text("\n\n").join(parts) if parts else Text()
108
+
109
+ def _render_streaming_display(self) -> Text:
110
+ display = self._completed_display.copy()
111
+ completed_needs_separator = self._completed.endswith("\n") or self._completed.endswith(
112
+ "\r"
113
+ )
114
+
115
+ if (
116
+ not self._stream_finalized
117
+ and completed_needs_separator
118
+ and not self._pending
119
+ and display.plain
120
+ ):
121
+ display.append("\n")
122
+
123
+ return display
124
+
125
+ def _schedule_streaming_update(self) -> None:
126
+ if self._stream_update_pending:
127
+ return
128
+ self._stream_update_pending = True
129
+ self.call_after_refresh(self._flush_streaming_update)
130
+
131
+ def _flush_streaming_update(self) -> None:
132
+ self._stream_update_pending = False
133
+ if self._stream_finalized:
134
+ # An update scheduled by the last newline can fire after finalize() already
135
+ # put the final render on the label. Don't overwrite it.
136
+ return
137
+ self._refresh_completed_display()
138
+ self._streaming_update_label(self._render_streaming_display())
139
+
140
+ def _append_streaming(self, text: str) -> None:
141
+ self._pending += text
142
+
143
+ last_nl = self._pending.rfind("\n")
144
+ if last_nl != -1:
145
+ self._completed += self._pending[: last_nl + 1]
146
+ self._pending = self._pending[last_nl + 1 :]
147
+ self._schedule_streaming_update()
148
+
149
+ def _flush_streaming(self) -> Text:
150
+ self._stream_finalized = True
151
+ if self._pending:
152
+ self._completed += self._pending
153
+ self._pending = ""
154
+ self._completed_display = format_markdown(self._completed) if self._completed else Text()
155
+ return self._render_streaming_display()
156
+
157
+
158
+ class ThinkingBlock(_StreamingMarkdownMixin, Static):
159
+ ALLOW_SELECT = True
160
+ can_focus = False
161
+
162
+ def __init__(self, content: str = "", finalized: bool = False, **kwargs) -> None:
163
+ super().__init__(**kwargs)
164
+ self._content = content
165
+ self._finalized = finalized
166
+ self._label: Label | None = None
167
+ self._init_streaming()
168
+ self.add_class("thinking-block")
169
+
170
+ def compose(self) -> ComposeResult:
171
+ if self._finalized and self._content and config.ui.collapse_thinking:
172
+ yield Label(self._format_collapsed(), id="thinking-content", markup=False)
173
+ else:
174
+ yield Label(self._content, id="thinking-content", markup=False)
175
+
176
+ @property
177
+ def label(self) -> Label:
178
+ if self._label is None:
179
+ self._label = self.query_one("#thinking-content", Label)
180
+ return self._label
181
+
182
+ def _format_collapsed(self) -> Text:
183
+ """Show collapsed thinking with configured line count."""
184
+ lines = self._content.strip().split("\n")
185
+ max_lines = self._get_max_lines()
186
+ style = f"{config.ui.colors.dim} italic"
187
+
188
+ if max_lines is None:
189
+ # No truncation — show everything
190
+ text = Text()
191
+ for i, line in enumerate(lines):
192
+ if i > 0:
193
+ text.append("\n")
194
+ text.append(strip_markdown_for_collapsed_text(line.strip()), style=style)
195
+ return text
196
+
197
+ visible = lines[:max_lines]
198
+ text = Text()
199
+ for i, line in enumerate(visible):
200
+ if i > 0:
201
+ text.append("\n")
202
+ text.append(strip_markdown_for_collapsed_text(line.strip()), style=style)
203
+
204
+ remaining = len(lines) - max_lines
205
+ if remaining > 0:
206
+ text.append(f" ... ({remaining} more lines)", style=style)
207
+ return text
208
+
209
+ @staticmethod
210
+ def _get_max_lines() -> int | None:
211
+ setting = config.ui.thinking_lines
212
+ if setting == "none":
213
+ return None
214
+ return int(setting)
215
+
216
+ def _streaming_update_label(self, display: Text) -> None:
217
+ self.label.update(display)
218
+ return None
219
+
220
+ def _streaming_pending_style(self) -> str | None:
221
+ return f"{config.ui.colors.dim} italic"
222
+
223
+ async def append(self, text: str) -> None:
224
+ self._content += text
225
+ self._append_streaming(text)
226
+
227
+ def finalize(self) -> None:
228
+ if self._content and not self._finalized:
229
+ self._finalized = True
230
+ self.label.update(self._flush_streaming())
231
+ self.call_after_refresh(self._do_finalize)
232
+
233
+ def _do_finalize(self) -> None:
234
+ if self._content and config.ui.collapse_thinking:
235
+ self.label.update(self._format_collapsed())
236
+
237
+ def set_content(self, text: str) -> None:
238
+ self._content = text
239
+ self._finalized = True
240
+ if config.ui.collapse_thinking:
241
+ self.label.update(self._format_collapsed())
242
+ else:
243
+ self.label.update(text)
244
+
245
+
246
+ class ContentBlock(_StreamingMarkdownMixin, Static):
247
+ # TODO: Consider switching to Textual's Markdown widget + MarkdownStream.write() for
248
+ # incremental rendering during streaming. This would eliminate the visual reflow when
249
+ # finalize() converts plain text to markdown. The tradeoff: our custom Rich-based
250
+ # formatting (CustomMarkdown with LeftJustifiedHeading, PlainListItem, PlainCodeBlock)
251
+ # is incompatible with Textual's Markdown pipeline, so we'd need to reimplement those
252
+ # customizations using Textual's theming/CSS system. See toad and mistral-vibe for
253
+ # reference implementations using MarkdownStream.
254
+
255
+ ALLOW_SELECT = True
256
+ can_focus = False
257
+
258
+ def __init__(self, content: str = "", finalized: bool = False, **kwargs) -> None:
259
+ super().__init__(**kwargs)
260
+ self._content = content
261
+ self._finalized = finalized
262
+ self._label: Label | None = None
263
+ self._init_streaming()
264
+ self.add_class("content-block")
265
+
266
+ def compose(self) -> ComposeResult:
267
+ if self._finalized and self._content:
268
+ yield Label(format_markdown(self._content), id="content-text", markup=False)
269
+ else:
270
+ yield Label(self._content, id="content-text", markup=False)
271
+
272
+ @property
273
+ def label(self) -> Label:
274
+ if self._label is None:
275
+ self._label = self.query_one("#content-text", Label)
276
+ return self._label
277
+
278
+ def _streaming_update_label(self, display: Text) -> None:
279
+ self.label.update(display)
280
+ return None
281
+
282
+ async def append(self, text: str) -> None:
283
+ self._content += text
284
+ self._append_streaming(text)
285
+
286
+ def finalize(self) -> None:
287
+ if self._content and not self._finalized:
288
+ self._finalized = True
289
+ self.label.update(self._flush_streaming())
290
+ self.call_after_refresh(self._do_finalize)
291
+
292
+ def _do_finalize(self) -> None:
293
+ if self._content:
294
+ self.label.update(format_markdown(self._content))
295
+
296
+ def set_content(self, text: str) -> None:
297
+ self._content = text
298
+ self._finalized = True
299
+ self.label.update(format_markdown(self._content))
300
+
301
+
302
+ class ToolBlock(Static):
303
+ """
304
+ Format:
305
+ TOOL_NAME call_msg
306
+ truncated output
307
+ """
308
+
309
+ ALLOW_SELECT = True
310
+ can_focus = False
311
+ MAX_HEADER_LINES = 2
312
+
313
+ def __init__(
314
+ self,
315
+ name: str = "",
316
+ call_msg: str | None = None,
317
+ icon: str = "→",
318
+ expanded: bool = False,
319
+ **kwargs,
320
+ ) -> None:
321
+ super().__init__(**kwargs)
322
+ self._name = name
323
+ self._icon = icon
324
+ self._call_msg = call_msg
325
+ self._ui_summary: str | None = None
326
+ self._ui_details: str | None = None
327
+ self._ui_details_full: str | None = None
328
+ self._images: list[ImageContent] | None = None
329
+ self._result_markup: bool = True
330
+ self._expanded: bool = expanded
331
+ self._success: bool | None = None
332
+ self._awaiting_approval: bool = False
333
+ self._approval_preview: str | None = None
334
+ self._approval_selection: ApprovalResponse = ApprovalResponse.APPROVE
335
+ self.add_class("tool-block")
336
+ self._set_state(None)
337
+
338
+ def compose(self) -> ComposeResult:
339
+ yield Label(self._format_header(), id="tool-header")
340
+ yield Label("", id="tool-output", classes="tool-output -hidden")
341
+
342
+ def _format_header(self, truncate: bool = True) -> Text:
343
+ colors = config.ui.colors
344
+ result = Text()
345
+ formatted_name = self._name or ""
346
+
347
+ success_style = Style(color=colors.muted, bold=True)
348
+ icon_style: str | Style = success_style
349
+ name_style: str | Style = success_style
350
+ if self._success is None:
351
+ icon_style = colors.running
352
+ name_style = colors.running
353
+ elif self._success is False:
354
+ icon_style = colors.failed
355
+ name_style = colors.failed
356
+ elif self._success is True and config.ui.colored_tool_badge:
357
+ badge_style = Style(color=colors.badge.label, bold=True)
358
+ icon_style = badge_style
359
+ name_style = badge_style
360
+
361
+ if self._awaiting_approval:
362
+ result.append(
363
+ " △ Permission required ",
364
+ style=Style(bgcolor=colors.notice, color=colors.bg, bold=True),
365
+ )
366
+ result.append("\n\n")
367
+
368
+ result.append(f"{self._icon} ", style=icon_style)
369
+ result.append(formatted_name, style=name_style)
370
+
371
+ if self._call_msg:
372
+ result.append(" ")
373
+ result.append_text(self._format_call_msg(truncate=truncate))
374
+
375
+ if self._ui_summary:
376
+ result.append(" ")
377
+ summary = self._render_markup_safe(self._ui_summary)
378
+ result.append_text(summary)
379
+
380
+ if self._success is None and not self._awaiting_approval and not self._call_msg:
381
+ result.append(" ...", style=colors.dim)
382
+
383
+ return result
384
+
385
+ def _format_call_msg(self, truncate: bool = True) -> Text:
386
+ if not self._call_msg:
387
+ return Text()
388
+
389
+ if truncate:
390
+ lines = self._call_msg.split("\n")
391
+ if len(lines) > self.MAX_HEADER_LINES:
392
+ content = "\n".join(lines[: self.MAX_HEADER_LINES])
393
+ content += f"\n... ({len(lines) - self.MAX_HEADER_LINES} more lines)"
394
+ else:
395
+ content = self._call_msg
396
+ else:
397
+ content = self._call_msg
398
+
399
+ if self._name == "bash":
400
+ return format_bash_command(content)
401
+
402
+ rendered = self._render_markup_safe(content)
403
+ return Text(rendered.plain, style=config.ui.colors.dim)
404
+
405
+ def _render_markup_safe(self, content: str) -> Text:
406
+ try:
407
+ text = Text.from_markup(content)
408
+ except Exception:
409
+ return Text(content)
410
+
411
+ for span in text.spans:
412
+ style = span.style
413
+ if isinstance(style, str):
414
+ try:
415
+ Style.parse(style)
416
+ except Exception:
417
+ return Text(content)
418
+
419
+ return text
420
+
421
+ def _pad_diff_backgrounds(self, text: Text, width: int) -> Text:
422
+ if DIFF_BG_PAD_MARKER not in text.plain or width <= 0:
423
+ return text
424
+
425
+ result = Text()
426
+ lines = text.split("\n", allow_blank=True)
427
+ for index, line in enumerate(lines):
428
+ marker_pos = line.plain.find(DIFF_BG_PAD_MARKER)
429
+ if marker_pos != -1:
430
+ line = line.copy()
431
+ marker_end = marker_pos + len(DIFF_BG_PAD_MARKER)
432
+ marker_spans = [span for span in line.spans if span.start <= marker_pos < span.end]
433
+ marker_style = marker_spans[0].style if marker_spans else None
434
+ line.plain = line.plain[:marker_pos] + line.plain[marker_end:]
435
+ line.spans = [
436
+ span
437
+ for span in line.spans
438
+ if not (span.start >= marker_pos and span.end <= marker_end)
439
+ ]
440
+ padding = max(0, width - len(line.plain))
441
+ if padding:
442
+ line.append(" " * padding, style=marker_style)
443
+ if index > 0:
444
+ result.append("\n")
445
+ result.append_text(line)
446
+ return result
447
+
448
+ def _set_state(self, success: bool | None) -> None:
449
+ self.remove_class("-pending", "-success", "-error", "-approval")
450
+ if success is None:
451
+ if self._awaiting_approval:
452
+ self.add_class("-approval")
453
+ else:
454
+ self.add_class("-pending")
455
+ elif success:
456
+ self.add_class("-success")
457
+ else:
458
+ self.add_class("-error")
459
+
460
+ def show_approval(
461
+ self, preview: str | None = None, selected: ApprovalResponse | None = None
462
+ ) -> None:
463
+ self._awaiting_approval = True
464
+ self._approval_preview = preview
465
+ if selected is not None:
466
+ self._approval_selection = selected
467
+ self._set_state(None)
468
+ self.query_one("#tool-header", Label).update(self._format_header())
469
+ self._render_approval_output()
470
+
471
+ def update_approval_selection(self, selected: ApprovalResponse) -> None:
472
+ if not self._awaiting_approval:
473
+ return
474
+ self._approval_selection = selected
475
+ self._render_approval_output()
476
+
477
+ def _render_approval_output(self) -> None:
478
+ output = self.query_one("#tool-output", Label)
479
+ self.remove_class("-with-details")
480
+ output.remove_class("-hidden")
481
+ output.remove_class("-details")
482
+
483
+ content = Text()
484
+ if self._approval_preview:
485
+ content.append_text(self._render_markup_safe(self._approval_preview))
486
+ content.append("\n\n")
487
+ content.append_text(self._format_approval_controls(self._approval_selection))
488
+ output.update(content)
489
+
490
+ def hide_approval(self) -> None:
491
+ self._awaiting_approval = False
492
+ self._approval_preview = None
493
+ self._approval_selection = ApprovalResponse.APPROVE
494
+ self._set_state(None)
495
+ self.query_one("#tool-header", Label).update(self._format_header())
496
+ output = self.query_one("#tool-output", Label)
497
+ self.remove_class("-with-details")
498
+ output.remove_class("-details")
499
+ output.add_class("-hidden")
500
+ output.update(Text(""))
501
+
502
+ def _format_approval_controls(
503
+ self, selected: ApprovalResponse = ApprovalResponse.APPROVE
504
+ ) -> Text:
505
+ colors = config.ui.colors
506
+ text = Text()
507
+ # The non-selected button uses the dim panel_alt background; the
508
+ # selected one gets the accent. Direct y/n keys submit immediately;
509
+ # left/right move the highlight; enter submits the highlight.
510
+ approve_selected = selected == ApprovalResponse.APPROVE
511
+ approve_style = Style(
512
+ bgcolor=colors.accent if approve_selected else colors.panel_alt,
513
+ color=colors.bg if approve_selected else colors.dim,
514
+ bold=True,
515
+ )
516
+ deny_style = Style(
517
+ bgcolor=colors.accent if not approve_selected else colors.panel_alt,
518
+ color=colors.bg if not approve_selected else colors.dim,
519
+ bold=True,
520
+ )
521
+ text.append("[y] approve ", style=approve_style)
522
+ text.append(" ")
523
+ text.append("[n] deny ", style=deny_style)
524
+ text.append(" ")
525
+ text.append("(← → enter)", style=Style(color=colors.dim))
526
+ return text
527
+
528
+ def update_call_msg(self, call_msg: str) -> None:
529
+ self._call_msg = call_msg
530
+ self.query_one("#tool-header", Label).update(self._format_header())
531
+
532
+ def set_result(
533
+ self,
534
+ ui_summary: str | None,
535
+ ui_details: str | None,
536
+ success: bool,
537
+ markup: bool = True,
538
+ ui_details_full: str | None = None,
539
+ images: list[ImageContent] | None = None,
540
+ ) -> None:
541
+ self._ui_summary = ui_summary
542
+ self._ui_details = ui_details
543
+ self._ui_details_full = ui_details_full
544
+ self._images = images
545
+ self._result_markup = markup
546
+ self._success = success
547
+ self._awaiting_approval = False
548
+ self._set_state(success)
549
+ self._render_result_output()
550
+ self.query_one("#tool-header", Label).update(self._format_header())
551
+
552
+ def set_expanded(self, expanded: bool) -> None:
553
+ if self._expanded == expanded:
554
+ return
555
+ self._expanded = expanded
556
+ self._render_result_output()
557
+
558
+ def on_resize(self, event: events.Resize) -> None:
559
+ del event
560
+ if self._ui_details or self._ui_details_full:
561
+ self._render_result_output()
562
+
563
+ def _render_result_output(self) -> None:
564
+ output = self.query_one("#tool-output", Label)
565
+ ui_details = (
566
+ self._ui_details_full if self._expanded and self._ui_details_full else self._ui_details
567
+ )
568
+ if ui_details:
569
+ rendered = (
570
+ self._render_markup_safe(ui_details) if self._result_markup else Text(ui_details)
571
+ )
572
+ is_diff_output = DIFF_BG_PAD_MARKER in rendered.plain
573
+ rendered = self._pad_diff_backgrounds(rendered, output.size.width or self.size.width)
574
+ # Detail blocks need a 1-line gap; drop compact spacing that was
575
+ # applied before we knew this tool would have output.
576
+ self.remove_class("-compact")
577
+ self.add_class("-with-details")
578
+ output.remove_class("-hidden")
579
+ output.remove_class("-details")
580
+ if is_diff_output:
581
+ output.add_class("-diff-output")
582
+ else:
583
+ output.remove_class("-diff-output")
584
+ output.update(rendered)
585
+ elif self._images:
586
+ image_count = len(self._images)
587
+ image_label = "image" if image_count == 1 else "images"
588
+ rendered = Text(f"Attached {image_count} {image_label}", style=config.ui.colors.dim)
589
+ self.remove_class("-compact")
590
+ self.add_class("-with-details")
591
+ output.remove_class("-hidden")
592
+ output.remove_class("-details")
593
+ output.remove_class("-diff-output")
594
+ output.update(rendered)
595
+ else:
596
+ output.update(Text(""))
597
+ self.remove_class("-with-details")
598
+ output.remove_class("-details")
599
+ output.remove_class("-diff-output")
600
+ output.add_class("-hidden")
601
+
602
+
603
+ class UserBlock(Static):
604
+ ALLOW_SELECT = True
605
+ can_focus = False
606
+
607
+ def __init__(self, content: str = "", highlighted_skill: str | None = None, **kwargs) -> None:
608
+ super().__init__(**kwargs)
609
+ self._content = content
610
+ self._highlighted_skill = highlighted_skill
611
+ self.add_class("user-block")
612
+ if highlighted_skill:
613
+ self.add_class("skill-trigger-message")
614
+
615
+ def compose(self) -> ComposeResult:
616
+ text = Text()
617
+ if self._highlighted_skill:
618
+ text.append(self._content)
619
+ stylize_badge_markers(text, [f"[{self._highlighted_skill}]", "[query]"])
620
+ else:
621
+ text.append(self._content)
622
+
623
+ yield Label(text)
624
+
625
+
626
+ class HandoffLinkBlock(Static):
627
+ ALLOW_SELECT = True
628
+ can_focus = False
629
+
630
+ def __init__(
631
+ self,
632
+ label: str,
633
+ target_session_id: str,
634
+ query: str,
635
+ direction: Literal["back", "forward"],
636
+ **kwargs,
637
+ ) -> None:
638
+ super().__init__(**kwargs)
639
+ self._label = label
640
+ self._target_session_id = target_session_id
641
+ self._query = query
642
+ self._direction: Literal["back", "forward"] = direction
643
+ self.add_class("handoff-link-block")
644
+
645
+ def compose(self) -> ComposeResult:
646
+ link_text = f"{self._target_session_id[:8]} (click to open)"
647
+ handoff_line = f"{self._label} → {link_text}"
648
+ text = Text(f"[handoff]\n{handoff_line}\n\n[query]\n{self._query}")
649
+ stylize_badge_markers(text, ("[handoff]", "[query]"))
650
+
651
+ link_start = text.plain.find(link_text)
652
+ if link_start != -1:
653
+ text.stylize(
654
+ f"{config.ui.colors.notice} underline", link_start, link_start + len(link_text)
655
+ )
656
+
657
+ yield Label(text)
658
+
659
+ def on_click(self, event: events.Click) -> None:
660
+ event.stop()
661
+ if not self._target_session_id:
662
+ return
663
+ self.post_message(
664
+ self.LinkSelected(self, self._target_session_id, self._query, self._direction)
665
+ )
666
+
667
+ class LinkSelected(Message):
668
+ def __init__(
669
+ self,
670
+ block: "HandoffLinkBlock",
671
+ target_session_id: str,
672
+ query: str,
673
+ direction: Literal["back", "forward"],
674
+ ) -> None:
675
+ super().__init__()
676
+ self.block = block
677
+ self.target_session_id = target_session_id
678
+ self.query = query
679
+ self.direction = direction
680
+
681
+
682
+ class UpdateAvailableBlock(Static):
683
+ ALLOW_SELECT = True
684
+ can_focus = False
685
+
686
+ def __init__(self, latest_version: str, changelog_url: str | None = None, **kwargs) -> None:
687
+ super().__init__(**kwargs)
688
+ self._latest_version = latest_version
689
+ self._changelog_url = changelog_url
690
+ self.add_class("update-available-block")
691
+
692
+ def compose(self) -> ComposeResult:
693
+ notice_color = config.ui.colors.notice
694
+ dim_color = config.ui.colors.dim
695
+ accent_color = config.ui.colors.accent
696
+
697
+ text = Text()
698
+ text.append("Update Available", style=f"{notice_color} bold")
699
+ text.append("\n", style=dim_color)
700
+ text.append(f"New version {self._latest_version} is available. ", style=dim_color)
701
+ text.append("Run: ", style=dim_color)
702
+ text.append(_UPDATE_COMMAND, style=accent_color)
703
+
704
+ if self._changelog_url:
705
+ text.append("\n", style=dim_color)
706
+ text.append("Changelog: ", style=dim_color)
707
+ text.append(self._changelog_url, style=accent_color)
708
+
709
+ yield Label(text)
710
+
711
+
712
+ class LaunchWarningsBlock(Static):
713
+ ALLOW_SELECT = True
714
+ can_focus = False
715
+
716
+ def __init__(self, warnings: list[LaunchWarning], **kwargs) -> None:
717
+ super().__init__(**kwargs)
718
+ self._warnings = warnings
719
+ self.add_class("launch-warnings-block")
720
+
721
+ def compose(self) -> ComposeResult:
722
+ notice_color = config.ui.colors.notice
723
+ error_color = config.ui.colors.error
724
+ dim_color = config.ui.colors.dim
725
+
726
+ text = Text()
727
+ text.append("Launch Warnings", style=f"{notice_color} bold")
728
+
729
+ for warning in self._warnings:
730
+ bullet = "\n✗ " if warning.severity == "error" else "\n! "
731
+ style = error_color if warning.severity == "error" else dim_color
732
+ text.append(bullet, style=style)
733
+ text.append(warning.message, style=style)
734
+
735
+ yield Label(text)