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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Callable, Generic, TypeVar, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from tau.tui.ansi import visible_width
|
|
7
|
+
from tau.tui.component import Component
|
|
8
|
+
from tau.tui.fuzzy import fuzzy_filter
|
|
9
|
+
from tau.tui.input import InputEvent, KeyEvent
|
|
10
|
+
from tau.tui.keybindings import get_keybindings
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from tau.tui.theme import SelectListTheme
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SelectItem(Generic[T]):
|
|
20
|
+
"""A single row in a SelectList."""
|
|
21
|
+
label: str
|
|
22
|
+
description: str = ""
|
|
23
|
+
value: T | None = None # type: ignore[assignment]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SelectList(Component, Generic[T]):
|
|
27
|
+
"""
|
|
28
|
+
Filterable, scrollable list of SelectItem rows.
|
|
29
|
+
|
|
30
|
+
- Fuzzy-filters items as `query` changes.
|
|
31
|
+
- Arrow keys / ctrl+p / ctrl+n navigate selection.
|
|
32
|
+
- Enter / Tab fires the on_confirm callback.
|
|
33
|
+
- Escape fires on_dismiss.
|
|
34
|
+
- Shows a scroll indicator when items overflow the viewport.
|
|
35
|
+
|
|
36
|
+
Usage::
|
|
37
|
+
|
|
38
|
+
lst = SelectList(items, max_visible=5, theme=theme.select)
|
|
39
|
+
lst.set_query(current_input)
|
|
40
|
+
lst.on_confirm(lambda item: ...)
|
|
41
|
+
lines = lst.render(width)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
items: list[SelectItem[T]] | None = None,
|
|
47
|
+
max_visible: int = 5,
|
|
48
|
+
theme: SelectListTheme | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._all_items: list[SelectItem[T]] = items or []
|
|
51
|
+
self._filtered: list[SelectItem[T]] = list(self._all_items)
|
|
52
|
+
self._max_visible = max(1, max_visible)
|
|
53
|
+
self._selected = 0
|
|
54
|
+
self._scroll_offset = 0
|
|
55
|
+
self._query = ""
|
|
56
|
+
self._on_confirm: Callable[[SelectItem[T]], None] | None = None
|
|
57
|
+
self._on_dismiss: Callable[[], None] | None = None
|
|
58
|
+
|
|
59
|
+
from tau.tui.theme import SelectListTheme as _ST
|
|
60
|
+
self._theme = theme or _ST()
|
|
61
|
+
|
|
62
|
+
# -------------------------------------------------------------------------
|
|
63
|
+
# Public API
|
|
64
|
+
# -------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def active(self) -> bool:
|
|
68
|
+
return bool(self._filtered)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def selected_item(self) -> SelectItem[T] | None:
|
|
72
|
+
if not self._filtered:
|
|
73
|
+
return None
|
|
74
|
+
return self._filtered[self._selected]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def line_count(self) -> int:
|
|
78
|
+
return min(self._max_visible, len(self._filtered))
|
|
79
|
+
|
|
80
|
+
def set_items(self, items: list[SelectItem[T]]) -> None:
|
|
81
|
+
self._all_items = items
|
|
82
|
+
self._apply_filter()
|
|
83
|
+
|
|
84
|
+
def set_query(self, query: str) -> None:
|
|
85
|
+
if query == self._query:
|
|
86
|
+
return
|
|
87
|
+
self._query = query
|
|
88
|
+
self._apply_filter()
|
|
89
|
+
|
|
90
|
+
def set_theme(self, theme: SelectListTheme) -> None:
|
|
91
|
+
self._theme = theme
|
|
92
|
+
|
|
93
|
+
def on_confirm(self, cb: Callable[[SelectItem[T]], None]) -> None:
|
|
94
|
+
self._on_confirm = cb
|
|
95
|
+
|
|
96
|
+
def on_dismiss(self, cb: Callable[[], None]) -> None:
|
|
97
|
+
self._on_dismiss = cb
|
|
98
|
+
|
|
99
|
+
def move_up(self) -> None:
|
|
100
|
+
if self._filtered:
|
|
101
|
+
self._selected = (self._selected - 1) % len(self._filtered)
|
|
102
|
+
self._clamp_scroll()
|
|
103
|
+
|
|
104
|
+
def move_down(self) -> None:
|
|
105
|
+
if self._filtered:
|
|
106
|
+
self._selected = (self._selected + 1) % len(self._filtered)
|
|
107
|
+
self._clamp_scroll()
|
|
108
|
+
|
|
109
|
+
# -------------------------------------------------------------------------
|
|
110
|
+
# Component
|
|
111
|
+
# -------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def render(self, width: int) -> list[str]:
|
|
114
|
+
t = self._theme
|
|
115
|
+
items = self._filtered
|
|
116
|
+
|
|
117
|
+
if not items:
|
|
118
|
+
return [t.empty(" no matches")]
|
|
119
|
+
|
|
120
|
+
count = len(items)
|
|
121
|
+
visible = min(self._max_visible, count)
|
|
122
|
+
|
|
123
|
+
# Keep scroll window so selected stays in view
|
|
124
|
+
self._clamp_scroll()
|
|
125
|
+
start = self._scroll_offset
|
|
126
|
+
|
|
127
|
+
# Label column width: widest label in visible slice (min 8, max ~40% of width)
|
|
128
|
+
label_w = max(8, min(
|
|
129
|
+
max(len(it.label) for it in items[start:start + visible]),
|
|
130
|
+
width // 2,
|
|
131
|
+
))
|
|
132
|
+
desc_w = max(0, width - label_w - 3) # 3 = " " indent + " " gap
|
|
133
|
+
|
|
134
|
+
lines: list[str] = []
|
|
135
|
+
|
|
136
|
+
# Scroll-up indicator
|
|
137
|
+
if start > 0:
|
|
138
|
+
lines.append(t.indicator(f" ↑ {start} more"))
|
|
139
|
+
|
|
140
|
+
for i in range(start, start + visible):
|
|
141
|
+
item = items[i]
|
|
142
|
+
is_sel = i == self._selected
|
|
143
|
+
|
|
144
|
+
label = item.label[:label_w].ljust(label_w)
|
|
145
|
+
desc = (item.description[:desc_w] if desc_w > 0 else "")
|
|
146
|
+
|
|
147
|
+
if is_sel:
|
|
148
|
+
row = " " + t.selected_label(label) + " " + t.selected_desc(desc)
|
|
149
|
+
if t.selected_bg:
|
|
150
|
+
# Fill to full width and apply background
|
|
151
|
+
vw = visible_width(row)
|
|
152
|
+
fill = max(0, width - vw)
|
|
153
|
+
row = t.selected_bg(row + " " * fill)
|
|
154
|
+
else:
|
|
155
|
+
row = " " + t.normal_label(label) + " " + t.normal_desc(desc)
|
|
156
|
+
|
|
157
|
+
lines.append(row)
|
|
158
|
+
|
|
159
|
+
# Scroll-down indicator
|
|
160
|
+
remaining = count - (start + visible)
|
|
161
|
+
if remaining > 0:
|
|
162
|
+
lines.append(t.indicator(f" ↓ {remaining} more"))
|
|
163
|
+
|
|
164
|
+
return lines
|
|
165
|
+
|
|
166
|
+
def handle_input(self, event: InputEvent) -> bool:
|
|
167
|
+
if not isinstance(event, KeyEvent):
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
kb = get_keybindings()
|
|
171
|
+
|
|
172
|
+
if kb.matches(event, "tui.select.up"):
|
|
173
|
+
self.move_up()
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
if kb.matches(event, "tui.select.down"):
|
|
177
|
+
self.move_down()
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
if kb.matches(event, "tui.select.confirm"):
|
|
181
|
+
item = self.selected_item
|
|
182
|
+
if item is not None and self._on_confirm is not None:
|
|
183
|
+
self._on_confirm(item)
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
if kb.matches(event, "tui.select.dismiss"):
|
|
187
|
+
if self._on_dismiss is not None:
|
|
188
|
+
self._on_dismiss()
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
# -------------------------------------------------------------------------
|
|
194
|
+
# Internal
|
|
195
|
+
# -------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def _apply_filter(self) -> None:
|
|
198
|
+
if not self._query:
|
|
199
|
+
self._filtered = list(self._all_items)
|
|
200
|
+
else:
|
|
201
|
+
self._filtered = fuzzy_filter(
|
|
202
|
+
self._all_items,
|
|
203
|
+
self._query,
|
|
204
|
+
lambda item: item.label + " " + item.description,
|
|
205
|
+
)
|
|
206
|
+
# Clamp selection to new list length
|
|
207
|
+
if self._filtered:
|
|
208
|
+
self._selected = min(self._selected, len(self._filtered) - 1)
|
|
209
|
+
else:
|
|
210
|
+
self._selected = 0
|
|
211
|
+
self._scroll_offset = 0
|
|
212
|
+
|
|
213
|
+
def _clamp_scroll(self) -> None:
|
|
214
|
+
count = len(self._filtered)
|
|
215
|
+
visible = min(self._max_visible, count)
|
|
216
|
+
# selected must be in [scroll_offset, scroll_offset + visible)
|
|
217
|
+
if self._selected < self._scroll_offset:
|
|
218
|
+
self._scroll_offset = self._selected
|
|
219
|
+
elif self._selected >= self._scroll_offset + visible:
|
|
220
|
+
self._scroll_offset = self._selected - visible + 1
|
|
221
|
+
# ensure scroll_offset valid
|
|
222
|
+
self._scroll_offset = max(0, min(self._scroll_offset, max(0, count - visible)))
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from tau.tui.ansi import RESET, BOLD, DIM, BRIGHT_BLACK, BRIGHT_WHITE
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class SettingItem:
|
|
11
|
+
id: str
|
|
12
|
+
label: str
|
|
13
|
+
current_value: str
|
|
14
|
+
description: str = ""
|
|
15
|
+
values: list[str] = field(default_factory=list)
|
|
16
|
+
submenu_items: list[str] = field(default_factory=list)
|
|
17
|
+
submenu_title: str = ""
|
|
18
|
+
text_input: bool = False
|
|
19
|
+
submenu_settings: list["SettingItem"] = field(default_factory=list)
|
|
20
|
+
submenu_on_change: "Callable[[str, str], None] | None" = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SettingsModal:
|
|
24
|
+
"""Interactive settings list.
|
|
25
|
+
|
|
26
|
+
- Up/Down navigate rows
|
|
27
|
+
- Enter/Space cycle value, open submenu/sub-panel, or enter text-edit mode
|
|
28
|
+
- Escape cancel text-edit / close submenu / close modal
|
|
29
|
+
- Type to fuzzy-search (or type into the edit buffer when editing)
|
|
30
|
+
- Backspace removes last search char (or last edit char when editing)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
items: list[SettingItem],
|
|
36
|
+
on_change: Callable[[str, str], None],
|
|
37
|
+
max_visible: int = 10,
|
|
38
|
+
title: str = "",
|
|
39
|
+
) -> None:
|
|
40
|
+
self._all_items = items
|
|
41
|
+
self._filtered: list[SettingItem] = list(items)
|
|
42
|
+
self._on_change = on_change
|
|
43
|
+
self._max_visible = max_visible
|
|
44
|
+
self._title = title
|
|
45
|
+
self._selected = 0
|
|
46
|
+
self._search = ""
|
|
47
|
+
|
|
48
|
+
# Submenu state — either a ListModal (for submenu_items) or a nested SettingsModal
|
|
49
|
+
self._submenu: object | None = None
|
|
50
|
+
self._submenu_id: str | None = None
|
|
51
|
+
|
|
52
|
+
# Inline text-edit state
|
|
53
|
+
self._editing = False
|
|
54
|
+
self._edit_buffer = ""
|
|
55
|
+
self._edit_id: str | None = None
|
|
56
|
+
|
|
57
|
+
# ── Public state ──────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def in_submenu(self) -> bool:
|
|
61
|
+
return self._submenu is not None or self._editing
|
|
62
|
+
|
|
63
|
+
# ── Navigation ────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def move_up(self) -> None:
|
|
66
|
+
if self._editing:
|
|
67
|
+
return
|
|
68
|
+
if self._submenu is not None:
|
|
69
|
+
self._submenu.move_up() # type: ignore[attr-defined]
|
|
70
|
+
elif self._filtered:
|
|
71
|
+
self._selected = (self._selected - 1) % len(self._filtered)
|
|
72
|
+
|
|
73
|
+
def move_down(self) -> None:
|
|
74
|
+
if self._editing:
|
|
75
|
+
return
|
|
76
|
+
if self._submenu is not None:
|
|
77
|
+
self._submenu.move_down() # type: ignore[attr-defined]
|
|
78
|
+
elif self._filtered:
|
|
79
|
+
self._selected = (self._selected + 1) % len(self._filtered)
|
|
80
|
+
|
|
81
|
+
def activate(self) -> None:
|
|
82
|
+
"""Enter/Space: confirm edit, activate submenu item, cycle value, or open sub-panel."""
|
|
83
|
+
if self._editing:
|
|
84
|
+
self._confirm_edit()
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
if self._submenu is not None:
|
|
88
|
+
if isinstance(self._submenu, SettingsModal):
|
|
89
|
+
self._submenu.activate()
|
|
90
|
+
else:
|
|
91
|
+
# ListModal: pick selected value and close
|
|
92
|
+
val = self._submenu.selected_value() # type: ignore[attr-defined]
|
|
93
|
+
if val is not None and self._submenu_id is not None:
|
|
94
|
+
self._apply_value(self._submenu_id, val)
|
|
95
|
+
self._submenu = None
|
|
96
|
+
self._submenu_id = None
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
if not self._filtered:
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
item = self._filtered[self._selected]
|
|
103
|
+
|
|
104
|
+
if item.submenu_settings:
|
|
105
|
+
self._submenu = SettingsModal(
|
|
106
|
+
item.submenu_settings,
|
|
107
|
+
item.submenu_on_change or self._on_change,
|
|
108
|
+
title=item.submenu_title or item.label,
|
|
109
|
+
)
|
|
110
|
+
self._submenu_id = item.id
|
|
111
|
+
elif item.submenu_items:
|
|
112
|
+
from tau.tui.components.modal import ListModal
|
|
113
|
+
self._submenu = ListModal(
|
|
114
|
+
item.submenu_items,
|
|
115
|
+
item.current_value,
|
|
116
|
+
item.submenu_title or item.label,
|
|
117
|
+
item.description,
|
|
118
|
+
)
|
|
119
|
+
self._submenu_id = item.id
|
|
120
|
+
elif item.text_input:
|
|
121
|
+
self._editing = True
|
|
122
|
+
self._edit_buffer = item.current_value
|
|
123
|
+
self._edit_id = item.id
|
|
124
|
+
elif item.values:
|
|
125
|
+
try:
|
|
126
|
+
idx = item.values.index(item.current_value)
|
|
127
|
+
new_val = item.values[(idx + 1) % len(item.values)]
|
|
128
|
+
except ValueError:
|
|
129
|
+
new_val = item.values[0]
|
|
130
|
+
self._apply_value(item.id, new_val)
|
|
131
|
+
|
|
132
|
+
def cancel_submenu(self) -> None:
|
|
133
|
+
if self._editing:
|
|
134
|
+
self._editing = False
|
|
135
|
+
self._edit_buffer = ""
|
|
136
|
+
self._edit_id = None
|
|
137
|
+
elif isinstance(self._submenu, SettingsModal) and self._submenu.in_submenu:
|
|
138
|
+
# Delegate Escape inward so nested edit/submenu closes first
|
|
139
|
+
self._submenu.cancel_submenu()
|
|
140
|
+
else:
|
|
141
|
+
self._submenu = None
|
|
142
|
+
self._submenu_id = None
|
|
143
|
+
|
|
144
|
+
# ── Search / text-edit input ──────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
def append_search(self, ch: str) -> None:
|
|
147
|
+
if self._editing:
|
|
148
|
+
self._edit_buffer += ch
|
|
149
|
+
return
|
|
150
|
+
if isinstance(self._submenu, SettingsModal):
|
|
151
|
+
self._submenu.append_search(ch)
|
|
152
|
+
return
|
|
153
|
+
if self._submenu is not None:
|
|
154
|
+
return
|
|
155
|
+
self._search += ch
|
|
156
|
+
self._refilter()
|
|
157
|
+
|
|
158
|
+
def backspace_search(self) -> None:
|
|
159
|
+
if self._editing:
|
|
160
|
+
self._edit_buffer = self._edit_buffer[:-1]
|
|
161
|
+
return
|
|
162
|
+
if isinstance(self._submenu, SettingsModal):
|
|
163
|
+
self._submenu.backspace_search()
|
|
164
|
+
return
|
|
165
|
+
if self._submenu is not None:
|
|
166
|
+
return
|
|
167
|
+
if self._search:
|
|
168
|
+
self._search = self._search[:-1]
|
|
169
|
+
self._refilter()
|
|
170
|
+
|
|
171
|
+
# ── Render ────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
def render(self, width: int) -> list[str]:
|
|
174
|
+
if self._submenu is not None:
|
|
175
|
+
return self._submenu.render(width) # type: ignore[attr-defined]
|
|
176
|
+
|
|
177
|
+
divider = BRIGHT_BLACK + "─" * width + RESET
|
|
178
|
+
lines: list[str] = []
|
|
179
|
+
|
|
180
|
+
if self._title:
|
|
181
|
+
lines.append(f" {BOLD}{self._title}{RESET}")
|
|
182
|
+
lines.append(divider)
|
|
183
|
+
|
|
184
|
+
if self._editing:
|
|
185
|
+
lines.append(f" {DIM}editing — enter to confirm, esc to cancel{RESET}")
|
|
186
|
+
elif self._search:
|
|
187
|
+
lines.append(f" {DIM}/{RESET}{self._search}█")
|
|
188
|
+
else:
|
|
189
|
+
lines.append(f" {DIM}type to search…{RESET}")
|
|
190
|
+
lines.append(divider)
|
|
191
|
+
|
|
192
|
+
if not self._filtered:
|
|
193
|
+
lines.append(f" {DIM}No matching settings{RESET}")
|
|
194
|
+
else:
|
|
195
|
+
max_label = min(28, max(len(i.label) for i in self._filtered))
|
|
196
|
+
count = len(self._filtered)
|
|
197
|
+
visible = min(self._max_visible, count)
|
|
198
|
+
start = max(0, min(self._selected - visible // 2, count - visible))
|
|
199
|
+
|
|
200
|
+
if start > 0:
|
|
201
|
+
lines.append(f" {DIM}↑ {start} more{RESET}")
|
|
202
|
+
|
|
203
|
+
for i in range(start, min(start + visible, count)):
|
|
204
|
+
item = self._filtered[i]
|
|
205
|
+
is_sel = i == self._selected
|
|
206
|
+
label_padded = item.label.ljust(max_label)
|
|
207
|
+
has_submenu = bool(item.submenu_items or item.submenu_settings)
|
|
208
|
+
|
|
209
|
+
if is_sel and self._editing:
|
|
210
|
+
val_display = self._edit_buffer + "\033[7m \033[27m"
|
|
211
|
+
row = (
|
|
212
|
+
f" {BOLD}{BRIGHT_WHITE}→ {label_padded}{RESET}"
|
|
213
|
+
f" {BRIGHT_WHITE}{val_display}{RESET}"
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
val_display = (item.current_value.replace("_", " ") + " ▸") if has_submenu else item.current_value.replace("_", " ")
|
|
217
|
+
if is_sel:
|
|
218
|
+
row = (
|
|
219
|
+
f" {BOLD}{BRIGHT_WHITE}→ {label_padded}{RESET}"
|
|
220
|
+
f" {BRIGHT_WHITE}{val_display}{RESET}"
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
row = f" {DIM}{label_padded}{RESET} {val_display}"
|
|
224
|
+
lines.append(row)
|
|
225
|
+
|
|
226
|
+
remaining = count - (start + visible)
|
|
227
|
+
if remaining > 0:
|
|
228
|
+
lines.append(f" {DIM}↓ {remaining} more{RESET}")
|
|
229
|
+
|
|
230
|
+
lines.append(divider)
|
|
231
|
+
desc = ""
|
|
232
|
+
if self._filtered and 0 <= self._selected < len(self._filtered):
|
|
233
|
+
desc = self._filtered[self._selected].description
|
|
234
|
+
lines.append(f" {DIM}{desc}{RESET}" if desc else f" {DIM}—{RESET}")
|
|
235
|
+
|
|
236
|
+
lines.append(divider)
|
|
237
|
+
if self._editing:
|
|
238
|
+
lines.append(f" {DIM}enter confirm esc cancel{RESET}")
|
|
239
|
+
else:
|
|
240
|
+
lines.append(
|
|
241
|
+
f" {DIM}↑/↓ move enter/spc toggle esc cancel type to search{RESET}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return lines
|
|
245
|
+
|
|
246
|
+
# ── Internal ──────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
def _confirm_edit(self) -> None:
|
|
249
|
+
buf = self._edit_buffer.strip()
|
|
250
|
+
edit_id = self._edit_id
|
|
251
|
+
self._editing = False
|
|
252
|
+
self._edit_buffer = ""
|
|
253
|
+
self._edit_id = None
|
|
254
|
+
if edit_id and buf:
|
|
255
|
+
self._apply_value(edit_id, buf)
|
|
256
|
+
|
|
257
|
+
def _apply_value(self, item_id: str, val: str) -> None:
|
|
258
|
+
for item in self._all_items:
|
|
259
|
+
if item.id == item_id:
|
|
260
|
+
item.current_value = val
|
|
261
|
+
break
|
|
262
|
+
for item in self._filtered:
|
|
263
|
+
if item.id == item_id:
|
|
264
|
+
item.current_value = val
|
|
265
|
+
break
|
|
266
|
+
self._on_change(item_id, val)
|
|
267
|
+
|
|
268
|
+
def _refilter(self) -> None:
|
|
269
|
+
if not self._search:
|
|
270
|
+
self._filtered = list(self._all_items)
|
|
271
|
+
else:
|
|
272
|
+
q = self._search.lower()
|
|
273
|
+
self._filtered = [i for i in self._all_items if q in i.label.lower()]
|
|
274
|
+
self._selected = min(self._selected, max(0, len(self._filtered) - 1))
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Build a ``/settings`` sub-panel for an extension from a declarative manifest schema.
|
|
2
|
+
|
|
3
|
+
Extensions can describe their settings in ``manifest.json`` instead of writing
|
|
4
|
+
imperative ``register_settings`` code. The framework reads the schema, builds the
|
|
5
|
+
panel, reads current values from the extension's config, and wires an ``on_change``
|
|
6
|
+
that — only when a value actually changed — persists to settings.json and reloads
|
|
7
|
+
just that extension so the change applies live.
|
|
8
|
+
|
|
9
|
+
Manifest shape (under the app key, e.g. ``"tau"``)::
|
|
10
|
+
|
|
11
|
+
"settings": {
|
|
12
|
+
"title": "Web search",
|
|
13
|
+
"fields": [
|
|
14
|
+
{"key": "engine", "label": "Search engine", "type": "enum",
|
|
15
|
+
"values": ["ddgs", "exa", "tavily"], "default": "ddgs"},
|
|
16
|
+
|
|
17
|
+
{"key": "exa", "label": "Exa", "type": "group", "fields": [
|
|
18
|
+
{"key": "api_key", "label": "API key", "type": "secret"},
|
|
19
|
+
{"key": "results", "label": "Results", "type": "int",
|
|
20
|
+
"default": 10, "min": 1, "max": 50}
|
|
21
|
+
]}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Field ``type`` values:
|
|
26
|
+
group nested sub-panel; ``fields`` are rendered one level deeper and
|
|
27
|
+
their keys are prefixed with the group key (``exa.api_key``)
|
|
28
|
+
enum / select cycle through ``values`` (required, non-empty)
|
|
29
|
+
bool toggle off/on (stored in config as a JSON boolean)
|
|
30
|
+
int numeric text input; optional ``min`` / ``max`` clamp
|
|
31
|
+
string / secret free text input; optional ``pattern`` (regex) the value must match
|
|
32
|
+
|
|
33
|
+
Keys support dot-notation directly too (``"exa.api_key"``). Unknown types and
|
|
34
|
+
malformed fields are skipped with a logged warning rather than rendering a
|
|
35
|
+
misleading control.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import logging
|
|
40
|
+
import re
|
|
41
|
+
from typing import Any, Callable
|
|
42
|
+
|
|
43
|
+
from tau.extensions.api import ExtensionSettingsRegistration
|
|
44
|
+
from tau.tui.components.settings_modal import SettingItem
|
|
45
|
+
|
|
46
|
+
_log = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
_LEAF_TYPES = {"enum", "select", "bool", "int", "string", "secret", "text"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_nested(d: dict, path: str, default: Any = "") -> Any:
|
|
52
|
+
obj: Any = d
|
|
53
|
+
for part in path.split("."):
|
|
54
|
+
if not isinstance(obj, dict) or part not in obj:
|
|
55
|
+
return default
|
|
56
|
+
obj = obj[part]
|
|
57
|
+
return obj if obj is not None else default
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _coerce(field_type: str, value: str, field: dict) -> Any:
|
|
61
|
+
"""Convert the panel's string value to the type the config should store."""
|
|
62
|
+
if field_type == "bool":
|
|
63
|
+
return str(value).lower() in ("on", "true")
|
|
64
|
+
if field_type == "int":
|
|
65
|
+
try:
|
|
66
|
+
n = int(value)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
return field.get("default", 0)
|
|
69
|
+
lo, hi = field.get("min"), field.get("max")
|
|
70
|
+
if isinstance(lo, int):
|
|
71
|
+
n = max(lo, n)
|
|
72
|
+
if isinstance(hi, int):
|
|
73
|
+
n = min(hi, n)
|
|
74
|
+
return n
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _valid(field_type: str, value: str, field: dict) -> bool:
|
|
79
|
+
"""Reject an incoming value that violates the field's declared constraints."""
|
|
80
|
+
if field_type == "int":
|
|
81
|
+
try:
|
|
82
|
+
int(value)
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
return False
|
|
85
|
+
if field_type in ("string", "secret", "text"):
|
|
86
|
+
pattern = field.get("pattern")
|
|
87
|
+
if pattern and not re.fullmatch(pattern, value or ""):
|
|
88
|
+
return False
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_manifest_panel(
|
|
93
|
+
schema: dict,
|
|
94
|
+
config: dict,
|
|
95
|
+
*,
|
|
96
|
+
default_title: str,
|
|
97
|
+
apply: Callable[[str, Any], None],
|
|
98
|
+
) -> ExtensionSettingsRegistration | None:
|
|
99
|
+
"""Construct an :class:`ExtensionSettingsRegistration` from a manifest schema.
|
|
100
|
+
|
|
101
|
+
``apply(key, value)`` is called with the full dot-path key and the coerced
|
|
102
|
+
value when — and only when — a field's value actually changes. Returns
|
|
103
|
+
``None`` if the schema yields no usable items.
|
|
104
|
+
"""
|
|
105
|
+
field_defs: dict[str, dict] = {} # full key -> field def (for coerce/validate)
|
|
106
|
+
currents: dict[str, Any] = {} # full key -> current config value (for diff)
|
|
107
|
+
|
|
108
|
+
def build_items(fields: list, prefix: str) -> list[SettingItem]:
|
|
109
|
+
items: list[SettingItem] = []
|
|
110
|
+
for f in fields:
|
|
111
|
+
if not isinstance(f, dict):
|
|
112
|
+
_log.warning("settings_schema: skipping non-object field %r", f)
|
|
113
|
+
continue
|
|
114
|
+
key = f.get("key")
|
|
115
|
+
if not key:
|
|
116
|
+
_log.warning("settings_schema: skipping field with no 'key': %r", f)
|
|
117
|
+
continue
|
|
118
|
+
full = f"{prefix}.{key}" if prefix else key
|
|
119
|
+
label = f.get("label", key)
|
|
120
|
+
description = f.get("description", "")
|
|
121
|
+
ftype = str(f.get("type") or "string").lower()
|
|
122
|
+
|
|
123
|
+
# ── Nested group → sub-panel ──────────────────────────────────────
|
|
124
|
+
if ftype == "group" or "fields" in f:
|
|
125
|
+
children = build_items(f.get("fields") or [], full)
|
|
126
|
+
if children:
|
|
127
|
+
items.append(SettingItem(
|
|
128
|
+
id=full, label=label, description=description,
|
|
129
|
+
current_value="→",
|
|
130
|
+
submenu_title=f.get("title") or label,
|
|
131
|
+
submenu_settings=children,
|
|
132
|
+
))
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
if ftype not in _LEAF_TYPES:
|
|
136
|
+
_log.warning("settings_schema: unknown field type %r for %r — skipping", ftype, full)
|
|
137
|
+
continue
|
|
138
|
+
if ftype in ("enum", "select") and not f.get("values"):
|
|
139
|
+
_log.warning("settings_schema: enum field %r has no values — skipping", full)
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
current = _get_nested(config, full, f.get("default", ""))
|
|
143
|
+
field_defs[full] = f
|
|
144
|
+
|
|
145
|
+
if ftype in ("enum", "select"):
|
|
146
|
+
currents[full] = current
|
|
147
|
+
items.append(SettingItem(
|
|
148
|
+
id=full, label=label, description=description,
|
|
149
|
+
current_value=str(current),
|
|
150
|
+
values=[str(v) for v in f.get("values", [])],
|
|
151
|
+
))
|
|
152
|
+
elif ftype == "bool":
|
|
153
|
+
stored = current is True or str(current).lower() in ("true", "on")
|
|
154
|
+
currents[full] = stored # config-space bool, matches _coerce output
|
|
155
|
+
items.append(SettingItem(
|
|
156
|
+
id=full, label=label, description=description,
|
|
157
|
+
current_value="on" if stored else "off",
|
|
158
|
+
values=["off", "on"],
|
|
159
|
+
))
|
|
160
|
+
else: # string, secret, int, text
|
|
161
|
+
currents[full] = current
|
|
162
|
+
items.append(SettingItem(
|
|
163
|
+
id=full, label=label, description=description,
|
|
164
|
+
current_value=str(current if current is not None else ""),
|
|
165
|
+
text_input=True,
|
|
166
|
+
))
|
|
167
|
+
return items
|
|
168
|
+
|
|
169
|
+
items = build_items(schema.get("fields") or [], "")
|
|
170
|
+
if not items:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
# Surface the first top-level bool (e.g. a master "Enabled" switch) as an
|
|
174
|
+
# on/off summary on the extension's parent row in the main /settings list.
|
|
175
|
+
summary = ""
|
|
176
|
+
summary_key = ""
|
|
177
|
+
for f in schema.get("fields") or []:
|
|
178
|
+
if isinstance(f, dict) and str(f.get("type") or "").lower() == "bool" and f.get("key"):
|
|
179
|
+
summary_key = f["key"]
|
|
180
|
+
summary = "on" if currents.get(summary_key) else "off"
|
|
181
|
+
break
|
|
182
|
+
|
|
183
|
+
def on_change(key: str, value: str) -> None:
|
|
184
|
+
field = field_defs.get(key)
|
|
185
|
+
if field is None:
|
|
186
|
+
return # not a leaf field we built (e.g. a group row) — ignore
|
|
187
|
+
ftype = str(field.get("type") or "string").lower()
|
|
188
|
+
if not _valid(ftype, value, field):
|
|
189
|
+
_log.warning("settings_schema: invalid value %r for %r — ignored", value, key)
|
|
190
|
+
return
|
|
191
|
+
coerced = _coerce(ftype, value, field)
|
|
192
|
+
if coerced == currents.get(key):
|
|
193
|
+
return # no change vs in-memory config — skip persist + reload
|
|
194
|
+
currents[key] = coerced
|
|
195
|
+
apply(key, coerced)
|
|
196
|
+
|
|
197
|
+
return ExtensionSettingsRegistration(
|
|
198
|
+
title=schema.get("title") or default_title,
|
|
199
|
+
items=items,
|
|
200
|
+
on_change=on_change,
|
|
201
|
+
summary=summary,
|
|
202
|
+
summary_key=summary_key,
|
|
203
|
+
)
|