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/floating_list.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Floating list overlay for inline completion.
|
|
3
|
+
|
|
4
|
+
A reusable overlay component that renders below the input, showing
|
|
5
|
+
a paginated list with arrow indicator and counter. Used for:
|
|
6
|
+
- Slash commands (/)
|
|
7
|
+
- File path search (@)
|
|
8
|
+
- Session selection
|
|
9
|
+
- Any other searchable list
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TypeVar
|
|
15
|
+
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from textual import events
|
|
18
|
+
from textual.reactive import reactive
|
|
19
|
+
from textual.widget import Widget
|
|
20
|
+
|
|
21
|
+
from vtx import config
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ListItem[T]:
|
|
28
|
+
value: T
|
|
29
|
+
label: str
|
|
30
|
+
description: str = ""
|
|
31
|
+
prefix: str = ""
|
|
32
|
+
prefix_style: str = ""
|
|
33
|
+
|
|
34
|
+
def __hash__(self) -> int:
|
|
35
|
+
return hash((self.label, self.description))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FloatingList[T](Widget):
|
|
39
|
+
"""
|
|
40
|
+
A floating overlay list with pagination and selection.
|
|
41
|
+
|
|
42
|
+
Features:
|
|
43
|
+
- Arrow indicator (→) for selected item
|
|
44
|
+
- Position counter (x/total)
|
|
45
|
+
- Window-based pagination (shows subset of items)
|
|
46
|
+
- Keyboard navigation (up/down)
|
|
47
|
+
- Optional search bar for filtering (two-layer commands)
|
|
48
|
+
- Hidden by default, show/hide controlled by parent
|
|
49
|
+
|
|
50
|
+
The parent widget is responsible for:
|
|
51
|
+
- Calling show(items) with filtered items
|
|
52
|
+
- Calling hide() to dismiss
|
|
53
|
+
- Calling move_up()/move_down() on key events
|
|
54
|
+
- Reading selected_item when user confirms
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
DEFAULT_CSS = """
|
|
58
|
+
FloatingList {
|
|
59
|
+
height: auto;
|
|
60
|
+
display: none;
|
|
61
|
+
padding: 0 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
FloatingList.-visible {
|
|
65
|
+
display: block;
|
|
66
|
+
}
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
# Reactive to trigger re-render
|
|
70
|
+
_selected_index: reactive[int] = reactive(0, repaint=False)
|
|
71
|
+
_visible: reactive[bool] = reactive(False, repaint=False)
|
|
72
|
+
_render_key: reactive[int] = reactive(0) # Force re-render
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
window_size: int = 5,
|
|
77
|
+
label_width: int = 6,
|
|
78
|
+
max_label_width: int = 30,
|
|
79
|
+
id: str | None = None,
|
|
80
|
+
classes: str | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
super().__init__(id=id, classes=classes)
|
|
83
|
+
self._window_size = window_size
|
|
84
|
+
self._min_label_width = label_width
|
|
85
|
+
self._default_max_label_width = max_label_width
|
|
86
|
+
self._max_label_width = max_label_width
|
|
87
|
+
self._label_width = label_width
|
|
88
|
+
self._items: list[ListItem[T]] = []
|
|
89
|
+
|
|
90
|
+
# Search state
|
|
91
|
+
self._search_enabled = False
|
|
92
|
+
self._search_query = ""
|
|
93
|
+
self._all_items: list[ListItem[T]] = []
|
|
94
|
+
self._description_width = 0
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def items(self) -> list[ListItem[T]]:
|
|
98
|
+
return self._items
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def selected_index(self) -> int:
|
|
102
|
+
return self._selected_index
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def selected_item(self) -> ListItem[T] | None:
|
|
106
|
+
if self._items and 0 <= self._selected_index < len(self._items):
|
|
107
|
+
return self._items[self._selected_index]
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def is_visible(self) -> bool:
|
|
112
|
+
return self._visible
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def search_enabled(self) -> bool:
|
|
116
|
+
return self._search_enabled
|
|
117
|
+
|
|
118
|
+
def _get_source_items(self) -> list[ListItem[T]]:
|
|
119
|
+
if self._search_enabled and self._all_items:
|
|
120
|
+
return self._all_items
|
|
121
|
+
return self._items
|
|
122
|
+
|
|
123
|
+
def _compute_label_width(self) -> int:
|
|
124
|
+
source = self._get_source_items()
|
|
125
|
+
if not source:
|
|
126
|
+
return self._min_label_width
|
|
127
|
+
|
|
128
|
+
max_len = max(len(item.label) for item in source)
|
|
129
|
+
return max(self._min_label_width, min(max_len, self._max_label_width))
|
|
130
|
+
|
|
131
|
+
def _compute_description_width(self) -> int:
|
|
132
|
+
source = self._get_source_items()
|
|
133
|
+
if not source:
|
|
134
|
+
return 0
|
|
135
|
+
max_desc_len = max((len(item.description) for item in source), default=0)
|
|
136
|
+
if max_desc_len == 0:
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
available_width = max(0, self.size.width - 4) if self.size.width else 0
|
|
140
|
+
if available_width == 0:
|
|
141
|
+
return min(max_desc_len, 20)
|
|
142
|
+
|
|
143
|
+
# Reserve space for arrow (2), gap between cols (3), label width, and margin (1)
|
|
144
|
+
reserved = 2 + 3 + self._label_width + 1
|
|
145
|
+
max_desc_width = max(0, available_width - reserved)
|
|
146
|
+
return min(max_desc_len, max_desc_width)
|
|
147
|
+
|
|
148
|
+
def show(
|
|
149
|
+
self,
|
|
150
|
+
items: list[ListItem[T]],
|
|
151
|
+
searchable: bool = False,
|
|
152
|
+
max_label_width: int | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
self._search_enabled = searchable
|
|
155
|
+
self._search_query = ""
|
|
156
|
+
self._all_items = items if searchable else []
|
|
157
|
+
self._items = items
|
|
158
|
+
self._selected_index = 0
|
|
159
|
+
self._max_label_width = (
|
|
160
|
+
max_label_width if max_label_width is not None else self._default_max_label_width
|
|
161
|
+
)
|
|
162
|
+
self._label_width = self._compute_label_width()
|
|
163
|
+
self._description_width = self._compute_description_width()
|
|
164
|
+
self._visible = True
|
|
165
|
+
self._render_key += 1
|
|
166
|
+
|
|
167
|
+
def hide(self) -> None:
|
|
168
|
+
self._visible = False
|
|
169
|
+
self._items = []
|
|
170
|
+
self._all_items = []
|
|
171
|
+
self._selected_index = 0
|
|
172
|
+
self._search_enabled = False
|
|
173
|
+
self._search_query = ""
|
|
174
|
+
|
|
175
|
+
def set_search_query(self, query: str) -> None:
|
|
176
|
+
if not self._search_enabled:
|
|
177
|
+
return
|
|
178
|
+
self._search_query = query
|
|
179
|
+
if not query:
|
|
180
|
+
self._items = self._all_items
|
|
181
|
+
else:
|
|
182
|
+
self._items = self._fuzzy_filter(query, self._all_items)
|
|
183
|
+
self._label_width = self._compute_label_width()
|
|
184
|
+
self._description_width = self._compute_description_width()
|
|
185
|
+
self._selected_index = 0
|
|
186
|
+
self._render_key += 1
|
|
187
|
+
|
|
188
|
+
def update_items(self, items: list[ListItem[T]]) -> None:
|
|
189
|
+
self._items = items
|
|
190
|
+
if self._search_enabled:
|
|
191
|
+
self._all_items = items
|
|
192
|
+
self._label_width = self._compute_label_width()
|
|
193
|
+
self._description_width = self._compute_description_width()
|
|
194
|
+
# Clamp selected index
|
|
195
|
+
if self._selected_index >= len(items):
|
|
196
|
+
self._selected_index = max(0, len(items) - 1)
|
|
197
|
+
self._render_key += 1
|
|
198
|
+
|
|
199
|
+
def select_value(self, value: T) -> None:
|
|
200
|
+
for index, item in enumerate(self._items):
|
|
201
|
+
if item.value == value:
|
|
202
|
+
self._selected_index = index
|
|
203
|
+
self._render_key += 1
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
def move_up(self) -> None:
|
|
207
|
+
if not self._items:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
if self._selected_index > 0:
|
|
211
|
+
self._selected_index -= 1
|
|
212
|
+
else:
|
|
213
|
+
self._selected_index = len(self._items) - 1
|
|
214
|
+
self._render_key += 1
|
|
215
|
+
|
|
216
|
+
def move_down(self) -> None:
|
|
217
|
+
if not self._items:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
if self._selected_index < len(self._items) - 1:
|
|
221
|
+
self._selected_index += 1
|
|
222
|
+
else:
|
|
223
|
+
self._selected_index = 0
|
|
224
|
+
self._render_key += 1
|
|
225
|
+
|
|
226
|
+
def render(self) -> Text:
|
|
227
|
+
_ = self._render_key # Subscribe to changes
|
|
228
|
+
|
|
229
|
+
if not self._visible:
|
|
230
|
+
return Text("")
|
|
231
|
+
|
|
232
|
+
lines = []
|
|
233
|
+
|
|
234
|
+
if not self._items:
|
|
235
|
+
if self._search_enabled:
|
|
236
|
+
dim_color = config.ui.colors.dim
|
|
237
|
+
lines.append(Text(" No matches", style=dim_color))
|
|
238
|
+
result = Text()
|
|
239
|
+
for i, line in enumerate(lines):
|
|
240
|
+
if i > 0:
|
|
241
|
+
result.append("\n")
|
|
242
|
+
result.append_text(line)
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
total = len(self._items)
|
|
246
|
+
selected = self._selected_index
|
|
247
|
+
|
|
248
|
+
# Calculate window
|
|
249
|
+
half_window = self._window_size // 2
|
|
250
|
+
start = max(0, selected - half_window)
|
|
251
|
+
end = min(total, start + self._window_size)
|
|
252
|
+
|
|
253
|
+
# Adjust start if we're near the end
|
|
254
|
+
if end - start < self._window_size and start > 0:
|
|
255
|
+
start = max(0, end - self._window_size)
|
|
256
|
+
|
|
257
|
+
# Render visible items
|
|
258
|
+
for i in range(start, end):
|
|
259
|
+
item = self._items[i]
|
|
260
|
+
is_selected = i == selected
|
|
261
|
+
lines.append(self._render_row(item, is_selected))
|
|
262
|
+
|
|
263
|
+
# Add counter row
|
|
264
|
+
dim_color = config.ui.colors.dim
|
|
265
|
+
counter = Text(f" ({selected + 1}/{total})", style=dim_color)
|
|
266
|
+
lines.append(counter)
|
|
267
|
+
|
|
268
|
+
# Join with newlines
|
|
269
|
+
result = Text()
|
|
270
|
+
for i, line in enumerate(lines):
|
|
271
|
+
if i > 0:
|
|
272
|
+
result.append("\n")
|
|
273
|
+
result.append_text(line)
|
|
274
|
+
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
278
|
+
del event
|
|
279
|
+
if not self._visible:
|
|
280
|
+
return
|
|
281
|
+
self._label_width = self._compute_label_width()
|
|
282
|
+
self._description_width = self._compute_description_width()
|
|
283
|
+
self._render_key += 1
|
|
284
|
+
|
|
285
|
+
def _render_row(self, item: ListItem[T], is_selected: bool) -> Text:
|
|
286
|
+
colors = config.ui.colors
|
|
287
|
+
selected_color = colors.selected
|
|
288
|
+
dim_color = colors.dim
|
|
289
|
+
text = Text()
|
|
290
|
+
|
|
291
|
+
# Arrow indicator
|
|
292
|
+
if is_selected:
|
|
293
|
+
text.append("→ ", style=f"bold {selected_color}")
|
|
294
|
+
else:
|
|
295
|
+
text.append(" ")
|
|
296
|
+
|
|
297
|
+
# Prefix (e.g. tree indent) rendered with its own style
|
|
298
|
+
prefix = item.prefix
|
|
299
|
+
if prefix:
|
|
300
|
+
text.append(prefix, style=item.prefix_style or "")
|
|
301
|
+
|
|
302
|
+
# Label (truncated if too long, padded to computed width + gap)
|
|
303
|
+
prefix_len = len(prefix)
|
|
304
|
+
effective_label_width = max(1, self._label_width - prefix_len)
|
|
305
|
+
raw_label = item.label
|
|
306
|
+
if len(raw_label) > effective_label_width:
|
|
307
|
+
label = raw_label[: effective_label_width - 1] + "…"
|
|
308
|
+
else:
|
|
309
|
+
label = raw_label
|
|
310
|
+
label = label.ljust(effective_label_width + 3)
|
|
311
|
+
if is_selected:
|
|
312
|
+
text.append(label, style=f"bold {selected_color}")
|
|
313
|
+
else:
|
|
314
|
+
text.append(label)
|
|
315
|
+
|
|
316
|
+
# Description (if any)
|
|
317
|
+
if item.description and self._description_width > 0:
|
|
318
|
+
description = item.description
|
|
319
|
+
if len(description) > self._description_width:
|
|
320
|
+
if self._description_width == 1:
|
|
321
|
+
description = "…"
|
|
322
|
+
else:
|
|
323
|
+
description = description[: self._description_width - 1] + "…"
|
|
324
|
+
text.append(description, style=f"bold {selected_color}" if is_selected else dim_color)
|
|
325
|
+
|
|
326
|
+
return text
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def _fuzzy_match(query: str, candidate: str) -> tuple[float, Sequence[int]]:
|
|
330
|
+
q = query.lower()
|
|
331
|
+
c = candidate.lower()
|
|
332
|
+
positions: list[int] = []
|
|
333
|
+
idx = 0
|
|
334
|
+
for char in q:
|
|
335
|
+
idx = c.find(char, idx)
|
|
336
|
+
if idx == -1:
|
|
337
|
+
return (0.0, [])
|
|
338
|
+
positions.append(idx)
|
|
339
|
+
idx += 1
|
|
340
|
+
|
|
341
|
+
# Simple scoring: consecutive matches and early matches score higher
|
|
342
|
+
score = float(len(positions))
|
|
343
|
+
if positions and positions[0] == 0:
|
|
344
|
+
score *= 1.2
|
|
345
|
+
groups = 1
|
|
346
|
+
for i in range(1, len(positions)):
|
|
347
|
+
if positions[i] != positions[i - 1] + 1:
|
|
348
|
+
groups += 1
|
|
349
|
+
if len(positions) > 1:
|
|
350
|
+
score *= 1 + (len(positions) - groups + 1) / len(positions)
|
|
351
|
+
return (score, positions)
|
|
352
|
+
|
|
353
|
+
@classmethod
|
|
354
|
+
def _fuzzy_filter(cls, query: str, items: list[ListItem[T]]) -> list[ListItem[T]]:
|
|
355
|
+
scored = []
|
|
356
|
+
for item in items:
|
|
357
|
+
# Match against both label and description
|
|
358
|
+
label_score, _ = cls._fuzzy_match(query, item.label)
|
|
359
|
+
desc_score, _ = cls._fuzzy_match(query, item.description)
|
|
360
|
+
best = max(label_score, desc_score * 0.8)
|
|
361
|
+
if best > 0:
|
|
362
|
+
scored.append((best, item))
|
|
363
|
+
scored.sort(key=lambda x: -x[0])
|
|
364
|
+
return [item for _, item in scored]
|
|
365
|
+
|
|
366
|
+
def watch__visible(self, visible: bool) -> None:
|
|
367
|
+
if visible:
|
|
368
|
+
self.add_class("-visible")
|
|
369
|
+
else:
|
|
370
|
+
self.remove_class("-visible")
|
vtx/ui/formatting.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import shutil
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from rich._loop import loop_first
|
|
6
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
7
|
+
from rich.markdown import CodeBlock, Heading, ListElement, ListItem, Markdown
|
|
8
|
+
from rich.segment import Segment
|
|
9
|
+
from rich.style import Style
|
|
10
|
+
from rich.syntax import Syntax
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from rich.theme import Theme
|
|
13
|
+
|
|
14
|
+
from vtx import config
|
|
15
|
+
|
|
16
|
+
from .latex import preprocess_latex
|
|
17
|
+
|
|
18
|
+
_MARKDOWN_THEME: Theme | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_markdown_theme() -> Theme:
|
|
22
|
+
global _MARKDOWN_THEME
|
|
23
|
+
if _MARKDOWN_THEME is None:
|
|
24
|
+
code_color = config.ui.colors.markdown_code
|
|
25
|
+
heading_style = Style(bold=True)
|
|
26
|
+
_MARKDOWN_THEME = Theme(
|
|
27
|
+
{
|
|
28
|
+
"markdown.h1": heading_style,
|
|
29
|
+
"markdown.h2": heading_style,
|
|
30
|
+
"markdown.h3": heading_style,
|
|
31
|
+
"markdown.h4": heading_style,
|
|
32
|
+
"markdown.h5": heading_style,
|
|
33
|
+
"markdown.h6": heading_style,
|
|
34
|
+
"markdown.code": Style(color=code_color),
|
|
35
|
+
"markdown.table.header": Style(bold=True),
|
|
36
|
+
"markdown.table.border": Style(),
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
return _MARKDOWN_THEME
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
MARKDOWN_THEME = get_markdown_theme()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LeftJustifiedHeading(Heading):
|
|
46
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
47
|
+
yield from console.render(self.text, options=options.update(justify="left"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PlainListItem(ListItem):
|
|
51
|
+
def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
52
|
+
render_options = options.update(width=options.max_width - 2)
|
|
53
|
+
lines = console.render_lines(self.elements, render_options, style=self.style)
|
|
54
|
+
bullet = Segment("- ")
|
|
55
|
+
padding = Segment(" ")
|
|
56
|
+
new_line = Segment("\n")
|
|
57
|
+
for first, line in loop_first(lines):
|
|
58
|
+
yield bullet if first else padding
|
|
59
|
+
yield from line
|
|
60
|
+
yield new_line
|
|
61
|
+
|
|
62
|
+
def render_number(
|
|
63
|
+
self, console: Console, options: ConsoleOptions, number: int, last_number: int
|
|
64
|
+
) -> RenderResult:
|
|
65
|
+
number_width = len(str(last_number)) + 2
|
|
66
|
+
render_options = options.update(width=options.max_width - number_width)
|
|
67
|
+
lines = console.render_lines(self.elements, render_options, style=self.style)
|
|
68
|
+
new_line = Segment("\n")
|
|
69
|
+
padding = Segment(" " * number_width)
|
|
70
|
+
numeral = Segment(f"{number}".rjust(number_width - 1) + " ")
|
|
71
|
+
for first, line in loop_first(lines):
|
|
72
|
+
yield numeral if first else padding
|
|
73
|
+
yield from line
|
|
74
|
+
yield new_line
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PlainListElement(ListElement):
|
|
78
|
+
def on_child_close(self, context, child) -> bool:
|
|
79
|
+
assert isinstance(child, ListItem)
|
|
80
|
+
self.items.append(child)
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
84
|
+
if self.list_type == "bullet_list_open":
|
|
85
|
+
for item in self.items:
|
|
86
|
+
if isinstance(item, PlainListItem):
|
|
87
|
+
yield from item.render_bullet(console, options)
|
|
88
|
+
else:
|
|
89
|
+
number = 1 if self.list_start is None else self.list_start
|
|
90
|
+
last_number = number + len(self.items)
|
|
91
|
+
for index, item in enumerate(self.items):
|
|
92
|
+
if isinstance(item, PlainListItem):
|
|
93
|
+
yield from item.render_number(console, options, number + index, last_number)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class PlainCodeBlock(CodeBlock):
|
|
97
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
98
|
+
code = str(self.text).rstrip()
|
|
99
|
+
syntax = Syntax(code, self.lexer_name, theme="ansi_dark", word_wrap=True, padding=0)
|
|
100
|
+
yield syntax
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class CustomMarkdown(Markdown):
|
|
104
|
+
elements: ClassVar[dict] = {
|
|
105
|
+
**Markdown.elements,
|
|
106
|
+
"heading_open": LeftJustifiedHeading,
|
|
107
|
+
"bullet_list_open": PlainListElement,
|
|
108
|
+
"ordered_list_open": PlainListElement,
|
|
109
|
+
"list_item_open": PlainListItem,
|
|
110
|
+
"fence": PlainCodeBlock,
|
|
111
|
+
"code_block": PlainCodeBlock,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _strip_inline_code_ticks_in_headings(text: str) -> str:
|
|
116
|
+
lines = text.splitlines(keepends=True)
|
|
117
|
+
in_fence = False
|
|
118
|
+
processed: list[str] = []
|
|
119
|
+
|
|
120
|
+
for line in lines:
|
|
121
|
+
stripped = line.lstrip()
|
|
122
|
+
if stripped.startswith("```"):
|
|
123
|
+
in_fence = not in_fence
|
|
124
|
+
processed.append(line)
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
if in_fence:
|
|
128
|
+
processed.append(line)
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
if re.match(r"^\s{0,3}#{1,6}\s+", line):
|
|
132
|
+
line = re.sub(r"`([^`]+)`", r"\1", line)
|
|
133
|
+
|
|
134
|
+
processed.append(line)
|
|
135
|
+
|
|
136
|
+
return "".join(processed)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def strip_markdown_for_collapsed_text(text: str) -> str:
|
|
140
|
+
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
|
|
141
|
+
text = re.sub(r"__([^_]+)__", r"\1", text)
|
|
142
|
+
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"\1", text)
|
|
143
|
+
text = re.sub(r"(?<!_)_([^_]+)_(?!_)", r"\1", text)
|
|
144
|
+
text = re.sub(r"`([^`]+)`", r"\1", text)
|
|
145
|
+
return text
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def markdown_render_width() -> int:
|
|
149
|
+
term_width = shutil.get_terminal_size().columns
|
|
150
|
+
return max(40, term_width - 4)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def format_markdown(text: str, width: int | None = None) -> Text:
|
|
154
|
+
text = preprocess_latex(text)
|
|
155
|
+
sanitized = _strip_inline_code_ticks_in_headings(text)
|
|
156
|
+
md = CustomMarkdown(sanitized)
|
|
157
|
+
if width is None:
|
|
158
|
+
width = markdown_render_width()
|
|
159
|
+
console = Console(force_terminal=True, no_color=False, theme=MARKDOWN_THEME, width=width)
|
|
160
|
+
with console.capture() as capture:
|
|
161
|
+
console.print(md)
|
|
162
|
+
rendered = capture.get()
|
|
163
|
+
return Text.from_ansi(rendered.rstrip("\n"))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def find_stable_block_boundary(text: str) -> int:
|
|
167
|
+
"""Offset just after the last blank line outside a code fence, 0 if none.
|
|
168
|
+
|
|
169
|
+
Everything before the boundary is a closed run of top-level markdown blocks.
|
|
170
|
+
Content streamed after it can no longer change how that text renders.
|
|
171
|
+
"""
|
|
172
|
+
boundary = 0
|
|
173
|
+
offset = 0
|
|
174
|
+
in_fence = False
|
|
175
|
+
for line in text.splitlines(keepends=True):
|
|
176
|
+
stripped = line.strip()
|
|
177
|
+
if stripped.startswith(("```", "~~~")):
|
|
178
|
+
in_fence = not in_fence
|
|
179
|
+
elif not stripped and not in_fence:
|
|
180
|
+
boundary = offset + len(line)
|
|
181
|
+
offset += len(line)
|
|
182
|
+
return boundary
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def format_markdown_block(text: str, width: int) -> Text:
|
|
186
|
+
"""Render a markdown fragment with blank edge lines stripped.
|
|
187
|
+
|
|
188
|
+
A fragment can render with stray blank edge lines (a lone list starts with one).
|
|
189
|
+
Stripping them lets cached blocks be joined with a single blank line, the same
|
|
190
|
+
spacing Rich puts between top-level elements in a full render.
|
|
191
|
+
"""
|
|
192
|
+
lines = list(format_markdown(text, width).split("\n"))
|
|
193
|
+
while lines and not lines[0].plain.strip():
|
|
194
|
+
lines.pop(0)
|
|
195
|
+
while lines and not lines[-1].plain.strip():
|
|
196
|
+
lines.pop()
|
|
197
|
+
return Text("\n").join(lines)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_BASH_TOKEN_RE = re.compile(
|
|
201
|
+
r"(?P<space>\s+)"
|
|
202
|
+
r"|(?P<op>\|\||&&|;;|[|;&()<>])"
|
|
203
|
+
r"|(?P<sq>'[^']*')"
|
|
204
|
+
r'|(?P<dq>"(?:\\.|[^"\\])*")'
|
|
205
|
+
r"|(?P<word>[^\s|;&()<>]+)"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _format_bash_command_tokens(command: str) -> Text:
|
|
210
|
+
"""Small shell highlighter for common command headers.
|
|
211
|
+
|
|
212
|
+
Pygments mostly highlights shell syntax, not ordinary argv words, so a
|
|
213
|
+
command like `git status --short && git log` can otherwise appear plain.
|
|
214
|
+
Use a compact Catppuccin-ish palette similar to Codex's default command
|
|
215
|
+
highlighting instead of dimming argv text.
|
|
216
|
+
"""
|
|
217
|
+
syntax = config.ui.colors.syntax_colors
|
|
218
|
+
command_style = syntax.command
|
|
219
|
+
arg_style = syntax.arg
|
|
220
|
+
option_style = syntax.option
|
|
221
|
+
operator_style = syntax.operator
|
|
222
|
+
string_style = syntax.string
|
|
223
|
+
variable_style = syntax.variable
|
|
224
|
+
|
|
225
|
+
text = Text()
|
|
226
|
+
expect_command = True
|
|
227
|
+
|
|
228
|
+
for match in _BASH_TOKEN_RE.finditer(command):
|
|
229
|
+
token = match.group(0)
|
|
230
|
+
kind = match.lastgroup
|
|
231
|
+
|
|
232
|
+
if kind == "space":
|
|
233
|
+
text.append(token)
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
if kind == "op":
|
|
237
|
+
text.append(token, style=operator_style)
|
|
238
|
+
expect_command = token in {"|", "||", "&&", ";", ";;", "("}
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
if kind in {"sq", "dq"}:
|
|
242
|
+
text.append(token, style=string_style)
|
|
243
|
+
expect_command = False
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
if token.startswith("$"):
|
|
247
|
+
text.append(token, style=variable_style)
|
|
248
|
+
expect_command = False
|
|
249
|
+
elif expect_command:
|
|
250
|
+
text.append(token, style=command_style)
|
|
251
|
+
expect_command = False
|
|
252
|
+
elif token.startswith("-"):
|
|
253
|
+
text.append(token, style=option_style)
|
|
254
|
+
else:
|
|
255
|
+
text.append(token, style=arg_style)
|
|
256
|
+
|
|
257
|
+
return text
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def format_bash_command(text: str, width: int | None = None) -> Text:
|
|
261
|
+
"""Syntax-highlight a bash command for compact tool headers."""
|
|
262
|
+
if width is None:
|
|
263
|
+
term_width = shutil.get_terminal_size().columns
|
|
264
|
+
width = max(40, term_width - 4)
|
|
265
|
+
|
|
266
|
+
prompt = ""
|
|
267
|
+
command = text
|
|
268
|
+
if text.startswith("$ "):
|
|
269
|
+
prompt = "$ "
|
|
270
|
+
command = text[2:]
|
|
271
|
+
|
|
272
|
+
highlighted = _format_bash_command_tokens(command)
|
|
273
|
+
|
|
274
|
+
if not prompt:
|
|
275
|
+
return highlighted
|
|
276
|
+
|
|
277
|
+
result = Text(prompt, style=config.ui.colors.dim)
|
|
278
|
+
result.append_text(highlighted)
|
|
279
|
+
return result
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def format_tokens(n: int) -> str:
|
|
283
|
+
if n >= 1_000_000:
|
|
284
|
+
return f"{int(n / 1_000_000)}m"
|
|
285
|
+
elif n >= 1_000:
|
|
286
|
+
return f"{int(n / 1_000)}k"
|
|
287
|
+
return str(n)
|