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.
- tau/__init__.py +0 -0
- tau/agent/__init__.py +11 -0
- tau/agent/prompt/__init__.py +10 -0
- tau/agent/prompt/builder.py +302 -0
- tau/agent/prompt/types.py +33 -0
- tau/agent/service.py +369 -0
- tau/agent/types.py +61 -0
- tau/auth/manager.py +247 -0
- tau/auth/storage.py +82 -0
- tau/auth/types.py +41 -0
- tau/builtins/__init__.py +4 -0
- tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__init__.py +41 -0
- tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
- tau/builtins/commands/clear.py +16 -0
- tau/builtins/commands/compact.py +28 -0
- tau/builtins/commands/reload.py +27 -0
- tau/builtins/commands/session.py +19 -0
- tau/builtins/extensions/footer/__init__.py +76 -0
- tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/git.py +26 -0
- tau/builtins/extensions/footer/model.py +69 -0
- tau/builtins/extensions/footer/utils.py +44 -0
- tau/builtins/extensions/header/__init__.py +18 -0
- tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__init__.py +0 -0
- tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/models/audio.py +43 -0
- tau/builtins/models/image.py +43 -0
- tau/builtins/models/text.py +482 -0
- tau/builtins/models/video.py +40 -0
- tau/builtins/prompts/commit.md +7 -0
- tau/builtins/prompts/docs.md +7 -0
- tau/builtins/prompts/explain.md +7 -0
- tau/builtins/prompts/fix.md +7 -0
- tau/builtins/prompts/refactor.md +7 -0
- tau/builtins/prompts/review.md +7 -0
- tau/builtins/prompts/test.md +7 -0
- tau/builtins/providers/__init__.py +0 -0
- tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/providers/audio.py +10 -0
- tau/builtins/providers/image.py +9 -0
- tau/builtins/providers/text.py +33 -0
- tau/builtins/providers/video.py +6 -0
- tau/builtins/skills/code-review/SKILL.md +4 -0
- tau/builtins/skills/debug/SKILL.md +4 -0
- tau/builtins/skills/git-commit/SKILL.md +4 -0
- tau/builtins/themes/dark.yaml +1 -0
- tau/builtins/themes/light.yaml +46 -0
- tau/builtins/tools/__init__.py +73 -0
- tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
- tau/builtins/tools/edit.py +215 -0
- tau/builtins/tools/glob.py +112 -0
- tau/builtins/tools/grep.py +146 -0
- tau/builtins/tools/ls.py +135 -0
- tau/builtins/tools/read.py +122 -0
- tau/builtins/tools/terminal.py +150 -0
- tau/builtins/tools/write.py +105 -0
- tau/commands/__init__.py +10 -0
- tau/commands/registry.py +71 -0
- tau/commands/types.py +33 -0
- tau/console/__init__.py +0 -0
- tau/console/cli.py +266 -0
- tau/console/commands/__init__.py +0 -0
- tau/console/commands/auth.py +193 -0
- tau/console/commands/packages.py +104 -0
- tau/console/commands/update.py +76 -0
- tau/core/__init__.py +0 -0
- tau/core/registry.py +102 -0
- tau/engine/__init__.py +47 -0
- tau/engine/service.py +768 -0
- tau/engine/types.py +163 -0
- tau/extensions/__init__.py +28 -0
- tau/extensions/api.py +928 -0
- tau/extensions/context.py +462 -0
- tau/extensions/events.py +70 -0
- tau/extensions/loader.py +386 -0
- tau/extensions/runtime.py +184 -0
- tau/extensions/settings.py +137 -0
- tau/hooks/__init__.py +112 -0
- tau/hooks/engine.py +237 -0
- tau/hooks/inference.py +21 -0
- tau/hooks/runtime.py +126 -0
- tau/hooks/service.py +121 -0
- tau/hooks/session.py +117 -0
- tau/hooks/tui.py +61 -0
- tau/hooks/types.py +72 -0
- tau/inference/__init__.py +180 -0
- tau/inference/api/__init__.py +0 -0
- tau/inference/api/audio/__init__.py +0 -0
- tau/inference/api/audio/base.py +29 -0
- tau/inference/api/audio/builtins.py +15 -0
- tau/inference/api/audio/elevenlabs_audio.py +183 -0
- tau/inference/api/audio/gemini_audio.py +95 -0
- tau/inference/api/audio/openai_audio.py +159 -0
- tau/inference/api/audio/registry.py +15 -0
- tau/inference/api/audio/sarvam_audio.py +163 -0
- tau/inference/api/audio/service.py +103 -0
- tau/inference/api/audio/utils.py +47 -0
- tau/inference/api/image/__init__.py +0 -0
- tau/inference/api/image/base.py +17 -0
- tau/inference/api/image/builtins.py +8 -0
- tau/inference/api/image/gemini_image.py +77 -0
- tau/inference/api/image/openai_image.py +103 -0
- tau/inference/api/image/openrouter.py +144 -0
- tau/inference/api/image/registry.py +15 -0
- tau/inference/api/image/service.py +71 -0
- tau/inference/api/registry.py +82 -0
- tau/inference/api/text/__init__.py +0 -0
- tau/inference/api/text/anthropic_claude_code.py +222 -0
- tau/inference/api/text/anthropic_messages.py +196 -0
- tau/inference/api/text/base.py +40 -0
- tau/inference/api/text/builtins.py +19 -0
- tau/inference/api/text/gemini_generate.py +234 -0
- tau/inference/api/text/github_copilot_chat.py +172 -0
- tau/inference/api/text/google_antigravity.py +522 -0
- tau/inference/api/text/mistral_chat.py +284 -0
- tau/inference/api/text/ollama_chat.py +200 -0
- tau/inference/api/text/openai_codex_responses.py +497 -0
- tau/inference/api/text/openai_completions.py +227 -0
- tau/inference/api/text/openai_responses.py +235 -0
- tau/inference/api/text/registry.py +50 -0
- tau/inference/api/text/service.py +297 -0
- tau/inference/api/text/types.py +7 -0
- tau/inference/api/text/utils.py +228 -0
- tau/inference/api/video/__init__.py +0 -0
- tau/inference/api/video/base.py +26 -0
- tau/inference/api/video/builtins.py +7 -0
- tau/inference/api/video/fal_video.py +119 -0
- tau/inference/api/video/openrouter_video.py +142 -0
- tau/inference/api/video/registry.py +15 -0
- tau/inference/api/video/service.py +72 -0
- tau/inference/model/__init__.py +0 -0
- tau/inference/model/registry.py +102 -0
- tau/inference/model/types.py +65 -0
- tau/inference/provider/__init__.py +0 -0
- tau/inference/provider/oauth/__init__.py +35 -0
- tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
- tau/inference/provider/oauth/github_copilot.py +333 -0
- tau/inference/provider/oauth/google_antigravity.py +258 -0
- tau/inference/provider/oauth/openai_codex.py +309 -0
- tau/inference/provider/oauth/pkce.py +14 -0
- tau/inference/provider/oauth/types.py +46 -0
- tau/inference/provider/oauth/utils.py +154 -0
- tau/inference/provider/registry.py +141 -0
- tau/inference/provider/types.py +114 -0
- tau/inference/types.py +549 -0
- tau/inference/utils.py +219 -0
- tau/message/__init__.py +0 -0
- tau/message/types.py +482 -0
- tau/message/utils.py +178 -0
- tau/packages/__init__.py +11 -0
- tau/packages/manager.py +190 -0
- tau/packages/types.py +20 -0
- tau/packages/utils.py +67 -0
- tau/prompts/expand.py +58 -0
- tau/prompts/loader.py +69 -0
- tau/prompts/registry.py +45 -0
- tau/prompts/types.py +24 -0
- tau/rpc/__init__.py +8 -0
- tau/rpc/mode.py +783 -0
- tau/rpc/types.py +252 -0
- tau/runtime/service.py +759 -0
- tau/runtime/types.py +303 -0
- tau/session/branch_summarization.py +312 -0
- tau/session/compaction.py +646 -0
- tau/session/manager.py +652 -0
- tau/session/types.py +188 -0
- tau/session/utils.py +233 -0
- tau/settings/manager.py +1077 -0
- tau/settings/paths.py +150 -0
- tau/settings/storage.py +63 -0
- tau/settings/types.py +173 -0
- tau/settings/utils.py +25 -0
- tau/skills/loader.py +91 -0
- tau/skills/registry.py +70 -0
- tau/skills/types.py +25 -0
- tau/themes/loader.py +238 -0
- tau/themes/registry.py +108 -0
- tau/themes/types.py +19 -0
- tau/tool/__init__.py +3 -0
- tau/tool/registry.py +117 -0
- tau/tool/render.py +21 -0
- tau/tool/types.py +244 -0
- tau/trust/__init__.py +13 -0
- tau/trust/manager.py +80 -0
- tau/trust/types.py +14 -0
- tau/trust/utils.py +72 -0
- tau/tui/__init__.py +54 -0
- tau/tui/agent_hooks.py +346 -0
- tau/tui/ansi.py +330 -0
- tau/tui/app.py +540 -0
- tau/tui/autocomplete.py +33 -0
- tau/tui/capabilities.py +119 -0
- tau/tui/commands/__init__.py +3 -0
- tau/tui/commands/appearance.py +498 -0
- tau/tui/commands/auth.py +232 -0
- tau/tui/commands/context.py +38 -0
- tau/tui/commands/misc.py +82 -0
- tau/tui/commands/model.py +118 -0
- tau/tui/commands/session.py +464 -0
- tau/tui/component.py +268 -0
- tau/tui/components/__init__.py +0 -0
- tau/tui/components/autocomplete_manager.py +267 -0
- tau/tui/components/autocomplete_picker.py +143 -0
- tau/tui/components/box.py +90 -0
- tau/tui/components/command_palette.py +144 -0
- tau/tui/components/dynamic_border.py +19 -0
- tau/tui/components/file_picker.py +233 -0
- tau/tui/components/image.py +181 -0
- tau/tui/components/inline_selector.py +71 -0
- tau/tui/components/layout.py +1194 -0
- tau/tui/components/message_list.py +692 -0
- tau/tui/components/modal.py +97 -0
- tau/tui/components/model_palette.py +204 -0
- tau/tui/components/picker_overlay.py +174 -0
- tau/tui/components/prompt_overlay.py +236 -0
- tau/tui/components/resume_modal.py +372 -0
- tau/tui/components/select_list.py +222 -0
- tau/tui/components/settings_modal.py +274 -0
- tau/tui/components/settings_schema.py +203 -0
- tau/tui/components/spinner.py +119 -0
- tau/tui/components/text_input.py +396 -0
- tau/tui/components/text_prompt.py +82 -0
- tau/tui/components/tree_select_list.py +580 -0
- tau/tui/components/trust_screen.py +97 -0
- tau/tui/diff.py +114 -0
- tau/tui/fuzzy.py +99 -0
- tau/tui/input.py +496 -0
- tau/tui/input_handler.py +716 -0
- tau/tui/keybindings.py +87 -0
- tau/tui/markdown.py +286 -0
- tau/tui/message_renderers.py +31 -0
- tau/tui/overlay.py +326 -0
- tau/tui/renderer.py +378 -0
- tau/tui/terminal.py +499 -0
- tau/tui/theme.py +148 -0
- tau/tui/tui.py +544 -0
- tau/tui/ui_context.py +768 -0
- tau/tui/utils.py +20 -0
- tau/utils/__init__.py +0 -0
- tau/utils/http_proxy.py +221 -0
- tau/utils/image_processing.py +172 -0
- tau/utils/secrets.py +59 -0
- tau/utils/version_check.py +60 -0
- tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
- tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
- tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
- tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- tau_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
tau/settings/manager.py
ADDED
|
@@ -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()
|