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
@@ -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)