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.
- vtx/__init__.py +63 -0
- vtx/async_utils.py +40 -0
- vtx/builtin_skills/github/SKILL.md +139 -0
- vtx/builtin_skills/init/SKILL.md +74 -0
- vtx/builtin_skills/review/SKILL.md +73 -0
- vtx/builtin_skills/skill-builder/SKILL.md +133 -0
- vtx/cli.py +90 -0
- vtx/config.py +741 -0
- vtx/context/__init__.py +15 -0
- vtx/context/_xml.py +8 -0
- vtx/context/agent_mds.py +128 -0
- vtx/context/git.py +64 -0
- vtx/context/loader.py +41 -0
- vtx/context/skills.py +423 -0
- vtx/core/__init__.py +47 -0
- vtx/core/compaction.py +89 -0
- vtx/core/errors.py +17 -0
- vtx/core/handoff.py +51 -0
- vtx/core/scratchpad.py +54 -0
- vtx/core/types.py +197 -0
- vtx/defaults/__init__.py +0 -0
- vtx/defaults/config.yml +53 -0
- vtx/diff_display.py +12 -0
- vtx/events.py +224 -0
- vtx/gh_cli.py +82 -0
- vtx/git_branch.py +90 -0
- vtx/headless.py +127 -0
- vtx/llm/__init__.py +93 -0
- vtx/llm/base.py +217 -0
- vtx/llm/context_length.py +150 -0
- vtx/llm/dynamic_models.py +735 -0
- vtx/llm/model_fetcher.py +279 -0
- vtx/llm/models.py +78 -0
- vtx/llm/oauth/__init__.py +59 -0
- vtx/llm/oauth/copilot.py +358 -0
- vtx/llm/oauth/dynamic.py +236 -0
- vtx/llm/oauth/openai.py +400 -0
- vtx/llm/phase_parser.py +270 -0
- vtx/llm/provider.yaml +280 -0
- vtx/llm/provider_catalog.py +230 -0
- vtx/llm/providers/__init__.py +45 -0
- vtx/llm/providers/anthropic_sdk.py +256 -0
- vtx/llm/providers/mock.py +249 -0
- vtx/llm/providers/openai_sdk.py +246 -0
- vtx/llm/providers/sanitize.py +14 -0
- vtx/llm/sdk/__init__.py +13 -0
- vtx/llm/sdk/anthropic.py +382 -0
- vtx/llm/sdk/base.py +82 -0
- vtx/llm/sdk/openai.py +344 -0
- vtx/llm/tool_parser.py +161 -0
- vtx/loop.py +272 -0
- vtx/notify.py +109 -0
- vtx/permissions.py +114 -0
- vtx/prompts/__init__.py +45 -0
- vtx/prompts/builder.py +86 -0
- vtx/prompts/env.py +58 -0
- vtx/prompts/identity.py +166 -0
- vtx/prompts/tooling.py +36 -0
- vtx/py.typed +0 -0
- vtx/runtime.py +580 -0
- vtx/session.py +868 -0
- vtx/sounds/completion.wav +0 -0
- vtx/sounds/error.wav +0 -0
- vtx/sounds/permission.wav +0 -0
- vtx/themes.py +1104 -0
- vtx/tools/__init__.py +68 -0
- vtx/tools/_read_image.py +106 -0
- vtx/tools/_tool_utils.py +90 -0
- vtx/tools/base.py +36 -0
- vtx/tools/bash.py +371 -0
- vtx/tools/edit.py +261 -0
- vtx/tools/find.py +132 -0
- vtx/tools/read.py +238 -0
- vtx/tools/skill.py +278 -0
- vtx/tools/web.py +238 -0
- vtx/tools/write.py +88 -0
- vtx/tools_manager.py +216 -0
- vtx/turn.py +789 -0
- vtx/ui/__init__.py +0 -0
- vtx/ui/agent_runner.py +417 -0
- vtx/ui/app.py +665 -0
- vtx/ui/app_protocol.py +29 -0
- vtx/ui/autocomplete.py +440 -0
- vtx/ui/blocks.py +735 -0
- vtx/ui/chat.py +613 -0
- vtx/ui/clipboard.py +59 -0
- vtx/ui/commands/__init__.py +100 -0
- vtx/ui/commands/auth.py +306 -0
- vtx/ui/commands/base.py +122 -0
- vtx/ui/commands/models.py +144 -0
- vtx/ui/commands/sessions.py +388 -0
- vtx/ui/commands/settings.py +286 -0
- vtx/ui/completion_ui.py +313 -0
- vtx/ui/export.py +703 -0
- vtx/ui/floating_list.py +370 -0
- vtx/ui/formatting.py +287 -0
- vtx/ui/input.py +760 -0
- vtx/ui/latex.py +349 -0
- vtx/ui/launch.py +108 -0
- vtx/ui/path_complete.py +228 -0
- vtx/ui/prompt_history.py +102 -0
- vtx/ui/queue_ui.py +141 -0
- vtx/ui/selection_mode.py +18 -0
- vtx/ui/session_ui.py +235 -0
- vtx/ui/startup.py +124 -0
- vtx/ui/styles.py +327 -0
- vtx/ui/tool_output.py +34 -0
- vtx/ui/tree.py +437 -0
- vtx/ui/welcome.py +51 -0
- vtx/ui/widgets.py +558 -0
- vtx/update_check.py +49 -0
- vtx/version.py +22 -0
- vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
- vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
- vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
- vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
- 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)
|