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
tau/tui/agent_hooks.py ADDED
@@ -0,0 +1,346 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import Callable, TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from tau.runtime.service import Runtime
9
+ from tau.tui.components.layout import Layout
10
+ from tau.tui.components.message_list import MessageBlock
11
+ from tau.tui.tui import TUI
12
+
13
+ # Flush streamed tokens to the block at most once per frame (~60fps).
14
+ # Markdown is re-parsed only on each flush, not on every token.
15
+ _STREAM_FLUSH_INTERVAL = 1 / 60
16
+
17
+
18
+ def _find_component(root: object, attr: str) -> object | None:
19
+ """Depth-first search the component tree for one exposing ``attr``."""
20
+ if root is None:
21
+ return None
22
+ if hasattr(root, attr):
23
+ return root
24
+ for child in (getattr(root, "children", None) or []):
25
+ found = _find_component(child, attr)
26
+ if found is not None:
27
+ return found
28
+ for slot in (getattr(root, "_slots", None) or []):
29
+ comp = slot[0] if isinstance(slot, tuple) else slot
30
+ found = _find_component(comp, attr)
31
+ if found is not None:
32
+ return found
33
+ return None
34
+
35
+
36
+ class AgentHookHandler:
37
+ """Subscribes to agent lifecycle hooks and drives the TUI in response.
38
+
39
+ Owns all streaming state (current block, terminal block) so App does not
40
+ need to track it. Call ``subscribe()`` after the agent is ready, then
41
+ ``unsubscribe()`` (or use the returned unsub callables) on teardown.
42
+ """
43
+
44
+ def __init__(self, runtime: Runtime, layout: Layout, tui: TUI,
45
+ on_palette_refresh: Callable[[], None] | None = None,
46
+ on_turn_content: Callable[[], None] | None = None) -> None:
47
+ self._runtime = runtime
48
+ self._layout = layout
49
+ self._tui = tui
50
+ self._on_palette_refresh = on_palette_refresh
51
+ self._on_turn_content = on_turn_content
52
+
53
+ self._current_block: MessageBlock | None = None
54
+ self._current_text_length: int = 0
55
+ self._current_terminal_block: MessageBlock | None = None
56
+ self._unsubs: list[Callable[[], None]] = []
57
+
58
+ # Streaming batch state — pending token flush
59
+ self._pending_msg: object = None
60
+ self._pending_flush_handle: asyncio.TimerHandle | None = None
61
+ self._last_flush_at: float = 0.0
62
+
63
+ def subscribe(self) -> None:
64
+ """Register all hook handlers on the current agent."""
65
+ agent = self._runtime.agent
66
+ if agent is None:
67
+ return
68
+ hooks = agent.hooks
69
+ self._unsubs = [
70
+ hooks.register("agent_start", self._on_agent_start),
71
+ hooks.register("agent_end", self._on_agent_end),
72
+ hooks.register("settled", self._on_settled),
73
+ hooks.register("message_start", self._on_message_start),
74
+ hooks.register("message_update", self._on_message_update),
75
+ hooks.register("message_end", self._on_message_end),
76
+ hooks.register("message_rollback", self._on_message_rollback),
77
+ hooks.register("tool_execution_start", self._on_tool_start),
78
+ hooks.register("tool_execution_end", self._on_tool_end),
79
+ hooks.register("model_select", self._on_model_select),
80
+ hooks.register("terminal_execution", self._on_terminal_execution),
81
+ hooks.register("terminal_output", self._on_terminal_output),
82
+ hooks.register("session_start", self._on_session_start),
83
+ hooks.register("queue_update", self._on_queue_update),
84
+ hooks.register("compaction_start", self._on_compaction_start),
85
+ hooks.register("compaction_end", self._on_compaction_end),
86
+ ]
87
+
88
+ def unsubscribe(self) -> None:
89
+ for unsub in self._unsubs:
90
+ unsub()
91
+ self._unsubs.clear()
92
+
93
+ # ── Session ───────────────────────────────────────────────────────────────
94
+
95
+ async def _on_session_start(self, event: object) -> None:
96
+ from tau.hooks.session import SessionStartReason
97
+ reason = getattr(event, "reason", None)
98
+ # Reasons that swap in a different message history: Fork/Resume/Clone
99
+ # replay the new branch into the transcript; New just clears it.
100
+ replay = reason in (
101
+ SessionStartReason.Fork,
102
+ SessionStartReason.Resume,
103
+ SessionStartReason.Clone,
104
+ )
105
+ if reason == SessionStartReason.New or replay:
106
+ self._layout.clear_messages()
107
+ self._layout.spinner.stop()
108
+ self._current_block = None
109
+ self._current_text_length = 0
110
+ self._current_terminal_block = None
111
+ if replay:
112
+ sm = self._runtime.session_manager
113
+ if sm is not None:
114
+ ctx = sm.build_session_context()
115
+ for msg in ctx.messages:
116
+ self._layout.add_message(msg)
117
+ sm = self._runtime.session_manager
118
+ if sm is not None:
119
+ self._layout.set_cwd(sm.cwd)
120
+ # Resume/branch swap in a different message history but run no agent turn,
121
+ # so the footer's context-usage badge would otherwise keep the previous
122
+ # session's value. Push fresh usage to it from this guaranteed refresh point.
123
+ self._refresh_footer_context()
124
+ if self._on_palette_refresh is not None:
125
+ self._on_palette_refresh()
126
+ self._tui.request_render()
127
+
128
+ def _refresh_footer_context(self) -> None:
129
+ """Re-push current context usage to the footer model badge, if present."""
130
+ footer = getattr(self._layout, "footer", None)
131
+ badge = _find_component(footer, "update_context_from_ctx")
132
+ update = getattr(badge, "update_context_from_ctx", None)
133
+ if not callable(update):
134
+ return
135
+ try:
136
+ from tau.extensions.context import ExtensionContext
137
+ update(ExtensionContext.from_runtime(self._runtime))
138
+ except Exception:
139
+ pass
140
+
141
+ # ── Agent lifecycle ───────────────────────────────────────────────────────
142
+
143
+ async def _on_agent_start(self, _event: object) -> None:
144
+ self._spinner(self._layout.spinner._theme.label_thinking, running=True)
145
+
146
+ async def _on_agent_end(self, _event: object) -> None:
147
+ self._spinner(running=False)
148
+
149
+ async def _on_settled(self, _event: object) -> None:
150
+ self._layout.set_pending_queue([], [])
151
+ self._spinner(running=False)
152
+
153
+ async def _on_compaction_start(self, _event: object) -> None:
154
+ self._spinner(self._layout.spinner._theme.label_compacting, running=True)
155
+
156
+ async def _on_compaction_end(self, _event: object) -> None:
157
+ self._spinner(running=False)
158
+
159
+ # ── Messages ──────────────────────────────────────────────────────────────
160
+
161
+ async def _on_message_start(self, event: object) -> None:
162
+ msg = getattr(event, "message", None)
163
+ if msg is None:
164
+ return
165
+ self._layout.spinner.set_label(self._layout.spinner._theme.label_thinking)
166
+ block = self._layout.add_message(msg, streaming=False)
167
+ self._current_block = block
168
+ self._current_text_length = _text_length(msg)
169
+ self._tui.request_render()
170
+
171
+ async def _on_message_update(self, event: object) -> None:
172
+ from tau.message.types import ThinkingContent, TextContent
173
+ msg = getattr(event, "message", None)
174
+ if msg is None or self._current_block is None:
175
+ return
176
+
177
+ # Update spinner label immediately — zero cost.
178
+ contents = getattr(msg, "contents", [])
179
+ if contents:
180
+ self._mark_turn_content()
181
+ last = contents[-1]
182
+ if isinstance(last, ThinkingContent):
183
+ self._layout.spinner.set_label(self._layout.spinner._theme.label_thinking)
184
+ elif isinstance(last, TextContent) and last.content:
185
+ self._layout.spinner.set_label(self._layout.spinner._theme.label_streaming)
186
+
187
+ # Buffer the latest message; schedule a flush if none pending.
188
+ self._pending_msg = msg
189
+ if self._pending_flush_handle is None:
190
+ elapsed = time.monotonic() - self._last_flush_at
191
+ delay = max(0.0, _STREAM_FLUSH_INTERVAL - elapsed)
192
+ loop = asyncio.get_event_loop()
193
+ self._pending_flush_handle = loop.call_later(delay, self._flush_pending)
194
+
195
+ def _flush_pending(self) -> None:
196
+ """Flush the buffered token batch: re-parse markdown once, then render."""
197
+ self._pending_flush_handle = None
198
+ msg = self._pending_msg
199
+ self._pending_msg = None
200
+ if msg is None or self._current_block is None:
201
+ return
202
+ tl = _text_length(msg)
203
+ self._update_block(msg, streaming=tl > self._current_text_length)
204
+ self._current_text_length = tl
205
+ self._last_flush_at = time.monotonic()
206
+ self._tui.request_render()
207
+
208
+ async def _on_message_end(self, event: object) -> None:
209
+ from tau.message.types import AssistantMessage, ToolMessage
210
+ # Cancel any pending batch flush — message_end supersedes it.
211
+ if self._pending_flush_handle is not None:
212
+ self._pending_flush_handle.cancel()
213
+ self._pending_flush_handle = None
214
+ self._pending_msg = None
215
+ self._last_flush_at = 0.0
216
+
217
+ msg = getattr(event, "message", None)
218
+ if msg is None:
219
+ return
220
+ if isinstance(msg, (AssistantMessage, ToolMessage)):
221
+ self._mark_turn_content()
222
+ if self._current_block is not None:
223
+ self._update_block(msg, streaming=False, clear=True)
224
+ else:
225
+ self._layout.add_message(msg)
226
+ self._tui.request_render()
227
+
228
+ async def _on_message_rollback(self, event: object) -> None:
229
+ """Remove the last ``count`` message blocks from the transcript.
230
+
231
+ Mirrors the engine dropping an interrupted tool turn: cancel any pending
232
+ flush, drop the assistant tool-call block and its tool-result block, and
233
+ reset the streaming cursor so the following interrupt marker renders fresh.
234
+ """
235
+ count = getattr(event, "count", 0)
236
+ if self._pending_flush_handle is not None:
237
+ self._pending_flush_handle.cancel()
238
+ self._pending_flush_handle = None
239
+ self._pending_msg = None
240
+ for _ in range(count):
241
+ if not self._layout.messages.remove_last():
242
+ break
243
+ self._current_block = None
244
+ self._current_text_length = 0
245
+ self._tui.request_render()
246
+
247
+ # ── Tools ─────────────────────────────────────────────────────────────────
248
+
249
+ async def _on_tool_start(self, _event: object) -> None:
250
+ self._mark_turn_content()
251
+ self._spinner(self._layout.spinner._theme.label_tool_calling)
252
+
253
+ async def _on_tool_end(self, _event: object) -> None:
254
+ self._spinner(self._layout.spinner._theme.label_thinking)
255
+
256
+ # ── Terminal ──────────────────────────────────────────────────────────
257
+
258
+ async def _on_terminal_execution(self, event: object) -> None:
259
+ msg = getattr(event, "message", None)
260
+ streaming = getattr(event, "streaming", False)
261
+ if msg is None:
262
+ return
263
+ if streaming:
264
+ block = self._layout.add_message(msg, streaming=True)
265
+ self._current_terminal_block = block
266
+ else:
267
+ if self._current_terminal_block is not None:
268
+ self._current_terminal_block.set_streaming(False)
269
+ self._current_terminal_block = None
270
+ self._tui.request_render()
271
+
272
+ async def _on_terminal_output(self, _event: object) -> None:
273
+ if self._current_terminal_block is not None:
274
+ self._current_terminal_block.invalidate()
275
+ self._tui.request_render()
276
+
277
+ # ── Model / queue ─────────────────────────────────────────────────────────
278
+
279
+ async def _on_model_select(self, _event: object) -> None:
280
+ if self._on_palette_refresh is not None:
281
+ self._on_palette_refresh()
282
+ self._tui.request_render()
283
+
284
+ async def _on_queue_update(self, event: object) -> None:
285
+ from tau.message.types import TextContent
286
+
287
+ engine_state = None
288
+ agent = self._runtime.agent
289
+ if agent is not None:
290
+ engine_state = agent._engine.state
291
+
292
+ steering: list[str] = []
293
+ followup: list[str] = []
294
+
295
+ if engine_state is not None:
296
+ for queue, out in [
297
+ (engine_state.steering_queue, steering),
298
+ (engine_state.follow_up_queue, followup),
299
+ ]:
300
+ if queue:
301
+ for msg in queue.snapshot():
302
+ text = "".join(
303
+ c.content for c in getattr(msg, "contents", [])
304
+ if isinstance(c, TextContent)
305
+ )
306
+ if text:
307
+ out.append(text)
308
+
309
+ self._layout.set_pending_queue(steering, followup)
310
+ self._tui.request_render()
311
+
312
+ # ── Helpers ───────────────────────────────────────────────────────────────
313
+
314
+ def _mark_turn_content(self) -> None:
315
+ """Tell the input handler the assistant has produced output this turn."""
316
+ if self._on_turn_content is not None:
317
+ self._on_turn_content()
318
+
319
+ def _spinner(self, label: str | None = None, *, running: bool | None = None) -> None:
320
+ if label is not None:
321
+ self._layout.spinner.set_label(label)
322
+ if running is True:
323
+ self._layout.spinner.start()
324
+ elif running is False:
325
+ self._layout.spinner.stop()
326
+ self._tui.request_render()
327
+
328
+ def _update_block(self, msg: object, *, streaming: bool, clear: bool = False) -> None:
329
+ if self._current_block is None:
330
+ return
331
+ self._current_block._message = msg
332
+ self._current_block.set_streaming(streaming)
333
+ self._current_block.invalidate()
334
+ if clear:
335
+ self._current_block = None
336
+ self._current_text_length = 0
337
+
338
+ def _refresh_model_badge(self) -> None:
339
+ if self._on_palette_refresh is not None:
340
+ self._on_palette_refresh()
341
+
342
+
343
+ def _text_length(message: object) -> int:
344
+ from tau.message.types import TextContent
345
+ contents = getattr(message, "contents", [])
346
+ return sum(len(item.content) for item in contents if isinstance(item, TextContent))
tau/tui/ansi.py ADDED
@@ -0,0 +1,330 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import unicodedata
5
+
6
+ # ── Regex to strip all ANSI escape sequences ─────────────────────────────────
7
+ _ANSI_RE = re.compile(
8
+ r"\x1b(?:"
9
+ r"\[[0-9;:<=>?]*[ -/]*[@-~]" # CSI sequences
10
+ r"|\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC sequences
11
+ r"|_[^\x1b]*(?:\x1b\\|\x07)" # APC sequences
12
+ r"|[PX^][^\x1b]*\x1b\\" # DCS / PM / SOS
13
+ r"|[@-_]" # 2-char Fe sequences (ESC @-_)
14
+ r")"
15
+ )
16
+
17
+ # ── Cursor marker ────────────────────────────────────────────────────────────
18
+ # An APC sequence (zero visible width, stripped by _ANSI_RE) injected into a
19
+ # rendered line to tell the Renderer where to position the hardware cursor for
20
+ # IME candidate windows. TextInput inserts it; Renderer removes it and moves
21
+ # the real terminal cursor to the marked column.
22
+ CURSOR_MARKER = "\x1b_C\x1b\\"
23
+
24
+ # ── SGR (colour / style) ──────────────────────────────────────────────────────
25
+
26
+ RESET = "\x1b[0m"
27
+ BOLD = "\x1b[1m"
28
+ DIM = "\x1b[2m"
29
+ ITALIC = "\x1b[3m"
30
+ UNDERLINE = "\x1b[4m"
31
+ BLINK = "\x1b[5m"
32
+ REVERSE = "\x1b[7m"
33
+ STRIKE = "\x1b[9m"
34
+
35
+ # Standard foreground colours
36
+ BLACK = "\x1b[30m"
37
+ RED = "\x1b[31m"
38
+ GREEN = "\x1b[32m"
39
+ YELLOW = "\x1b[33m"
40
+ BLUE = "\x1b[34m"
41
+ MAGENTA = "\x1b[35m"
42
+ CYAN = "\x1b[36m"
43
+ WHITE = "\x1b[37m"
44
+ DEFAULT = "\x1b[39m"
45
+
46
+ # Bright foreground colours
47
+ BRIGHT_BLACK = "\x1b[90m"
48
+ BRIGHT_RED = "\x1b[91m"
49
+ BRIGHT_GREEN = "\x1b[92m"
50
+ BRIGHT_YELLOW = "\x1b[93m"
51
+ BRIGHT_BLUE = "\x1b[94m"
52
+ BRIGHT_MAGENTA = "\x1b[95m"
53
+ BRIGHT_CYAN = "\x1b[96m"
54
+ BRIGHT_WHITE = "\x1b[97m"
55
+
56
+ # Standard background colours
57
+ BG_BLACK = "\x1b[40m"
58
+ BG_RED = "\x1b[41m"
59
+ BG_GREEN = "\x1b[42m"
60
+ BG_YELLOW = "\x1b[43m"
61
+ BG_BLUE = "\x1b[44m"
62
+ BG_MAGENTA = "\x1b[45m"
63
+ BG_CYAN = "\x1b[46m"
64
+ BG_WHITE = "\x1b[47m"
65
+ BG_DEFAULT = "\x1b[49m"
66
+
67
+
68
+ def fg(r: int, g: int, b: int) -> str:
69
+ """Truecolor foreground."""
70
+ return f"\x1b[38;2;{r};{g};{b}m"
71
+
72
+
73
+ def bg(r: int, g: int, b: int) -> str:
74
+ """Truecolor background."""
75
+ return f"\x1b[48;2;{r};{g};{b}m"
76
+
77
+
78
+ def fg256(code: int) -> str:
79
+ """256-colour foreground (0–255)."""
80
+ return f"\x1b[38;5;{code}m"
81
+
82
+
83
+ def bg256(code: int) -> str:
84
+ """256-colour background (0–255)."""
85
+ return f"\x1b[48;5;{code}m"
86
+
87
+
88
+ def style(text: str, *codes: str) -> str:
89
+ """Wrap text with one or more SGR codes, reset at end."""
90
+ return "".join(codes) + text + RESET
91
+
92
+
93
+ # ── Width calculation ─────────────────────────────────────────────────────────
94
+
95
+ def _char_width(ch: str) -> int:
96
+ """Return the terminal column width of a single character."""
97
+ cp = ord(ch)
98
+ # Null byte / C0 / C1 control chars
99
+ if cp == 0 or (0x007F <= cp <= 0x009F):
100
+ return 0
101
+ eaw = unicodedata.east_asian_width(ch)
102
+ if eaw in ("W", "F"):
103
+ return 2
104
+ if eaw == "Na" or unicodedata.category(ch) in ("Mn", "Me", "Cf"):
105
+ # Combining marks and format chars have zero width
106
+ if unicodedata.category(ch) in ("Mn", "Me", "Cf"):
107
+ return 0
108
+ return 1
109
+
110
+
111
+ def visible_width(text: str) -> int:
112
+ """Return the number of terminal columns the string will occupy."""
113
+ # Fast path: pure ASCII printable
114
+ if text.isascii() and text.isprintable():
115
+ return len(text)
116
+ stripped = strip_ansi(text)
117
+ return sum(_char_width(ch) for ch in stripped)
118
+
119
+
120
+ # ── Strip ─────────────────────────────────────────────────────────────────────
121
+
122
+ def strip_ansi(text: str) -> str:
123
+ """Remove all ANSI escape sequences from text."""
124
+ return _ANSI_RE.sub("", text)
125
+
126
+
127
+ # ── Truncation ────────────────────────────────────────────────────────────────
128
+
129
+ def truncate(text: str, max_width: int, ellipsis: str = "…") -> str:
130
+ """
131
+ Truncate text to max_width columns, preserving ANSI codes.
132
+ Appends ellipsis if truncation occurred.
133
+ """
134
+ if visible_width(text) <= max_width:
135
+ return text
136
+
137
+ ellipsis_w = visible_width(ellipsis)
138
+ target = max_width - ellipsis_w
139
+ result, _ = _take_columns(text, target)
140
+ return result + RESET + ellipsis + RESET
141
+
142
+
143
+ def pad(text: str, width: int, char: str = " ", align: str = "left") -> str:
144
+ """
145
+ Pad text to exactly width columns with char.
146
+ align: 'left' | 'right' | 'center'
147
+ """
148
+ current = visible_width(text)
149
+ deficit = max(0, width - current)
150
+ if align == "right":
151
+ return char * deficit + text
152
+ if align == "center":
153
+ left = deficit // 2
154
+ right = deficit - left
155
+ return char * left + text + char * right
156
+ return text + char * deficit
157
+
158
+
159
+ # ── Wrapping ──────────────────────────────────────────────────────────────────
160
+
161
+ def wrap(text: str, width: int) -> list[str]:
162
+ """
163
+ Wrap text to width columns, preserving ANSI escape codes across line breaks.
164
+ Hard newlines in the source are respected.
165
+ """
166
+ if width <= 0:
167
+ return [text]
168
+
169
+ result: list[str] = []
170
+ for source_line in text.split("\n"):
171
+ result.extend(_wrap_single_line(source_line, width))
172
+ return result
173
+
174
+
175
+ def _wrap_single_line(line: str, width: int) -> list[str]:
176
+ """Wrap a single line (no embedded newlines) to width columns."""
177
+ if visible_width(line) <= width:
178
+ return [line]
179
+
180
+ lines: list[str] = []
181
+ tracker = _AnsiStateTracker()
182
+ remaining = line
183
+
184
+ while remaining:
185
+ chunk, remaining = _split_at_columns(remaining, width, tracker)
186
+ lines.append(chunk)
187
+
188
+ return lines
189
+
190
+
191
+ def _split_at_columns(text: str, width: int, tracker: _AnsiStateTracker) -> tuple[str, str]:
192
+ """
193
+ Split text into (head, tail) where head fits within width columns.
194
+ Tracker maintains active SGR state so head starts with the right codes
195
+ and tail can be resumed correctly.
196
+ """
197
+ prefix = tracker.active_codes()
198
+ taken, col = "", 0
199
+ i = 0
200
+
201
+ while i < len(text):
202
+ # Check for ANSI escape at current position
203
+ m = _ANSI_RE.match(text, i)
204
+ if m:
205
+ code = m.group(0)
206
+ tracker.process(code)
207
+ taken += code
208
+ i += len(code)
209
+ continue
210
+
211
+ ch = text[i]
212
+ w = _char_width(ch)
213
+ if col + w > width:
214
+ break
215
+ taken += ch
216
+ col += w
217
+ i += 1
218
+
219
+ head = prefix + taken + (RESET if tracker.has_state() else "")
220
+ tail = text[i:]
221
+ return head, tail
222
+
223
+
224
+ def _take_columns(text: str, max_cols: int) -> tuple[str, int]:
225
+ """Return (text_up_to_max_cols, actual_cols_taken), preserving ANSI codes."""
226
+ result, col = "", 0
227
+ i = 0
228
+ while i < len(text):
229
+ m = _ANSI_RE.match(text, i)
230
+ if m:
231
+ result += m.group(0)
232
+ i += len(m.group(0))
233
+ continue
234
+ ch = text[i]
235
+ w = _char_width(ch)
236
+ if col + w > max_cols:
237
+ break
238
+ result += ch
239
+ col += w
240
+ i += 1
241
+ return result, col
242
+
243
+
244
+ # ── ANSI state tracker ────────────────────────────────────────────────────────
245
+
246
+ class _AnsiStateTracker:
247
+ """
248
+ Tracks active SGR (colour/style) state so it can be re-applied after
249
+ a line break during wrapping.
250
+ """
251
+
252
+ def __init__(self) -> None:
253
+ self._bold = False
254
+ self._dim = False
255
+ self._italic = False
256
+ self._underline = False
257
+ self._fg: str | None = None
258
+ self._bg: str | None = None
259
+
260
+ def process(self, code: str) -> None:
261
+ m = re.match(r"\x1b\[([\d;]*)m$", code)
262
+ if not m:
263
+ return
264
+ params = m.group(1)
265
+ if not params or params == "0":
266
+ self._reset()
267
+ return
268
+ nums = [int(x) for x in params.split(";") if x]
269
+ i = 0
270
+ while i < len(nums):
271
+ n = nums[i]
272
+ if n == 0:
273
+ self._reset()
274
+ elif n == 1:
275
+ self._bold = True
276
+ elif n == 2:
277
+ self._dim = True
278
+ elif n == 3:
279
+ self._italic = True
280
+ elif n == 4:
281
+ self._underline = True
282
+ elif n == 22:
283
+ self._bold = self._dim = False
284
+ elif n == 23:
285
+ self._italic = False
286
+ elif n == 24:
287
+ self._underline = False
288
+ elif 30 <= n <= 37 or n == 39 or 90 <= n <= 97:
289
+ self._fg = f"\x1b[{n}m"
290
+ elif n == 38 and i + 2 < len(nums) and nums[i + 1] == 5:
291
+ self._fg = f"\x1b[38;5;{nums[i + 2]}m"
292
+ i += 2
293
+ elif n == 38 and i + 4 < len(nums) and nums[i + 1] == 2:
294
+ self._fg = f"\x1b[38;2;{nums[i+2]};{nums[i+3]};{nums[i+4]}m"
295
+ i += 4
296
+ elif 40 <= n <= 47 or n == 49 or 100 <= n <= 107:
297
+ self._bg = f"\x1b[{n}m"
298
+ elif n == 48 and i + 2 < len(nums) and nums[i + 1] == 5:
299
+ self._bg = f"\x1b[48;5;{nums[i + 2]}m"
300
+ i += 2
301
+ elif n == 48 and i + 4 < len(nums) and nums[i + 1] == 2:
302
+ self._bg = f"\x1b[48;2;{nums[i+2]};{nums[i+3]};{nums[i+4]}m"
303
+ i += 4
304
+ i += 1
305
+
306
+ def _reset(self) -> None:
307
+ self._bold = self._dim = self._italic = self._underline = False
308
+ self._fg = self._bg = None
309
+
310
+ def has_state(self) -> bool:
311
+ return bool(self._bold or self._dim or self._italic or
312
+ self._underline or self._fg or self._bg)
313
+
314
+ def active_codes(self) -> str:
315
+ if not self.has_state():
316
+ return ""
317
+ parts = []
318
+ if self._bold:
319
+ parts.append(BOLD)
320
+ if self._dim:
321
+ parts.append(DIM)
322
+ if self._italic:
323
+ parts.append(ITALIC)
324
+ if self._underline:
325
+ parts.append(UNDERLINE)
326
+ if self._fg:
327
+ parts.append(self._fg)
328
+ if self._bg:
329
+ parts.append(self._bg)
330
+ return "".join(parts)