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,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"