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,236 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable
4
+
5
+ from tau.tui.ansi import BOLD, DIM, RESET, pad, visible_width
6
+ from tau.tui.component import Component
7
+ from tau.tui.input import InputEvent, KeyEvent
8
+
9
+
10
+ def _box(inner_lines: list[str], title: str, width: int) -> list[str]:
11
+ inner_w = max(1, width - 4)
12
+ if title:
13
+ t = f" {title} "
14
+ tv = visible_width(t)
15
+ dashes = max(0, width - 2 - tv)
16
+ left_d = dashes // 2
17
+ right_d = dashes - left_d
18
+ top = "╭" + "─" * left_d + BOLD + t + RESET + "─" * right_d + "╮"
19
+ else:
20
+ top = "╭" + "─" * (width - 2) + "╮"
21
+ lines = [top]
22
+ for line in inner_lines:
23
+ lines.append("│ " + pad(line, inner_w) + " │")
24
+ lines.append("╰" + "─" * (width - 2) + "╯")
25
+ return lines
26
+
27
+
28
+ class PromptOverlay(Component):
29
+ """A floating single-line text input overlay.
30
+
31
+ Usage::
32
+
33
+ handle_ref = []
34
+
35
+ def on_commit(value):
36
+ handle_ref[0].close()
37
+ save_key(value)
38
+
39
+ def on_cancel():
40
+ handle_ref[0].close()
41
+
42
+ prompt = PromptOverlay("Enter API key", on_commit=on_commit,
43
+ on_cancel=on_cancel, secret=True)
44
+ handle = tui.show_overlay(prompt, OverlayOptions(width="50%"))
45
+ handle_ref.append(handle)
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ label: str,
51
+ on_commit: Callable[[str], None] | None = None,
52
+ on_cancel: Callable[[], None] | None = None,
53
+ secret: bool = False,
54
+ ) -> None:
55
+ self._label = label
56
+ self._on_commit = on_commit
57
+ self._on_cancel = on_cancel
58
+ self._secret = secret
59
+ self._value = ""
60
+
61
+ # ── Component ─────────────────────────────────────────────────────────────
62
+
63
+ def render(self, width: int) -> list[str]:
64
+ display = "*" * len(self._value) if self._secret else self._value
65
+ inner = [
66
+ f" {BOLD}{self._label}{RESET}",
67
+ f" {DIM}Enter to confirm · Esc to cancel{RESET}",
68
+ f" {display}█",
69
+ ]
70
+ return _box(inner, "", width)
71
+
72
+ def handle_input(self, event: InputEvent) -> bool:
73
+ if not isinstance(event, KeyEvent):
74
+ return False
75
+
76
+ match event.key:
77
+ case "enter":
78
+ val = self._value
79
+ if self._on_commit is not None:
80
+ self._on_commit(val)
81
+ case "escape":
82
+ if self._on_cancel is not None:
83
+ self._on_cancel()
84
+ case "backspace":
85
+ self._value = self._value[:-1]
86
+ case ch if len(ch) == 1 and ch.isprintable():
87
+ self._value += ch
88
+ case _:
89
+ return False
90
+
91
+ return True
92
+
93
+ def invalidate(self) -> None:
94
+ pass
95
+
96
+
97
+ class EditorOverlay(Component):
98
+ """A floating multi-line text editor overlay.
99
+
100
+ ``Ctrl+S`` or ``Ctrl+Enter`` saves; ``Escape`` cancels.
101
+ Arrow keys and Backspace work normally; Enter inserts a newline.
102
+ """
103
+
104
+ VISIBLE_ROWS = 12
105
+
106
+ def __init__(
107
+ self,
108
+ title: str,
109
+ prefill: str = "",
110
+ on_commit: Callable[[str], None] | None = None,
111
+ on_cancel: Callable[[], None] | None = None,
112
+ ) -> None:
113
+ self._title = title
114
+ self._lines: list[str] = prefill.splitlines() or [""]
115
+ self._cursor_row = len(self._lines) - 1
116
+ self._cursor_col = len(self._lines[-1])
117
+ self._scroll_top = 0
118
+ self._on_commit = on_commit
119
+ self._on_cancel = on_cancel
120
+
121
+ # ── Cursor helpers ────────────────────────────────────────────────────────
122
+
123
+ def _current_line(self) -> str:
124
+ return self._lines[self._cursor_row]
125
+
126
+ def _clamp_scroll(self) -> None:
127
+ if self._cursor_row < self._scroll_top:
128
+ self._scroll_top = self._cursor_row
129
+ elif self._cursor_row >= self._scroll_top + self.VISIBLE_ROWS:
130
+ self._scroll_top = self._cursor_row - self.VISIBLE_ROWS + 1
131
+
132
+ # ── Component ─────────────────────────────────────────────────────────────
133
+
134
+ def render(self, width: int) -> list[str]:
135
+ inner_w = max(1, width - 4)
136
+ self._clamp_scroll()
137
+
138
+ visible = self._lines[self._scroll_top: self._scroll_top + self.VISIBLE_ROWS]
139
+ rows: list[str] = []
140
+ for ri, line in enumerate(visible):
141
+ abs_row = self._scroll_top + ri
142
+ if abs_row == self._cursor_row:
143
+ before = line[: self._cursor_col]
144
+ after = line[self._cursor_col:]
145
+ content = before + "█" + after
146
+ else:
147
+ content = line
148
+ rows.append("│ " + pad(content[:inner_w], inner_w) + " │")
149
+
150
+ # scroll indicator
151
+ total = len(self._lines)
152
+ if total > self.VISIBLE_ROWS:
153
+ pct = int(self._scroll_top / max(1, total - self.VISIBLE_ROWS) * 100)
154
+ rows.append("│" + DIM + f" ↕ {pct}%".rjust(width - 2) + RESET + "│")
155
+ else:
156
+ rows.append("│" + " " * (width - 2) + "│")
157
+
158
+ hint = f" {DIM}Ctrl+S to save · Esc to cancel{RESET}"
159
+ rows.append("│ " + pad(hint, inner_w) + " │")
160
+
161
+ return _box(rows, self._title, width)
162
+
163
+ def handle_input(self, event: InputEvent) -> bool:
164
+ if not isinstance(event, KeyEvent):
165
+ return False
166
+
167
+ k = event.key
168
+ if k in ("ctrl+s", "ctrl+enter"):
169
+ text = "\n".join(self._lines)
170
+ if self._on_commit is not None:
171
+ self._on_commit(text)
172
+ return True
173
+ if k == "escape":
174
+ if self._on_cancel is not None:
175
+ self._on_cancel()
176
+ return True
177
+ if k == "enter":
178
+ line = self._lines[self._cursor_row]
179
+ before, after = line[: self._cursor_col], line[self._cursor_col:]
180
+ self._lines[self._cursor_row] = before
181
+ self._lines.insert(self._cursor_row + 1, after)
182
+ self._cursor_row += 1
183
+ self._cursor_col = 0
184
+ return True
185
+ if k == "backspace":
186
+ if self._cursor_col > 0:
187
+ line = self._lines[self._cursor_row]
188
+ self._lines[self._cursor_row] = line[: self._cursor_col - 1] + line[self._cursor_col:]
189
+ self._cursor_col -= 1
190
+ elif self._cursor_row > 0:
191
+ prev = self._lines[self._cursor_row - 1]
192
+ merged = prev + self._lines.pop(self._cursor_row)
193
+ self._cursor_row -= 1
194
+ self._cursor_col = len(prev)
195
+ self._lines[self._cursor_row] = merged
196
+ return True
197
+ if k == "up":
198
+ if self._cursor_row > 0:
199
+ self._cursor_row -= 1
200
+ self._cursor_col = min(self._cursor_col, len(self._current_line()))
201
+ return True
202
+ if k == "down":
203
+ if self._cursor_row < len(self._lines) - 1:
204
+ self._cursor_row += 1
205
+ self._cursor_col = min(self._cursor_col, len(self._current_line()))
206
+ return True
207
+ if k == "left":
208
+ if self._cursor_col > 0:
209
+ self._cursor_col -= 1
210
+ elif self._cursor_row > 0:
211
+ self._cursor_row -= 1
212
+ self._cursor_col = len(self._current_line())
213
+ return True
214
+ if k == "right":
215
+ line = self._current_line()
216
+ if self._cursor_col < len(line):
217
+ self._cursor_col += 1
218
+ elif self._cursor_row < len(self._lines) - 1:
219
+ self._cursor_row += 1
220
+ self._cursor_col = 0
221
+ return True
222
+ if k == "home":
223
+ self._cursor_col = 0
224
+ return True
225
+ if k == "end":
226
+ self._cursor_col = len(self._current_line())
227
+ return True
228
+ if len(k) == 1 and k.isprintable():
229
+ line = self._lines[self._cursor_row]
230
+ self._lines[self._cursor_row] = line[: self._cursor_col] + k + line[self._cursor_col:]
231
+ self._cursor_col += 1
232
+ return True
233
+ return False
234
+
235
+ def invalidate(self) -> None:
236
+ pass
@@ -0,0 +1,372 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Callable
7
+
8
+ _MEDIA_UUID_PATTERN = re.compile(r"\[(?:image|audio|video):([^\]]+)\]")
9
+
10
+
11
+ def _cleanup_session_media(session_path: Path) -> None:
12
+ """Delete media files that were only referenced by the given session file.
13
+
14
+ Scans the deleted session for [image/audio/video:{uuid}] markers, then checks
15
+ all sibling sessions in the same project dir. Any UUID not referenced elsewhere
16
+ has its media file removed from session_dir/media/.
17
+ """
18
+ session_dir = session_path.parent
19
+ media_dir = session_dir / "media"
20
+ if not media_dir.is_dir():
21
+ return
22
+
23
+ deleted_uuids: set[str] = set()
24
+ try:
25
+ for line in session_path.read_text(encoding="utf-8", errors="replace").splitlines():
26
+ for m in _MEDIA_UUID_PATTERN.finditer(line):
27
+ deleted_uuids.add(m.group(1))
28
+ except OSError:
29
+ return
30
+
31
+ if not deleted_uuids:
32
+ return
33
+
34
+ live_uuids: set[str] = set()
35
+ for sibling in session_dir.glob("*.jsonl"):
36
+ if sibling == session_path:
37
+ continue
38
+ try:
39
+ for line in sibling.read_text(encoding="utf-8", errors="replace").splitlines():
40
+ for m in _MEDIA_UUID_PATTERN.finditer(line):
41
+ live_uuids.add(m.group(1))
42
+ except OSError:
43
+ pass
44
+
45
+ for uid in deleted_uuids - live_uuids:
46
+ for media_file in media_dir.glob(f"{uid}.*"):
47
+ try:
48
+ media_file.unlink(missing_ok=True)
49
+ except OSError:
50
+ pass
51
+
52
+ from tau.tui.ansi import (
53
+ RESET, BOLD, DIM, BRIGHT_BLACK, BRIGHT_WHITE, BRIGHT_RED,
54
+ BRIGHT_YELLOW, CYAN,
55
+ )
56
+
57
+
58
+ def _age(dt: datetime) -> str:
59
+ """Format a datetime as a compact relative age string."""
60
+ if dt.tzinfo is None:
61
+ now = datetime.now()
62
+ else:
63
+ now = datetime.now(tz=timezone.utc)
64
+ secs = max(0, (now - dt).total_seconds())
65
+ mins = int(secs / 60)
66
+ if mins < 1:
67
+ return "now"
68
+ if mins < 60:
69
+ return f"{mins}m"
70
+ hours = mins // 60
71
+ if hours < 24:
72
+ return f"{hours}h"
73
+ days = hours // 24
74
+ if days < 7:
75
+ return f"{days}d"
76
+ if days < 30:
77
+ return f"{days // 7}w"
78
+ if days < 365:
79
+ return f"{days // 30}mo"
80
+ return f"{days // 365}y"
81
+
82
+
83
+ def _shorten(path: Path) -> str:
84
+ home = Path.home()
85
+ try:
86
+ return "~/" + str(path.relative_to(home))
87
+ except ValueError:
88
+ return str(path)
89
+
90
+
91
+ class ResumeModal:
92
+ """Session resume selector.
93
+
94
+ - Up/Down navigate
95
+ - Enter select session
96
+ - Tab toggle scope (current folder ↔ all)
97
+ - Ctrl+R cycle sort (date desc → date asc → name)
98
+ - Ctrl+D start delete-confirmation
99
+ - Enter/Esc confirm/cancel delete
100
+ - Type search by name / id
101
+ - Backspace delete last search char
102
+ - Escape cancel (when not in delete-confirmation)
103
+ """
104
+
105
+ _SORT_LABELS = ["Recent", "Oldest", "Name"]
106
+
107
+ def __init__(
108
+ self,
109
+ current_sessions: list,
110
+ all_sessions_loader: Callable[[], list],
111
+ current_session_path: Path | None = None,
112
+ max_visible: int = 10,
113
+ ) -> None:
114
+ self._current = list(current_sessions)
115
+ self._all_loader = all_sessions_loader
116
+ self._all: list | None = None
117
+ self._cur_path = current_session_path
118
+ self._max_visible = max_visible
119
+
120
+ self._scope = "current" # "current" | "all"
121
+ self._sort_idx = 0 # index into _SORT_LABELS
122
+ self._search = ""
123
+ self._filtered: list = []
124
+ self._selected = 0
125
+
126
+ self._confirming_delete: Path | None = None
127
+ self._status_msg: str = ""
128
+
129
+ self._refilter()
130
+
131
+ # ── Public state ──────────────────────────────────────────────────────────
132
+
133
+ @property
134
+ def confirming_delete(self) -> bool:
135
+ return self._confirming_delete is not None
136
+
137
+ def selected_path(self) -> Path | None:
138
+ if not self._filtered:
139
+ return None
140
+ s = self._filtered[self._selected]
141
+ return Path(s.path) if not isinstance(s.path, Path) else s.path
142
+
143
+ # ── Navigation ────────────────────────────────────────────────────────────
144
+
145
+ def move_up(self) -> None:
146
+ if self._confirming_delete is None and self._filtered:
147
+ self._selected = max(0, self._selected - 1)
148
+ self._status_msg = ""
149
+
150
+ def move_down(self) -> None:
151
+ if self._confirming_delete is None and self._filtered:
152
+ self._selected = min(len(self._filtered) - 1, self._selected + 1)
153
+ self._status_msg = ""
154
+
155
+ def toggle_scope(self) -> None:
156
+ if self._confirming_delete is not None:
157
+ return
158
+ if self._scope == "current":
159
+ self._scope = "all"
160
+ if self._all is None:
161
+ try:
162
+ self._all = list(self._all_loader())
163
+ except Exception:
164
+ self._all = []
165
+ else:
166
+ self._scope = "current"
167
+ self._selected = 0
168
+ self._refilter()
169
+
170
+ def cycle_sort(self) -> None:
171
+ if self._confirming_delete is not None:
172
+ return
173
+ self._sort_idx = (self._sort_idx + 1) % len(self._SORT_LABELS)
174
+ self._refilter()
175
+
176
+ def start_delete(self) -> None:
177
+ if not self._filtered:
178
+ return
179
+ sel = self._filtered[self._selected]
180
+ sel_path = Path(sel.path) if not isinstance(sel.path, Path) else sel.path
181
+ if self._cur_path and sel_path == self._cur_path:
182
+ self._status_msg = "Cannot delete the active session"
183
+ return
184
+ self._confirming_delete = sel_path
185
+
186
+ def confirm_delete(self) -> None:
187
+ path = self._confirming_delete
188
+ self._confirming_delete = None
189
+ if path is None:
190
+ return
191
+ try:
192
+ _cleanup_session_media(path)
193
+ path.unlink(missing_ok=True)
194
+ self._current = [s for s in self._current if Path(s.path) != path]
195
+ if self._all is not None:
196
+ self._all = [s for s in self._all if Path(s.path) != path]
197
+ self._refilter()
198
+ self._selected = min(self._selected, max(0, len(self._filtered) - 1))
199
+ self._status_msg = "Session deleted"
200
+ except Exception as exc:
201
+ self._status_msg = f"Delete failed: {exc}"
202
+
203
+ def cancel_delete(self) -> None:
204
+ self._confirming_delete = None
205
+
206
+ # ── Search ────────────────────────────────────────────────────────────────
207
+
208
+ def append_search(self, ch: str) -> None:
209
+ if self._confirming_delete is not None:
210
+ return
211
+ self._search += ch
212
+ self._selected = 0
213
+ self._refilter()
214
+
215
+ def backspace_search(self) -> None:
216
+ if self._confirming_delete is not None:
217
+ return
218
+ if self._search:
219
+ self._search = self._search[:-1]
220
+ self._selected = 0
221
+ self._refilter()
222
+
223
+ # ── Render ────────────────────────────────────────────────────────────────
224
+
225
+ def render(self, width: int) -> list[str]:
226
+ divider = BRIGHT_BLACK + "─" * width + RESET
227
+ lines: list[str] = []
228
+
229
+ # Header bar
230
+ scope_label = (
231
+ f"{BRIGHT_WHITE}◉ Folder{RESET} {DIM}○ All{RESET}"
232
+ if self._scope == "current"
233
+ else f"{DIM}○ Folder{RESET} {BRIGHT_WHITE}◉ All{RESET}"
234
+ )
235
+ sort_label = f"{DIM}Sort:{RESET} {CYAN}{self._SORT_LABELS[self._sort_idx]}{RESET}"
236
+ header_right = f"{scope_label} {sort_label}"
237
+ title_left = f" {BOLD}{BRIGHT_WHITE}Resume Session{RESET}"
238
+ # Truncate right to fit
239
+ right_plain_len = _visible_len(header_right)
240
+ pad = max(0, width - _visible_len(title_left) - right_plain_len - 1)
241
+ lines.append(title_left + " " * pad + header_right)
242
+
243
+ # Hints
244
+ if self._confirming_delete is not None:
245
+ del_path = self._confirming_delete
246
+ short = _shorten(del_path)[:width - 20]
247
+ lines.append(
248
+ f" {BRIGHT_RED}Delete '{short}'? enter=yes esc=no{RESET}"
249
+ )
250
+ elif self._status_msg:
251
+ lines.append(f" {BRIGHT_YELLOW}{self._status_msg}{RESET}")
252
+ else:
253
+ hints = (
254
+ f" {DIM}tab scope ctrl+r sort ctrl+d delete type search esc cancel{RESET}"
255
+ )
256
+ lines.append(hints)
257
+
258
+ # Search line
259
+ if self._search:
260
+ lines.append(f" {DIM}/{self._search}█{RESET}")
261
+
262
+ lines.append(divider)
263
+
264
+ # Session list
265
+ if not self._filtered:
266
+ if self._search:
267
+ lines.append(f" {DIM}No sessions match '{self._search}'{RESET}")
268
+ elif self._scope == "current":
269
+ lines.append(f" {DIM}No sessions in current folder — press Tab for all{RESET}")
270
+ else:
271
+ lines.append(f" {DIM}No sessions found{RESET}")
272
+ else:
273
+ count = len(self._filtered)
274
+ visible = min(self._max_visible, count)
275
+ start = max(0, min(self._selected - visible // 2, count - visible))
276
+
277
+ if start > 0:
278
+ lines.append(f" {DIM}↑ {start} more{RESET}")
279
+
280
+ show_cwd = self._scope == "all"
281
+
282
+ for i in range(start, min(start + visible, count)):
283
+ session = self._filtered[i]
284
+ is_sel = i == self._selected
285
+ sel_path = Path(session.path) if not isinstance(session.path, Path) else session.path
286
+ is_del_target = sel_path == self._confirming_delete
287
+
288
+ display = session.name or session.id[:20]
289
+ age_str = _age(session.modified)
290
+ count_str = str(getattr(session, "message_count", 0))
291
+
292
+ right_parts = [count_str, age_str]
293
+ if show_cwd and hasattr(session, "cwd") and session.cwd:
294
+ right_parts.insert(0, _shorten(Path(session.cwd)))
295
+ right = " ".join(right_parts)
296
+
297
+ cursor = "→ " if is_sel else " "
298
+ right_len = _visible_len(right)
299
+ available = width - 4 - right_len - 2
300
+ msg = display[:max(8, available)]
301
+
302
+ if is_del_target:
303
+ msg_styled = f"{BRIGHT_RED}{BOLD}{msg}{RESET}"
304
+ right_styled = f"{BRIGHT_RED}{right}{RESET}"
305
+ elif is_sel:
306
+ msg_styled = f"{BOLD}{BRIGHT_WHITE}{msg}{RESET}"
307
+ right_styled = f"{DIM}{right}{RESET}"
308
+ elif session.name:
309
+ msg_styled = f"{BRIGHT_YELLOW}{msg}{RESET}"
310
+ right_styled = f"{DIM}{right}{RESET}"
311
+ else:
312
+ msg_styled = msg
313
+ right_styled = f"{DIM}{right}{RESET}"
314
+
315
+ cursor_styled = f"{BRIGHT_WHITE}{cursor}{RESET}" if is_sel else cursor
316
+ left = cursor_styled + msg_styled
317
+ spacing = max(1, width - _visible_len(left) - right_len)
318
+ lines.append(left + " " * spacing + right_styled)
319
+
320
+ remaining = count - (start + visible)
321
+ if remaining > 0:
322
+ lines.append(f" {DIM}↓ {remaining} more{RESET}")
323
+
324
+ lines.append(divider)
325
+ return lines
326
+
327
+ # ── Internal ──────────────────────────────────────────────────────────────
328
+
329
+ def _active_sessions(self) -> list:
330
+ if self._scope == "all" and self._all is not None:
331
+ return self._all
332
+ return self._current
333
+
334
+ def _refilter(self) -> None:
335
+ sessions = self._active_sessions()
336
+ q = self._search.lower()
337
+
338
+ if q:
339
+ filtered = [
340
+ s for s in sessions
341
+ if q in (s.name or "").lower()
342
+ or q in s.id.lower()
343
+ or q in str(getattr(s, "cwd", "")).lower()
344
+ ]
345
+ else:
346
+ filtered = list(sessions)
347
+
348
+ # Exclude active session from resume list
349
+ if self._cur_path:
350
+ filtered = [
351
+ s for s in filtered
352
+ if (Path(s.path) if not isinstance(s.path, Path) else s.path) != self._cur_path
353
+ ]
354
+
355
+ # Sort
356
+ label = self._SORT_LABELS[self._sort_idx]
357
+ if label == "Recent":
358
+ filtered.sort(key=lambda s: s.modified.timestamp(), reverse=True)
359
+ elif label == "Oldest":
360
+ filtered.sort(key=lambda s: s.modified.timestamp())
361
+ elif label == "Name":
362
+ filtered.sort(key=lambda s: (s.name or s.id).lower())
363
+
364
+ self._filtered = filtered
365
+ self._selected = min(self._selected, max(0, len(filtered) - 1))
366
+
367
+
368
+ def _visible_len(s: str) -> int:
369
+ """Approximate visible terminal width of a string (strips ANSI escapes)."""
370
+ import re
371
+ plain = re.sub(r"\x1b\[[0-9;]*[mK]|\x1b\][^\x07]*\x07", "", s)
372
+ return len(plain)