tau-coding-agent 0.1.0__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 (283) hide show
  1. tau/__init__.py +0 -0
  2. tau/agent/__init__.py +11 -0
  3. tau/agent/prompt/__init__.py +10 -0
  4. tau/agent/prompt/builder.py +302 -0
  5. tau/agent/prompt/types.py +33 -0
  6. tau/agent/service.py +369 -0
  7. tau/agent/types.py +61 -0
  8. tau/auth/manager.py +247 -0
  9. tau/auth/storage.py +82 -0
  10. tau/auth/types.py +41 -0
  11. tau/builtins/__init__.py +4 -0
  12. tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
  13. tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
  14. tau/builtins/commands/__init__.py +41 -0
  15. tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
  16. tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
  17. tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
  18. tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
  19. tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
  20. tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
  21. tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
  22. tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
  23. tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
  24. tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
  25. tau/builtins/commands/clear.py +16 -0
  26. tau/builtins/commands/compact.py +28 -0
  27. tau/builtins/commands/reload.py +27 -0
  28. tau/builtins/commands/session.py +19 -0
  29. tau/builtins/extensions/footer/__init__.py +76 -0
  30. tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
  31. tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
  32. tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
  33. tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
  34. tau/builtins/extensions/footer/git.py +26 -0
  35. tau/builtins/extensions/footer/model.py +69 -0
  36. tau/builtins/extensions/footer/utils.py +44 -0
  37. tau/builtins/extensions/header/__init__.py +18 -0
  38. tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
  39. tau/builtins/models/__init__.py +0 -0
  40. tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
  41. tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
  42. tau/builtins/models/audio.py +43 -0
  43. tau/builtins/models/image.py +43 -0
  44. tau/builtins/models/text.py +482 -0
  45. tau/builtins/models/video.py +40 -0
  46. tau/builtins/prompts/commit.md +7 -0
  47. tau/builtins/prompts/docs.md +7 -0
  48. tau/builtins/prompts/explain.md +7 -0
  49. tau/builtins/prompts/fix.md +7 -0
  50. tau/builtins/prompts/refactor.md +7 -0
  51. tau/builtins/prompts/review.md +7 -0
  52. tau/builtins/prompts/test.md +7 -0
  53. tau/builtins/providers/__init__.py +0 -0
  54. tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  55. tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
  56. tau/builtins/providers/audio.py +10 -0
  57. tau/builtins/providers/image.py +9 -0
  58. tau/builtins/providers/text.py +33 -0
  59. tau/builtins/providers/video.py +6 -0
  60. tau/builtins/skills/code-review/SKILL.md +4 -0
  61. tau/builtins/skills/debug/SKILL.md +4 -0
  62. tau/builtins/skills/git-commit/SKILL.md +4 -0
  63. tau/builtins/themes/dark.yaml +1 -0
  64. tau/builtins/themes/light.yaml +46 -0
  65. tau/builtins/tools/__init__.py +73 -0
  66. tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  67. tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  68. tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
  69. tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
  70. tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
  71. tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
  72. tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
  73. tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
  74. tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
  75. tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
  76. tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
  77. tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
  78. tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
  79. tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
  80. tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
  81. tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
  82. tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
  83. tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
  84. tau/builtins/tools/edit.py +215 -0
  85. tau/builtins/tools/glob.py +112 -0
  86. tau/builtins/tools/grep.py +146 -0
  87. tau/builtins/tools/ls.py +135 -0
  88. tau/builtins/tools/read.py +122 -0
  89. tau/builtins/tools/terminal.py +150 -0
  90. tau/builtins/tools/write.py +105 -0
  91. tau/commands/__init__.py +10 -0
  92. tau/commands/registry.py +71 -0
  93. tau/commands/types.py +33 -0
  94. tau/console/__init__.py +0 -0
  95. tau/console/cli.py +266 -0
  96. tau/console/commands/__init__.py +0 -0
  97. tau/console/commands/auth.py +193 -0
  98. tau/console/commands/packages.py +104 -0
  99. tau/console/commands/update.py +76 -0
  100. tau/core/__init__.py +0 -0
  101. tau/core/registry.py +102 -0
  102. tau/engine/__init__.py +47 -0
  103. tau/engine/service.py +768 -0
  104. tau/engine/types.py +163 -0
  105. tau/extensions/__init__.py +28 -0
  106. tau/extensions/api.py +928 -0
  107. tau/extensions/context.py +462 -0
  108. tau/extensions/events.py +70 -0
  109. tau/extensions/loader.py +386 -0
  110. tau/extensions/runtime.py +184 -0
  111. tau/extensions/settings.py +137 -0
  112. tau/hooks/__init__.py +112 -0
  113. tau/hooks/engine.py +237 -0
  114. tau/hooks/inference.py +21 -0
  115. tau/hooks/runtime.py +126 -0
  116. tau/hooks/service.py +121 -0
  117. tau/hooks/session.py +117 -0
  118. tau/hooks/tui.py +61 -0
  119. tau/hooks/types.py +72 -0
  120. tau/inference/__init__.py +180 -0
  121. tau/inference/api/__init__.py +0 -0
  122. tau/inference/api/audio/__init__.py +0 -0
  123. tau/inference/api/audio/base.py +29 -0
  124. tau/inference/api/audio/builtins.py +15 -0
  125. tau/inference/api/audio/elevenlabs_audio.py +183 -0
  126. tau/inference/api/audio/gemini_audio.py +95 -0
  127. tau/inference/api/audio/openai_audio.py +159 -0
  128. tau/inference/api/audio/registry.py +15 -0
  129. tau/inference/api/audio/sarvam_audio.py +163 -0
  130. tau/inference/api/audio/service.py +103 -0
  131. tau/inference/api/audio/utils.py +47 -0
  132. tau/inference/api/image/__init__.py +0 -0
  133. tau/inference/api/image/base.py +17 -0
  134. tau/inference/api/image/builtins.py +8 -0
  135. tau/inference/api/image/gemini_image.py +77 -0
  136. tau/inference/api/image/openai_image.py +103 -0
  137. tau/inference/api/image/openrouter.py +144 -0
  138. tau/inference/api/image/registry.py +15 -0
  139. tau/inference/api/image/service.py +71 -0
  140. tau/inference/api/registry.py +82 -0
  141. tau/inference/api/text/__init__.py +0 -0
  142. tau/inference/api/text/anthropic_claude_code.py +222 -0
  143. tau/inference/api/text/anthropic_messages.py +196 -0
  144. tau/inference/api/text/base.py +40 -0
  145. tau/inference/api/text/builtins.py +19 -0
  146. tau/inference/api/text/gemini_generate.py +234 -0
  147. tau/inference/api/text/github_copilot_chat.py +172 -0
  148. tau/inference/api/text/google_antigravity.py +522 -0
  149. tau/inference/api/text/mistral_chat.py +284 -0
  150. tau/inference/api/text/ollama_chat.py +200 -0
  151. tau/inference/api/text/openai_codex_responses.py +497 -0
  152. tau/inference/api/text/openai_completions.py +227 -0
  153. tau/inference/api/text/openai_responses.py +235 -0
  154. tau/inference/api/text/registry.py +50 -0
  155. tau/inference/api/text/service.py +297 -0
  156. tau/inference/api/text/types.py +7 -0
  157. tau/inference/api/text/utils.py +228 -0
  158. tau/inference/api/video/__init__.py +0 -0
  159. tau/inference/api/video/base.py +26 -0
  160. tau/inference/api/video/builtins.py +7 -0
  161. tau/inference/api/video/fal_video.py +119 -0
  162. tau/inference/api/video/openrouter_video.py +142 -0
  163. tau/inference/api/video/registry.py +15 -0
  164. tau/inference/api/video/service.py +72 -0
  165. tau/inference/model/__init__.py +0 -0
  166. tau/inference/model/registry.py +102 -0
  167. tau/inference/model/types.py +65 -0
  168. tau/inference/provider/__init__.py +0 -0
  169. tau/inference/provider/oauth/__init__.py +35 -0
  170. tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
  171. tau/inference/provider/oauth/github_copilot.py +333 -0
  172. tau/inference/provider/oauth/google_antigravity.py +258 -0
  173. tau/inference/provider/oauth/openai_codex.py +309 -0
  174. tau/inference/provider/oauth/pkce.py +14 -0
  175. tau/inference/provider/oauth/types.py +46 -0
  176. tau/inference/provider/oauth/utils.py +154 -0
  177. tau/inference/provider/registry.py +141 -0
  178. tau/inference/provider/types.py +114 -0
  179. tau/inference/types.py +549 -0
  180. tau/inference/utils.py +219 -0
  181. tau/message/__init__.py +0 -0
  182. tau/message/types.py +482 -0
  183. tau/message/utils.py +178 -0
  184. tau/packages/__init__.py +11 -0
  185. tau/packages/manager.py +190 -0
  186. tau/packages/types.py +20 -0
  187. tau/packages/utils.py +67 -0
  188. tau/prompts/expand.py +58 -0
  189. tau/prompts/loader.py +69 -0
  190. tau/prompts/registry.py +45 -0
  191. tau/prompts/types.py +24 -0
  192. tau/rpc/__init__.py +8 -0
  193. tau/rpc/mode.py +783 -0
  194. tau/rpc/types.py +252 -0
  195. tau/runtime/service.py +759 -0
  196. tau/runtime/types.py +303 -0
  197. tau/session/branch_summarization.py +312 -0
  198. tau/session/compaction.py +646 -0
  199. tau/session/manager.py +652 -0
  200. tau/session/types.py +188 -0
  201. tau/session/utils.py +233 -0
  202. tau/settings/manager.py +1077 -0
  203. tau/settings/paths.py +150 -0
  204. tau/settings/storage.py +63 -0
  205. tau/settings/types.py +173 -0
  206. tau/settings/utils.py +25 -0
  207. tau/skills/loader.py +91 -0
  208. tau/skills/registry.py +70 -0
  209. tau/skills/types.py +25 -0
  210. tau/themes/loader.py +238 -0
  211. tau/themes/registry.py +108 -0
  212. tau/themes/types.py +19 -0
  213. tau/tool/__init__.py +3 -0
  214. tau/tool/registry.py +117 -0
  215. tau/tool/render.py +21 -0
  216. tau/tool/types.py +244 -0
  217. tau/trust/__init__.py +13 -0
  218. tau/trust/manager.py +80 -0
  219. tau/trust/types.py +14 -0
  220. tau/trust/utils.py +72 -0
  221. tau/tui/__init__.py +54 -0
  222. tau/tui/agent_hooks.py +346 -0
  223. tau/tui/ansi.py +330 -0
  224. tau/tui/app.py +540 -0
  225. tau/tui/autocomplete.py +33 -0
  226. tau/tui/capabilities.py +119 -0
  227. tau/tui/commands/__init__.py +3 -0
  228. tau/tui/commands/appearance.py +498 -0
  229. tau/tui/commands/auth.py +232 -0
  230. tau/tui/commands/context.py +38 -0
  231. tau/tui/commands/misc.py +82 -0
  232. tau/tui/commands/model.py +118 -0
  233. tau/tui/commands/session.py +464 -0
  234. tau/tui/component.py +268 -0
  235. tau/tui/components/__init__.py +0 -0
  236. tau/tui/components/autocomplete_manager.py +267 -0
  237. tau/tui/components/autocomplete_picker.py +143 -0
  238. tau/tui/components/box.py +90 -0
  239. tau/tui/components/command_palette.py +144 -0
  240. tau/tui/components/dynamic_border.py +19 -0
  241. tau/tui/components/file_picker.py +233 -0
  242. tau/tui/components/image.py +181 -0
  243. tau/tui/components/inline_selector.py +71 -0
  244. tau/tui/components/layout.py +1194 -0
  245. tau/tui/components/message_list.py +692 -0
  246. tau/tui/components/modal.py +97 -0
  247. tau/tui/components/model_palette.py +204 -0
  248. tau/tui/components/picker_overlay.py +174 -0
  249. tau/tui/components/prompt_overlay.py +236 -0
  250. tau/tui/components/resume_modal.py +372 -0
  251. tau/tui/components/select_list.py +222 -0
  252. tau/tui/components/settings_modal.py +274 -0
  253. tau/tui/components/settings_schema.py +203 -0
  254. tau/tui/components/spinner.py +119 -0
  255. tau/tui/components/text_input.py +396 -0
  256. tau/tui/components/text_prompt.py +82 -0
  257. tau/tui/components/tree_select_list.py +580 -0
  258. tau/tui/components/trust_screen.py +97 -0
  259. tau/tui/diff.py +114 -0
  260. tau/tui/fuzzy.py +99 -0
  261. tau/tui/input.py +496 -0
  262. tau/tui/input_handler.py +716 -0
  263. tau/tui/keybindings.py +87 -0
  264. tau/tui/markdown.py +286 -0
  265. tau/tui/message_renderers.py +31 -0
  266. tau/tui/overlay.py +326 -0
  267. tau/tui/renderer.py +378 -0
  268. tau/tui/terminal.py +499 -0
  269. tau/tui/theme.py +148 -0
  270. tau/tui/tui.py +544 -0
  271. tau/tui/ui_context.py +768 -0
  272. tau/tui/utils.py +20 -0
  273. tau/utils/__init__.py +0 -0
  274. tau/utils/http_proxy.py +221 -0
  275. tau/utils/image_processing.py +172 -0
  276. tau/utils/secrets.py +59 -0
  277. tau/utils/version_check.py +60 -0
  278. tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
  279. tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
  280. tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
  281. tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  282. tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  283. tau_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1077 @@
