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
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Session lifecycle commands: /clear, /new, /resume, /tree, /session, /handoff,
|
|
2
|
+
/compact, /export and /copy."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from vtx import config
|
|
10
|
+
|
|
11
|
+
from ...session import Session, SessionInfo
|
|
12
|
+
from ..chat import ChatLog
|
|
13
|
+
from ..clipboard import copy_to_clipboard
|
|
14
|
+
from ..floating_list import FloatingList, ListItem
|
|
15
|
+
from ..input import InputBox
|
|
16
|
+
from ..selection_mode import SelectionMode
|
|
17
|
+
from ..tree import TreeSelector
|
|
18
|
+
from ..widgets import InfoBar, StatusLine, format_path
|
|
19
|
+
from .base import CommandSupport
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionCommands(CommandSupport):
|
|
23
|
+
HANDOFF_BACKLINK_TYPE = "handoff_backlink"
|
|
24
|
+
HANDOFF_FORWARD_LINK_TYPE = "handoff_forward_link"
|
|
25
|
+
|
|
26
|
+
def _clear_conversation(self) -> None:
|
|
27
|
+
if self._runtime.session:
|
|
28
|
+
self._runtime.new_session()
|
|
29
|
+
self._sync_runtime_state()
|
|
30
|
+
info_bar = self.query_one("#info-bar", InfoBar)
|
|
31
|
+
info_bar.set_tokens(0, 0, 0, 0)
|
|
32
|
+
info_bar.set_file_changes({})
|
|
33
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
34
|
+
chat.add_info_message("Conversation cleared")
|
|
35
|
+
|
|
36
|
+
def _new_conversation(self) -> None:
|
|
37
|
+
self._runtime.new_session()
|
|
38
|
+
self._sync_runtime_state()
|
|
39
|
+
|
|
40
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
41
|
+
info_bar = self.query_one("#info-bar", InfoBar)
|
|
42
|
+
status = self.query_one("#status-line", StatusLine)
|
|
43
|
+
|
|
44
|
+
self.run_worker(self._do_new_conversation(chat, info_bar, status), exclusive=False)
|
|
45
|
+
|
|
46
|
+
async def _do_new_conversation(self, chat: ChatLog, info_bar, status) -> None:
|
|
47
|
+
await self._reset_session_ui(chat, info_bar, status)
|
|
48
|
+
chat.add_info_message("Started new conversation")
|
|
49
|
+
|
|
50
|
+
async def _reset_session_ui(self, chat: ChatLog, info_bar, status) -> None:
|
|
51
|
+
await chat.remove_all_children()
|
|
52
|
+
|
|
53
|
+
status.reset()
|
|
54
|
+
|
|
55
|
+
info_bar.set_tokens(0, 0, 0, 0)
|
|
56
|
+
info_bar.set_file_changes({})
|
|
57
|
+
info_bar.set_thinking_level(self._runtime.thinking_level)
|
|
58
|
+
|
|
59
|
+
chat.add_session_info(getattr(self, "VERSION", ""))
|
|
60
|
+
|
|
61
|
+
self._runtime.reload_context()
|
|
62
|
+
self._sync_runtime_state()
|
|
63
|
+
if self._runtime.agent is not None:
|
|
64
|
+
self._sync_slash_commands()
|
|
65
|
+
# TODO: Surface self._runtime.agent.context.skill_warnings in UI
|
|
66
|
+
chat.add_loaded_resources(
|
|
67
|
+
context_paths=[
|
|
68
|
+
format_path(f.path) for f in self._runtime.agent.context.agents_files
|
|
69
|
+
],
|
|
70
|
+
skills=self._runtime.agent.context.skills,
|
|
71
|
+
tools=self._runtime.tools,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def _handle_handoff_command(self, args: str) -> None:
|
|
75
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
76
|
+
|
|
77
|
+
if self._is_running:
|
|
78
|
+
chat.add_info_message("Cannot handoff while agent is running", error=True)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
self._runtime.provider is None
|
|
83
|
+
or self._runtime.session is None
|
|
84
|
+
or self._runtime.agent is None
|
|
85
|
+
):
|
|
86
|
+
chat.add_info_message("Agent not initialized", error=True)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
query = args.strip()
|
|
90
|
+
if not query:
|
|
91
|
+
chat.add_info_message(
|
|
92
|
+
"Usage: /handoff <query>. Example: /handoff implement phase two", error=True
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if not self._runtime.session.all_messages:
|
|
97
|
+
chat.add_info_message("No conversation to handoff", error=True)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
chat.show_spinner_status("Creating handoff...")
|
|
101
|
+
self.run_worker(self._do_handoff(query), exclusive=False)
|
|
102
|
+
|
|
103
|
+
def _resolve_system_prompt(self, session: Session | None = None) -> str:
|
|
104
|
+
return self._runtime.resolve_system_prompt(session)
|
|
105
|
+
|
|
106
|
+
def _create_new_session(self) -> Session:
|
|
107
|
+
return self._runtime.create_session()
|
|
108
|
+
|
|
109
|
+
async def _do_handoff(self, query: str) -> None:
|
|
110
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
111
|
+
info_bar = self.query_one("#info-bar", InfoBar)
|
|
112
|
+
status = self.query_one("#status-line", StatusLine)
|
|
113
|
+
input_box = self.query_one("#input-box", InputBox)
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
self._runtime.provider is None
|
|
117
|
+
or self._runtime.session is None
|
|
118
|
+
or self._runtime.agent is None
|
|
119
|
+
):
|
|
120
|
+
chat.add_info_message("Agent not initialized", error=True)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
result = await self._runtime.create_handoff(query)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
chat.show_status("Handoff failed")
|
|
127
|
+
chat.add_info_message(f"Handoff failed: {e}", error=True)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
self._sync_runtime_state()
|
|
131
|
+
await self._reset_session_ui(chat, info_bar, status)
|
|
132
|
+
self._render_session_entries(result.new_session)
|
|
133
|
+
|
|
134
|
+
input_box.clear()
|
|
135
|
+
input_box.insert(result.prompt)
|
|
136
|
+
chat.show_status("Handoff ready")
|
|
137
|
+
input_box.focus()
|
|
138
|
+
|
|
139
|
+
def _show_session_info(self) -> None:
|
|
140
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
141
|
+
if not self._runtime.session:
|
|
142
|
+
chat.add_info_message("No active session")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
session_path = self._runtime.session.session_file
|
|
146
|
+
session_dir = str(session_path.parent) if session_path else None
|
|
147
|
+
session_file = session_path.name if session_path else "(in-memory session)"
|
|
148
|
+
|
|
149
|
+
counts = self._runtime.session.message_counts()
|
|
150
|
+
token_totals = self._runtime.session.token_totals()
|
|
151
|
+
|
|
152
|
+
chat.add_session_details(
|
|
153
|
+
session_dir=session_dir,
|
|
154
|
+
session_file=session_file,
|
|
155
|
+
user_messages=counts.user_messages,
|
|
156
|
+
assistant_messages=counts.assistant_messages,
|
|
157
|
+
tool_calls=counts.tool_calls,
|
|
158
|
+
tool_results=counts.tool_results,
|
|
159
|
+
total_messages=counts.total_messages,
|
|
160
|
+
input_tokens=token_totals.input_tokens,
|
|
161
|
+
output_tokens=token_totals.output_tokens,
|
|
162
|
+
cache_read_tokens=token_totals.cache_read_tokens,
|
|
163
|
+
cache_write_tokens=token_totals.cache_write_tokens,
|
|
164
|
+
total_tokens=token_totals.total_tokens,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _build_resume_items(self) -> list[ListItem]:
|
|
168
|
+
sessions = Session.list(self._cwd)
|
|
169
|
+
|
|
170
|
+
# Build tree structure from handoff relationships
|
|
171
|
+
by_id: dict[str, SessionInfo] = {s.id: s for s in sessions}
|
|
172
|
+
children: dict[str, list[SessionInfo]] = {}
|
|
173
|
+
roots: list[SessionInfo] = []
|
|
174
|
+
|
|
175
|
+
for session in sessions:
|
|
176
|
+
pid = session.parent_session_id
|
|
177
|
+
if pid and pid in by_id:
|
|
178
|
+
children.setdefault(pid, []).append(session)
|
|
179
|
+
else:
|
|
180
|
+
roots.append(session)
|
|
181
|
+
|
|
182
|
+
# Sort children within each parent by modified time (newest first,
|
|
183
|
+
# matching the root-level sort from Session.list)
|
|
184
|
+
for kids in children.values():
|
|
185
|
+
kids.sort(key=lambda s: s.modified, reverse=True)
|
|
186
|
+
|
|
187
|
+
# DFS flatten: roots are already sorted by modified (from Session.list)
|
|
188
|
+
items: list[ListItem] = []
|
|
189
|
+
accent = config.ui.colors.accent
|
|
190
|
+
|
|
191
|
+
def _walk(node: SessionInfo, depth: int) -> None:
|
|
192
|
+
prefix = ""
|
|
193
|
+
if depth > 0:
|
|
194
|
+
prefix = f"{' ' * (depth - 1)} └ [handoff] "
|
|
195
|
+
label = self._format_session_label(node.first_message)
|
|
196
|
+
caption = f"{self._format_session_age(node.modified)} {node.message_count}"
|
|
197
|
+
items.append(
|
|
198
|
+
ListItem(
|
|
199
|
+
value=node,
|
|
200
|
+
label=label,
|
|
201
|
+
description=caption,
|
|
202
|
+
prefix=prefix,
|
|
203
|
+
prefix_style=accent,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
for child in children.get(node.id, []):
|
|
207
|
+
_walk(child, depth + 1)
|
|
208
|
+
|
|
209
|
+
for root in roots:
|
|
210
|
+
_walk(root, 0)
|
|
211
|
+
|
|
212
|
+
return items
|
|
213
|
+
|
|
214
|
+
def _show_tree_selector(self) -> None:
|
|
215
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
216
|
+
input_box = self.query_one("#input-box", InputBox)
|
|
217
|
+
if self._is_running:
|
|
218
|
+
chat.add_info_message("Cannot open tree while agent is running", error=True)
|
|
219
|
+
return
|
|
220
|
+
if not self._runtime.session or not self._runtime.session.all_entries:
|
|
221
|
+
chat.add_info_message("No entries in session")
|
|
222
|
+
return
|
|
223
|
+
tree = self._runtime.session.get_tree()
|
|
224
|
+
selector = self.query_one("#tree-selector", TreeSelector)
|
|
225
|
+
input_box.clear()
|
|
226
|
+
input_box.set_autocomplete_enabled(False)
|
|
227
|
+
input_box.set_completing(True)
|
|
228
|
+
size = getattr(self, "size", None)
|
|
229
|
+
height = size.height if size is not None else 24
|
|
230
|
+
selector.show(tree, self._runtime.session.leaf_id, height)
|
|
231
|
+
self._selection_mode = SelectionMode.TREE
|
|
232
|
+
|
|
233
|
+
def _show_resume_sessions(self) -> None:
|
|
234
|
+
items = self._build_resume_items()
|
|
235
|
+
if not items:
|
|
236
|
+
self.notify(
|
|
237
|
+
"No saved sessions found", title="Sessions", timeout=3, severity="information"
|
|
238
|
+
)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
self._show_selection_picker(items, SelectionMode.SESSION, max_label_width=87)
|
|
242
|
+
|
|
243
|
+
def _delete_selected_resume_session(self) -> None:
|
|
244
|
+
if self._selection_mode != SelectionMode.SESSION:
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
completion_list = self.query_one("#completion-list", FloatingList)
|
|
248
|
+
selected_item = completion_list.selected_item
|
|
249
|
+
if selected_item is None:
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
session_info = selected_item.value
|
|
253
|
+
session_path = Path(session_info.path)
|
|
254
|
+
|
|
255
|
+
current_session_path: Path | None = None
|
|
256
|
+
if self._runtime.session and self._runtime.session.session_file is not None:
|
|
257
|
+
current_session_path = Path(self._runtime.session.session_file)
|
|
258
|
+
|
|
259
|
+
if current_session_path is not None and session_path == current_session_path:
|
|
260
|
+
self.notify(
|
|
261
|
+
"Cannot delete current session", title="Sessions", timeout=2, severity="warning"
|
|
262
|
+
)
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
session_path.unlink()
|
|
267
|
+
except FileNotFoundError:
|
|
268
|
+
pass
|
|
269
|
+
except Exception as exc:
|
|
270
|
+
self.notify(
|
|
271
|
+
f"Failed to delete session: {exc}", title="Sessions", timeout=3, severity="error"
|
|
272
|
+
)
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
items = self._build_resume_items()
|
|
276
|
+
was_at_bottom = self._is_chat_at_bottom()
|
|
277
|
+
if not items:
|
|
278
|
+
self._hide_completion_list()
|
|
279
|
+
input_box = self.query_one("#input-box", InputBox)
|
|
280
|
+
input_box.set_autocomplete_enabled(True)
|
|
281
|
+
input_box.set_completing(False)
|
|
282
|
+
self._selection_mode = None
|
|
283
|
+
self.notify(
|
|
284
|
+
"Session deleted (no saved sessions left)",
|
|
285
|
+
title="Sessions",
|
|
286
|
+
timeout=2,
|
|
287
|
+
severity="information",
|
|
288
|
+
)
|
|
289
|
+
else:
|
|
290
|
+
completion_list.update_items(items)
|
|
291
|
+
self.notify("Session deleted", title="Sessions", timeout=2, severity="information")
|
|
292
|
+
|
|
293
|
+
self._restore_chat_scroll_after_refresh(was_at_bottom)
|
|
294
|
+
|
|
295
|
+
def _handle_export_command(self) -> None:
|
|
296
|
+
from ..export import export_session_html
|
|
297
|
+
|
|
298
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
299
|
+
|
|
300
|
+
if not self._runtime.session:
|
|
301
|
+
chat.add_info_message("No active session to export")
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
if not self._runtime.session.entries:
|
|
305
|
+
chat.add_info_message("Session has no messages to export")
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
path = export_session_html(
|
|
310
|
+
cwd=self._cwd,
|
|
311
|
+
session_id=self._runtime.session.id,
|
|
312
|
+
output_dir=self._cwd,
|
|
313
|
+
version=getattr(self, "VERSION", ""),
|
|
314
|
+
)
|
|
315
|
+
chat.add_info_message(f"Session exported to {path.name}")
|
|
316
|
+
except Exception as e:
|
|
317
|
+
chat.add_info_message(f"Export failed: {e}", error=True)
|
|
318
|
+
|
|
319
|
+
def _handle_copy_command(self) -> None:
|
|
320
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
321
|
+
|
|
322
|
+
if not self._runtime.session:
|
|
323
|
+
chat.add_info_message("No agent messages to copy yet", error=True)
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
text = self._runtime.session.get_last_assistant_text()
|
|
327
|
+
if not text:
|
|
328
|
+
chat.add_info_message("No agent messages to copy yet", error=True)
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
copy_to_clipboard(text)
|
|
332
|
+
chat.show_status("Copied last agent message to clipboard")
|
|
333
|
+
|
|
334
|
+
def _handle_compact_command(self) -> None:
|
|
335
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
336
|
+
|
|
337
|
+
if self._is_running:
|
|
338
|
+
chat.add_info_message("Cannot compact while agent is running", error=True)
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
if self._runtime.provider is None or self._runtime.session is None:
|
|
342
|
+
chat.add_info_message("Agent not initialized", error=True)
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
if not self._runtime.session.all_messages:
|
|
346
|
+
chat.add_info_message("No conversation to compact", error=True)
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
chat.show_spinner_status("Compacting...")
|
|
350
|
+
self.run_worker(self._do_compact(), exclusive=False)
|
|
351
|
+
|
|
352
|
+
async def _do_compact(self) -> None:
|
|
353
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
354
|
+
|
|
355
|
+
if self._runtime.provider is None or self._runtime.session is None:
|
|
356
|
+
chat.add_info_message("Agent not initialized", error=True)
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
result = await self._runtime.compact_now()
|
|
361
|
+
chat.add_compaction_message(result.tokens_before)
|
|
362
|
+
except Exception as e:
|
|
363
|
+
chat.show_status("Compaction failed")
|
|
364
|
+
chat.add_info_message(f"Compaction failed: {e}", error=True)
|
|
365
|
+
|
|
366
|
+
def _format_session_label(self, message: str) -> str:
|
|
367
|
+
return " ".join(message.split())
|
|
368
|
+
|
|
369
|
+
def _format_session_age(self, modified: datetime) -> str:
|
|
370
|
+
now = datetime.now(UTC)
|
|
371
|
+
delta = max(0, int((now - modified).total_seconds()))
|
|
372
|
+
minutes = delta // 60
|
|
373
|
+
hours = delta // 3600
|
|
374
|
+
days = delta // 86400
|
|
375
|
+
weeks = days // 7
|
|
376
|
+
|
|
377
|
+
if minutes < 60:
|
|
378
|
+
value, unit = minutes, "m"
|
|
379
|
+
elif hours < 24:
|
|
380
|
+
value, unit = hours, "h"
|
|
381
|
+
elif days < 7:
|
|
382
|
+
value, unit = days, "d"
|
|
383
|
+
elif weeks < 52:
|
|
384
|
+
value, unit = weeks, "w"
|
|
385
|
+
else:
|
|
386
|
+
value, unit = weeks // 52, "y"
|
|
387
|
+
|
|
388
|
+
return f"{value:>2}{unit}"
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""/settings, /themes, /permissions, /thinking and /notifications commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from vtx import (
|
|
8
|
+
config,
|
|
9
|
+
set_colored_tool_badge,
|
|
10
|
+
set_git_context,
|
|
11
|
+
set_notifications_enabled,
|
|
12
|
+
set_permissions_mode,
|
|
13
|
+
set_show_welcome_shortcuts,
|
|
14
|
+
set_theme,
|
|
15
|
+
set_thinking_lines,
|
|
16
|
+
)
|
|
17
|
+
from vtx.config import (
|
|
18
|
+
NOTIFICATION_MODES,
|
|
19
|
+
PERMISSION_MODES,
|
|
20
|
+
THINKING_LINES_OPTIONS,
|
|
21
|
+
NotificationMode,
|
|
22
|
+
PermissionMode,
|
|
23
|
+
ThinkingLinesOption,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from ...themes import get_theme_options
|
|
27
|
+
from ..chat import ChatLog
|
|
28
|
+
from ..floating_list import FloatingList, ListItem
|
|
29
|
+
from ..selection_mode import SelectionMode
|
|
30
|
+
from ..widgets import InfoBar
|
|
31
|
+
from .base import CommandSupport
|
|
32
|
+
|
|
33
|
+
SettingsSelectionResult = Literal["reopened-picker", "closed"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SettingsCommands(CommandSupport):
|
|
37
|
+
def _handle_themes_command(self, args: str) -> None:
|
|
38
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
39
|
+
|
|
40
|
+
requested = args.strip()
|
|
41
|
+
if requested:
|
|
42
|
+
try:
|
|
43
|
+
self._select_theme(requested)
|
|
44
|
+
except ValueError as e:
|
|
45
|
+
chat.add_info_message(str(e), error=True)
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
current_theme = config.ui.theme
|
|
49
|
+
items = [
|
|
50
|
+
ListItem(value=theme_id, label=f"{label} ✓" if theme_id == current_theme else label)
|
|
51
|
+
for theme_id, label in get_theme_options()
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
self._show_selection_picker(items, SelectionMode.THEME)
|
|
55
|
+
|
|
56
|
+
def _select_theme(self, theme_id: str) -> None:
|
|
57
|
+
set_theme(theme_id)
|
|
58
|
+
self._apply_theme(theme_id)
|
|
59
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
60
|
+
chat.add_info_message(
|
|
61
|
+
f"Theme changed to {theme_id}. Full theme refresh applies when vtx is restarted.",
|
|
62
|
+
warning=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _handle_permissions_command(self, args: str) -> None:
|
|
66
|
+
descriptions: dict[PermissionMode, str] = {
|
|
67
|
+
"prompt": "ask before mutating tool calls",
|
|
68
|
+
"auto": "allow tool calls without approval prompts",
|
|
69
|
+
}
|
|
70
|
+
self._handle_choice_command(
|
|
71
|
+
args,
|
|
72
|
+
name="permission",
|
|
73
|
+
choices=PERMISSION_MODES,
|
|
74
|
+
current=config.permissions.mode,
|
|
75
|
+
descriptions=descriptions,
|
|
76
|
+
selection_mode=SelectionMode.PERMISSIONS,
|
|
77
|
+
select=self._select_permission_mode,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _select_permission_mode(self, mode: PermissionMode) -> None:
|
|
81
|
+
set_permissions_mode(mode)
|
|
82
|
+
info_bar = self.query_one("#info-bar", InfoBar)
|
|
83
|
+
info_bar.set_permission_mode(mode)
|
|
84
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
85
|
+
chat.show_status(f"Permission mode changed to {mode}")
|
|
86
|
+
|
|
87
|
+
def _handle_thinking_command(self, args: str) -> None:
|
|
88
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
89
|
+
if self._runtime.provider is None:
|
|
90
|
+
chat.add_info_message("Agent not initialized", error=True)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
requested = args.strip()
|
|
94
|
+
if requested:
|
|
95
|
+
if requested in self._runtime.provider.thinking_levels:
|
|
96
|
+
self._select_thinking_level(requested)
|
|
97
|
+
else:
|
|
98
|
+
valid_levels = ", ".join(self._runtime.provider.thinking_levels)
|
|
99
|
+
chat.add_info_message(
|
|
100
|
+
f"Invalid thinking level: {requested}. Use one of: {valid_levels}", error=True
|
|
101
|
+
)
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
items = [
|
|
105
|
+
ListItem(
|
|
106
|
+
value=level, label=f"{level} ✓" if level == self._runtime.thinking_level else level
|
|
107
|
+
)
|
|
108
|
+
for level in self._runtime.provider.thinking_levels
|
|
109
|
+
]
|
|
110
|
+
self._show_selection_picker(items, SelectionMode.THINKING)
|
|
111
|
+
|
|
112
|
+
def _select_thinking_level(self, level: str) -> None:
|
|
113
|
+
if self._runtime.provider is None:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
self._runtime.set_thinking_level(level)
|
|
117
|
+
self._sync_runtime_state()
|
|
118
|
+
|
|
119
|
+
info_bar = self.query_one("#info-bar", InfoBar)
|
|
120
|
+
info_bar.set_thinking_level(level)
|
|
121
|
+
self._apply_thinking_level_style(level)
|
|
122
|
+
|
|
123
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
124
|
+
chat.show_status(f"Thinking level changed to {level}")
|
|
125
|
+
|
|
126
|
+
def _show_thinking_lines_picker(self) -> None:
|
|
127
|
+
descriptions: dict[ThinkingLinesOption, str] = {
|
|
128
|
+
"1": "show 1 line",
|
|
129
|
+
"2": "show 2 lines",
|
|
130
|
+
"3": "show 3 lines",
|
|
131
|
+
"4": "show 4 lines",
|
|
132
|
+
"5": "show 5 lines",
|
|
133
|
+
"none": "no truncation",
|
|
134
|
+
}
|
|
135
|
+
items = self._build_choice_items(
|
|
136
|
+
THINKING_LINES_OPTIONS, config.ui.thinking_lines, descriptions
|
|
137
|
+
)
|
|
138
|
+
self._show_selection_picker(items, SelectionMode.THINKING_LINES)
|
|
139
|
+
|
|
140
|
+
def _select_thinking_lines(self, lines: ThinkingLinesOption) -> None:
|
|
141
|
+
set_thinking_lines(lines)
|
|
142
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
143
|
+
label = (
|
|
144
|
+
"no truncation" if lines == "none" else f"{lines} line{'s' if lines != '1' else ''}"
|
|
145
|
+
)
|
|
146
|
+
chat.show_status(f"Thinking lines changed to {label}")
|
|
147
|
+
|
|
148
|
+
def _handle_notifications_command(self, args: str) -> None:
|
|
149
|
+
current: NotificationMode = "on" if config.notifications.enabled else "off"
|
|
150
|
+
descriptions: dict[NotificationMode, str] = {
|
|
151
|
+
"on": "play notification sounds",
|
|
152
|
+
"off": "disable notification sounds",
|
|
153
|
+
}
|
|
154
|
+
self._handle_choice_command(
|
|
155
|
+
args,
|
|
156
|
+
name="notifications",
|
|
157
|
+
choices=NOTIFICATION_MODES,
|
|
158
|
+
current=current,
|
|
159
|
+
descriptions=descriptions,
|
|
160
|
+
selection_mode=SelectionMode.NOTIFICATIONS,
|
|
161
|
+
select=self._select_notifications_mode,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _select_notifications_mode(self, mode: NotificationMode) -> None:
|
|
165
|
+
set_notifications_enabled(mode == "on")
|
|
166
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
167
|
+
chat.show_status(f"Notifications turned {mode}")
|
|
168
|
+
|
|
169
|
+
# -------------------------------------------------------------------------
|
|
170
|
+
# Settings (unified panel for themes, permissions, notifications, thinking)
|
|
171
|
+
# -------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def _build_settings_items(self) -> list[ListItem[str]]:
|
|
174
|
+
notification_status = "on" if config.notifications.enabled else "off"
|
|
175
|
+
try:
|
|
176
|
+
thinking_level = self._runtime.thinking_level or "off"
|
|
177
|
+
except Exception:
|
|
178
|
+
thinking_level = "off"
|
|
179
|
+
|
|
180
|
+
shortcut_status = "on" if config.ui.show_welcome_shortcuts else "off"
|
|
181
|
+
thinking_lines_status = config.ui.thinking_lines
|
|
182
|
+
colored_badge_status = "on" if config.ui.colored_tool_badge else "off"
|
|
183
|
+
git_context_status = "on" if config.llm.system_prompt.git_context else "off"
|
|
184
|
+
return [
|
|
185
|
+
ListItem(
|
|
186
|
+
value="colored-tool-badge",
|
|
187
|
+
label="colored-tool-badge",
|
|
188
|
+
description=colored_badge_status,
|
|
189
|
+
),
|
|
190
|
+
ListItem(value="git-context", label="git-context", description=git_context_status),
|
|
191
|
+
ListItem(
|
|
192
|
+
value="notifications", label="notifications", description=notification_status
|
|
193
|
+
),
|
|
194
|
+
ListItem(value="show-shortcuts", label="show-shortcuts", description=shortcut_status),
|
|
195
|
+
ListItem(
|
|
196
|
+
value="permissions", label="permissions", description=config.permissions.mode
|
|
197
|
+
),
|
|
198
|
+
ListItem(value="themes", label="themes", description=config.ui.theme),
|
|
199
|
+
ListItem(value="thinking", label="thinking", description=thinking_level),
|
|
200
|
+
ListItem(
|
|
201
|
+
value="thinking-lines", label="thinking-lines", description=thinking_lines_status
|
|
202
|
+
),
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
def _show_settings_picker(self, selected_value: str | None = None) -> None:
|
|
206
|
+
items = self._build_settings_items()
|
|
207
|
+
self._show_selection_picker(items, SelectionMode.SETTINGS, max_label_width=40)
|
|
208
|
+
self._settings_selected_value = selected_value
|
|
209
|
+
if selected_value is not None:
|
|
210
|
+
completion_list = self.query_one("#completion-list", FloatingList)
|
|
211
|
+
completion_list.select_value(selected_value)
|
|
212
|
+
|
|
213
|
+
def _handle_settings_command(self) -> None:
|
|
214
|
+
self._show_settings_picker()
|
|
215
|
+
|
|
216
|
+
def _handle_settings_select(self, item_value: str) -> SettingsSelectionResult:
|
|
217
|
+
if item_value == "notifications":
|
|
218
|
+
current_enabled = config.notifications.enabled
|
|
219
|
+
set_notifications_enabled(not current_enabled)
|
|
220
|
+
mode: NotificationMode = "on" if not current_enabled else "off"
|
|
221
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
222
|
+
chat.show_status(f"Notifications turned {mode}")
|
|
223
|
+
self._show_settings_picker(selected_value=item_value)
|
|
224
|
+
return "reopened-picker"
|
|
225
|
+
|
|
226
|
+
elif item_value == "show-shortcuts":
|
|
227
|
+
shortcuts_current = config.ui.show_welcome_shortcuts
|
|
228
|
+
set_show_welcome_shortcuts(not shortcuts_current)
|
|
229
|
+
mode = "on" if not shortcuts_current else "off"
|
|
230
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
231
|
+
chat.show_status(f"Welcome shortcuts turned {mode}")
|
|
232
|
+
self._show_settings_picker(selected_value=item_value)
|
|
233
|
+
return "reopened-picker"
|
|
234
|
+
|
|
235
|
+
elif item_value == "permissions":
|
|
236
|
+
current: PermissionMode = config.permissions.mode
|
|
237
|
+
new_mode: PermissionMode = "auto" if current == "prompt" else "prompt"
|
|
238
|
+
set_permissions_mode(new_mode)
|
|
239
|
+
info_bar = self.query_one("#info-bar", InfoBar)
|
|
240
|
+
info_bar.set_permission_mode(new_mode)
|
|
241
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
242
|
+
chat.show_status(f"Permission mode changed to {new_mode}")
|
|
243
|
+
self._show_settings_picker(selected_value=item_value)
|
|
244
|
+
return "reopened-picker"
|
|
245
|
+
|
|
246
|
+
elif item_value == "themes":
|
|
247
|
+
self._settings_active = True
|
|
248
|
+
self._handle_themes_command("")
|
|
249
|
+
return "reopened-picker"
|
|
250
|
+
|
|
251
|
+
elif item_value == "thinking":
|
|
252
|
+
if self._runtime.provider is None:
|
|
253
|
+
self._handle_thinking_command("")
|
|
254
|
+
return "closed"
|
|
255
|
+
self._settings_active = True
|
|
256
|
+
self._handle_thinking_command("")
|
|
257
|
+
return "reopened-picker"
|
|
258
|
+
|
|
259
|
+
elif item_value == "thinking-lines":
|
|
260
|
+
self._settings_active = True
|
|
261
|
+
self._show_thinking_lines_picker()
|
|
262
|
+
return "reopened-picker"
|
|
263
|
+
|
|
264
|
+
elif item_value == "colored-tool-badge":
|
|
265
|
+
badge_current = config.ui.colored_tool_badge
|
|
266
|
+
set_colored_tool_badge(not badge_current)
|
|
267
|
+
mode = "on" if not badge_current else "off"
|
|
268
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
269
|
+
chat.show_status(f"Colored tool badge turned {mode}")
|
|
270
|
+
self._show_settings_picker(selected_value=item_value)
|
|
271
|
+
return "reopened-picker"
|
|
272
|
+
|
|
273
|
+
elif item_value == "git-context":
|
|
274
|
+
git_current = config.llm.system_prompt.git_context
|
|
275
|
+
set_git_context(not git_current)
|
|
276
|
+
mode = "on" if not git_current else "off"
|
|
277
|
+
chat = self.query_one("#chat-log", ChatLog)
|
|
278
|
+
chat.show_status(f"Git context turned {mode}")
|
|
279
|
+
chat.add_info_message(
|
|
280
|
+
"Git context change applies on new conversations (use /new) or on vtx restart.",
|
|
281
|
+
warning=True,
|
|
282
|
+
)
|
|
283
|
+
self._show_settings_picker(selected_value=item_value)
|
|
284
|
+
return "reopened-picker"
|
|
285
|
+
|
|
286
|
+
return "closed"
|