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/prompt_history.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from vtx import get_config_dir
|
|
7
|
+
|
|
8
|
+
MAX_HISTORY_ENTRIES = 50
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _history_path() -> Path:
|
|
12
|
+
return get_config_dir() / "prompt-history.jsonl"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PromptHistory:
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._entries: list[str] = []
|
|
18
|
+
self._index: int = 0
|
|
19
|
+
self._draft: str = ""
|
|
20
|
+
self._load()
|
|
21
|
+
|
|
22
|
+
def _load(self) -> None:
|
|
23
|
+
path = _history_path()
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return
|
|
26
|
+
try:
|
|
27
|
+
text = path.read_text(encoding="utf-8")
|
|
28
|
+
except OSError:
|
|
29
|
+
return
|
|
30
|
+
lines: list[str] = []
|
|
31
|
+
for line in text.strip().split("\n"):
|
|
32
|
+
if not line:
|
|
33
|
+
continue
|
|
34
|
+
try:
|
|
35
|
+
entry = json.loads(line)
|
|
36
|
+
except (json.JSONDecodeError, ValueError):
|
|
37
|
+
continue
|
|
38
|
+
if isinstance(entry, str) and entry:
|
|
39
|
+
lines.append(entry)
|
|
40
|
+
self._entries = lines[-MAX_HISTORY_ENTRIES:]
|
|
41
|
+
if len(lines) > MAX_HISTORY_ENTRIES:
|
|
42
|
+
self._rewrite()
|
|
43
|
+
|
|
44
|
+
def _rewrite(self) -> None:
|
|
45
|
+
path = _history_path()
|
|
46
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
try:
|
|
48
|
+
content = "\n".join(json.dumps(e) for e in self._entries) + "\n"
|
|
49
|
+
path.write_text(content, encoding="utf-8")
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def _append_to_file(self, entry: str) -> None:
|
|
54
|
+
path = _history_path()
|
|
55
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
try:
|
|
57
|
+
with path.open("a", encoding="utf-8") as f:
|
|
58
|
+
f.write(json.dumps(entry) + "\n")
|
|
59
|
+
except OSError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def append(self, text: str) -> None:
|
|
63
|
+
if not text:
|
|
64
|
+
return
|
|
65
|
+
if self._entries and self._entries[-1] == text:
|
|
66
|
+
self._reset_index()
|
|
67
|
+
return
|
|
68
|
+
self._entries.append(text)
|
|
69
|
+
trimmed = len(self._entries) > MAX_HISTORY_ENTRIES
|
|
70
|
+
if trimmed:
|
|
71
|
+
self._entries = self._entries[-MAX_HISTORY_ENTRIES:]
|
|
72
|
+
self._rewrite()
|
|
73
|
+
else:
|
|
74
|
+
self._append_to_file(text)
|
|
75
|
+
self._reset_index()
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_browsing(self) -> bool:
|
|
79
|
+
return self._index != 0
|
|
80
|
+
|
|
81
|
+
def _reset_index(self) -> None:
|
|
82
|
+
self._index = 0
|
|
83
|
+
self._draft = ""
|
|
84
|
+
|
|
85
|
+
def navigate(self, direction: int, current_text: str) -> str | None:
|
|
86
|
+
if not self._entries:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
if self._index == 0:
|
|
90
|
+
self._draft = current_text
|
|
91
|
+
|
|
92
|
+
new_index = self._index + direction
|
|
93
|
+
|
|
94
|
+
if new_index > 0 or abs(new_index) > len(self._entries):
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
self._index = new_index
|
|
98
|
+
|
|
99
|
+
if self._index == 0:
|
|
100
|
+
return self._draft
|
|
101
|
+
|
|
102
|
+
return self._entries[self._index]
|
vtx/ui/queue_ui.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Pending/steer message queue state and its QueueDisplay rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from .input import InputBox
|
|
9
|
+
from .widgets import QueueDisplay
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class QueueUIMixin:
|
|
13
|
+
"""Manages the two message queues: steer (injected mid-run) and pending (run next)."""
|
|
14
|
+
|
|
15
|
+
_pending_queue: deque[tuple[str, str]]
|
|
16
|
+
_steer_queue: deque[tuple[str, str]]
|
|
17
|
+
_queue_selection: tuple[bool, int] | None
|
|
18
|
+
_queue_editing: tuple[bool, int, tuple[str, str]] | None
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
query_one: Any
|
|
22
|
+
|
|
23
|
+
def _queue_items(self) -> list[tuple[bool, int, str, str]]:
|
|
24
|
+
steer = [
|
|
25
|
+
(True, index, display, query)
|
|
26
|
+
for index, (display, query) in enumerate(self._steer_queue)
|
|
27
|
+
]
|
|
28
|
+
pending = [
|
|
29
|
+
(False, index, display, query)
|
|
30
|
+
for index, (display, query) in enumerate(self._pending_queue)
|
|
31
|
+
]
|
|
32
|
+
return steer + pending
|
|
33
|
+
|
|
34
|
+
def _selected_queue_flat_index(self) -> int | None:
|
|
35
|
+
if self._queue_selection is None:
|
|
36
|
+
return None
|
|
37
|
+
for flat_index, (is_steer, index, _, _) in enumerate(self._queue_items()):
|
|
38
|
+
if self._queue_selection == (is_steer, index):
|
|
39
|
+
return flat_index
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def _set_queue_selection_by_flat_index(self, flat_index: int | None) -> None:
|
|
43
|
+
items = self._queue_items()
|
|
44
|
+
if flat_index is None or flat_index < 0 or flat_index >= len(items):
|
|
45
|
+
self._queue_selection = None
|
|
46
|
+
else:
|
|
47
|
+
is_steer, index, _, _ = items[flat_index]
|
|
48
|
+
self._queue_selection = (is_steer, index)
|
|
49
|
+
self._update_queue_display()
|
|
50
|
+
|
|
51
|
+
def _update_queue_display(self) -> None:
|
|
52
|
+
queue_display = self.query_one("#queue-display", QueueDisplay)
|
|
53
|
+
steer_items = [(display, True) for display, _ in self._steer_queue]
|
|
54
|
+
normal_items = [(display, False) for display, _ in self._pending_queue]
|
|
55
|
+
selected = self._selected_queue_flat_index()
|
|
56
|
+
editing = None
|
|
57
|
+
|
|
58
|
+
if self._queue_editing:
|
|
59
|
+
is_steer, index, original = self._queue_editing
|
|
60
|
+
target_items = steer_items if is_steer else normal_items
|
|
61
|
+
edit_index = min(index, len(target_items))
|
|
62
|
+
target_items.insert(edit_index, (original[0], is_steer))
|
|
63
|
+
editing = edit_index if is_steer else len(steer_items) + edit_index
|
|
64
|
+
selected = editing
|
|
65
|
+
|
|
66
|
+
queue_display.update_items(steer_items + normal_items, selected=selected, editing=editing)
|
|
67
|
+
|
|
68
|
+
def select_queue_from_input(self, direction: int) -> bool:
|
|
69
|
+
if self._queue_editing is not None:
|
|
70
|
+
return False
|
|
71
|
+
items = self._queue_items()
|
|
72
|
+
if not items:
|
|
73
|
+
return False
|
|
74
|
+
current = self._selected_queue_flat_index()
|
|
75
|
+
if current is None:
|
|
76
|
+
next_index = len(items) - 1 if direction < 0 else 0
|
|
77
|
+
else:
|
|
78
|
+
next_index = current + direction
|
|
79
|
+
if next_index < 0 or next_index >= len(items):
|
|
80
|
+
self._set_queue_selection_by_flat_index(None)
|
|
81
|
+
return False
|
|
82
|
+
self._set_queue_selection_by_flat_index(next_index)
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
def delete_selected_queue_item(self) -> bool:
|
|
86
|
+
if self._queue_editing is not None or self._queue_selection is None:
|
|
87
|
+
return False
|
|
88
|
+
is_steer, index = self._queue_selection
|
|
89
|
+
queue = self._steer_queue if is_steer else self._pending_queue
|
|
90
|
+
if index >= len(queue):
|
|
91
|
+
self._set_queue_selection_by_flat_index(None)
|
|
92
|
+
return False
|
|
93
|
+
del queue[index]
|
|
94
|
+
items = self._queue_items()
|
|
95
|
+
if items:
|
|
96
|
+
self._set_queue_selection_by_flat_index(min(index, len(items) - 1))
|
|
97
|
+
else:
|
|
98
|
+
self._set_queue_selection_by_flat_index(None)
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
def start_queue_edit(self) -> bool:
|
|
102
|
+
if self._queue_selection is None or self._queue_editing is not None:
|
|
103
|
+
return False
|
|
104
|
+
is_steer, index = self._queue_selection
|
|
105
|
+
queue = self._steer_queue if is_steer else self._pending_queue
|
|
106
|
+
if index >= len(queue):
|
|
107
|
+
self._set_queue_selection_by_flat_index(None)
|
|
108
|
+
return False
|
|
109
|
+
original = queue[index]
|
|
110
|
+
del queue[index]
|
|
111
|
+
self._queue_editing = (is_steer, index, original)
|
|
112
|
+
input_box = self.query_one("#input-box", InputBox)
|
|
113
|
+
input_box.clear(reset_pastes=False)
|
|
114
|
+
input_box.insert(original[1])
|
|
115
|
+
input_box.focus()
|
|
116
|
+
self._update_queue_display()
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
def finish_queue_edit(self, display_text: str, query_text: str) -> bool:
|
|
120
|
+
if self._queue_editing is None:
|
|
121
|
+
return False
|
|
122
|
+
is_steer, index, _ = self._queue_editing
|
|
123
|
+
queue = self._steer_queue if is_steer else self._pending_queue
|
|
124
|
+
queue.insert(min(index, len(queue)), (display_text, query_text))
|
|
125
|
+
self._queue_editing = None
|
|
126
|
+
self._queue_selection = (is_steer, min(index, len(queue) - 1))
|
|
127
|
+
self._update_queue_display()
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
def cancel_queue_edit(self) -> bool:
|
|
131
|
+
if self._queue_editing is None:
|
|
132
|
+
return False
|
|
133
|
+
is_steer, index, original = self._queue_editing
|
|
134
|
+
queue = self._steer_queue if is_steer else self._pending_queue
|
|
135
|
+
queue.insert(min(index, len(queue)), original)
|
|
136
|
+
self._queue_editing = None
|
|
137
|
+
self._queue_selection = (is_steer, min(index, len(queue) - 1))
|
|
138
|
+
self._update_queue_display()
|
|
139
|
+
input_box = self.query_one("#input-box", InputBox)
|
|
140
|
+
input_box.clear(reset_pastes=False)
|
|
141
|
+
return True
|
vtx/ui/selection_mode.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SelectionMode(StrEnum):
|
|
5
|
+
SESSION = "session"
|
|
6
|
+
MODEL = "model"
|
|
7
|
+
THEME = "theme"
|
|
8
|
+
LOGIN = "login"
|
|
9
|
+
LOGOUT = "logout"
|
|
10
|
+
PERMISSIONS = "permissions"
|
|
11
|
+
THINKING = "thinking"
|
|
12
|
+
THINKING_LINES = "thinking_lines"
|
|
13
|
+
COLORED_TOOL_BADGE = "colored_tool_badge"
|
|
14
|
+
NOTIFICATIONS = "notifications"
|
|
15
|
+
SETTINGS = "settings"
|
|
16
|
+
TREE = "tree"
|
|
17
|
+
API_KEY = "api_key"
|
|
18
|
+
API_KEY_ACTION = "api_key_action"
|
vtx/ui/session_ui.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from ..core.types import (
|
|
8
|
+
AssistantMessage,
|
|
9
|
+
ImageContent,
|
|
10
|
+
TextContent,
|
|
11
|
+
ThinkingContent,
|
|
12
|
+
ToolCall,
|
|
13
|
+
ToolResultMessage,
|
|
14
|
+
UserMessage,
|
|
15
|
+
)
|
|
16
|
+
from ..runtime import ConversationRuntime
|
|
17
|
+
from ..session import CompactionEntry, CustomMessageEntry, MessageEntry, Session
|
|
18
|
+
from ..tools import BaseTool, get_tool, tools_by_name
|
|
19
|
+
from .chat import ChatLog
|
|
20
|
+
from .commands import CommandsMixin
|
|
21
|
+
from .input import InputBox
|
|
22
|
+
from .tool_output import escape_tool_output_text, truncate_tool_output_text
|
|
23
|
+
from .widgets import InfoBar, StatusLine, format_path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SessionUIMixin:
|
|
27
|
+
_cwd: str
|
|
28
|
+
_hide_thinking: bool
|
|
29
|
+
_current_block_type: str | None
|
|
30
|
+
_api_key: str | None
|
|
31
|
+
_tools: list[BaseTool]
|
|
32
|
+
_openai_compat_auth_mode: Any
|
|
33
|
+
_anthropic_compat_auth_mode: Any
|
|
34
|
+
_runtime: ConversationRuntime
|
|
35
|
+
|
|
36
|
+
# Methods from App - declared for type checking
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
query_one: Any
|
|
39
|
+
|
|
40
|
+
# Methods from other mixins/main class
|
|
41
|
+
def _sync_runtime_state(self) -> None: ...
|
|
42
|
+
def _apply_thinking_level_style(self, level: str) -> None: ...
|
|
43
|
+
|
|
44
|
+
def _resolve_system_prompt(self, session: Session | None = None) -> str:
|
|
45
|
+
return self._runtime.resolve_system_prompt(session)
|
|
46
|
+
|
|
47
|
+
def _extract_text_content(self, content: str | list[TextContent | ImageContent]) -> str:
|
|
48
|
+
if isinstance(content, str):
|
|
49
|
+
return content
|
|
50
|
+
|
|
51
|
+
parts: list[str] = []
|
|
52
|
+
for part in content:
|
|
53
|
+
if isinstance(part, TextContent):
|
|
54
|
+
parts.append(part.text)
|
|
55
|
+
elif isinstance(part, ImageContent):
|
|
56
|
+
parts.append("[image]")
|
|
57
|
+
|
|
58
|
+
return "".join(parts).strip() or "(no content)"
|
|
59
|
+
|
|
60
|
+
def _format_tool_call(self, tool_call: ToolCall) -> str:
|
|
61
|
+
tool = tools_by_name.get(tool_call.name)
|
|
62
|
+
if not tool:
|
|
63
|
+
return json.dumps(tool_call.arguments) if tool_call.arguments else ""
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
params = tool.params(**tool_call.arguments)
|
|
67
|
+
return tool.format_call(params)
|
|
68
|
+
except Exception:
|
|
69
|
+
return json.dumps(tool_call.arguments) if tool_call.arguments else ""
|
|
70
|
+
|
|
71
|
+
def _format_tool_result_text(self, message: ToolResultMessage) -> tuple[str, str | None]:
|
|
72
|
+
if message.content:
|
|
73
|
+
parts = [part.text for part in message.content if isinstance(part, TextContent)]
|
|
74
|
+
full_text = "".join(parts)
|
|
75
|
+
collapsed_text, truncated = truncate_tool_output_text(full_text)
|
|
76
|
+
return collapsed_text, escape_tool_output_text(full_text) if truncated else None
|
|
77
|
+
|
|
78
|
+
return "", None
|
|
79
|
+
|
|
80
|
+
def _render_session_entries(self, session: Session) -> None:
|
|
81
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
82
|
+
started_tools: set[str] = set()
|
|
83
|
+
|
|
84
|
+
for entry in session.active_entries:
|
|
85
|
+
if isinstance(entry, MessageEntry):
|
|
86
|
+
message = entry.message
|
|
87
|
+
if isinstance(message, UserMessage):
|
|
88
|
+
chat.add_user_message(self._extract_text_content(message.content))
|
|
89
|
+
elif isinstance(message, AssistantMessage):
|
|
90
|
+
for part in message.content:
|
|
91
|
+
if isinstance(part, TextContent) and part.text:
|
|
92
|
+
chat.add_content(part.text)
|
|
93
|
+
elif isinstance(part, ThinkingContent) and part.thinking:
|
|
94
|
+
block = chat.add_thinking(part.thinking)
|
|
95
|
+
if self._hide_thinking:
|
|
96
|
+
block.add_class("-hidden")
|
|
97
|
+
elif isinstance(part, ToolCall):
|
|
98
|
+
call_msg = self._format_tool_call(part)
|
|
99
|
+
tool = get_tool(part.name)
|
|
100
|
+
icon = tool.tool_icon if tool else "→"
|
|
101
|
+
chat.start_tool(part.name, part.id, call_msg, icon=icon)
|
|
102
|
+
started_tools.add(part.id)
|
|
103
|
+
elif isinstance(message, ToolResultMessage):
|
|
104
|
+
tool_id = message.tool_call_id
|
|
105
|
+
if tool_id not in started_tools:
|
|
106
|
+
tool = get_tool(message.tool_name)
|
|
107
|
+
icon = tool.tool_icon if tool else "→"
|
|
108
|
+
chat.start_tool(message.tool_name, tool_id, "", icon=icon)
|
|
109
|
+
started_tools.add(tool_id)
|
|
110
|
+
|
|
111
|
+
markup = True
|
|
112
|
+
ui_summary = message.ui_summary
|
|
113
|
+
ui_details = message.ui_details
|
|
114
|
+
ui_details_full = message.ui_details_full
|
|
115
|
+
if ui_summary is None and ui_details is None:
|
|
116
|
+
ui_details, ui_details_full = self._format_tool_result_text(message)
|
|
117
|
+
|
|
118
|
+
images = [part for part in message.content if isinstance(part, ImageContent)]
|
|
119
|
+
chat.set_tool_result(
|
|
120
|
+
tool_id,
|
|
121
|
+
ui_summary,
|
|
122
|
+
ui_details,
|
|
123
|
+
not message.is_error,
|
|
124
|
+
markup=markup,
|
|
125
|
+
ui_details_full=ui_details_full,
|
|
126
|
+
images=images or None,
|
|
127
|
+
)
|
|
128
|
+
elif isinstance(entry, CompactionEntry):
|
|
129
|
+
chat.add_compaction_message(entry.tokens_before)
|
|
130
|
+
elif isinstance(entry, CustomMessageEntry):
|
|
131
|
+
target_session_id = str(
|
|
132
|
+
(entry.details or {}).get("target_session_id") or ""
|
|
133
|
+
).strip()
|
|
134
|
+
query = str((entry.details or {}).get("query") or "").strip()
|
|
135
|
+
if entry.custom_type == CommandsMixin.HANDOFF_BACKLINK_TYPE and target_session_id:
|
|
136
|
+
chat.add_handoff_link_message(
|
|
137
|
+
label="Origin session",
|
|
138
|
+
target_session_id=target_session_id,
|
|
139
|
+
query=query,
|
|
140
|
+
direction="back",
|
|
141
|
+
)
|
|
142
|
+
elif (
|
|
143
|
+
entry.custom_type == CommandsMixin.HANDOFF_FORWARD_LINK_TYPE
|
|
144
|
+
and target_session_id
|
|
145
|
+
):
|
|
146
|
+
chat.add_handoff_link_message(
|
|
147
|
+
label="Handoff session",
|
|
148
|
+
target_session_id=target_session_id,
|
|
149
|
+
query=query,
|
|
150
|
+
direction="forward",
|
|
151
|
+
)
|
|
152
|
+
elif entry.custom_type == "shell_command":
|
|
153
|
+
details = entry.details or {}
|
|
154
|
+
command = str(details.get("command") or "")
|
|
155
|
+
output = str(details.get("output") or "")
|
|
156
|
+
success = bool(details.get("success", True))
|
|
157
|
+
tool_id = f"shell-replay-{entry.id}"
|
|
158
|
+
chat.start_tool("bash", tool_id, f"$ {command}", icon="$")
|
|
159
|
+
chat.set_tool_result(
|
|
160
|
+
tool_id, None, output or "(no output)", success, markup=False
|
|
161
|
+
)
|
|
162
|
+
elif entry.display:
|
|
163
|
+
chat.add_info_message(entry.content)
|
|
164
|
+
|
|
165
|
+
async def _load_session_by_id(self, session_id: str) -> None:
|
|
166
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
167
|
+
input_box = self.query_one("#input-box", InputBox)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
session = Session.continue_by_id(self._cwd, session_id)
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
chat.add_info_message(f"Failed to load linked session: {exc}", error=True)
|
|
173
|
+
input_box.focus()
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
if session.session_file is None:
|
|
177
|
+
chat.add_info_message(
|
|
178
|
+
"Failed to load linked session: missing session file", error=True
|
|
179
|
+
)
|
|
180
|
+
input_box.focus()
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
await self._load_session(session.session_file)
|
|
184
|
+
|
|
185
|
+
async def _load_session(self, session_path: str | Path) -> None:
|
|
186
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
187
|
+
info_bar = self.query_one("#info-bar", InfoBar)
|
|
188
|
+
status = self.query_one("#status-line", StatusLine)
|
|
189
|
+
input_box = self.query_one("#input-box", InputBox)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
session = self._runtime.load_session(session_path)
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
chat.add_info_message(f"Failed to load session: {exc}", error=True)
|
|
195
|
+
input_box.focus()
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
self._sync_runtime_state()
|
|
199
|
+
self._current_block_type = None
|
|
200
|
+
|
|
201
|
+
status.reset()
|
|
202
|
+
token_totals = session.token_totals()
|
|
203
|
+
info_bar.set_tokens(
|
|
204
|
+
token_totals.input_tokens,
|
|
205
|
+
token_totals.output_tokens,
|
|
206
|
+
token_totals.context_tokens,
|
|
207
|
+
token_totals.cache_read_tokens,
|
|
208
|
+
token_totals.cache_write_tokens,
|
|
209
|
+
)
|
|
210
|
+
info_bar.set_file_changes(session.file_changes_summary())
|
|
211
|
+
|
|
212
|
+
model_info = session.model
|
|
213
|
+
if model_info:
|
|
214
|
+
provider, model_id, _ = model_info
|
|
215
|
+
info_bar.set_model(model_id, provider)
|
|
216
|
+
|
|
217
|
+
info_bar.set_thinking_level(self._runtime.thinking_level)
|
|
218
|
+
self._apply_thinking_level_style(self._runtime.thinking_level)
|
|
219
|
+
|
|
220
|
+
await chat.remove_all_children()
|
|
221
|
+
|
|
222
|
+
chat.add_session_info(getattr(self, "VERSION", ""))
|
|
223
|
+
|
|
224
|
+
if self._runtime.agent:
|
|
225
|
+
chat.add_loaded_resources(
|
|
226
|
+
context_paths=[
|
|
227
|
+
format_path(f.path) for f in self._runtime.agent.context.agents_files
|
|
228
|
+
],
|
|
229
|
+
skills=self._runtime.agent.context.skills,
|
|
230
|
+
tools=self._runtime.tools,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
self._render_session_entries(session)
|
|
234
|
+
chat.add_info_message("Resumed session")
|
|
235
|
+
input_box.focus()
|
vtx/ui/startup.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Background startup chores: binary download, update check, file-path scan,
|
|
2
|
+
git-branch refresh and launch warnings."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import glob
|
|
8
|
+
import os
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
10
|
+
|
|
11
|
+
from vtx import update_available_binaries
|
|
12
|
+
from vtx.tools_manager import ensure_tools
|
|
13
|
+
from vtx.version import PACKAGE_NAME, VERSION
|
|
14
|
+
|
|
15
|
+
from ..update_check import get_newer_pypi_version
|
|
16
|
+
from .blocks import LaunchWarning
|
|
17
|
+
from .chat import ChatLog
|
|
18
|
+
from .input import InputBox
|
|
19
|
+
from .widgets import InfoBar
|
|
20
|
+
|
|
21
|
+
_CHANGELOG_URL = "https://github.com/OEvortex/vtx-coding-agent/blob/main/CHANGELOG.md"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StartupMixin:
|
|
25
|
+
_cwd: str
|
|
26
|
+
_fd_path: str | None
|
|
27
|
+
_is_running: bool
|
|
28
|
+
_startup_complete: bool
|
|
29
|
+
_update_notice_shown: bool
|
|
30
|
+
_pending_update_notice_version: str | None
|
|
31
|
+
_git_branch_refresh_inflight: bool
|
|
32
|
+
_launch_warnings: list[LaunchWarning]
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
query_one: Any
|
|
36
|
+
call_later: Any
|
|
37
|
+
|
|
38
|
+
async def _refresh_git_branch(self) -> None:
|
|
39
|
+
# Skip the tick if the previous refresh is still resolving in its thread.
|
|
40
|
+
if self._git_branch_refresh_inflight:
|
|
41
|
+
return
|
|
42
|
+
self._git_branch_refresh_inflight = True
|
|
43
|
+
try:
|
|
44
|
+
info_bar = self.query_one("#info-bar", InfoBar)
|
|
45
|
+
await info_bar.refresh_git_branch()
|
|
46
|
+
finally:
|
|
47
|
+
self._git_branch_refresh_inflight = False
|
|
48
|
+
|
|
49
|
+
def _scan_file_paths(self) -> list[str]:
|
|
50
|
+
patterns = [
|
|
51
|
+
"**/*.py",
|
|
52
|
+
"**/*.js",
|
|
53
|
+
"**/*.ts",
|
|
54
|
+
"**/*.tsx",
|
|
55
|
+
"**/*.json",
|
|
56
|
+
"**/*.md",
|
|
57
|
+
"**/*.yaml",
|
|
58
|
+
"**/*.yml",
|
|
59
|
+
"**/*.toml",
|
|
60
|
+
]
|
|
61
|
+
paths = []
|
|
62
|
+
for pattern in patterns:
|
|
63
|
+
for path in glob.glob(os.path.join(self._cwd, pattern), recursive=True):
|
|
64
|
+
rel_path = os.path.relpath(path, self._cwd)
|
|
65
|
+
if not rel_path.startswith(
|
|
66
|
+
(".git", "node_modules", "__pycache__", ".venv", "venv")
|
|
67
|
+
):
|
|
68
|
+
paths.append(rel_path)
|
|
69
|
+
return sorted(paths)
|
|
70
|
+
|
|
71
|
+
async def _collect_file_paths(self) -> None:
|
|
72
|
+
"""Collect file paths using glob (fallback when fd is unavailable)."""
|
|
73
|
+
# The recursive glob can take seconds on large repos; keep it off the event loop.
|
|
74
|
+
paths = await asyncio.to_thread(self._scan_file_paths)
|
|
75
|
+
self.query_one("#input-box", InputBox).set_file_paths(paths)
|
|
76
|
+
|
|
77
|
+
async def _ensure_binaries(self) -> None:
|
|
78
|
+
paths = await ensure_tools(silent=True)
|
|
79
|
+
update_available_binaries()
|
|
80
|
+
|
|
81
|
+
if not self._fd_path and paths.get("fd"):
|
|
82
|
+
self._fd_path = paths["fd"]
|
|
83
|
+
self.query_one("#input-box", InputBox).set_fd_path(self._fd_path)
|
|
84
|
+
|
|
85
|
+
async def _check_for_updates(self) -> None:
|
|
86
|
+
latest = await get_newer_pypi_version(PACKAGE_NAME, VERSION)
|
|
87
|
+
if latest is None:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
self._pending_update_notice_version = latest
|
|
91
|
+
self.call_later(self._show_pending_update_notice_if_idle)
|
|
92
|
+
|
|
93
|
+
def _show_pending_update_notice_if_idle(self) -> None:
|
|
94
|
+
if not self._startup_complete or self._is_running:
|
|
95
|
+
return
|
|
96
|
+
if self._update_notice_shown or self._pending_update_notice_version is None:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
100
|
+
chat.add_update_available_message(
|
|
101
|
+
self._pending_update_notice_version, changelog_url=_CHANGELOG_URL
|
|
102
|
+
)
|
|
103
|
+
self._update_notice_shown = True
|
|
104
|
+
self._pending_update_notice_version = None
|
|
105
|
+
|
|
106
|
+
def _add_launch_warning(
|
|
107
|
+
self, message: str, *, severity: Literal["warning", "error"] = "warning"
|
|
108
|
+
) -> None:
|
|
109
|
+
cleaned = message.strip()
|
|
110
|
+
if not cleaned:
|
|
111
|
+
return
|
|
112
|
+
self._launch_warnings.append(LaunchWarning(message=cleaned, severity=severity))
|
|
113
|
+
|
|
114
|
+
def _flush_launch_warnings(self, chat: ChatLog) -> None:
|
|
115
|
+
if self._launch_warnings:
|
|
116
|
+
chat.add_launch_warnings(self._launch_warnings)
|
|
117
|
+
|
|
118
|
+
async def _ensure_models_dev(self) -> None:
|
|
119
|
+
import contextlib
|
|
120
|
+
|
|
121
|
+
from ..llm.dynamic_models import _fetch_models_dev
|
|
122
|
+
|
|
123
|
+
with contextlib.suppress(Exception):
|
|
124
|
+
await _fetch_models_dev()
|