tau-coding-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. tau/__init__.py +0 -0
  2. tau/agent/__init__.py +11 -0
  3. tau/agent/prompt/__init__.py +10 -0
  4. tau/agent/prompt/builder.py +302 -0
  5. tau/agent/prompt/types.py +33 -0
  6. tau/agent/service.py +369 -0
  7. tau/agent/types.py +61 -0
  8. tau/auth/manager.py +247 -0
  9. tau/auth/storage.py +82 -0
  10. tau/auth/types.py +41 -0
  11. tau/builtins/__init__.py +4 -0
  12. tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
  13. tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
  14. tau/builtins/commands/__init__.py +41 -0
  15. tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
  16. tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
  17. tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
  18. tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
  19. tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
  20. tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
  21. tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
  22. tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
  23. tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
  24. tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
  25. tau/builtins/commands/clear.py +16 -0
  26. tau/builtins/commands/compact.py +28 -0
  27. tau/builtins/commands/reload.py +27 -0
  28. tau/builtins/commands/session.py +19 -0
  29. tau/builtins/extensions/footer/__init__.py +76 -0
  30. tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
  31. tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
  32. tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
  33. tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
  34. tau/builtins/extensions/footer/git.py +26 -0
  35. tau/builtins/extensions/footer/model.py +69 -0
  36. tau/builtins/extensions/footer/utils.py +44 -0
  37. tau/builtins/extensions/header/__init__.py +18 -0
  38. tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
  39. tau/builtins/models/__init__.py +0 -0
  40. tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
  41. tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
  42. tau/builtins/models/audio.py +43 -0
  43. tau/builtins/models/image.py +43 -0
  44. tau/builtins/models/text.py +482 -0
  45. tau/builtins/models/video.py +40 -0
  46. tau/builtins/prompts/commit.md +7 -0
  47. tau/builtins/prompts/docs.md +7 -0
  48. tau/builtins/prompts/explain.md +7 -0
  49. tau/builtins/prompts/fix.md +7 -0
  50. tau/builtins/prompts/refactor.md +7 -0
  51. tau/builtins/prompts/review.md +7 -0
  52. tau/builtins/prompts/test.md +7 -0
  53. tau/builtins/providers/__init__.py +0 -0
  54. tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  55. tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
  56. tau/builtins/providers/audio.py +10 -0
  57. tau/builtins/providers/image.py +9 -0
  58. tau/builtins/providers/text.py +33 -0
  59. tau/builtins/providers/video.py +6 -0
  60. tau/builtins/skills/code-review/SKILL.md +4 -0
  61. tau/builtins/skills/debug/SKILL.md +4 -0
  62. tau/builtins/skills/git-commit/SKILL.md +4 -0
  63. tau/builtins/themes/dark.yaml +1 -0
  64. tau/builtins/themes/light.yaml +46 -0
  65. tau/builtins/tools/__init__.py +73 -0
  66. tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  67. tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  68. tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
  69. tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
  70. tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
  71. tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
  72. tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
  73. tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
  74. tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
  75. tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
  76. tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
  77. tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
  78. tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
  79. tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
  80. tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
  81. tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
  82. tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
  83. tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
  84. tau/builtins/tools/edit.py +215 -0
  85. tau/builtins/tools/glob.py +112 -0
  86. tau/builtins/tools/grep.py +146 -0
  87. tau/builtins/tools/ls.py +135 -0
  88. tau/builtins/tools/read.py +122 -0
  89. tau/builtins/tools/terminal.py +150 -0
  90. tau/builtins/tools/write.py +105 -0
  91. tau/commands/__init__.py +10 -0
  92. tau/commands/registry.py +71 -0
  93. tau/commands/types.py +33 -0
  94. tau/console/__init__.py +0 -0
  95. tau/console/cli.py +266 -0
  96. tau/console/commands/__init__.py +0 -0
  97. tau/console/commands/auth.py +193 -0
  98. tau/console/commands/packages.py +104 -0
  99. tau/console/commands/update.py +76 -0
  100. tau/core/__init__.py +0 -0
  101. tau/core/registry.py +102 -0
  102. tau/engine/__init__.py +47 -0
  103. tau/engine/service.py +768 -0
  104. tau/engine/types.py +163 -0
  105. tau/extensions/__init__.py +28 -0
  106. tau/extensions/api.py +928 -0
  107. tau/extensions/context.py +462 -0
  108. tau/extensions/events.py +70 -0
  109. tau/extensions/loader.py +386 -0
  110. tau/extensions/runtime.py +184 -0
  111. tau/extensions/settings.py +137 -0
  112. tau/hooks/__init__.py +112 -0
  113. tau/hooks/engine.py +237 -0
  114. tau/hooks/inference.py +21 -0
  115. tau/hooks/runtime.py +126 -0
  116. tau/hooks/service.py +121 -0
  117. tau/hooks/session.py +117 -0
  118. tau/hooks/tui.py +61 -0
  119. tau/hooks/types.py +72 -0
  120. tau/inference/__init__.py +180 -0
  121. tau/inference/api/__init__.py +0 -0
  122. tau/inference/api/audio/__init__.py +0 -0
  123. tau/inference/api/audio/base.py +29 -0
  124. tau/inference/api/audio/builtins.py +15 -0
  125. tau/inference/api/audio/elevenlabs_audio.py +183 -0
  126. tau/inference/api/audio/gemini_audio.py +95 -0
  127. tau/inference/api/audio/openai_audio.py +159 -0
  128. tau/inference/api/audio/registry.py +15 -0
  129. tau/inference/api/audio/sarvam_audio.py +163 -0
  130. tau/inference/api/audio/service.py +103 -0
  131. tau/inference/api/audio/utils.py +47 -0
  132. tau/inference/api/image/__init__.py +0 -0
  133. tau/inference/api/image/base.py +17 -0
  134. tau/inference/api/image/builtins.py +8 -0
  135. tau/inference/api/image/gemini_image.py +77 -0
  136. tau/inference/api/image/openai_image.py +103 -0
  137. tau/inference/api/image/openrouter.py +144 -0
  138. tau/inference/api/image/registry.py +15 -0
  139. tau/inference/api/image/service.py +71 -0
  140. tau/inference/api/registry.py +82 -0
  141. tau/inference/api/text/__init__.py +0 -0
  142. tau/inference/api/text/anthropic_claude_code.py +222 -0
  143. tau/inference/api/text/anthropic_messages.py +196 -0
  144. tau/inference/api/text/base.py +40 -0
  145. tau/inference/api/text/builtins.py +19 -0
  146. tau/inference/api/text/gemini_generate.py +234 -0
  147. tau/inference/api/text/github_copilot_chat.py +172 -0
  148. tau/inference/api/text/google_antigravity.py +522 -0
  149. tau/inference/api/text/mistral_chat.py +284 -0
  150. tau/inference/api/text/ollama_chat.py +200 -0
  151. tau/inference/api/text/openai_codex_responses.py +497 -0
  152. tau/inference/api/text/openai_completions.py +227 -0
  153. tau/inference/api/text/openai_responses.py +235 -0
  154. tau/inference/api/text/registry.py +50 -0
  155. tau/inference/api/text/service.py +297 -0
  156. tau/inference/api/text/types.py +7 -0
  157. tau/inference/api/text/utils.py +228 -0
  158. tau/inference/api/video/__init__.py +0 -0
  159. tau/inference/api/video/base.py +26 -0
  160. tau/inference/api/video/builtins.py +7 -0
  161. tau/inference/api/video/fal_video.py +119 -0
  162. tau/inference/api/video/openrouter_video.py +142 -0
  163. tau/inference/api/video/registry.py +15 -0
  164. tau/inference/api/video/service.py +72 -0
  165. tau/inference/model/__init__.py +0 -0
  166. tau/inference/model/registry.py +102 -0
  167. tau/inference/model/types.py +65 -0
  168. tau/inference/provider/__init__.py +0 -0
  169. tau/inference/provider/oauth/__init__.py +35 -0
  170. tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
  171. tau/inference/provider/oauth/github_copilot.py +333 -0
  172. tau/inference/provider/oauth/google_antigravity.py +258 -0
  173. tau/inference/provider/oauth/openai_codex.py +309 -0
  174. tau/inference/provider/oauth/pkce.py +14 -0
  175. tau/inference/provider/oauth/types.py +46 -0
  176. tau/inference/provider/oauth/utils.py +154 -0
  177. tau/inference/provider/registry.py +141 -0
  178. tau/inference/provider/types.py +114 -0
  179. tau/inference/types.py +549 -0
  180. tau/inference/utils.py +219 -0
  181. tau/message/__init__.py +0 -0
  182. tau/message/types.py +482 -0
  183. tau/message/utils.py +178 -0
  184. tau/packages/__init__.py +11 -0
  185. tau/packages/manager.py +190 -0
  186. tau/packages/types.py +20 -0
  187. tau/packages/utils.py +67 -0
  188. tau/prompts/expand.py +58 -0
  189. tau/prompts/loader.py +69 -0
  190. tau/prompts/registry.py +45 -0
  191. tau/prompts/types.py +24 -0
  192. tau/rpc/__init__.py +8 -0
  193. tau/rpc/mode.py +783 -0
  194. tau/rpc/types.py +252 -0
  195. tau/runtime/service.py +759 -0
  196. tau/runtime/types.py +303 -0
  197. tau/session/branch_summarization.py +312 -0
  198. tau/session/compaction.py +646 -0
  199. tau/session/manager.py +652 -0
  200. tau/session/types.py +188 -0
  201. tau/session/utils.py +233 -0
  202. tau/settings/manager.py +1077 -0
  203. tau/settings/paths.py +150 -0
  204. tau/settings/storage.py +63 -0
  205. tau/settings/types.py +173 -0
  206. tau/settings/utils.py +25 -0
  207. tau/skills/loader.py +91 -0
  208. tau/skills/registry.py +70 -0
  209. tau/skills/types.py +25 -0
  210. tau/themes/loader.py +238 -0
  211. tau/themes/registry.py +108 -0
  212. tau/themes/types.py +19 -0
  213. tau/tool/__init__.py +3 -0
  214. tau/tool/registry.py +117 -0
  215. tau/tool/render.py +21 -0
  216. tau/tool/types.py +244 -0
  217. tau/trust/__init__.py +13 -0
  218. tau/trust/manager.py +80 -0
  219. tau/trust/types.py +14 -0
  220. tau/trust/utils.py +72 -0
  221. tau/tui/__init__.py +54 -0
  222. tau/tui/agent_hooks.py +346 -0
  223. tau/tui/ansi.py +330 -0
  224. tau/tui/app.py +540 -0
  225. tau/tui/autocomplete.py +33 -0
  226. tau/tui/capabilities.py +119 -0
  227. tau/tui/commands/__init__.py +3 -0
  228. tau/tui/commands/appearance.py +498 -0
  229. tau/tui/commands/auth.py +232 -0
  230. tau/tui/commands/context.py +38 -0
  231. tau/tui/commands/misc.py +82 -0
  232. tau/tui/commands/model.py +118 -0
  233. tau/tui/commands/session.py +464 -0
  234. tau/tui/component.py +268 -0
  235. tau/tui/components/__init__.py +0 -0
  236. tau/tui/components/autocomplete_manager.py +267 -0
  237. tau/tui/components/autocomplete_picker.py +143 -0
  238. tau/tui/components/box.py +90 -0
  239. tau/tui/components/command_palette.py +144 -0
  240. tau/tui/components/dynamic_border.py +19 -0
  241. tau/tui/components/file_picker.py +233 -0
  242. tau/tui/components/image.py +181 -0
  243. tau/tui/components/inline_selector.py +71 -0
  244. tau/tui/components/layout.py +1194 -0
  245. tau/tui/components/message_list.py +692 -0
  246. tau/tui/components/modal.py +97 -0
  247. tau/tui/components/model_palette.py +204 -0
  248. tau/tui/components/picker_overlay.py +174 -0
  249. tau/tui/components/prompt_overlay.py +236 -0
  250. tau/tui/components/resume_modal.py +372 -0
  251. tau/tui/components/select_list.py +222 -0
  252. tau/tui/components/settings_modal.py +274 -0
  253. tau/tui/components/settings_schema.py +203 -0
  254. tau/tui/components/spinner.py +119 -0
  255. tau/tui/components/text_input.py +396 -0
  256. tau/tui/components/text_prompt.py +82 -0
  257. tau/tui/components/tree_select_list.py +580 -0
  258. tau/tui/components/trust_screen.py +97 -0
  259. tau/tui/diff.py +114 -0
  260. tau/tui/fuzzy.py +99 -0
  261. tau/tui/input.py +496 -0
  262. tau/tui/input_handler.py +716 -0
  263. tau/tui/keybindings.py +87 -0
  264. tau/tui/markdown.py +286 -0
  265. tau/tui/message_renderers.py +31 -0
  266. tau/tui/overlay.py +326 -0
  267. tau/tui/renderer.py +378 -0
  268. tau/tui/terminal.py +499 -0
  269. tau/tui/theme.py +148 -0
  270. tau/tui/tui.py +544 -0
  271. tau/tui/ui_context.py +768 -0
  272. tau/tui/utils.py +20 -0
  273. tau/utils/__init__.py +0 -0
  274. tau/utils/http_proxy.py +221 -0
  275. tau/utils/image_processing.py +172 -0
  276. tau/utils/secrets.py +59 -0
  277. tau/utils/version_check.py +60 -0
  278. tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
  279. tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
  280. tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
  281. tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  282. tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  283. tau_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ )