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/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
|