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/tree.py ADDED
@@ -0,0 +1,437 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import ClassVar
5
+
6
+ from rich.text import Text
7
+ from textual.binding import Binding, BindingType
8
+ from textual.message import Message
9
+ from textual.reactive import reactive
10
+ from textual.widget import Widget
11
+
12
+ from vtx import config
13
+ from vtx.core.types import AssistantMessage, TextContent, ToolCall, ToolResultMessage, UserMessage
14
+ from vtx.session import MessageEntry, SessionEntry, TreeNode
15
+ from vtx.tools import get_tool
16
+ from vtx.tools._tool_utils import shorten_path
17
+
18
+
19
+ @dataclass
20
+ class GutterInfo:
21
+ position: int
22
+ show: bool
23
+
24
+
25
+ @dataclass
26
+ class FlatNode:
27
+ node: TreeNode
28
+ indent: int
29
+ show_connector: bool
30
+ is_last: bool
31
+ gutters: list[GutterInfo]
32
+ is_virtual_root_child: bool
33
+
34
+
35
+ class TreeSelector(Widget):
36
+ BINDINGS: ClassVar[list[BindingType]] = [
37
+ Binding("up", "move_up", "Move", priority=True),
38
+ Binding("down", "move_down", "Move", priority=True),
39
+ Binding("left,pageup", "page_up", "Page", priority=True),
40
+ Binding("right,pagedown", "page_down", "Page", priority=True),
41
+ Binding("enter", "select", "Select", priority=True),
42
+ Binding("escape", "cancel", "Cancel", priority=True),
43
+ ]
44
+
45
+ DEFAULT_CSS = """
46
+ TreeSelector {
47
+ height: auto;
48
+ display: none;
49
+ }
50
+
51
+ TreeSelector.-visible {
52
+ display: block;
53
+ }
54
+ """
55
+
56
+ class Selected(Message):
57
+ def __init__(self, entry_id: str) -> None:
58
+ super().__init__()
59
+ self.entry_id = entry_id
60
+
61
+ class Cancelled(Message):
62
+ pass
63
+
64
+ _visible: reactive[bool] = reactive(False, repaint=False)
65
+ _render_key: reactive[int] = reactive(0)
66
+
67
+ def __init__(self, id: str | None = None) -> None:
68
+ super().__init__(id=id)
69
+ self._flat_nodes: list[FlatNode] = []
70
+ self._filtered_nodes: list[FlatNode] = []
71
+ self._selected_index = 0
72
+ self._current_leaf_id: str | None = None
73
+ self._multiple_roots = False
74
+ self._active_path_ids: set[str] = set()
75
+ self._last_selected_id: str | None = None
76
+ self._max_visible_lines = 10
77
+ self._tool_calls_by_id: dict[str, ToolCall] = {}
78
+
79
+ @property
80
+ def is_visible(self) -> bool:
81
+ return self._visible
82
+
83
+ def show(self, tree: list[TreeNode], current_leaf_id: str | None, height: int = 24) -> None:
84
+ self._current_leaf_id = current_leaf_id
85
+ self._max_visible_lines = max(5, height // 3)
86
+ self._tool_calls_by_id = self._collect_tool_calls(tree)
87
+ self._multiple_roots = len(tree) > 1
88
+ self._flat_nodes = self._flatten_tree(tree)
89
+ self._filtered_nodes = [
90
+ node for node in self._flat_nodes if self._should_show_entry(node.node.entry)
91
+ ]
92
+ self._build_active_path()
93
+ self._selected_index = self._find_nearest_visible_index(current_leaf_id)
94
+ self._last_selected_id = (
95
+ self._filtered_nodes[self._selected_index].node.entry.id
96
+ if self._filtered_nodes
97
+ else None
98
+ )
99
+ self._visible = True
100
+ self._render_key += 1
101
+ self.focus()
102
+
103
+ def hide(self) -> None:
104
+ self._visible = False
105
+ self._flat_nodes = []
106
+ self._filtered_nodes = []
107
+ self._selected_index = 0
108
+ self._last_selected_id = None
109
+ self._render_key += 1
110
+
111
+ def watch__visible(self, visible: bool) -> None:
112
+ if visible:
113
+ self.add_class("-visible")
114
+ else:
115
+ self.remove_class("-visible")
116
+
117
+ def _collect_tool_calls(self, roots: list[TreeNode]) -> dict[str, ToolCall]:
118
+ calls: dict[str, ToolCall] = {}
119
+ stack = list(roots)
120
+ while stack:
121
+ node = stack.pop()
122
+ entry = node.entry
123
+ if isinstance(entry, MessageEntry) and isinstance(entry.message, AssistantMessage):
124
+ for part in entry.message.content:
125
+ if isinstance(part, ToolCall):
126
+ calls[part.id] = part
127
+ stack.extend(node.children)
128
+ return calls
129
+
130
+ def _flatten_tree(self, roots: list[TreeNode]) -> list[FlatNode]:
131
+ result: list[FlatNode] = []
132
+ contains_active: dict[str, bool] = {}
133
+
134
+ def mark(node: TreeNode) -> bool:
135
+ has = self._current_leaf_id is not None and node.entry.id == self._current_leaf_id
136
+ for child in node.children:
137
+ has = mark(child) or has
138
+ contains_active[node.entry.id] = has
139
+ return has
140
+
141
+ for root in roots:
142
+ mark(root)
143
+
144
+ ordered_roots = sorted(
145
+ roots, key=lambda n: contains_active.get(n.entry.id, False), reverse=True
146
+ )
147
+ multiple_roots = len(roots) > 1
148
+ stack: list[tuple[TreeNode, int, bool, bool, bool, list[GutterInfo], bool]] = []
149
+ for index in range(len(ordered_roots) - 1, -1, -1):
150
+ stack.append(
151
+ (
152
+ ordered_roots[index],
153
+ 1 if multiple_roots else 0,
154
+ multiple_roots,
155
+ multiple_roots,
156
+ index == len(ordered_roots) - 1,
157
+ [],
158
+ multiple_roots,
159
+ )
160
+ )
161
+
162
+ while stack:
163
+ (
164
+ node,
165
+ indent,
166
+ just_branched,
167
+ show_connector,
168
+ is_last,
169
+ gutters,
170
+ is_virtual_root_child,
171
+ ) = stack.pop()
172
+ result.append(
173
+ FlatNode(node, indent, show_connector, is_last, gutters, is_virtual_root_child)
174
+ )
175
+
176
+ children = node.children
177
+ multiple_children = len(children) > 1
178
+ active_children = [c for c in children if contains_active.get(c.entry.id, False)]
179
+ other_children = [c for c in children if not contains_active.get(c.entry.id, False)]
180
+ ordered_children = active_children + other_children
181
+
182
+ if multiple_children or (just_branched and indent > 0):
183
+ child_indent = indent + 1
184
+ else:
185
+ child_indent = indent
186
+
187
+ connector_displayed = show_connector and not is_virtual_root_child
188
+ display_indent = max(0, indent - 1) if self._multiple_roots else indent
189
+ connector_position = max(0, display_indent - 1)
190
+ child_gutters = (
191
+ [*gutters, GutterInfo(connector_position, not is_last)]
192
+ if connector_displayed
193
+ else gutters
194
+ )
195
+
196
+ for index in range(len(ordered_children) - 1, -1, -1):
197
+ stack.append(
198
+ (
199
+ ordered_children[index],
200
+ child_indent,
201
+ multiple_children,
202
+ multiple_children,
203
+ index == len(ordered_children) - 1,
204
+ child_gutters,
205
+ False,
206
+ )
207
+ )
208
+
209
+ return result
210
+
211
+ def _build_active_path(self) -> None:
212
+ self._active_path_ids.clear()
213
+ by_id = {flat.node.entry.id: flat.node.entry for flat in self._flat_nodes}
214
+ current_id = self._current_leaf_id
215
+ while current_id:
216
+ self._active_path_ids.add(current_id)
217
+ entry = by_id.get(current_id)
218
+ current_id = entry.parent_id if entry else None
219
+
220
+ def _find_nearest_visible_index(self, entry_id: str | None) -> int:
221
+ if not self._filtered_nodes:
222
+ return 0
223
+ entry_map = {flat.node.entry.id: flat.node.entry for flat in self._flat_nodes}
224
+ visible = {flat.node.entry.id: i for i, flat in enumerate(self._filtered_nodes)}
225
+ current_id = entry_id
226
+ while current_id is not None:
227
+ if current_id in visible:
228
+ return visible[current_id]
229
+ entry = entry_map.get(current_id)
230
+ current_id = entry.parent_id if entry else None
231
+ return len(self._filtered_nodes) - 1
232
+
233
+ def _should_show_entry(self, entry: SessionEntry) -> bool:
234
+ if not isinstance(entry, MessageEntry):
235
+ return False
236
+ message = entry.message
237
+ if isinstance(message, UserMessage | ToolResultMessage):
238
+ return True
239
+ if isinstance(message, AssistantMessage):
240
+ return any(
241
+ isinstance(part, TextContent) and part.text.strip() for part in message.content
242
+ )
243
+ return False
244
+
245
+ def _entry_plain_text(self, entry: SessionEntry) -> str:
246
+ if isinstance(entry, MessageEntry):
247
+ message = entry.message
248
+ if isinstance(message, UserMessage):
249
+ if isinstance(message.content, str):
250
+ return message.content
251
+ return "".join(
252
+ part.text if isinstance(part, TextContent) else "[image]"
253
+ for part in message.content
254
+ )
255
+ if isinstance(message, AssistantMessage):
256
+ return "".join(
257
+ part.text for part in message.content if isinstance(part, TextContent)
258
+ )
259
+ if isinstance(message, ToolResultMessage):
260
+ return message.tool_name
261
+ return ""
262
+
263
+ def _entry_display_text(self, entry: SessionEntry, selected: bool) -> Text:
264
+ colors = config.ui.colors
265
+ text = Text()
266
+ if isinstance(entry, MessageEntry):
267
+ message = entry.message
268
+ if isinstance(message, UserMessage):
269
+ text.append("user: ", style=colors.accent)
270
+ text.append(self._normalize(self._entry_plain_text(entry)))
271
+ elif isinstance(message, AssistantMessage):
272
+ text.append("assistant: ", style=colors.success)
273
+ content = self._normalize(self._entry_plain_text(entry))
274
+ text.append(content or "(no content)", style=None if content else colors.dim)
275
+ elif isinstance(message, ToolResultMessage):
276
+ text.append(self._format_tool_result(message), style=colors.dim)
277
+ if selected:
278
+ text.stylize("bold")
279
+ return text
280
+
281
+ def _format_tool_result(self, message: ToolResultMessage) -> str:
282
+ call = self._tool_calls_by_id.get(message.tool_call_id)
283
+ name = call.name if call else message.tool_name
284
+ tool = get_tool(name)
285
+ if tool and call:
286
+ try:
287
+ params = tool.params(**call.arguments)
288
+ return self._normalize(f"[{name}: {tool.format_call(params)}]", max_len=120)
289
+ except Exception:
290
+ pass
291
+ if call and call.arguments:
292
+ return self._normalize(
293
+ f"[{name}: {self._format_tool_args(call.arguments)}]", max_len=120
294
+ )
295
+ return f"[{name}]"
296
+
297
+ def _format_tool_args(self, args: dict[str, object]) -> str:
298
+ for key in ("path", "file_path", "command", "pattern", "url"):
299
+ value = args.get(key)
300
+ if isinstance(value, str) and value.strip():
301
+ return shorten_path(value.strip())[:80]
302
+ if not args:
303
+ return ""
304
+ return str(args)[:80]
305
+
306
+ def _normalize(self, value: str, max_len: int = 200) -> str:
307
+ text = " ".join(value.replace("\t", " ").split())
308
+ if len(text) <= max_len:
309
+ return text
310
+ return f"{text[: max_len - 1]}…"
311
+
312
+ def render(self) -> Text:
313
+ _ = self._render_key
314
+ out = Text()
315
+ if not self._visible:
316
+ return out
317
+
318
+ colors = config.ui.colors
319
+ width = max(10, self.size.width or 80)
320
+ out.append("─" * width, style=colors.border)
321
+ out.append("\n Session Tree\n", style=f"bold {colors.title}")
322
+ out.append(" ↑/↓ move • ←/→ jump\n", style=colors.dim)
323
+ out.append("─" * width, style=colors.border)
324
+ out.append("\n")
325
+
326
+ if not self._filtered_nodes:
327
+ out.append(" No entries found\n", style=colors.dim)
328
+ out.append(" (0/0)", style=colors.dim)
329
+ return out
330
+
331
+ start = max(
332
+ 0,
333
+ min(
334
+ self._selected_index - self._max_visible_lines // 2,
335
+ len(self._filtered_nodes) - self._max_visible_lines,
336
+ ),
337
+ )
338
+ end = min(start + self._max_visible_lines, len(self._filtered_nodes))
339
+ for index in range(start, end):
340
+ flat = self._filtered_nodes[index]
341
+ entry = flat.node.entry
342
+ selected = index == self._selected_index
343
+ line = Text(" ")
344
+ if selected:
345
+ line.append("❯", style=colors.accent) # noqa: RUF001
346
+ else:
347
+ line.append(" ")
348
+ display_indent = max(0, flat.indent - 1) if self._multiple_roots else flat.indent
349
+ connector = (
350
+ "└─ "
351
+ if flat.show_connector and not flat.is_virtual_root_child and flat.is_last
352
+ else "├─ "
353
+ if flat.show_connector and not flat.is_virtual_root_child
354
+ else ""
355
+ )
356
+ connector_position = display_indent - 1 if connector else -1
357
+ prefix_chars: list[str] = []
358
+ for char_index in range(display_indent * 3):
359
+ level = char_index // 3
360
+ pos = char_index % 3
361
+ gutter = next((g for g in flat.gutters if g.position == level), None)
362
+ if gutter:
363
+ prefix_chars.append("│" if pos == 0 and gutter.show else " ")
364
+ elif connector and level == connector_position:
365
+ if pos == 0:
366
+ prefix_chars.append("└" if flat.is_last else "├")
367
+ elif pos == 1:
368
+ prefix_chars.append("─")
369
+ else:
370
+ prefix_chars.append(" ")
371
+ else:
372
+ prefix_chars.append(" ")
373
+ line.append("".join(prefix_chars), style=colors.dim)
374
+ if entry.id in self._active_path_ids:
375
+ line.append("• ", style=colors.accent)
376
+ line.append_text(self._entry_display_text(entry, selected))
377
+ if selected:
378
+ line.stylize(f"on {colors.panel_alt}")
379
+ line.truncate(width, overflow="ellipsis")
380
+ out.append_text(line)
381
+ out.append("\n")
382
+
383
+ out.append(f" ({self._selected_index + 1}/{len(self._filtered_nodes)})", style=colors.dim)
384
+ out.append("\n")
385
+ out.append("─" * width, style=colors.border)
386
+ return out
387
+
388
+ def action_move_up(self) -> None:
389
+ if self._filtered_nodes:
390
+ self._selected_index = (
391
+ len(self._filtered_nodes) - 1
392
+ if self._selected_index == 0
393
+ else self._selected_index - 1
394
+ )
395
+ self._last_selected_id = self._filtered_nodes[self._selected_index].node.entry.id
396
+ self._render_key += 1
397
+
398
+ def action_move_down(self) -> None:
399
+ if self._filtered_nodes:
400
+ self._selected_index = (
401
+ 0
402
+ if self._selected_index == len(self._filtered_nodes) - 1
403
+ else self._selected_index + 1
404
+ )
405
+ self._last_selected_id = self._filtered_nodes[self._selected_index].node.entry.id
406
+ self._render_key += 1
407
+
408
+ def action_page_up(self) -> None:
409
+ self._jump_to_message("up")
410
+
411
+ def action_page_down(self) -> None:
412
+ self._jump_to_message("down")
413
+
414
+ def _jump_to_message(self, direction: str) -> None:
415
+ if not self._filtered_nodes:
416
+ return
417
+ step = -1 if direction == "up" else 1
418
+ index = self._selected_index + step
419
+ while 0 <= index < len(self._filtered_nodes):
420
+ entry = self._filtered_nodes[index].node.entry
421
+ if isinstance(entry, MessageEntry) and isinstance(
422
+ entry.message, UserMessage | AssistantMessage
423
+ ):
424
+ self._selected_index = index
425
+ self._last_selected_id = entry.id
426
+ self._render_key += 1
427
+ return
428
+ index += step
429
+
430
+ def action_select(self) -> None:
431
+ if self._filtered_nodes:
432
+ self.post_message(
433
+ self.Selected(self._filtered_nodes[self._selected_index].node.entry.id)
434
+ )
435
+
436
+ def action_cancel(self) -> None:
437
+ self.post_message(self.Cancelled())
vtx/ui/welcome.py ADDED
@@ -0,0 +1,51 @@
1
+ from rich import box
2
+ from rich.panel import Panel
3
+ from rich.text import Text
4
+
5
+ from vtx import config
6
+
7
+ _LOGO = ("░█░█░███░█░█", "░█░█░░█░░░█░", "░░█░░░█░░█░█")
8
+
9
+ _SHORTCUT_ROWS = (
10
+ (("/", "slash commands"), ("@", "files/dirs"), ("tab", "complete paths"), ("↑/↓", "history")),
11
+ (("shift+tab", "permissions"), ("esc", "to interrupt"), ("shift+enter", "add newline")),
12
+ (("ctrl+c", "clear input"), ("ctrl+c x2", "exit"), ("enter", "queue"), ("alt+enter", "steer")),
13
+ (("↑/↓", "select queue"), ("ctrl+t", "cycle thinking"), ("ctrl+shift+t", "toggle thinking")),
14
+ )
15
+
16
+
17
+ def build_welcome(version: str) -> tuple[Text, Panel]:
18
+ accent = config.ui.colors.accent
19
+ dim = config.ui.colors.dim
20
+ muted = config.ui.colors.muted
21
+ border_color = config.ui.colors.border
22
+
23
+ logo = Text()
24
+ for i, line in enumerate(_LOGO):
25
+ logo.append(line, style=accent)
26
+ if i == len(_LOGO) - 1:
27
+ logo.append(f" v{version}", style=dim)
28
+ logo.append("\n")
29
+ logo.append("\n")
30
+
31
+ shortcuts = Text()
32
+ for row_idx, row in enumerate(_SHORTCUT_ROWS):
33
+ for item_idx, (key, desc) in enumerate(row):
34
+ if item_idx > 0:
35
+ shortcuts.append(" • ", style=dim)
36
+ shortcuts.append(key, style=muted)
37
+ shortcuts.append(f" {desc}", style=dim)
38
+ if row_idx < len(_SHORTCUT_ROWS) - 1:
39
+ shortcuts.append("\n")
40
+
41
+ panel = Panel(
42
+ shortcuts,
43
+ title=None,
44
+ title_align="left",
45
+ box=box.SQUARE,
46
+ border_style=border_color,
47
+ padding=(0, 1),
48
+ expand=False,
49
+ )
50
+
51
+ return logo, panel