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