1
+ from __future__ import annotations
2
+ import copy
3
+ import dataclasses as dc
4
+ import json
5
+ from dataclasses import asdict
6
+ from pathlib import Path
7
+ from typing import Optional, Set, Dict, List, Any, Callable
8
+
9
+ from tau.settings.storage import SettingsStorage, FileSettingsStorage, InMemorySettingsStorage, LockResult
10
+ from tau.settings.types import (
11
+ Settings, SCOPE, SettingsError,
12
+ RetrySettings, ProviderRetrySettings, ThinkingBudgetsSettings,
13
+ ImageSettings, CompactionSettings, BranchSummarySettings,
14
+ HTTPProxySettings, TerminalSettings,
15
+ ExtensionEntry, ExtensionsSettings,
16
+ PackageEntry, PackagesSettings,
17
+ )
18
+ from tau.engine.types import SteeringMode, FollowupMode
19
+ from tau.inference.types import Transport, ThinkingLevel
20
+ from tau.settings.utils import set_nested, coerce_enum
21
+
22
+ _NESTED_FIELD_TYPES: dict[str, type] = {
23
+ 'retry': RetrySettings,
24
+ 'thinking_budgets': ThinkingBudgetsSettings,
25
+ 'image': ImageSettings,
26
+ 'compaction': CompactionSettings,
27
+ 'branch_summary': BranchSummarySettings,
28
+ 'http_proxy': HTTPProxySettings,
29
+ 'terminal': TerminalSettings,
30
+ }
31
+
32
+ # Enum-typed top-level fields — JSON stores these as plain strings, so they must
33
+ # be coerced back into enum instances on load (the type hints promise enums and
34
+ # callers rely on `.value` / enum comparisons).
35
+ _ENUM_FIELD_TYPES: dict[str, type] = {
36
+ 'thinking_level': ThinkingLevel,
37
+ 'transport': Transport,
38
+ 'steering_mode': SteeringMode,
39
+ 'follow_up_mode': FollowupMode,
40
+ }
41
+
42
+
43
+
44
+ class SettingsManager:
45
+ def __init__(
46
+ self,
47
+ storage: SettingsStorage,
48
+ initial_global: Settings,
49
+ initial_project: Settings,
50
+ global_load_error: Optional[Exception] = None,
51
+ project_load_error: Optional[Exception] = None,
52
+ initial_errors: Optional[List[SettingsError]] = None,
53
+ project_trusted: bool = True,
54
+ ):
55
+ """Initialise with pre-loaded global and project settings and any load errors."""
56
+ self.storage = storage
57
+ self.global_settings = initial_global
58
+ self._project_trusted: bool = project_trusted
59
+ # Don't merge project settings if the project is untrusted
60
+ self.project_settings = initial_project if project_trusted else Settings()
61
+ self.settings = self._deep_merge_settings(initial_global, self.project_settings)
62
+ self.modified_fields: Set[str] = set()
63
+ self.modified_nested_fields: Dict[str, Set[str]] = {}
64
+ self.modified_project_fields: Set[str] = set()
65
+ self.modified_project_nested_fields: Dict[str, Set[str]] = {}
66
+ self.global_settings_load_error: Optional[Exception] = global_load_error
67
+ self.project_settings_load_error: Optional[Exception] = project_load_error
68
+ self.errors: List[SettingsError] = initial_errors.copy() if initial_errors else []
69
+ self._write_queue = None
70
+ self._batch_mode: bool = False
71
+
72
+ @staticmethod
73
+ def create(
74
+ cwd: Path,
75
+ config_dir: Optional[Path] = None,
76
+ project_trusted: bool = True,
77
+ ) -> SettingsManager:
78
+ """Create a SettingsManager backed by files in cwd (and optional config_dir for global settings)."""
79
+ storage = FileSettingsStorage(cwd, config_dir)
80
+ return SettingsManager.from_storage(storage, project_trusted=project_trusted)
81
+
82
+ @staticmethod
83
+ def from_storage(storage: SettingsStorage, project_trusted: bool = True) -> SettingsManager:
84
+ """Create a SettingsManager from an arbitrary storage backend."""
85
+ global_settings, global_error = SettingsManager._try_load_from_storage(storage, SCOPE.GLOBAL)
86
+ project_settings, project_error = SettingsManager._try_load_from_storage(storage, SCOPE.PROJECT)
87
+ initial_errors = []
88
+ if global_error:
89
+ initial_errors.append(SettingsError(scope=SCOPE.GLOBAL, error=global_error))
90
+ if project_error:
91
+ initial_errors.append(SettingsError(scope=SCOPE.PROJECT, error=project_error))
92
+ return SettingsManager(storage, global_settings, project_settings,
93
+ global_error, project_error, initial_errors, project_trusted=project_trusted)
94
+
95
+ @staticmethod
96
+ def in_memory(settings: Optional[Dict] = None) -> SettingsManager:
97
+ """Create an in-memory SettingsManager with optional seed data (no file I/O, useful for testing)."""
98
+ storage = InMemorySettingsStorage()
99
+ settings_dict = settings or {}
100
+ storage.with_lock(SCOPE.GLOBAL, lambda _: LockResult(result=None, next=json.dumps(settings_dict, indent=2)))
101
+ return SettingsManager.from_storage(storage)
102
+
103
+ @staticmethod
104
+ def _parse_extension_entry(raw: Any) -> ExtensionEntry | None:
105
+ if not isinstance(raw, dict) or 'path' not in raw:
106
+ return None
107
+ valid = {f.name for f in dc.fields(ExtensionEntry)}
108
+ return ExtensionEntry(**{k: v for k, v in raw.items() if k in valid})
109
+
110
+ @staticmethod
111
+ def _parse_package_entry(raw: Any) -> PackageEntry | None:
112
+ if not isinstance(raw, dict) or 'source' not in raw or 'name' not in raw:
113
+ return None
114
+ valid = {f.name for f in dc.fields(PackageEntry)}
115
+ return PackageEntry(**{k: v for k, v in raw.items() if k in valid})
116
+
117
+ @staticmethod
118
+ def _settings_from_dict(data: dict) -> Settings:
119
+ """Construct a Settings instance from a raw dict, rebuilding nested dataclasses from plain dicts."""
120
+ valid_settings = {f.name for f in dc.fields(Settings)}
121
+ kwargs: dict[str, Any] = {}
122
+ for key, value in data.items():
123
+ if key not in valid_settings:
124
+ continue
125
+ if key in _NESTED_FIELD_TYPES and isinstance(value, dict):
126
+ nested_cls = _NESTED_FIELD_TYPES[key]
127
+ valid_nested = {f.name for f in dc.fields(nested_cls)}
128
+ kwargs[key] = nested_cls(**{k: v for k, v in value.items() if k in valid_nested})
129
+ elif key == 'retry' and isinstance(value, dict):
130
+ provider = value.get('provider')
131
+ if isinstance(provider, dict):
132
+ valid_provider = {f.name for f in dc.fields(ProviderRetrySettings)}
133
+ provider = ProviderRetrySettings(**{k: v for k, v in provider.items() if k in valid_provider})
134
+ valid_retry = {f.name for f in dc.fields(RetrySettings)}
135
+ retry_kwargs = {k: v for k, v in value.items() if k in valid_retry and k != 'provider'}
136
+ kwargs[key] = RetrySettings(**retry_kwargs, provider=provider)
137
+ elif key == 'extensions' and isinstance(value, dict):
138
+ entries = None
139
+ if isinstance(value.get('list'), list):
140
+ entries = [
141
+ e for e in (
142
+ SettingsManager._parse_extension_entry(item)
143
+ for item in value['list']
144
+ ) if e is not None
145
+ ]
146
+ kwargs[key] = ExtensionsSettings(
147
+ enabled=value.get('enabled'),
148
+ list=entries,
149
+ )
150
+ elif key == 'packages' and isinstance(value, dict):
151
+ pkg_entries = None
152
+ if isinstance(value.get('list'), list):
153
+ pkg_entries = [
154
+ e for e in (
155
+ SettingsManager._parse_package_entry(item)
156
+ for item in value['list']
157
+ ) if e is not None
158
+ ]
159
+ kwargs[key] = PackagesSettings(list=pkg_entries)
160
+ elif key in _ENUM_FIELD_TYPES:
161
+ kwargs[key] = coerce_enum(_ENUM_FIELD_TYPES[key], value)
162
+ else:
163
+ kwargs[key] = value
164
+ return Settings(**kwargs)
165
+
166
+ @staticmethod
167
+ def _load_from_storage(storage: SettingsStorage, scope: SCOPE) -> Settings:
168
+ """Read and parse settings for the given scope from storage."""
169
+ def load_fn(current):
170
+ if not current:
171
+ return LockResult(result=Settings(), next=None)
172
+ return LockResult(result=SettingsManager._settings_from_dict(json.loads(current)), next=None)
173
+ return storage.with_lock(scope, load_fn).result
174
+
175
+ @staticmethod
176
+ def _try_load_from_storage(storage: SettingsStorage, scope: SCOPE) -> tuple[Settings, Optional[Exception]]:
177
+ """Load settings for the given scope, returning an empty Settings and the error on failure."""
178
+ try:
179
+ return (SettingsManager._load_from_storage(storage, scope), None)
180
+ except Exception as e:
181
+ return (Settings(), e)
182
+
183
+ def _deep_merge_settings(self, global_settings: Settings, project_settings: Settings) -> Settings:
184
+ """Merge global and project settings; project wins, nested dataclasses merge field by field."""
185
+ merged = copy.deepcopy(global_settings)
186
+ for key, value in vars(project_settings).items():
187
+ if value is None:
188
+ continue
189
+ existing = getattr(merged, key)
190
+ if dc.is_dataclass(value) and existing is not None and dc.is_dataclass(existing):
191
+ merged_nested = copy.deepcopy(existing)
192
+ for f in dc.fields(value):
193
+ nested_val = getattr(value, f.name)
194
+ if nested_val is not None:
195
+ setattr(merged_nested, f.name, nested_val)
196
+ setattr(merged, key, merged_nested)
197
+ else:
198
+ setattr(merged, key, value)
199
+ return merged
200
+
201
+ def _mark_modified(self, field: str, nested_field: Optional[str] = None):
202
+ """Record a global settings field (and optional nested key) as modified."""
203
+ self.modified_fields.add(field)
204
+ if nested_field:
205
+ self.modified_nested_fields.setdefault(field, set()).add(nested_field)
206
+
207
+ def _mark_project_modified(self, field: str, nested_field: Optional[str] = None):
208
+ """Record a project settings field (and optional nested key) as modified."""
209
+ self.modified_project_fields.add(field)
210
+ if nested_field:
211
+ self.modified_project_nested_fields.setdefault(field, set()).add(nested_field)
212
+
213
+ def _clone_modified_nested_fields(self, source: Dict[str, Set[str]]) -> Dict[str, Set[str]]:
214
+ """Snapshot the nested-field modification tracker so async writes see state at enqueue time."""
215
+ return {key: set(value) for key, value in source.items()}
216
+
217
+ def _record_error(self, scope: SCOPE, error: Exception):
218
+ """Append a scoped error to the error queue for later retrieval via drain_errors()."""
219
+ self.errors.append(SettingsError(scope=scope, error=error))
220
+
221
+ def _clear_modified_scope(self, scope: SCOPE):
222
+ """Reset modification tracking for a scope after a successful write."""
223
+ match scope:
224
+ case SCOPE.GLOBAL:
225
+ self.modified_fields.clear()
226
+ self.modified_nested_fields.clear()
227
+ case SCOPE.PROJECT:
228
+ self.modified_project_fields.clear()
229
+ self.modified_project_nested_fields.clear()
230
+
231
+ def _enqueue_write(self, scope: SCOPE, task: Callable[..., None]):
232
+ """Chain an async write task so concurrent saves are serialised and never interleave."""
233
+ import asyncio
234
+ prev = self._write_queue
235
+
236
+ async def chained() -> None:
237
+ if prev is not None:
238
+ try:
239
+ await prev
240
+ except Exception:
241
+ pass
242
+ try:
243
+ task()
244
+ self._clear_modified_scope(scope)
245
+ except Exception as e:
246
+ self._record_error(scope, e)
247
+
248
+ self._write_queue = asyncio.create_task(chained())
249
+
250
+ def _persist_scoped_settings(
251
+ self,
252
+ scope: SCOPE,
253
+ snapshot_settings: Settings,
254
+ modified_fields: Set[str],
255
+ modified_nested_fields: Dict[str, Set[str]],
256
+ ):
257
+ """Write only the modified fields back to storage, merging at the key level to preserve concurrent changes."""
258
+ def persist_fn(current):
259
+ current_dict = json.loads(current) if current else {}
260
+ snapshot_dict = asdict(snapshot_settings)
261
+ merged = dict(current_dict)
262
+ for field_name in modified_fields:
263
+ value = snapshot_dict.get(field_name)
264
+ if field_name in modified_nested_fields and isinstance(value, dict):
265
+ base = current_dict.get(field_name) or {}
266
+ if isinstance(base, dict):
267
+ merged_nested = {**base}
268
+ for nested_key in modified_nested_fields[field_name]:
269
+ merged_nested[nested_key] = value.get(nested_key)
270
+ merged[field_name] = merged_nested
271
+ else:
272
+ merged[field_name] = value
273
+ else:
274
+ merged[field_name] = value
275
+ return LockResult(result=None, next=json.dumps(merged, indent=2, default=str))
276
+
277
+ self.storage.with_lock(scope, persist_fn)
278
+
279
+ def begin_batch(self) -> None:
280
+ """Suppress disk writes until save_batch() is called. In-memory state still updates immediately."""
281
+ self._batch_mode = True
282
+
283
+ def save_batch(self) -> None:
284
+ """End batch mode and write all accumulated changes to disk."""
285
+ self._batch_mode = False
286
+ self._save()
287
+
288
+ def _save(self):
289
+ """Update the merged view and enqueue an async write of modified global settings."""
290
+ self.settings = self._deep_merge_settings(self.global_settings, self.project_settings)
291
+ if self.global_settings_load_error or self._batch_mode:
292
+ return
293
+ snapshot_global = copy.deepcopy(self.global_settings)
294
+ modified_fields = set(self.modified_fields)
295
+ modified_nested_fields = self._clone_modified_nested_fields(self.modified_nested_fields)
296
+
297
+ def write_task():
298
+ self._persist_scoped_settings(SCOPE.GLOBAL, snapshot_global, modified_fields, modified_nested_fields)
299
+
300
+ self._enqueue_write(SCOPE.GLOBAL, write_task)
301
+
302
+ def _save_project_settings(self, settings: Settings):
303
+ """Update the merged view and enqueue an async write of modified project settings."""
304
+ self.project_settings = copy.deepcopy(settings)
305
+ self.settings = self._deep_merge_settings(self.global_settings, self.project_settings)
306
+ if self.project_settings_load_error:
307
+ return
308
+ snapshot_project = copy.deepcopy(self.project_settings)
309
+ modified_fields = set(self.modified_project_fields)
310
+ modified_nested_fields = self._clone_modified_nested_fields(self.modified_project_nested_fields)
311
+
312
+ def write_task():
313
+ self._persist_scoped_settings(SCOPE.PROJECT, snapshot_project, modified_fields, modified_nested_fields)
314
+
315
+ self._enqueue_write(SCOPE.PROJECT, write_task)
316
+
317
+ async def flush(self) -> None:
318
+ """Wait for any pending async writes to complete."""
319
+ if self._write_queue is not None:
320
+ await self._write_queue
321
+
322
+ def drain_errors(self) -> List[SettingsError]:
323
+ """Return and clear all accumulated load and write errors."""
324
+ drained = self.errors.copy()
325
+ self.errors.clear()
326
+ return drained
327
+
328
+ async def reload(self) -> None:
329
+ """Flush pending writes, reload both scopes from storage, and recompute the merged view."""
330
+ await self.flush()
331
+ global_load = SettingsManager._try_load_from_storage(self.storage, SCOPE.GLOBAL)
332
+ if not global_load[1]:
333
+ self.global_settings = global_load[0]
334
+ self.global_settings_load_error = None
335
+ else:
336
+ self.global_settings_load_error = global_load[1]
337
+ self._record_error(SCOPE.GLOBAL, global_load[1])
338
+
339
+ self.modified_fields.clear()
340
+ self.modified_nested_fields.clear()
341
+ self.modified_project_fields.clear()
342
+ self.modified_project_nested_fields.clear()
343
+
344
+ project_load = SettingsManager._try_load_from_storage(self.storage, SCOPE.PROJECT)
345
+ if not project_load[1]:
346
+ self.project_settings = project_load[0]
347
+ self.project_settings_load_error = None
348
+ else:
349
+ self.project_settings_load_error = project_load[1]
350
+ self._record_error(SCOPE.PROJECT, project_load[1])
351
+
352
+ self.settings = self._deep_merge_settings(self.global_settings, self.project_settings)
353
+
354
+ def apply_overrides(self, overrides: Dict[str, Any]):
355
+ """Apply runtime overrides on top of the current merged settings without persisting."""
356
+ override_settings = SettingsManager._settings_from_dict(overrides)
357
+ self.settings = self._deep_merge_settings(self.settings, override_settings)
358
+
359
+ def get_global_settings(self) -> Settings:
360
+ """Return a deep copy of the raw global settings (before project merge)."""
361
+ return copy.deepcopy(self.global_settings)
362
+
363
+ def get_project_settings(self) -> Settings:
364
+ """Return a deep copy of the raw project settings (before global merge)."""
365
+ return copy.deepcopy(self.project_settings)
366
+
367
+ def get_provider(self) -> Optional[str]:
368
+ """Return the default LLM provider, or None if unset."""
369
+ return self.settings.provider
370
+
371
+ def set_provider(self, provider: str):
372
+ """Set the default LLM provider and persist to global settings."""
373
+ self.global_settings.provider = provider
374
+ self._mark_modified("provider")
375
+ self._save()
376
+
377
+ def get_theme(self) -> Optional[str]:
378
+ """Return the persisted UI theme name, or None if unset."""
379
+ return self.settings.theme
380
+
381
+ def set_theme(self, theme: str):
382
+ """Set the UI theme name and persist to global settings."""
383
+ self.global_settings.theme = theme
384
+ self._mark_modified("theme")
385
+ self._save()
386
+
387
+ def get_http_idle_timeout_ms(self) -> int:
388
+ """Return the HTTP idle timeout in milliseconds (default: 60000)."""
389
+ v = self.settings.http_idle_timeout_ms
390
+ return v if v is not None else 60_000
391
+
392
+ def set_http_idle_timeout_ms(self, value: int):
393
+ """Set the HTTP idle timeout in milliseconds and persist to global settings."""
394
+ self.global_settings.http_idle_timeout_ms = max(0, value)
395
+ self._mark_modified("http_idle_timeout_ms")
396
+ self._save()
397
+
398
+ def get_quiet_startup(self) -> bool:
399
+ """Return whether to suppress startup messages (default: False)."""
400
+ v = self.settings.quiet_startup
401
+ return v if v is not None else False
402
+
403
+ def set_quiet_startup(self, value: bool):
404
+ """Set whether to suppress startup messages and persist to global settings."""
405
+ self.global_settings.quiet_startup = value
406
+ self._mark_modified("quiet_startup")
407
+ self._save()
408
+
409
+ def get_picker_max_visible(self) -> int:
410
+ """Return the maximum number of visible picker items (default: 8)."""
411
+ v = self.settings.picker_max_visible
412
+ return v if v is not None else 8
413
+
414
+ def set_picker_max_visible(self, value: int):
415
+ """Set the maximum number of visible picker items and persist to global settings."""
416
+ self.global_settings.picker_max_visible = max(1, value)
417
+ self._mark_modified("picker_max_visible")
418
+ self._save()
419
+
420
+ def get_show_thinking(self) -> bool:
421
+ """Return whether to display extended thinking in responses (default: True)."""
422
+ v = self.settings.show_thinking
423
+ return v if v is not None else True
424
+
425
+ def set_show_thinking(self, value: bool):
426
+ """Set whether to display extended thinking and persist to global settings."""
427
+ self.global_settings.show_thinking = value
428
+ self._mark_modified("show_thinking")
429
+ self._save()
430
+
431
+ def get_show_tool_calls(self) -> bool:
432
+ """Return whether to display tool calls in responses (default: True)."""
433
+ v = self.settings.show_tool_calls
434
+ return v if v is not None else True
435
+
436
+ def set_show_tool_calls(self, value: bool):
437
+ """Set whether to display tool calls and persist to global settings."""
438
+ self.global_settings.show_tool_calls = value
439
+
440
+ def get_show_images(self) -> bool:
441
+ """Return whether to render inline images (default: True)."""
442
+ v = self.settings.show_images
443
+ return v if v is not None else True
444
+
445
+ def set_show_images(self, value: bool) -> None:
446
+ """Set whether to render inline images and persist to global settings."""
447
+ self.global_settings.show_images = value
448
+ self._mark_modified("show_images")
449
+ self._save()
450
+ self._mark_modified("show_tool_calls")
451
+ self._save()
452
+
453
+ def get_model(self) -> Optional[str]:
454
+ """Return the default model ID, or None if unset."""
455
+ return self.settings.model
456
+
457
+ def set_model(self, model: str):
458
+ """Set the default model ID and persist to global settings."""
459
+ self.global_settings.model = model
460
+ self._mark_modified("model")
461
+ self._save()
462
+
463
+ def set_model_and_provider(self, provider: str, model_id: str):
464
+ """Set both the default provider and model in a single write."""
465
+ self.global_settings.provider = provider
466
+ self.global_settings.model = model_id
467
+ self._mark_modified("provider")
468
+ self._mark_modified("model")
469
+ self._save()
470
+
471
+ def get_thinking_level(self) -> Optional[ThinkingLevel]:
472
+ """Return the default thinking level, or None if unset."""
473
+ return self.settings.thinking_level
474
+
475
+ def set_thinking_level(self, level: ThinkingLevel):
476
+ """Set the default thinking level and persist to global settings."""
477
+ self.global_settings.thinking_level = level
478
+ self._mark_modified("thinking_level")
479
+ self._save()
480
+
481
+ def get_transport(self) -> Transport:
482
+ """Return the configured transport, defaulting to Transport.Auto."""
483
+ return self.settings.transport or Transport.Auto
484
+
485
+ def set_transport(self, transport: Transport):
486
+ """Set the transport and persist to global settings."""
487
+ self.global_settings.transport = transport
488
+ self._mark_modified("transport")
489
+ self._save()
490
+
491
+ def get_steering_mode(self) -> SteeringMode:
492
+ """Return the steering mode, defaulting to SteeringMode.OneAtATime."""
493
+ return self.settings.steering_mode or SteeringMode.OneAtATime
494
+
495
+ def set_steering_mode(self, mode: SteeringMode):
496
+ """Set the steering mode and persist to global settings."""
497
+ self.global_settings.steering_mode = mode
498
+ self._mark_modified("steering_mode")
499
+ self._save()
500
+
501
+ def get_follow_up_mode(self) -> FollowupMode:
502
+ """Return the follow-up mode, defaulting to FollowupMode.OneAtATime."""
503
+ return self.settings.follow_up_mode or FollowupMode.OneAtATime
504
+
505
+ def set_follow_up_mode(self, mode: FollowupMode):
506
+ """Set the follow-up mode and persist to global settings."""
507
+ self.global_settings.follow_up_mode = mode
508
+ self._mark_modified("follow_up_mode")
509
+ self._save()
510
+
511
+ def get_enabled_models(self) -> list[str] | None:
512
+ """Return the model filter patterns, or None if all models are enabled."""
513
+ return self.settings.enabled_models
514
+
515
+ def set_enabled_models(self, patterns: list[str] | None):
516
+ """Set the model filter patterns and persist to global settings."""
517
+ self.global_settings.enabled_models = patterns
518
+ self._mark_modified("enabled_models")
519
+ self._save()
520
+
521
+ def get_session_dir(self) -> Path | None:
522
+ """Return the resolved session storage directory, expanding ~ if present."""
523
+ session_dir = self.settings.session_dir
524
+ if session_dir is None:
525
+ return None
526
+ if session_dir == "~":
527
+ return Path.home()
528
+ if session_dir.startswith("~/"):
529
+ return Path.home() / session_dir[2:]
530
+ return Path(session_dir).resolve()
531
+
532
+ def set_session_dir(self, path: str | None):
533
+ """Set the session storage directory and persist to global settings."""
534
+ self.global_settings.session_dir = path
535
+ self._mark_modified("session_dir")
536
+ self._save()
537
+
538
+ # ── Image ─────────────────────────────────────────────────────────────────
539
+
540
+ def get_image_auto_resize(self) -> bool:
541
+ """Return whether images are auto-resized to 2000×2000 before being sent to the LLM (default: True)."""
542
+ i = self.settings.image
543
+ return i.auto_resize if i and i.auto_resize is not None else True
544
+
545
+ def set_image_auto_resize(self, enabled: bool):
546
+ """Set whether to auto-resize images and persist to global settings."""
547
+ if not self.global_settings.image:
548
+ self.global_settings.image = ImageSettings()
549
+ self.global_settings.image.auto_resize = enabled
550
+ self._mark_modified("image", "auto_resize")
551
+ self._save()
552
+
553
+ def get_image_block_images(self) -> bool:
554
+ """Return whether sending images to the LLM is blocked entirely (default: False)."""
555
+ i = self.settings.image
556
+ return i.block_images if i and i.block_images is not None else False
557
+
558
+ def set_image_block_images(self, enabled: bool):
559
+ """Set whether to block sending images to the LLM and persist to global settings."""
560
+ if not self.global_settings.image:
561
+ self.global_settings.image = ImageSettings()
562
+ self.global_settings.image.block_images = enabled
563
+ self._mark_modified("image", "block_images")
564
+ self._save()
565
+
566
+ # ── Terminal / execution ──────────────────────────────────────────────────────
567
+
568
+ def get_shell_path(self) -> str | None:
569
+ return self.settings.terminal.shell_path if self.settings.terminal else None
570
+
571
+ def set_shell_path(self, path: str | None):
572
+ if self.global_settings.terminal is None:
573
+ self.global_settings.terminal = TerminalSettings()
574
+ self.global_settings.terminal.shell_path = path
575
+ self._mark_modified("terminal")
576
+ self._save()
577
+
578
+ def get_shell_command_prefix(self) -> str | None:
579
+ return self.settings.terminal.shell_command_prefix if self.settings.terminal else None
580
+
581
+ def set_shell_command_prefix(self, prefix: str | None):
582
+ if self.global_settings.terminal is None:
583
+ self.global_settings.terminal = TerminalSettings()
584
+ self.global_settings.terminal.shell_command_prefix = prefix
585
+ self._mark_modified("terminal")
586
+ self._save()
587
+
588
+ # ── Branch summary ────────────────────────────────────────────────────────
589
+
590
+ # ── Compaction ────────────────────────────────────────────────────────────
591
+
592
+ def is_compaction_enabled(self) -> bool:
593
+ """Return whether auto-compaction is enabled (default: True)."""
594
+ cs = self.settings.compaction
595
+ v = cs.enabled if cs is not None else None
596
+ return v if v is not None else True
597
+
598
+ def get_compaction_reserve_tokens(self) -> int:
599
+ """Return the token reserve for LLM response (default: 16384)."""
600
+ cs = self.settings.compaction
601
+ v = cs.reserve_tokens if cs is not None else None
602
+ return v if v is not None else 16_384
603
+
604
+ def get_compaction_keep_recent_tokens(self) -> int:
605
+ """Return the token count for recent messages to keep (default: 20000)."""
606
+ cs = self.settings.compaction
607
+ v = cs.keep_recent_tokens if cs is not None else None
608
+ return v if v is not None else 20_000
609
+
610
+ def set_compaction_enabled(self, value: bool) -> None:
611
+ if self.global_settings.compaction is None:
612
+ self.global_settings.compaction = CompactionSettings()
613
+ self.global_settings.compaction.enabled = value
614
+ self._mark_modified("compaction.enabled")
615
+
616
+ def set_compaction_reserve_tokens(self, value: int) -> None:
617
+ if self.global_settings.compaction is None:
618
+ self.global_settings.compaction = CompactionSettings()
619
+ self.global_settings.compaction.reserve_tokens = max(1, value)
620
+ self._mark_modified("compaction.reserve_tokens")
621
+
622
+ def set_compaction_keep_recent_tokens(self, value: int) -> None:
623
+ if self.global_settings.compaction is None:
624
+ self.global_settings.compaction = CompactionSettings()
625
+ self.global_settings.compaction.keep_recent_tokens = max(1, value)
626
+ self._mark_modified("compaction.keep_recent_tokens")
627
+
628
+ def is_branch_summary_enabled(self) -> bool:
629
+ """Return whether branch summarization is enabled (default: True)."""
630
+ bs = self.settings.branch_summary
631
+ v = bs.enabled if bs is not None else None
632
+ return v if v is not None else True
633
+
634
+ def set_branch_summary_enabled(self, value: bool) -> None:
635
+ """Set whether branch summarization is enabled and persist to global settings."""
636
+ if self.global_settings.branch_summary is None:
637
+ self.global_settings.branch_summary = BranchSummarySettings()
638
+ self.global_settings.branch_summary.enabled = value
639
+ self._mark_modified("branch_summary.enabled")
640
+ self._save()
641
+
642
+ def get_branch_summary_skip_prompt(self) -> bool:
643
+ """Return whether to skip the 'Summarize branch?' prompt (default: False)."""
644
+ bs = self.settings.branch_summary
645
+ v = bs.skip_prompt if bs is not None else None
646
+ return v if v is not None else False
647
+
648
+ def get_branch_summary_reserve_tokens(self) -> int:
649
+ """Return the token reserve for branch summarization (default: 16384)."""
650
+ bs = self.settings.branch_summary
651
+ v = bs.reserve_tokens if bs is not None else None
652
+ return v if v is not None else 16_384
653
+
654
+ def set_branch_summary_reserve_tokens(self, value: int) -> None:
655
+ """Set the token reserve for branch summarization and persist to global settings."""
656
+ if self.global_settings.branch_summary is None:
657
+ self.global_settings.branch_summary = BranchSummarySettings()
658
+ self.global_settings.branch_summary.reserve_tokens = max(1, value)
659
+ self._mark_modified("branch_summary.reserve_tokens")
660
+ self._save()
661
+
662
+ def set_branch_summary_skip_prompt(self, value: bool) -> None:
663
+ """Set whether to skip the 'Summarize branch?' prompt and persist to global settings."""
664
+ if self.global_settings.branch_summary is None:
665
+ self.global_settings.branch_summary = BranchSummarySettings()
666
+ self.global_settings.branch_summary.skip_prompt = value
667
+ self._mark_modified("branch_summary.skip_prompt")
668
+ self._save()
669
+
670
+ # ── Retry ─────────────────────────────────────────────────────────────────
671
+
672
+ def is_retry_enabled(self) -> bool:
673
+ """Return whether automatic retry is enabled (default: False)."""
674
+ rs = self.settings.retry
675
+ v = rs.enabled if rs is not None else None
676
+ return v if v is not None else False
677
+
678
+ def get_retry_max_retries(self) -> int:
679
+ """Return the maximum retry attempts (default: 3)."""
680
+ rs = self.settings.retry
681
+ v = rs.max_retries if rs is not None else None
682
+ return v if v is not None else 3
683
+
684
+ def get_retry_base_delay_ms(self) -> int:
685
+ """Return the base retry delay in milliseconds (default: 1000)."""
686
+ rs = self.settings.retry
687
+ v = rs.base_delay_ms if rs is not None else None
688
+ return v if v is not None else 1000
689
+
690
+ def set_retry_enabled(self, enabled: bool) -> None:
691
+ """Set whether automatic retry is enabled and persist to global settings."""
692
+ if self.global_settings.retry is None:
693
+ self.global_settings.retry = RetrySettings()
694
+ self.global_settings.retry.enabled = enabled
695
+ self._mark_modified("retry", "enabled")
696
+ self._save()
697
+
698
+ def set_retry_max_retries(self, value: int) -> None:
699
+ """Set the maximum retry attempts and persist to global settings."""
700
+ if self.global_settings.retry is None:
701
+ self.global_settings.retry = RetrySettings()
702
+ self.global_settings.retry.max_retries = max(0, value)
703
+ self._mark_modified("retry", "max_retries")
704
+ self._save()
705
+
706
+ def set_retry_base_delay_ms(self, value: int) -> None:
707
+ """Set the base retry delay in milliseconds and persist to global settings."""
708
+ if self.global_settings.retry is None:
709
+ self.global_settings.retry = RetrySettings()
710
+ self.global_settings.retry.base_delay_ms = max(1, value)
711
+ self._mark_modified("retry", "base_delay_ms")
712
+ self._save()
713
+
714
+ # ── Thinking budgets ──────────────────────────────────────────────────────
715
+
716
+ def get_thinking_budget(self, level: str) -> int:
717
+ """Return the token budget for a thinking level (minimal, low, medium, high, xhigh, max)."""
718
+ tb = self.settings.thinking_budgets
719
+ if tb is None:
720
+ tb = ThinkingBudgetsSettings()
721
+ level_lower = level.lower()
722
+ v = getattr(tb, level_lower, None)
723
+ if v is not None:
724
+ return v
725
+ # Fall back to defaults matching ThinkingBudgets in inference/types.py
726
+ defaults = {
727
+ "minimal": 1024,
728
+ "low": 2048,
729
+ "medium": 4096,
730
+ "high": 8192,
731
+ "xhigh": 16384,
732
+ "max": 32768,
733
+ }
734
+ return defaults.get(level_lower, 4096)
735
+
736
+ def get_all_thinking_budgets(self) -> dict[str, int]:
737
+ """Return all thinking budgets as a dict (includes defaults for unset levels)."""
738
+ result = {}
739
+ for level in ["minimal", "low", "medium", "high", "xhigh", "max"]:
740
+ result[level] = self.get_thinking_budget(level)
741
+ return result
742
+
743
+ def set_thinking_budget(self, level: str, value: int) -> None:
744
+ """Set the token budget for a thinking level and persist to global settings."""
745
+ if self.global_settings.thinking_budgets is None:
746
+ self.global_settings.thinking_budgets = ThinkingBudgetsSettings()
747
+ level_lower = level.lower()
748
+ if level_lower not in ("minimal", "low", "medium", "high", "xhigh", "max"):
749
+ raise ValueError(f"Invalid thinking level: {level}")
750
+ setattr(self.global_settings.thinking_budgets, level_lower, max(1, value))
751
+ self._mark_modified("thinking_budgets", level_lower)
752
+ self._save()
753
+
754
+ def set_all_thinking_budgets(self, budgets: dict[str, int]) -> None:
755
+ """Set all thinking budgets from a dict and persist to global settings."""
756
+ if self.global_settings.thinking_budgets is None:
757
+ self.global_settings.thinking_budgets = ThinkingBudgetsSettings()
758
+ for level, value in budgets.items():
759
+ level_lower = level.lower()
760
+ if level_lower in ("minimal", "low", "medium", "high", "xhigh", "max"):
761
+ setattr(self.global_settings.thinking_budgets, level_lower, max(1, value))
762
+ self._mark_modified("thinking_budgets")
763
+ self._save()
764
+
765
+ # ── Extensions ────────────────────────────────────────────────────────────
766
+
767
+ def is_extensions_enabled(self) -> bool:
768
+ """Return whether extensions are globally enabled (default: True)."""
769
+ ext = self.settings.extensions
770
+ return ext.enabled if ext is not None and ext.enabled is not None else True
771
+
772
+ def set_extensions_enabled(self, enabled: bool) -> None:
773
+ """Toggle all extensions on/off and persist to global settings."""
774
+ if self.global_settings.extensions is None:
775
+ self.global_settings.extensions = ExtensionsSettings()
776
+ self.global_settings.extensions.enabled = enabled
777
+ self._mark_modified("extensions", "enabled")
778
+ self._save()
779
+
780
+ def get_extension_list(self) -> list[ExtensionEntry]:
781
+ """Return extension entries from the merged settings view (project overrides global)."""
782
+ ext = self.settings.extensions
783
+ return (ext.list if ext is not None and ext.list is not None else [])
784
+
785
+ def get_extension_paths(self) -> list[str]:
786
+ """Return extension paths from the merged entry list (convenience flat view)."""
787
+ return [entry.path for entry in self.get_extension_list()]
788
+
789
+ def set_extension_paths(self, paths: list[str]) -> None:
790
+ """Set extension paths as plain entries, preserving the list shape."""
791
+ self.set_extension_list([ExtensionEntry(path=p) for p in paths])
792
+
793
+ def set_extension_config_key(self, ext_path: str, key: str, value: Any) -> None:
794
+ """Set a key (dot-notation supported) in the config dict of the matching extension entry.
795
+
796
+ ``key`` may be a dot-separated path such as ``"retry.enabled"`` to set
797
+ nested values; intermediate dicts are created automatically.
798
+ """
799
+ if self.global_settings.extensions is None:
800
+ self.global_settings.extensions = ExtensionsSettings()
801
+ if self.global_settings.extensions.list is None:
802
+ self.global_settings.extensions.list = []
803
+ for entry in self.global_settings.extensions.list:
804
+ if entry.path == ext_path:
805
+ if entry.settings is None:
806
+ entry.settings = {}
807
+ set_nested(entry.settings, key, value)
808
+ self._mark_modified("extensions", "list")
809
+ self._save()
810
+ return
811
+ # No matching entry found — create one
812
+ config: dict = {}
813
+ set_nested(config, key, value)
814
+ new_entry = ExtensionEntry(path=ext_path, settings=config)
815
+ self.global_settings.extensions.list.append(new_entry)
816
+ self._mark_modified("extensions", "list")
817
+ self._save()
818
+
819
+ def set_extension_list(self, entries: list[ExtensionEntry]) -> None:
820
+ """Set the global extension list and persist."""
821
+ if self.global_settings.extensions is None:
822
+ self.global_settings.extensions = ExtensionsSettings()
823
+ self.global_settings.extensions.list = entries
824
+ self._mark_modified("extensions", "list")
825
+ self._save()
826
+
827
+ def set_project_extension_list(self, entries: list[ExtensionEntry]) -> None:
828
+ """Set the project-scoped extension list and persist."""
829
+ if self.project_settings.extensions is None:
830
+ self.project_settings.extensions = ExtensionsSettings()
831
+ self.project_settings.extensions.list = entries
832
+ self._mark_project_modified("extensions", "list")
833
+ self._save_project_settings(self.project_settings)
834
+
835
+ # ── Packages ──────────────────────────────────────────────────────────────
836
+
837
+ def get_all_packages(self) -> list[PackageEntry]:
838
+ """Return all packages from both global and project settings (for runtime loading)."""
839
+ global_pkgs = self.global_settings.packages
840
+ project_pkgs = self.project_settings.packages
841
+ result: list[PackageEntry] = []
842
+ if global_pkgs and global_pkgs.list:
843
+ result.extend(global_pkgs.list)
844
+ if project_pkgs and project_pkgs.list:
845
+ result.extend(project_pkgs.list)
846
+ return result
847
+
848
+ def get_packages(self, local: bool = False) -> list[PackageEntry]:
849
+ """Return packages from the given scope (global by default, project if local=True)."""
850
+ source = self.project_settings if local else self.global_settings
851
+ pkgs = source.packages
852
+ return list(pkgs.list) if pkgs and pkgs.list else []
853
+
854
+ def add_package(self, entry: PackageEntry, local: bool = False) -> None:
855
+ """Add or replace a package entry in the given scope and persist."""
856
+ if local:
857
+ if self.project_settings.packages is None:
858
+ self.project_settings.packages = PackagesSettings()
859
+ pkgs = list(self.project_settings.packages.list or [])
860
+ pkgs = [p for p in pkgs if p.name != entry.name]
861
+ pkgs.append(entry)
862
+ self.project_settings.packages.list = pkgs
863
+ self._mark_project_modified("packages", "list")
864
+ self._save_project_settings(self.project_settings)
865
+ else:
866
+ if self.global_settings.packages is None:
867
+ self.global_settings.packages = PackagesSettings()
868
+ pkgs = list(self.global_settings.packages.list or [])
869
+ pkgs = [p for p in pkgs if p.name != entry.name]
870
+ pkgs.append(entry)
871
+ self.global_settings.packages.list = pkgs
872
+ self._mark_modified("packages", "list")
873
+ self._save()
874
+
875
+ def remove_package(self, name: str, local: bool = False) -> None:
876
+ """Remove a package entry by name from the given scope and persist."""
877
+ if local:
878
+ if self.project_settings.packages is None:
879
+ return
880
+ pkgs = [p for p in (self.project_settings.packages.list or []) if p.name != name]
881
+ self.project_settings.packages.list = pkgs
882
+ self._mark_project_modified("packages", "list")
883
+ self._save_project_settings(self.project_settings)
884
+ else:
885
+ if self.global_settings.packages is None:
886
+ return
887
+ pkgs = [p for p in (self.global_settings.packages.list or []) if p.name != name]
888
+ self.global_settings.packages.list = pkgs
889
+ self._mark_modified("packages", "list")
890
+ self._save()
891
+
892
+ def update_package_version(self, name: str, version: str | None, local: bool = False) -> None:
893
+ """Update the stored version (and installed_path) for an existing package."""
894
+ if local:
895
+ pkgs = self.project_settings.packages
896
+ else:
897
+ pkgs = self.global_settings.packages
898
+ if not pkgs or not pkgs.list:
899
+ return
900
+ for entry in pkgs.list:
901
+ if entry.name == name:
902
+ entry.version = version
903
+ break
904
+ if local:
905
+ self._mark_project_modified("packages", "list")
906
+ self._save_project_settings(self.project_settings)
907
+ else:
908
+ self._mark_modified("packages", "list")
909
+ self._save()
910
+
911
+ # ── UI behaviour ──────────────────────────────────────────────────────────
912
+
913
+ def get_double_escape_action(self) -> str:
914
+ """Return the double-escape key action: 'fork', 'tree', or 'none' (default: 'fork')."""
915
+ v = self.settings.double_escape_action
916
+ return v if v is not None else "fork"
917
+
918
+ def set_double_escape_action(self, value: str) -> None:
919
+ """Set the double-escape key action and persist to global settings."""
920
+ if value not in ("fork", "tree", "none"):
921
+ raise ValueError(f"double_escape_action must be 'fork', 'tree', or 'none', got {value!r}")
922
+ self.global_settings.double_escape_action = value # type: ignore[assignment]
923
+ self._mark_modified("double_escape_action")
924
+ self._save()
925
+
926
+ def get_tree_filter_mode(self) -> str:
927
+ """Return the message tree filter mode (default: 'default')."""
928
+ v = self.settings.tree_filter_mode
929
+ return v if v is not None else "default"
930
+
931
+ def set_tree_filter_mode(self, value: str) -> None:
932
+ """Set the message tree filter mode and persist to global settings."""
933
+ valid = ("default", "no-tools", "user-only", "labeled-only", "all")
934
+ if value not in valid:
935
+ raise ValueError(f"tree_filter_mode must be one of {valid}, got {value!r}")
936
+ self.global_settings.tree_filter_mode = value # type: ignore[assignment]
937
+ self._mark_modified("tree_filter_mode")
938
+ self._save()
939
+
940
+ def get_autocomplete_max_visible(self) -> int:
941
+ """Return the maximum number of visible autocomplete suggestions (default: 5)."""
942
+ v = self.settings.autocomplete_max_visible
943
+ return v if v is not None else 5
944
+
945
+ def set_autocomplete_max_visible(self, value: int) -> None:
946
+ """Set the maximum number of visible autocomplete suggestions and persist to global settings."""
947
+ self.global_settings.autocomplete_max_visible = max(1, value)
948
+ self._mark_modified("autocomplete_max_visible")
949
+ self._save()
950
+
951
+ def get_show_hardware_cursor(self) -> bool:
952
+ """Return whether to show hardware cursor in the UI (default: False)."""
953
+ v = self.settings.show_hardware_cursor
954
+ return v if v is not None else False
955
+
956
+ def set_show_hardware_cursor(self, value: bool) -> None:
957
+ """Set whether to show hardware cursor and persist to global settings."""
958
+ self.global_settings.show_hardware_cursor = value
959
+ self._mark_modified("show_hardware_cursor")
960
+ self._save()
961
+
962
+ def get_editor_padding_x(self) -> int:
963
+ """Return the horizontal editor padding in characters (default: 0)."""
964
+ v = self.settings.editor_padding_x
965
+ return v if v is not None else 0
966
+
967
+ def set_editor_padding_x(self, value: int) -> None:
968
+ """Set the horizontal editor padding and persist to global settings."""
969
+ self.global_settings.editor_padding_x = max(0, value)
970
+ self._mark_modified("editor_padding_x")
971
+ self._save()
972
+
973
+ def get_websocket_connect_timeout_ms(self) -> int | None:
974
+ """Return the websocket connection timeout in milliseconds, or None if unset."""
975
+ return self.settings.websocket_connect_timeout_ms
976
+
977
+ def set_websocket_connect_timeout_ms(self, value: int | None) -> None:
978
+ """Set the websocket connection timeout and persist to global settings."""
979
+ self.global_settings.websocket_connect_timeout_ms = value
980
+ self._mark_modified("websocket_connect_timeout_ms")
981
+ self._save()
982
+
983
+ # ── HTTP Proxy ────────────────────────────────────────────────────────────
984
+
985
+ def get_proxy_url(self) -> str | None:
986
+ """Return the HTTP/HTTPS proxy URL from settings (overrides env vars).
987
+
988
+ The stored value may be a literal URL, ``$ENV_VAR``, or ``!command``; it
989
+ is resolved once and cached (see ``tau.utils.secrets``).
990
+ """
991
+ from tau.utils.secrets import resolve_secret
992
+ proxy = self.settings.http_proxy
993
+ if not (proxy and proxy.url):
994
+ return None
995
+ return resolve_secret(proxy.url) or None
996
+
997
+ def get_no_proxy(self) -> str | None:
998
+ """Return the NO_PROXY exclusion list from settings (overrides env var)."""
999
+ proxy = self.settings.http_proxy
1000
+ return proxy.no_proxy if proxy and proxy.no_proxy else None
1001
+
1002
+ def set_proxy_url(self, url: str | None) -> None:
1003
+ """Set the HTTP/HTTPS proxy URL and persist to global settings."""
1004
+ if self.global_settings.http_proxy is None:
1005
+ self.global_settings.http_proxy = HTTPProxySettings()
1006
+ self.global_settings.http_proxy.url = url
1007
+ self._mark_modified("http_proxy", "url")
1008
+ self._save()
1009
+
1010
+ def set_no_proxy(self, hosts: str | None) -> None:
1011
+ """Set the NO_PROXY exclusion list and persist to global settings."""
1012
+ if self.global_settings.http_proxy is None:
1013
+ self.global_settings.http_proxy = HTTPProxySettings()
1014
+ self.global_settings.http_proxy.no_proxy = hosts
1015
+ self._mark_modified("http_proxy", "no_proxy")
1016
+ self._save()
1017
+
1018
+ def get_proxy_headers(self) -> dict[str, str] | None:
1019
+ """Return custom proxy headers (e.g., for authentication).
1020
+
1021
+ Header values may be a literal, ``$ENV_VAR``, or ``!command``; each is
1022
+ resolved once and cached (see ``tau.utils.secrets``).
1023
+ """
1024
+ from tau.utils.secrets import resolve_secrets
1025
+ proxy = self.settings.http_proxy
1026
+ if not (proxy and proxy.headers):
1027
+ return None
1028
+ return resolve_secrets(proxy.headers)
1029
+
1030
+ def set_proxy_headers(self, headers: dict[str, str] | None) -> None:
1031
+ """Set custom proxy headers and persist to global settings."""
1032
+ if self.global_settings.http_proxy is None:
1033
+ self.global_settings.http_proxy = HTTPProxySettings()
1034
+ self.global_settings.http_proxy.headers = headers
1035
+ self._mark_modified("http_proxy", "headers")
1036
+ self._save()
1037
+
1038
+ # ── Project trust ─────────────────────────────────────────────────────────
1039
+
1040
+ def is_project_trusted(self) -> bool:
1041
+ """Return True if the current project directory is trusted."""
1042
+ return self._project_trusted
1043
+
1044
+ def set_project_trusted(self, trusted: bool) -> None:
1045
+ """Mark the project as trusted/untrusted and reload project settings if trust is granted."""
1046
+ if self._project_trusted == trusted:
1047
+ return
1048
+ self._project_trusted = trusted
1049
+ if trusted:
1050
+ # Re-load project settings now that trust is granted
1051
+ project_settings, project_error = SettingsManager._try_load_from_storage(
1052
+ self.storage, SCOPE.PROJECT
1053
+ )
1054
+ self.project_settings = project_settings
1055
+ if project_error:
1056
+ err = SettingsError(scope=SCOPE.PROJECT, error=project_error)
1057
+ if not any(e.scope == SCOPE.PROJECT for e in self.errors):
1058
+ self.errors.append(err)
1059
+ self.project_settings_load_error = project_error
1060
+ else:
1061
+ self.project_settings_load_error = None
1062
+ else:
1063
+ self.project_settings = Settings()
1064
+ self.settings = self._deep_merge_settings(self.global_settings, self.project_settings)
1065
+
1066
+ def get_project_trust(self) -> str:
1067
+ """Return the global project trust policy: 'ask' | 'always' | 'never'."""
1068
+ v = self.global_settings.project_trust
1069
+ return v if v is not None else "ask"
1070
+
1071
+ def set_project_trust(self, value: str) -> None:
1072
+ valid = ("ask", "always", "never")
1073
+ if value not in valid:
1074
+ raise ValueError(f"project_trust must be one of {valid}, got {value!r}")
1075
+ self.global_settings.project_trust = value # type: ignore[assignment]
1076
+ self._mark_modified("project_trust")
1077
+ self._save()