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
vtx/config.py ADDED
@@ -0,0 +1,741 @@
1
+ import contextlib
2
+ import os
3
+ import shutil
4
+ import sys
5
+ import tempfile
6
+ from contextvars import ContextVar
7
+ from copy import deepcopy
8
+ from datetime import datetime
9
+ from importlib import resources
10
+ from pathlib import Path
11
+ from typing import Any, Literal, get_args
12
+
13
+ from pydantic import BaseModel, Field, ValidationError, field_validator
14
+
15
+ from .themes import ColorsConfig, get_theme, get_theme_ids
16
+
17
+ CONFIG_DIR_NAME: str = "vtx"
18
+
19
+ OnOverflowMode = Literal["continue", "pause"]
20
+ AuthMode = Literal["auto", "required", "none"]
21
+ PermissionMode = Literal["prompt", "auto"]
22
+ NotificationMode = Literal["on", "off"]
23
+ PERMISSION_MODES: tuple[PermissionMode, ...] = get_args(PermissionMode)
24
+ NOTIFICATION_MODES: tuple[NotificationMode, ...] = get_args(NotificationMode)
25
+
26
+
27
+ # =================================================================================================
28
+ # Persisted Config Schema and Defaults
29
+ # =================================================================================================
30
+
31
+
32
+ def _load_default_config_yaml() -> dict[str, Any]:
33
+ import yaml
34
+
35
+ return (
36
+ yaml.safe_load(
37
+ resources.files("vtx.defaults").joinpath("config.yml").read_text(encoding="utf-8")
38
+ )
39
+ or {}
40
+ )
41
+
42
+
43
+ _DEFAULT_CONFIG_DATA = _load_default_config_yaml()
44
+ CURRENT_CONFIG_VERSION = int(_DEFAULT_CONFIG_DATA.get("meta", {}).get("config_version", 1))
45
+
46
+
47
+ def _resolve_default_system_prompt() -> str:
48
+ """Return the default base identity string.
49
+
50
+ Pulled from :mod:`vtx.prompts.identity` so the prompt is owned by
51
+ Python code rather than the shipped YAML. The YAML keeps an empty
52
+ placeholder for schema stability; this function fills it in.
53
+ """
54
+ from .prompts.identity import DEFAULT_VTX_BASE
55
+
56
+ return DEFAULT_VTX_BASE
57
+
58
+
59
+ _config_var: ContextVar["Config | None"] = ContextVar("vtx_config", default=None)
60
+ _config_warnings: list[str] = []
61
+
62
+
63
+ class MetaConfig(BaseModel):
64
+ config_version: int = CURRENT_CONFIG_VERSION
65
+
66
+
67
+ ThinkingLinesOption = Literal["1", "2", "3", "4", "5", "none"]
68
+ THINKING_LINES_OPTIONS: tuple[ThinkingLinesOption, ...] = get_args(ThinkingLinesOption)
69
+
70
+
71
+ class UIConfig(BaseModel):
72
+ theme: str = "gruvbox-dark"
73
+ # When true, finalized thinking blocks are collapsed to a single line summary.
74
+ # Set to false to always show the full thinking content.
75
+ collapse_thinking: bool = True
76
+ # Number of lines to show when thinking is collapsed. "none" means no truncation.
77
+ thinking_lines: ThinkingLinesOption = "1"
78
+ # When true, tool icon and name use badge label color on success.
79
+ colored_tool_badge: bool = True
80
+ # Show the list of keyboard shortcuts in the welcome section on launch.
81
+ # Set to false to hide the shortcuts panel.
82
+ show_welcome_shortcuts: bool = True
83
+ # Models hidden from the /model picker. Use a provider name ("github-copilot")
84
+ # to hide all its models, or "provider:model" to hide a specific model.
85
+ # Hidden models remain usable via config defaults or session resume.
86
+ hidden_models: list[str] = []
87
+
88
+ @field_validator("theme")
89
+ @classmethod
90
+ def _validate_theme(cls, value: str) -> str:
91
+ if value not in get_theme_ids():
92
+ raise ValueError(f"Unknown theme: {value}")
93
+ return value
94
+
95
+ @property
96
+ def colors(self) -> ColorsConfig:
97
+ return get_theme(self.theme).colors
98
+
99
+
100
+ class SystemPromptConfig(BaseModel):
101
+ content: str
102
+ git_context: bool = False
103
+
104
+
105
+ class AuthConfig(BaseModel):
106
+ openai_compat: AuthMode = "auto"
107
+ anthropic_compat: AuthMode = "auto"
108
+
109
+
110
+ class TLSConfig(BaseModel):
111
+ insecure_skip_verify: bool = False
112
+
113
+
114
+ class LLMConfig(BaseModel):
115
+ default_provider: str
116
+ default_model: str
117
+ default_base_url: str = ""
118
+ default_thinking_level: str
119
+ system_prompt: SystemPromptConfig
120
+ tool_call_idle_timeout_seconds: float = 180
121
+ request_timeout_seconds: float = 600
122
+ auth: AuthConfig = AuthConfig()
123
+ tls: TLSConfig = TLSConfig()
124
+
125
+
126
+ class CompactionConfig(BaseModel):
127
+ on_overflow: OnOverflowMode = "continue"
128
+ buffer_tokens: int = 20000
129
+
130
+
131
+ class AgentConfig(BaseModel):
132
+ max_turns: int = 500
133
+ default_context_window: int = 200000
134
+
135
+
136
+ class PermissionsConfig(BaseModel):
137
+ mode: PermissionMode = "prompt"
138
+
139
+
140
+ class NotificationsConfig(BaseModel):
141
+ enabled: bool = False
142
+ volume: float = Field(default=0.5, ge=0.0, le=1.0)
143
+
144
+
145
+ class LastSelectedConfig(BaseModel):
146
+ model_id: str | None = None
147
+ provider: str | None = None
148
+ thinking_level: str | None = None
149
+
150
+
151
+ class ConfigSchema(BaseModel):
152
+ meta: MetaConfig
153
+ llm: LLMConfig
154
+ ui: UIConfig
155
+ compaction: CompactionConfig
156
+ agent: AgentConfig
157
+ permissions: PermissionsConfig
158
+ notifications: NotificationsConfig = NotificationsConfig()
159
+ last_selected: LastSelectedConfig = LastSelectedConfig()
160
+
161
+
162
+ # =================================================================================================
163
+ # Runtime Config Accessors
164
+ # =================================================================================================
165
+
166
+
167
+ class _BinariesConfig:
168
+ def __init__(self, binaries: set[str]) -> None:
169
+ self._binaries = binaries
170
+
171
+ def has(self, binary: str) -> bool:
172
+ return binary in self._binaries
173
+
174
+ @property
175
+ def rg(self) -> bool:
176
+ return "rg" in self._binaries
177
+
178
+ @property
179
+ def fd(self) -> bool:
180
+ return "fd" in self._binaries
181
+
182
+ @property
183
+ def gh(self) -> bool:
184
+ return "gh" in self._binaries
185
+
186
+
187
+ class Config:
188
+ def __init__(self, data: dict[str, Any]) -> None:
189
+ merged = self.merge_with_defaults(data)
190
+ self._parsed = ConfigSchema.model_validate(merged)
191
+
192
+ @staticmethod
193
+ def deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
194
+ merged = deepcopy(base)
195
+ for key, value in overrides.items():
196
+ current_value = merged.get(key)
197
+ if isinstance(current_value, dict) and isinstance(value, dict):
198
+ merged[key] = Config.deep_merge(current_value, value)
199
+ else:
200
+ merged[key] = deepcopy(value)
201
+ return merged
202
+
203
+ @staticmethod
204
+ def _apply_legacy_key_shims(data: dict[str, Any]) -> dict[str, Any]:
205
+ normalized_data = deepcopy(data)
206
+
207
+ llm = normalized_data.get("llm")
208
+ if isinstance(llm, dict):
209
+ legacy_prompt = llm.get("system_prompt")
210
+ if isinstance(legacy_prompt, str):
211
+ llm["system_prompt"] = {"content": legacy_prompt}
212
+
213
+ legacy_git_context = llm.pop("system_prompt_git_context", None)
214
+ if isinstance(legacy_git_context, bool):
215
+ system_prompt = llm.get("system_prompt")
216
+ if not isinstance(system_prompt, dict):
217
+ system_prompt = {}
218
+ llm["system_prompt"] = system_prompt
219
+ system_prompt.setdefault("git_context", legacy_git_context)
220
+
221
+ # Fill the default base identity from Python when the YAML left
222
+ # the placeholder empty (or did not include it at all).
223
+ system_prompt = llm.get("system_prompt")
224
+ if isinstance(system_prompt, dict) and not system_prompt.get("content"):
225
+ system_prompt["content"] = _resolve_default_system_prompt()
226
+
227
+ return normalized_data
228
+
229
+ @staticmethod
230
+ def merge_with_defaults(data: dict[str, Any]) -> dict[str, Any]:
231
+ normalized_data = Config._apply_legacy_key_shims(data)
232
+ return Config.deep_merge(_DEFAULT_CONFIG_DATA, normalized_data)
233
+
234
+ @property
235
+ def llm(self) -> LLMConfig:
236
+ return self._parsed.llm
237
+
238
+ @property
239
+ def ui(self) -> UIConfig:
240
+ return self._parsed.ui
241
+
242
+ @property
243
+ def compaction(self) -> CompactionConfig:
244
+ return self._parsed.compaction
245
+
246
+ @property
247
+ def agent(self) -> AgentConfig:
248
+ return self._parsed.agent
249
+
250
+ @property
251
+ def permissions(self) -> PermissionsConfig:
252
+ return self._parsed.permissions
253
+
254
+ @property
255
+ def notifications(self) -> NotificationsConfig:
256
+ return self._parsed.notifications
257
+
258
+ @property
259
+ def binaries(self) -> _BinariesConfig:
260
+ return _BinariesConfig(AVAILABLE_BINARIES)
261
+
262
+
263
+ # =================================================================================================
264
+ # Persisted Config IO, Migration, and Serialization
265
+ # =================================================================================================
266
+
267
+
268
+ def get_config_dir() -> Path:
269
+ return Path.home() / f".{CONFIG_DIR_NAME}"
270
+
271
+
272
+ def get_agents_dir() -> Path:
273
+ return Path.home() / ".agents"
274
+
275
+
276
+ def _ensure_config_file() -> Path:
277
+ config_dir = get_config_dir()
278
+ config_file = config_dir / "config.yml"
279
+
280
+ if not config_file.exists():
281
+ config_dir.mkdir(parents=True, exist_ok=True)
282
+ import yaml
283
+
284
+ defaults = Config._apply_legacy_key_shims(_load_default_config_yaml())
285
+ config_file.write_text(yaml.dump(defaults, default_flow_style=False), encoding="utf-8")
286
+
287
+ return config_file
288
+
289
+
290
+ def _record_config_warning(message: str) -> None:
291
+ _config_warnings.append(message)
292
+ print(message, file=sys.stderr)
293
+
294
+
295
+ def consume_config_warnings() -> list[str]:
296
+ warnings = _config_warnings.copy()
297
+ _config_warnings.clear()
298
+ return warnings
299
+
300
+
301
+ def _detect_available_binaries() -> set[str]:
302
+ binaries = {"rg", "fd", "gh"}
303
+ available = set()
304
+ bin_dir = get_config_dir() / "bin"
305
+
306
+ for binary in binaries:
307
+ if shutil.which(binary) or (bin_dir / binary).exists():
308
+ available.add(binary)
309
+
310
+ return available
311
+
312
+
313
+ def _get_config_version(data: dict[str, Any]) -> int:
314
+ meta = data.get("meta")
315
+ if not isinstance(meta, dict):
316
+ return 0
317
+ version = meta.get("config_version")
318
+ if isinstance(version, int) and version >= 0:
319
+ return version
320
+ return 0
321
+
322
+
323
+ def _migrate_v0_to_v1(data: dict[str, Any]) -> dict[str, Any]:
324
+ migrated = Config._apply_legacy_key_shims(data)
325
+ meta = migrated.get("meta")
326
+ if not isinstance(meta, dict):
327
+ migrated["meta"] = {"config_version": 1}
328
+ else:
329
+ meta["config_version"] = 1
330
+ return migrated
331
+
332
+
333
+ def _migrate_v1_to_v2(data: dict[str, Any]) -> dict[str, Any]:
334
+ migrated = Config._apply_legacy_key_shims(data)
335
+ meta = migrated.get("meta")
336
+ if not isinstance(meta, dict):
337
+ migrated["meta"] = {"config_version": 2}
338
+ else:
339
+ meta["config_version"] = 2
340
+ return migrated
341
+
342
+
343
+ def _migrate_v2_to_v3(data: dict[str, Any]) -> dict[str, Any]:
344
+ migrated = Config._apply_legacy_key_shims(data)
345
+ ui = migrated.get("ui")
346
+ if not isinstance(ui, dict):
347
+ ui = {}
348
+ migrated["ui"] = ui
349
+
350
+ ui["theme"] = "gruvbox-dark"
351
+ ui.pop("colors", None)
352
+
353
+ meta = migrated.get("meta")
354
+ if not isinstance(meta, dict):
355
+ migrated["meta"] = {"config_version": 3}
356
+ else:
357
+ meta["config_version"] = 3
358
+ return migrated
359
+
360
+
361
+ def _migrate_v3_to_v4(data: dict[str, Any]) -> dict[str, Any]:
362
+ migrated = Config._apply_legacy_key_shims(data)
363
+ llm = migrated.get("llm")
364
+ if not isinstance(llm, dict):
365
+ llm = {}
366
+ migrated["llm"] = llm
367
+
368
+ auth = llm.get("auth")
369
+ if not isinstance(auth, dict):
370
+ auth = {}
371
+ llm["auth"] = auth
372
+
373
+ auth.setdefault("openai_compat", "auto")
374
+ auth.setdefault("anthropic_compat", "auto")
375
+
376
+ meta = migrated.get("meta")
377
+ if not isinstance(meta, dict):
378
+ migrated["meta"] = {"config_version": 4}
379
+ else:
380
+ meta["config_version"] = 4
381
+ return migrated
382
+
383
+
384
+ def _migrate_v4_to_v5(data: dict[str, Any]) -> dict[str, Any]:
385
+ migrated = Config._apply_legacy_key_shims(data)
386
+ notifications = migrated.get("notifications")
387
+ if not isinstance(notifications, dict):
388
+ notifications = {}
389
+ migrated["notifications"] = notifications
390
+
391
+ notifications.setdefault("volume", 0.5)
392
+
393
+ meta = migrated.get("meta")
394
+ if not isinstance(meta, dict):
395
+ migrated["meta"] = {"config_version": 5}
396
+ else:
397
+ meta["config_version"] = 5
398
+ return migrated
399
+
400
+
401
+ def _migrate_v5_to_v6(data: dict[str, Any]) -> dict[str, Any]:
402
+ migrated = Config._apply_legacy_key_shims(data)
403
+ llm = migrated.get("llm")
404
+ if not isinstance(llm, dict):
405
+ llm = {}
406
+ migrated["llm"] = llm
407
+
408
+ system_prompt = llm.get("system_prompt")
409
+ if not isinstance(system_prompt, dict):
410
+ system_prompt = {}
411
+ llm["system_prompt"] = system_prompt
412
+
413
+ # Pull the default identity from Python so the YAML placeholder
414
+ # remains a single source of truth.
415
+ system_prompt["content"] = _resolve_default_system_prompt()
416
+ system_prompt["git_context"] = _DEFAULT_CONFIG_DATA["llm"]["system_prompt"]["git_context"]
417
+
418
+ meta = migrated.get("meta")
419
+ if not isinstance(meta, dict):
420
+ migrated["meta"] = {"config_version": 6}
421
+ else:
422
+ meta["config_version"] = 6
423
+ return migrated
424
+
425
+
426
+ def _migrate_config_data(data: dict[str, Any]) -> tuple[dict[str, Any], int, int, bool]:
427
+ original = deepcopy(data)
428
+ current_version = _get_config_version(original)
429
+ migrated = deepcopy(original)
430
+
431
+ while current_version < CURRENT_CONFIG_VERSION:
432
+ if current_version == 0:
433
+ migrated = _migrate_v0_to_v1(migrated)
434
+ current_version = 1
435
+ continue
436
+ if current_version == 1:
437
+ migrated = _migrate_v1_to_v2(migrated)
438
+ current_version = 2
439
+ continue
440
+ if current_version == 2:
441
+ migrated = _migrate_v2_to_v3(migrated)
442
+ current_version = 3
443
+ continue
444
+ if current_version == 3:
445
+ migrated = _migrate_v3_to_v4(migrated)
446
+ current_version = 4
447
+ continue
448
+ if current_version == 4:
449
+ migrated = _migrate_v4_to_v5(migrated)
450
+ current_version = 5
451
+ continue
452
+ if current_version == 5:
453
+ migrated = _migrate_v5_to_v6(migrated)
454
+ current_version = 6
455
+ continue
456
+ break
457
+
458
+ migrated_version = _get_config_version(migrated)
459
+ did_migrate = migrated != original
460
+ return migrated, _get_config_version(original), migrated_version, did_migrate
461
+
462
+
463
+ def _serialize_config_yaml(data: dict[str, Any]) -> str:
464
+ import yaml
465
+
466
+ return yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False) + "\n"
467
+
468
+
469
+ def _atomic_write_text(path: Path, content: str) -> None:
470
+ fd, tmp_name = tempfile.mkstemp(prefix=f"{path.name}.", suffix=".tmp", dir=path.parent)
471
+ tmp_path = Path(tmp_name)
472
+ try:
473
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
474
+ f.write(content)
475
+ os.replace(tmp_path, path)
476
+ except Exception:
477
+ with contextlib.suppress(FileNotFoundError):
478
+ tmp_path.unlink()
479
+ raise
480
+
481
+
482
+ def _backup_and_write_migrated_config(config_file: Path, data: dict[str, Any]) -> Path:
483
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
484
+ backup_path = config_file.with_name(f"{config_file.name}.bak.{timestamp}")
485
+ shutil.copy2(config_file, backup_path)
486
+ _atomic_write_text(config_file, _serialize_config_yaml(data))
487
+ return backup_path
488
+
489
+
490
+ # =================================================================================================
491
+ # Runtime Environment Capabilities
492
+ # TODO: Consider moving runtime capability detection and caching to a dedicated runtime.py module.
493
+ # =================================================================================================
494
+
495
+
496
+ AVAILABLE_BINARIES = _detect_available_binaries()
497
+
498
+
499
+ def update_available_binaries() -> None:
500
+ AVAILABLE_BINARIES.clear()
501
+ AVAILABLE_BINARIES.update(_detect_available_binaries())
502
+
503
+
504
+ # =================================================================================================
505
+ # Persisted Config Loading and Runtime Cache
506
+ # =================================================================================================
507
+
508
+
509
+ def _read_config_data(config_file: Path) -> dict[str, Any]:
510
+ try:
511
+ import yaml
512
+
513
+ with open(config_file, encoding="utf-8") as f:
514
+ return yaml.safe_load(f) or {}
515
+ except (OSError, yaml.YAMLError) as exc:
516
+ _record_config_warning(
517
+ f"Invalid config at {config_file}: {exc}. Falling back to built-in defaults."
518
+ )
519
+ return {}
520
+
521
+
522
+ def _load_config() -> Config:
523
+ config_file = _ensure_config_file()
524
+ data = _read_config_data(config_file)
525
+
526
+ try:
527
+ migrated_data, from_version, to_version, did_migrate = _migrate_config_data(data)
528
+ if did_migrate and data:
529
+ try:
530
+ backup = _backup_and_write_migrated_config(config_file, migrated_data)
531
+ _record_config_warning(
532
+ f"Migrated config at {config_file} from v{from_version} to v{to_version}. "
533
+ f"Backup saved to {backup}."
534
+ )
535
+ except Exception as exc:
536
+ _record_config_warning(
537
+ f"Failed to persist migrated config at {config_file}: {exc}. "
538
+ "Continuing with in-memory migrated config."
539
+ )
540
+ return Config(migrated_data)
541
+ except ValidationError as exc:
542
+ _record_config_warning(
543
+ f"Invalid config values at {config_file}: {exc}. Falling back to built-in defaults."
544
+ )
545
+ return Config({})
546
+
547
+
548
+ def get_config() -> Config:
549
+ """
550
+ Get the current config instance.
551
+
552
+ Returns the config from context variable if set, otherwise loads from file.
553
+ The loaded config is cached in the context variable.
554
+ """
555
+ cfg = _config_var.get()
556
+ if cfg is None:
557
+ cfg = _load_config()
558
+ _config_var.set(cfg)
559
+ return cfg
560
+
561
+
562
+ def set_config(config: Config) -> None:
563
+ """Set the config instance (useful for testing)."""
564
+ _config_var.set(config)
565
+
566
+
567
+ def reload_config() -> Config:
568
+ """Reload config from file and update the context variable."""
569
+ cfg = _load_config()
570
+ _config_var.set(cfg)
571
+ return cfg
572
+
573
+
574
+ def _set_config_version(data: dict[str, Any]) -> None:
575
+ meta = data.get("meta")
576
+ if not isinstance(meta, dict):
577
+ data["meta"] = {"config_version": CURRENT_CONFIG_VERSION}
578
+ else:
579
+ meta["config_version"] = CURRENT_CONFIG_VERSION
580
+
581
+
582
+ def set_theme(theme: str) -> Config:
583
+ get_theme(theme)
584
+
585
+ config_file = _ensure_config_file()
586
+ data = _read_config_data(config_file)
587
+
588
+ ui = data.get("ui")
589
+ if not isinstance(ui, dict):
590
+ ui = {}
591
+ data["ui"] = ui
592
+
593
+ ui["theme"] = theme
594
+ ui.pop("colors", None)
595
+ _set_config_version(data)
596
+
597
+ _atomic_write_text(config_file, _serialize_config_yaml(data))
598
+ return reload_config()
599
+
600
+
601
+ def set_show_welcome_shortcuts(enabled: bool) -> Config:
602
+ config_file = _ensure_config_file()
603
+ data = _read_config_data(config_file)
604
+
605
+ ui = data.get("ui")
606
+ if not isinstance(ui, dict):
607
+ ui = {}
608
+ data["ui"] = ui
609
+
610
+ ui["show_welcome_shortcuts"] = enabled
611
+ _set_config_version(data)
612
+
613
+ _atomic_write_text(config_file, _serialize_config_yaml(data))
614
+ return reload_config()
615
+
616
+
617
+ def set_permissions_mode(mode: PermissionMode) -> Config:
618
+ config_file = _ensure_config_file()
619
+ data = _read_config_data(config_file)
620
+
621
+ perms = data.get("permissions")
622
+ if not isinstance(perms, dict):
623
+ perms = {}
624
+ data["permissions"] = perms
625
+
626
+ perms["mode"] = mode
627
+ _set_config_version(data)
628
+
629
+ _atomic_write_text(config_file, _serialize_config_yaml(data))
630
+ return reload_config()
631
+
632
+
633
+ def set_thinking_lines(lines: ThinkingLinesOption) -> Config:
634
+ config_file = _ensure_config_file()
635
+ data = _read_config_data(config_file)
636
+
637
+ ui = data.get("ui")
638
+ if not isinstance(ui, dict):
639
+ ui = {}
640
+ data["ui"] = ui
641
+
642
+ ui["thinking_lines"] = lines
643
+ _set_config_version(data)
644
+
645
+ _atomic_write_text(config_file, _serialize_config_yaml(data))
646
+ return reload_config()
647
+
648
+
649
+ def set_git_context(enabled: bool) -> Config:
650
+ config_file = _ensure_config_file()
651
+ data = _read_config_data(config_file)
652
+
653
+ llm = data.get("llm")
654
+ if not isinstance(llm, dict):
655
+ llm = {}
656
+ data["llm"] = llm
657
+
658
+ system_prompt = llm.get("system_prompt")
659
+ if not isinstance(system_prompt, dict):
660
+ system_prompt = {}
661
+ llm["system_prompt"] = system_prompt
662
+
663
+ system_prompt["git_context"] = enabled
664
+ _set_config_version(data)
665
+
666
+ _atomic_write_text(config_file, _serialize_config_yaml(data))
667
+ return reload_config()
668
+
669
+
670
+ def set_colored_tool_badge(enabled: bool) -> Config:
671
+ config_file = _ensure_config_file()
672
+ data = _read_config_data(config_file)
673
+
674
+ ui = data.get("ui")
675
+ if not isinstance(ui, dict):
676
+ ui = {}
677
+ data["ui"] = ui
678
+
679
+ ui["colored_tool_badge"] = enabled
680
+ _set_config_version(data)
681
+
682
+ _atomic_write_text(config_file, _serialize_config_yaml(data))
683
+ return reload_config()
684
+
685
+
686
+ def set_notifications_enabled(enabled: bool) -> Config:
687
+ config_file = _ensure_config_file()
688
+ data = _read_config_data(config_file)
689
+
690
+ notifications = data.get("notifications")
691
+ if not isinstance(notifications, dict):
692
+ notifications = {}
693
+ data["notifications"] = notifications
694
+
695
+ notifications["enabled"] = enabled
696
+ _set_config_version(data)
697
+
698
+ _atomic_write_text(config_file, _serialize_config_yaml(data))
699
+ return reload_config()
700
+
701
+
702
+ def reset_config() -> None:
703
+ """Reset config to uninitialized state (next get_config() will reload from file)."""
704
+ _config_var.set(None)
705
+ _config_warnings.clear()
706
+
707
+
708
+ def set_last_selected(
709
+ model_id: str | None, provider: str | None, thinking_level: str | None
710
+ ) -> None:
711
+ """Save the last selected model, provider, and thinking level."""
712
+ config_file = _ensure_config_file()
713
+ data = _read_config_data(config_file)
714
+
715
+ last_selected = data.get("last_selected", {})
716
+ if not isinstance(last_selected, dict):
717
+ last_selected = {}
718
+
719
+ last_selected["model_id"] = model_id
720
+ last_selected["provider"] = provider
721
+ last_selected["thinking_level"] = thinking_level
722
+
723
+ data["last_selected"] = last_selected
724
+ _set_config_version(data)
725
+ _atomic_write_text(config_file, _serialize_config_yaml(data))
726
+
727
+
728
+ def get_last_selected() -> LastSelectedConfig:
729
+ """Get the last selected model, provider, and thinking level."""
730
+ config_file = _ensure_config_file()
731
+ data = _read_config_data(config_file)
732
+
733
+ last_selected = data.get("last_selected", {})
734
+ if not isinstance(last_selected, dict):
735
+ last_selected = {}
736
+
737
+ return LastSelectedConfig(
738
+ model_id=last_selected.get("model_id"),
739
+ provider=last_selected.get("provider"),
740
+ thinking_level=last_selected.get("thinking_level"),
741
+ )