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,580 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Callable, Generic, Literal, TypeVar
5
+
6
+ from tau.tui.ansi import BOLD, RESET, truncate, visible_width
7
+ from tau.tui.fuzzy import fuzzy_filter
8
+
9
+ T = TypeVar("T")
10
+
11
+ ColorFn = Callable[[str], str]
12
+
13
+ FilterMode = Literal["default", "no-tools", "user-only", "labeled-only", "all"]
14
+
15
+ _TOOL_ROLES = frozenset({
16
+ "tool", "terminal", "terminal_execution", "error", "skill", "template",
17
+ })
18
+
19
+ _SETTINGS_ROLES = frozenset({"label", "model", "thinking_level"})
20
+
21
+
22
+ @dataclass
23
+ class TreeRow(Generic[T]):
24
+ """A single row in a TreeSelectList: tree connectors + a role-colored snippet."""
25
+ prefix: str
26
+ role: str
27
+ text: str
28
+ on_active_path: bool = False
29
+ is_current: bool = False
30
+ selectable: bool = True
31
+ value: T | None = None
32
+ parent_value: T | None = None # actual parent entry ID (for visible-tree maps)
33
+ has_children: bool = False # has children in the full (unfiltered) tree
34
+ label: str | None = None
35
+ label_timestamp: str | None = None
36
+ search_text: str = field(default="")
37
+
38
+ def __post_init__(self) -> None:
39
+ if not self.search_text:
40
+ self.search_text = f"{self.role} {self.text}"
41
+
42
+
43
+ class TreeSelectList(Generic[T]):
44
+ """
45
+ Picker that renders full pre-built rows (tree connectors + role-colored
46
+ content) on one line each. Supports fuzzy search, filter modes, folding,
47
+ labels, and centered-viewport scrolling.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ rows: list[TreeRow[T]],
53
+ role_color: Callable[[str, str], ColorFn],
54
+ accent_color: ColorFn,
55
+ dim_color: ColorFn,
56
+ max_visible: int = 10,
57
+ selected_bg: ColorFn | None = None,
58
+ on_label_change: Callable[[T, str | None], None] | None = None,
59
+ ) -> None:
60
+ self._all_rows: list[TreeRow[T]] = rows
61
+ self._filtered: list[TreeRow[T]] = list(rows)
62
+ self._role_color = role_color
63
+ self._accent_color = accent_color
64
+ self._dim_color = dim_color
65
+ self._max_visible = max(1, max_visible)
66
+ self._selected_bg = selected_bg
67
+ self._on_label_change = on_label_change
68
+
69
+ # Selection state
70
+ self._selected = next((i for i, r in enumerate(rows) if r.is_current), 0)
71
+ self._last_selected_value: T | None = None
72
+
73
+ # Search / filter
74
+ self._query = ""
75
+ self._filter_mode: FilterMode = "default"
76
+
77
+ # Fold state
78
+ self._folded_nodes: set[T] = set()
79
+
80
+ # Visible tree maps (rebuilt after each filter/fold change)
81
+ # These maps reflect which nodes are VISIBLE (not filtered or folded away).
82
+ self._visible_parent_map: dict[T, T | None] = {}
83
+ self._visible_children_map: dict[T | None, list[T]] = {}
84
+
85
+ # In-memory label store: value → (label_text, iso_timestamp)
86
+ self._labels: dict[T, tuple[str, str]] = {}
87
+ self._show_label_timestamps: bool = False
88
+
89
+ # Label editing state
90
+ self._label_editing: bool = False
91
+ self._label_input: str = ""
92
+ self._label_editing_value: T | None = None
93
+
94
+ self._apply_filter()
95
+
96
+ # ------------------------------------------------------------------
97
+ # Public interface (duck-types SelectList subset used by InlineSelector)
98
+ # ------------------------------------------------------------------
99
+
100
+ @property
101
+ def selected_item(self) -> TreeRow[T] | None:
102
+ if not self._filtered:
103
+ return None
104
+ return self._filtered[self._selected]
105
+
106
+ @property
107
+ def label_editing(self) -> bool:
108
+ return self._label_editing
109
+
110
+ def move_up(self) -> None:
111
+ self._move(-1)
112
+
113
+ def move_down(self) -> None:
114
+ self._move(1)
115
+
116
+ def page_up(self) -> None:
117
+ self._selected = max(0, self._selected - self._max_visible)
118
+
119
+ def page_down(self) -> None:
120
+ n = len(self._filtered)
121
+ if n:
122
+ self._selected = min(n - 1, self._selected + self._max_visible)
123
+
124
+ def fold_or_up(self) -> None:
125
+ """Fold the current node if foldable and open; else jump to segment start upward."""
126
+ item = self.selected_item
127
+ if item is None or item.value is None:
128
+ return
129
+ val = item.value
130
+ if self._is_foldable(val) and val not in self._folded_nodes:
131
+ self._folded_nodes.add(val)
132
+ self._apply_filter()
133
+ else:
134
+ self._selected = self._find_branch_segment_start("up")
135
+
136
+ def unfold_or_down(self) -> None:
137
+ """Unfold the current node if folded; else jump to segment start downward."""
138
+ item = self.selected_item
139
+ if item is None or item.value is None:
140
+ return
141
+ val = item.value
142
+ if val in self._folded_nodes:
143
+ self._folded_nodes.discard(val)
144
+ self._apply_filter()
145
+ else:
146
+ self._selected = self._find_branch_segment_start("down")
147
+
148
+ def cycle_filter(self) -> None:
149
+ modes: list[FilterMode] = ["default", "no-tools", "user-only", "labeled-only", "all"]
150
+ idx = modes.index(self._filter_mode)
151
+ self._filter_mode = modes[(idx + 1) % len(modes)]
152
+ self._folded_nodes.clear()
153
+ self._apply_filter()
154
+
155
+ def set_filter(self, mode: FilterMode) -> None:
156
+ self._filter_mode = mode
157
+ self._folded_nodes.clear()
158
+ self._apply_filter()
159
+
160
+ def toggle_filter(self, mode: FilterMode) -> None:
161
+ self._filter_mode = "default" if self._filter_mode == mode else mode
162
+ self._folded_nodes.clear()
163
+ self._apply_filter()
164
+
165
+ def set_query(self, query: str) -> None:
166
+ if query == self._query:
167
+ return
168
+ self._query = query
169
+ self._folded_nodes.clear()
170
+ self._apply_filter()
171
+
172
+ def toggle_label_timestamps(self) -> None:
173
+ self._show_label_timestamps = not self._show_label_timestamps
174
+
175
+ # ------------------------------------------------------------------
176
+ # Label editing
177
+ # ------------------------------------------------------------------
178
+
179
+ def start_label_edit(self) -> None:
180
+ item = self.selected_item
181
+ if item is None or item.value is None:
182
+ return
183
+ self._label_editing = True
184
+ self._label_editing_value = item.value
185
+ existing = self._labels.get(item.value)
186
+ self._label_input = existing[0] if existing else (item.label or "")
187
+
188
+ def label_edit_key(self, event: object) -> None:
189
+ """Route a KeyEvent into the label editing sub-mode."""
190
+ key = getattr(event, "key", "")
191
+ char = getattr(event, "char", None)
192
+ ctrl = getattr(event, "ctrl", False)
193
+ alt = getattr(event, "alt", False)
194
+ if key == "enter":
195
+ self.commit_label_edit()
196
+ elif key in ("escape",) or (key == "c" and ctrl):
197
+ self.cancel_label_edit()
198
+ elif key == "backspace":
199
+ self._label_input = self._label_input[:-1]
200
+ elif char and char.isprintable() and not ctrl and not alt:
201
+ self._label_input += char
202
+
203
+ def commit_label_edit(self) -> None:
204
+ if self._label_editing_value is not None:
205
+ text = self._label_input.strip() or None
206
+ self._set_label(self._label_editing_value, text)
207
+ self._label_editing = False
208
+ self._label_input = ""
209
+ self._label_editing_value = None
210
+
211
+ def cancel_label_edit(self) -> None:
212
+ self._label_editing = False
213
+ self._label_input = ""
214
+ self._label_editing_value = None
215
+
216
+ def update_label(self, value: T, label: str | None, timestamp: str | None = None) -> None:
217
+ """External update (e.g. from session storage on load)."""
218
+ self._set_label(value, label, timestamp)
219
+
220
+ # ------------------------------------------------------------------
221
+ # Internal helpers
222
+ # ------------------------------------------------------------------
223
+
224
+ def _set_label(self, value: T, label: str | None, timestamp: str | None = None) -> None:
225
+ if label:
226
+ from datetime import datetime, timezone
227
+ ts = timestamp or datetime.now(tz=timezone.utc).isoformat()
228
+ self._labels[value] = (label, ts)
229
+ else:
230
+ self._labels.pop(value, None)
231
+ # Propagate to the all_rows entry so search_text picks up the label
232
+ for row in self._all_rows:
233
+ if row.value == value:
234
+ row.label = label
235
+ row.label_timestamp = self._labels.get(value, (None, None))[1] # type: ignore[misc]
236
+ break
237
+ if self._on_label_change is not None:
238
+ self._on_label_change(value, label)
239
+
240
+ def _move(self, direction: int) -> None:
241
+ n = len(self._filtered)
242
+ if not n:
243
+ return
244
+ for _ in range(n):
245
+ self._selected = (self._selected + direction) % n
246
+ if self._filtered[self._selected].selectable:
247
+ break
248
+
249
+ def _build_all_children_map(self) -> dict[T, list[T]]:
250
+ """parent_value → [child values] across ALL rows (for fold descendant exclusion)."""
251
+ m: dict[T, list[T]] = {}
252
+ for r in self._all_rows:
253
+ if r.value is not None and r.parent_value is not None:
254
+ m.setdefault(r.parent_value, []).append(r.value)
255
+ return m
256
+
257
+ def _build_visible_maps(self) -> None:
258
+ """Rebuild _visible_parent_map and _visible_children_map from _filtered."""
259
+ visible_values: set[T] = {r.value for r in self._filtered if r.value is not None}
260
+ all_by_value: dict[T, TreeRow[T]] = {
261
+ r.value: r for r in self._all_rows if r.value is not None
262
+ }
263
+
264
+ def find_visible_ancestor(val: T) -> T | None:
265
+ row = all_by_value.get(val)
266
+ if row is None:
267
+ return None
268
+ parent = row.parent_value
269
+ while parent is not None:
270
+ if parent in visible_values:
271
+ return parent
272
+ prow = all_by_value.get(parent)
273
+ if prow is None:
274
+ break
275
+ parent = prow.parent_value
276
+ return None
277
+
278
+ parent_map: dict[T, T | None] = {}
279
+ children_map: dict[T | None, list[T]] = {None: []}
280
+
281
+ for row in self._filtered:
282
+ if row.value is None:
283
+ continue
284
+ anc = find_visible_ancestor(row.value)
285
+ parent_map[row.value] = anc
286
+ children_map.setdefault(anc, []).append(row.value)
287
+
288
+ self._visible_parent_map = parent_map
289
+ self._visible_children_map = children_map
290
+
291
+ def _apply_filter(self) -> None:
292
+ """Rebuild _filtered from _all_rows, applying mode + folds + fuzzy query."""
293
+ # Save current selection value for preservation
294
+ if self._filtered and 0 <= self._selected < len(self._filtered):
295
+ self._last_selected_value = self._filtered[self._selected].value
296
+
297
+ rows = self._all_rows
298
+
299
+ if self._filter_mode == "default":
300
+ rows = [
301
+ r for r in rows
302
+ if r.role not in _TOOL_ROLES
303
+ and r.role not in _SETTINGS_ROLES
304
+ and not r.role.startswith("info:")
305
+ and r.selectable
306
+ ]
307
+ elif self._filter_mode == "no-tools":
308
+ rows = [r for r in rows if r.role not in _TOOL_ROLES]
309
+ elif self._filter_mode == "user-only":
310
+ rows = [r for r in rows if r.role == "user"]
311
+ elif self._filter_mode == "labeled-only":
312
+ rows = [
313
+ r for r in rows
314
+ if r.value is not None and (
315
+ r.value in self._labels or r.label is not None
316
+ )
317
+ ]
318
+ # "all": keep everything
319
+
320
+ # Exclude descendants of folded nodes
321
+ if self._folded_nodes:
322
+ all_children = self._build_all_children_map()
323
+ skip: set[T] = set()
324
+ queue: list[T] = []
325
+ for fid in self._folded_nodes:
326
+ queue.extend(all_children.get(fid, []))
327
+ while queue:
328
+ nid = queue.pop()
329
+ skip.add(nid)
330
+ queue.extend(all_children.get(nid, []))
331
+ rows = [r for r in rows if r.value not in skip]
332
+
333
+ if self._query:
334
+ rows = fuzzy_filter(rows, self._query, lambda r: r.search_text)
335
+
336
+ self._filtered = rows
337
+
338
+ # Restore selection: exact match first, then nearest visible ancestor
339
+ if self._last_selected_value is not None and rows:
340
+ for i, r in enumerate(rows):
341
+ if r.value == self._last_selected_value:
342
+ self._selected = i
343
+ self._build_visible_maps()
344
+ return
345
+ self._selected = self._find_nearest_visible_index(self._last_selected_value)
346
+ else:
347
+ self._selected = min(self._selected, max(0, len(rows) - 1)) if rows else 0
348
+
349
+ self._build_visible_maps()
350
+
351
+ def _find_nearest_visible_index(self, value: T) -> int:
352
+ """Walk up the parent chain until we find a visible ancestor; fallback to last."""
353
+ if not self._filtered:
354
+ return 0
355
+ visible: dict[T, int] = {
356
+ r.value: i for i, r in enumerate(self._filtered) if r.value is not None
357
+ }
358
+ all_by_value: dict[T, TreeRow[T]] = {
359
+ r.value: r for r in self._all_rows if r.value is not None
360
+ }
361
+ cur: T | None = value
362
+ while cur is not None:
363
+ if cur in visible:
364
+ return visible[cur]
365
+ row = all_by_value.get(cur)
366
+ if row is None:
367
+ break
368
+ cur = row.parent_value
369
+ return max(0, len(self._filtered) - 1)
370
+
371
+ def _is_foldable(self, value: T) -> bool:
372
+ """A node is foldable if it has visible children AND is a root or segment start."""
373
+ children = self._visible_children_map.get(value)
374
+ if not children:
375
+ return False
376
+ parent = self._visible_parent_map.get(value)
377
+ if parent is None:
378
+ return True # root node is always foldable if it has children
379
+ siblings = self._visible_children_map.get(parent)
380
+ return siblings is not None and len(siblings) > 1
381
+
382
+ def _find_branch_segment_start(self, direction: str) -> int:
383
+ """Jump to the start or end of the current branch segment."""
384
+ item = self.selected_item
385
+ if item is None or item.value is None:
386
+ return self._selected
387
+
388
+ index_by_value: dict[T, int] = {
389
+ r.value: i for i, r in enumerate(self._filtered) if r.value is not None
390
+ }
391
+ current = item.value
392
+
393
+ if direction == "down":
394
+ while True:
395
+ children = self._visible_children_map.get(current, [])
396
+ if not children:
397
+ return index_by_value.get(current, self._selected)
398
+ if len(children) > 1:
399
+ return index_by_value.get(children[0], self._selected)
400
+ current = children[0]
401
+ else: # up
402
+ while True:
403
+ parent = self._visible_parent_map.get(current)
404
+ if parent is None:
405
+ return index_by_value.get(current, self._selected)
406
+ siblings = self._visible_children_map.get(parent, [])
407
+ if len(siblings) > 1:
408
+ seg = index_by_value.get(current, self._selected)
409
+ if seg < self._selected:
410
+ return seg
411
+ current = parent
412
+
413
+ def _scroll_start(self) -> int:
414
+ """Center the selected row in the viewport."""
415
+ count = len(self._filtered)
416
+ visible = min(self._max_visible, count)
417
+ center = self._selected - visible // 2
418
+ return max(0, min(center, count - visible))
419
+
420
+ def _status_label(self) -> str:
421
+ match self._filter_mode:
422
+ case "default": return ""
423
+ case "no-tools": return " [no-tools]"
424
+ case "user-only": return " [user]"
425
+ case "labeled-only": return " [labeled]"
426
+ case "all": return " [all]"
427
+ return ""
428
+
429
+ @staticmethod
430
+ def _help_lines(dim: ColorFn, width: int) -> list[str]:
431
+ """Render key-hint chunks onto as many lines as needed (greedy wrap)."""
432
+ hints = [
433
+ "↑/↓ move",
434
+ "←/→ fold/expand",
435
+ "pgup/pgdn page",
436
+ "shift+L label",
437
+ "shift+T label time",
438
+ "ctrl+d/t/u/l/a filters",
439
+ "ctrl+f cycle",
440
+ "type to search",
441
+ ]
442
+ sep = " · "
443
+ indent = " "
444
+ out: list[str] = []
445
+ current = ""
446
+ for hint in hints:
447
+ candidate = (indent + hint) if not current else (current + sep + hint)
448
+ if not current or len(candidate) <= width:
449
+ current = candidate
450
+ else:
451
+ out.append(dim(current))
452
+ current = indent + hint
453
+ if current:
454
+ out.append(dim(current))
455
+ return out
456
+
457
+ @staticmethod
458
+ def _format_label_ts(ts: str) -> str:
459
+ """Format a label ISO timestamp (HH:MM or M/D HH:MM or YY/M/D HH:MM)."""
460
+ try:
461
+ from datetime import datetime, timezone
462
+ dt = datetime.fromisoformat(ts)
463
+ now = datetime.now(tz=timezone.utc)
464
+ hh_mm = dt.strftime("%H:%M")
465
+ if dt.date() == now.date():
466
+ return hh_mm
467
+ if dt.year == now.year:
468
+ return f"{dt.month}/{dt.day} {hh_mm}"
469
+ return f"{str(dt.year)[-2:]}/{dt.month}/{dt.day} {hh_mm}"
470
+ except Exception:
471
+ return ts
472
+
473
+ # ------------------------------------------------------------------
474
+ # Render
475
+ # ------------------------------------------------------------------
476
+
477
+ def render(self, width: int) -> list[str]:
478
+ title = f"{BOLD} Session Tree{RESET}"
479
+
480
+ if self._query:
481
+ search_display = (
482
+ self._dim_color(" Type to search: ")
483
+ + self._accent_color(self._query)
484
+ + self._dim_color("█")
485
+ )
486
+ else:
487
+ search_display = self._dim_color(" Type to search:")
488
+
489
+ lines: list[str] = (
490
+ [title]
491
+ + self._help_lines(self._dim_color, width)
492
+ + [search_display, self._dim_color("─" * width), ""]
493
+ )
494
+
495
+ # Label-editing sub-mode: replace tree items with input prompt
496
+ if self._label_editing:
497
+ lines.append(self._dim_color(" Label (empty to remove):"))
498
+ lines.append(f" {self._label_input}█")
499
+ lines.append(self._dim_color(" enter: save · esc: cancel"))
500
+ lines.append("")
501
+ return lines
502
+
503
+ items = self._filtered
504
+ if not items:
505
+ lines.append(self._dim_color(" no matches"))
506
+ lines.append(self._dim_color(f" (0/0){self._status_label()}"))
507
+ lines.append("")
508
+ return lines
509
+
510
+ count = len(items)
511
+ visible = min(self._max_visible, count)
512
+ start = self._scroll_start()
513
+ end = start + visible
514
+
515
+ for i in range(start, end):
516
+ row = items[i]
517
+ is_sel = i == self._selected
518
+ val = row.value
519
+
520
+ cursor = self._accent_color("› ") if is_sel else " "
521
+
522
+ # Fold marker: replace ─ in connector with ⊟ (foldable) or ⊞ (folded)
523
+ is_folded = val in self._folded_nodes if val is not None else False
524
+ is_foldable = self._is_foldable(val) if val is not None else False
525
+ has_connector = "├─" in row.prefix or "└─" in row.prefix
526
+ raw_prefix = row.prefix
527
+ if has_connector:
528
+ if is_folded:
529
+ raw_prefix = raw_prefix.replace("├─", "├⊞", 1).replace("└─", "└⊞", 1)
530
+ elif is_foldable:
531
+ raw_prefix = raw_prefix.replace("├─", "├⊟", 1).replace("└─", "└⊟", 1)
532
+ prefix = self._dim_color(raw_prefix)
533
+
534
+ # Folded root nodes (no connector) get a ⊞ fold marker before the path marker
535
+ fold_root_marker = ""
536
+ if is_folded and not has_connector:
537
+ fold_root_marker = self._accent_color("⊞ ")
538
+
539
+ # Active path: "" for non-active nodes (no reserved space)
540
+ marker = self._accent_color("• ") if row.on_active_path else ""
541
+
542
+ # Label (in-memory override takes precedence over row.label)
543
+ label_str = ""
544
+ stored = self._labels.get(val) if val is not None else None
545
+ lbl_text = (stored[0] if stored else None) or row.label
546
+ if lbl_text:
547
+ label_str = f"\x1b[33m[{lbl_text}]\x1b[0m " # warning yellow
548
+ if self._show_label_timestamps:
549
+ ts = (stored[1] if stored else None) or row.label_timestamp
550
+ if ts:
551
+ label_str += self._dim_color(self._format_label_ts(ts) + " ")
552
+
553
+ # Entry content
554
+ color = self._dim_color if not row.selectable else self._role_color(row.role, row.text)
555
+ if row.role in ("user", "assistant"):
556
+ content = color(f"{row.role}: ") + row.text
557
+ elif row.text:
558
+ content = color(f"[{row.role}]: {row.text}")
559
+ else:
560
+ content = color(f"[{row.role}]")
561
+ if not row.selectable:
562
+ content += self._dim_color(" (pending tool result — can't branch here)")
563
+ elif row.is_current:
564
+ content += self._dim_color(" (current)")
565
+
566
+ line = truncate(cursor + prefix + fold_root_marker + marker + label_str + content, width)
567
+ if is_sel and self._selected_bg is not None:
568
+ fill = max(0, width - visible_width(line))
569
+ line = self._selected_bg(line + " " * fill)
570
+
571
+ lines.append(line)
572
+
573
+ # counter then blank line
574
+ status = self._status_label()
575
+ if self._show_label_timestamps:
576
+ status += " [+label time]"
577
+ lines.append(self._dim_color(f" ({self._selected + 1}/{count}){status}"))
578
+ lines.append("")
579
+
580
+ return lines
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable, TYPE_CHECKING
4
+
5
+ from tau.tui.ansi import RESET, BOLD, BRIGHT_BLACK, BRIGHT_WHITE, CYAN
6
+ from tau.tui.component import Component
7
+ from tau.tui.input import InputEvent, KeyEvent
8
+
9
+ if TYPE_CHECKING:
10
+ from tau.trust.manager import TrustOption
11
+
12
+
13
+ class TrustScreen(Component):
14
+ """Full-screen trust prompt shown before the normal TUI layout.
15
+
16
+ Replaces the TUI root until the user makes a trust decision.
17
+ Navigation: up/down arrows, Enter to confirm, Esc to cancel.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ cwd: str,
23
+ options: list[TrustOption],
24
+ on_commit: Callable[[TrustOption | None], None],
25
+ ) -> None:
26
+ self._cwd = cwd
27
+ self._options = options
28
+ self._selected = 0
29
+ self._on_commit = on_commit
30
+
31
+ # -------------------------------------------------------------------------
32
+ # Component
33
+ # -------------------------------------------------------------------------
34
+
35
+ def render(self, width: int) -> list[str]:
36
+ lines: list[str] = []
37
+ indent = " "
38
+
39
+ lines.append("")
40
+ lines.append("")
41
+
42
+ lines.append(indent + BOLD + BRIGHT_WHITE + "Trust project folder?" + RESET)
43
+ lines.append("")
44
+
45
+ cwd_display = self._cwd
46
+ if len(cwd_display) > width - len(indent) - 2:
47
+ cwd_display = "…" + cwd_display[-(width - len(indent) - 3):]
48
+ lines.append(indent + CYAN + cwd_display + RESET)
49
+ lines.append("")
50
+
51
+ lines.append(indent + BRIGHT_BLACK + "This allows tau to load .py settings and resources," + RESET)
52
+ lines.append(indent + BRIGHT_BLACK + "install missing project packages, and run project extensions." + RESET)
53
+ lines.append("")
54
+ lines.append("")
55
+
56
+ for i, opt in enumerate(self._options):
57
+ is_sel = i == self._selected
58
+ prefix = "› " if is_sel else " "
59
+ label = opt.label
60
+ row = indent + prefix + label
61
+ if is_sel:
62
+ row = BOLD + BRIGHT_WHITE + row + RESET
63
+ else:
64
+ row = BRIGHT_BLACK + row + RESET
65
+ lines.append(row)
66
+
67
+ lines.append("")
68
+ lines.append("")
69
+
70
+ lines.append(indent + BRIGHT_BLACK + "↑↓ navigate · Enter select · Esc cancel" + RESET)
71
+
72
+ return lines
73
+
74
+ def handle_input(self, event: InputEvent) -> bool:
75
+ if not isinstance(event, KeyEvent):
76
+ return False
77
+
78
+ if event.key == "up":
79
+ self._selected = (self._selected - 1) % len(self._options)
80
+ return True
81
+
82
+ if event.key == "down":
83
+ self._selected = (self._selected + 1) % len(self._options)
84
+ return True
85
+
86
+ if event.key in ("enter", "return"):
87
+ self._on_commit(self._options[self._selected])
88
+ return True
89
+
90
+ if event.key == "escape":
91
+ self._on_commit(None)
92
+ return True
93
+
94
+ return False
95
+
96
+ def invalidate(self) -> None:
97
+ pass