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,464 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from tau.tui.commands.context import CommandContext
6
+
7
+
8
+ def open_resume_selector(ctx: CommandContext) -> None:
9
+ from tau.session.manager import SessionManager
10
+
11
+ sm = ctx.runtime.session_manager
12
+ cwd = sm.cwd if sm is not None else None
13
+ current_path = sm.session_file if sm is not None else None
14
+
15
+ current_sessions = SessionManager.list(cwd) if cwd is not None else []
16
+
17
+ def all_loader() -> list:
18
+ return SessionManager.list_all()
19
+
20
+ def commit(path: object) -> None:
21
+ from pathlib import Path
22
+ asyncio.ensure_future(_apply_resume(ctx, Path(str(path))))
23
+
24
+ ctx.layout.open_resume_selector(
25
+ sessions=current_sessions,
26
+ on_commit=commit,
27
+ on_cancel=lambda: ctx.notify("Resume cancelled."),
28
+ all_sessions_loader=all_loader,
29
+ current_session_path=current_path,
30
+ )
31
+
32
+
33
+ async def _apply_resume(ctx: CommandContext, path: object) -> None:
34
+ from pathlib import Path
35
+ p = Path(str(path))
36
+ try:
37
+ await ctx.runtime.resume_session(p)
38
+ ctx.notify(f"Resumed session {p.stem[:32]}")
39
+ except Exception as exc:
40
+ ctx.notify(f"Failed to resume: {exc}")
41
+
42
+
43
+ def _message_snippet(message: object) -> tuple[str, str]:
44
+ """Return (role_label, text_snippet) for any AgentMessage variant."""
45
+ from tau.message.types import (
46
+ TerminalExecutionMessage,
47
+ BranchSummaryMessage,
48
+ CompactionSummaryMessage,
49
+ CustomMessage,
50
+ SkillInvocationMessage,
51
+ TemplateInvocationMessage,
52
+ TextContent,
53
+ ThinkingContent,
54
+ ToolCallContent,
55
+ ToolResultContent,
56
+ )
57
+
58
+ role_attr = getattr(message, "role", "")
59
+ role = getattr(role_attr, "value", role_attr) or type(message).__name__
60
+
61
+ if isinstance(message, TerminalExecutionMessage):
62
+ return "terminal", message.command
63
+ if isinstance(message, CompactionSummaryMessage):
64
+ return "compaction", message.summary
65
+ if isinstance(message, BranchSummaryMessage):
66
+ return "branch_summary", message.summary
67
+ if isinstance(message, SkillInvocationMessage):
68
+ return "skill", f"{message.name} {message.content}".strip()
69
+ if isinstance(message, TemplateInvocationMessage):
70
+ return "template", message.name
71
+
72
+ contents = getattr(message, "contents", None)
73
+ if isinstance(contents, list):
74
+ parts: list[str] = []
75
+ for c in contents:
76
+ if isinstance(c, TextContent):
77
+ parts.append(c.content)
78
+ elif isinstance(c, ThinkingContent):
79
+ parts.append(f"(thinking) {c.content}")
80
+ elif isinstance(c, ToolCallContent):
81
+ parts.append(f"[tool: {c.name}]")
82
+ elif isinstance(c, ToolResultContent):
83
+ prefix = "[error] " if c.is_error else ""
84
+ parts.append(f"{prefix}{c.content}")
85
+ text = " ".join(p for p in parts if p)
86
+ if isinstance(message, CustomMessage):
87
+ role = f"custom:{message.custom_type}"
88
+ return role, text
89
+
90
+ return role, ""
91
+
92
+
93
+ def _message_selectable(message: object) -> bool:
94
+ """False for an assistant turn with unanswered tool calls (would create a dangling tool_call)."""
95
+ from tau.message.types import AssistantMessage
96
+ return not (isinstance(message, AssistantMessage) and message.tool_calls())
97
+
98
+
99
+ def open_tree_selector(ctx: CommandContext) -> None:
100
+ from tau.session.types import (
101
+ BranchSummaryEntry,
102
+ CompactionEntry,
103
+ CustomInfoEntry,
104
+ CustomMessageEntry,
105
+ LabelEntry,
106
+ MessageEntry,
107
+ ModelChangeEntry,
108
+ ThinkingLevelChangeEntry,
109
+ )
110
+ from tau.message.types import TextContent
111
+ from tau.tui.components.tree_select_list import TreeRow
112
+
113
+ sm = ctx.runtime.session_manager
114
+ if sm is None:
115
+ ctx.notify("No active session.")
116
+ return
117
+
118
+ nodes = sm.get_tree()
119
+ if not nodes:
120
+ ctx.notify("Session tree is empty.")
121
+ return
122
+
123
+ current_leaf = sm.get_leaf_id()
124
+ rows: list[TreeRow[str]] = []
125
+
126
+ # Flatten once to map id -> parent_id, so we can walk current_leaf's
127
+ # ancestor chain and mark the active path (independent of tree nesting).
128
+ parent_of: dict[str, str | None] = {}
129
+
130
+ def _index(node_list: list) -> None:
131
+ for node in node_list:
132
+ parent_of[node.entry.id] = node.entry.parent_id
133
+ _index(node.children)
134
+
135
+ _index(nodes)
136
+
137
+ active_ids: set[str] = set()
138
+ cur = current_leaf
139
+ while cur is not None and cur in parent_of:
140
+ active_ids.add(cur)
141
+ cur = parent_of[cur]
142
+
143
+ def _entry_role_text(entry: object) -> tuple[str, str] | None:
144
+ if isinstance(entry, MessageEntry):
145
+ return _message_snippet(entry.message)
146
+ if isinstance(entry, CompactionEntry):
147
+ return "compaction", entry.summary
148
+ if isinstance(entry, BranchSummaryEntry):
149
+ return "branch_summary", entry.summary
150
+ if isinstance(entry, CustomMessageEntry):
151
+ text = " ".join(c.content for c in entry.content if isinstance(c, TextContent))
152
+ return f"custom:{entry.custom_type}", text
153
+ if isinstance(entry, LabelEntry):
154
+ return ("label", entry.label) if entry.label else None
155
+ if isinstance(entry, ModelChangeEntry):
156
+ return "model", f"{entry.provider_id}/{entry.model_id}"
157
+ if isinstance(entry, ThinkingLevelChangeEntry):
158
+ return "thinking_level", str(entry.thinking_level)
159
+ if isinstance(entry, CustomInfoEntry):
160
+ return f"info:{entry.custom_type}", ""
161
+ return None
162
+
163
+ disabled_ids: set[str] = set()
164
+
165
+ def _contains_active(node: object) -> bool:
166
+ """True if node or any descendant is on the active path."""
167
+ stack = [node]
168
+ while stack:
169
+ n = stack.pop()
170
+ if n.entry.id in active_ids: # type: ignore[attr-defined]
171
+ return True
172
+ stack.extend(n.children) # type: ignore[attr-defined]
173
+ return False
174
+
175
+ def _build_prefix(
176
+ gutters: list[tuple[int, bool]],
177
+ show_connector: bool,
178
+ is_last: bool,
179
+ display_indent: int,
180
+ ) -> str:
181
+ """Char-by-char prefix: gutters (│) + connector (├─/└─) + spaces."""
182
+ if display_indent == 0:
183
+ return ""
184
+ connector_pos = display_indent - 1
185
+ chars: list[str] = []
186
+ for ci in range(display_indent * 3):
187
+ level = ci // 3
188
+ pos = ci % 3
189
+ gutter_show = next((s for lv, s in gutters if lv == level), None)
190
+ if gutter_show is not None:
191
+ chars.append("│" if pos == 0 and gutter_show else " ")
192
+ elif show_connector and level == connector_pos:
193
+ if pos == 0:
194
+ chars.append("└" if is_last else "├")
195
+ elif pos == 1:
196
+ chars.append("─")
197
+ else:
198
+ chars.append(" ")
199
+ else:
200
+ chars.append(" ")
201
+ return "".join(chars)
202
+
203
+ def _walk(
204
+ node_list: list,
205
+ gutters: list[tuple[int, bool]],
206
+ display_indent: int,
207
+ just_branched: bool,
208
+ ) -> None:
209
+ """
210
+ Tree walk:
211
+ - Connectors (├─/└─) only when multiple siblings exist.
212
+ - Linear single-child chains stay flat (no indent increase, no connector).
213
+ - Gutters (│) track open branch lines for descendants.
214
+ - just_branched: parent had multiple children → first gen after branch also indents.
215
+ """
216
+ n = len(node_list)
217
+ is_branching = n > 1 # multiple siblings → show connectors
218
+
219
+ for i, node in enumerate(node_list):
220
+ is_last = i == n - 1
221
+ entry = node.entry
222
+ role_text = _entry_role_text(entry)
223
+
224
+ if role_text is not None:
225
+ role, text = role_text
226
+ show_connector = is_branching and display_indent > 0
227
+ prefix = _build_prefix(gutters, show_connector, is_last, display_indent)
228
+ selectable = not isinstance(entry, MessageEntry) or _message_selectable(entry.message)
229
+ if not selectable:
230
+ disabled_ids.add(entry.id)
231
+ rows.append(TreeRow(
232
+ prefix=prefix,
233
+ role=role,
234
+ text=text[:80].replace("\n", " ").replace("\t", " "),
235
+ on_active_path=entry.id in active_ids,
236
+ is_current=entry.id == current_leaf,
237
+ selectable=selectable,
238
+ value=entry.id,
239
+ parent_value=getattr(entry, "parent_id", None),
240
+ has_children=len(node.children) > 0,
241
+ ))
242
+
243
+ # Sort children so the branch containing the active leaf comes first
244
+ children = node.children
245
+ if len(children) > 1:
246
+ children = sorted(children, key=lambda n: 0 if _contains_active(n) else 1)
247
+
248
+ # Child indent rules:
249
+ # - node has multiple children → +1 (they will branch)
250
+ # - current level is branching AND not at root → +1 (just-branched grouping)
251
+ # - linear single-child chain → stay flat (no change)
252
+ multiple_children = len(children) > 1
253
+ if multiple_children:
254
+ child_indent = display_indent + 1
255
+ elif is_branching and display_indent > 0:
256
+ child_indent = display_indent + 1
257
+ else:
258
+ child_indent = display_indent
259
+
260
+ # Gutters: when this level branches, record a │ column for descendants
261
+ if is_branching and display_indent > 0:
262
+ child_gutters = gutters + [(display_indent - 1, not is_last)]
263
+ else:
264
+ child_gutters = gutters
265
+
266
+ _walk(children, child_gutters, child_indent, just_branched=is_branching)
267
+
268
+ _walk(nodes, [], 0, just_branched=False)
269
+
270
+ if not rows:
271
+ ctx.notify("No navigable branches found.")
272
+ return
273
+
274
+ def commit(entry_id: str) -> None:
275
+ if entry_id in disabled_ids:
276
+ ctx.notify("Can't branch from a pending tool call — pick the tool result or a later message instead.")
277
+ return
278
+ asyncio.ensure_future(_apply_tree_branch(ctx, entry_id))
279
+
280
+ ctx.layout.open_branch_tree_selector(rows, commit, lambda: ctx.notify("Branch navigation cancelled."))
281
+
282
+
283
+ def _extract_user_message_text(message: object) -> str | None:
284
+ """Return the text content of a UserMessage, or None if not a plain user message."""
285
+ from tau.message.types import UserMessage, TextContent
286
+ if not isinstance(message, UserMessage):
287
+ return None
288
+ contents = getattr(message, "contents", None)
289
+ if not isinstance(contents, list):
290
+ return None
291
+ parts = [c.content for c in contents if isinstance(c, TextContent)]
292
+ return " ".join(parts) if parts else None
293
+
294
+
295
+ async def _apply_tree_branch(ctx: CommandContext, entry_id: str) -> None:
296
+ from tau.session.types import MessageEntry
297
+ sm = ctx.runtime.session_manager
298
+ settings = ctx.runtime.settings_manager
299
+
300
+ # No-op if already at this node
301
+ if sm is not None and sm.get_leaf_id() == entry_id:
302
+ ctx.notify("Already at this point.")
303
+ return
304
+
305
+ # Detect if the selected entry is a user message — if so, navigate to its
306
+ # parent and restore the message text into the editor instead of the history.
307
+ navigate_id = entry_id
308
+ restore_text: str | None = None
309
+ if sm is not None and entry_id in sm.by_id:
310
+ entry = sm.by_id[entry_id]
311
+ if isinstance(entry, MessageEntry):
312
+ user_text = _extract_user_message_text(entry.message)
313
+ if user_text is not None:
314
+ restore_text = user_text
315
+ navigate_id = entry.parent_id or entry_id
316
+
317
+ # Determine whether to ask about summarization
318
+ summary_enabled = settings.is_branch_summary_enabled() if settings is not None else True
319
+ skip_prompt = settings.get_branch_summary_skip_prompt() if settings is not None else False
320
+
321
+ summarize = False
322
+ if summary_enabled and not skip_prompt:
323
+ from tau.tui.components.select_list import SelectItem
324
+ summary_items: list[SelectItem[str]] = [
325
+ SelectItem(label="No summary", description="Switch branch without summarizing", value="none"),
326
+ SelectItem(label="Summarize", description="Generate a summary of the abandoned branch", value="yes"),
327
+ ]
328
+ loop = asyncio.get_running_loop()
329
+ fut: asyncio.Future[str | None] = loop.create_future()
330
+
331
+ def _commit(value: str) -> None:
332
+ if not fut.done():
333
+ fut.set_result(value)
334
+
335
+ def _cancel() -> None:
336
+ if not fut.done():
337
+ fut.set_result(None)
338
+
339
+ ctx.layout.open_tree_selector(summary_items, _commit, _cancel)
340
+ choice = await fut
341
+
342
+ if choice is None:
343
+ return # user cancelled
344
+
345
+ summarize = choice == "yes"
346
+
347
+ # Show spinner label while summarizing
348
+ if summarize:
349
+ ctx.layout.spinner.set_label("Summarizing branch…")
350
+
351
+ try:
352
+ # When restoring a user message, navigate to its parent (navigate_id may differ from entry_id)
353
+ if sm is not None and navigate_id != sm.get_leaf_id():
354
+ ok = await ctx.runtime.navigate_tree(navigate_id, summarize=summarize)
355
+ if not ok:
356
+ ctx.notify("Branch navigation cancelled.")
357
+ return
358
+ elif restore_text is None:
359
+ # Already at this node (assistant message case), covered above
360
+ pass
361
+
362
+ if restore_text is not None:
363
+ ctx.layout.input.set_text(restore_text)
364
+ ctx.notify("Restored message to input.")
365
+ else:
366
+ ctx.notify(f"Switched to branch at {entry_id[:8]}")
367
+ except Exception as exc:
368
+ ctx.notify(f"Failed to switch branch: {exc}")
369
+ finally:
370
+ if summarize:
371
+ # Restore default spinner label
372
+ ctx.layout.spinner.set_label(ctx.layout.spinner._theme.label_thinking)
373
+
374
+
375
+ def cmd_clone(ctx: CommandContext) -> None:
376
+ asyncio.ensure_future(_apply_clone(ctx))
377
+
378
+
379
+ async def _apply_clone(ctx: CommandContext) -> None:
380
+ try:
381
+ await ctx.runtime.clone_session()
382
+ sm = ctx.runtime.session_manager
383
+ name = sm.session_file.name[:40] if sm and sm.session_file else "new session"
384
+ ctx.notify(f"Cloned into {name}")
385
+ except Exception as exc:
386
+ ctx.notify(f"Failed to clone: {exc}")
387
+
388
+
389
+ def cmd_session(ctx: CommandContext) -> None:
390
+ from tau.tui.ansi import BOLD, DIM, RESET
391
+ from tau.session.types import MessageEntry as SessionMessageEntry
392
+ from tau.message.types import AssistantMessage, ToolMessage, UserMessage
393
+
394
+ sm = ctx.runtime.session_manager
395
+ if sm is None:
396
+ ctx.notify("No active session.")
397
+ return
398
+
399
+ branch = sm.get_branch()
400
+
401
+ user_count = 0
402
+ assistant_count = 0
403
+ tool_call_count = 0
404
+ tool_result_count = 0
405
+ input_tokens = 0
406
+ output_tokens = 0
407
+ cache_read_tokens = 0
408
+ cache_write_tokens = 0
409
+ total_cost = 0.0
410
+
411
+ for entry in branch:
412
+ if not isinstance(entry, SessionMessageEntry):
413
+ continue
414
+ msg = entry.message
415
+ if isinstance(msg, UserMessage):
416
+ user_count += 1
417
+ elif isinstance(msg, AssistantMessage):
418
+ assistant_count += 1
419
+ tool_call_count += len(msg.tool_calls())
420
+ input_tokens += msg.usage.input_tokens
421
+ output_tokens += msg.usage.output_tokens
422
+ cache_read_tokens += msg.usage.cache_read_tokens
423
+ cache_write_tokens += msg.usage.cache_write_tokens
424
+ total_cost += msg.usage.cost.total
425
+ elif isinstance(msg, ToolMessage):
426
+ tool_result_count += len(msg.contents)
427
+
428
+ total_messages = user_count + assistant_count + (1 if tool_result_count else 0)
429
+ total_tokens = input_tokens + output_tokens + cache_read_tokens + cache_write_tokens
430
+
431
+ session_name = sm.get_session_name()
432
+ session_file = sm.session_file
433
+ session_id = sm.session_id or ""
434
+
435
+ W = 14
436
+ lines: list[str] = []
437
+ lines.append(f"{BOLD}Session Info{RESET}")
438
+ lines.append("")
439
+ if session_name:
440
+ lines.append(f"{DIM}{'Name':<{W}}{RESET} {session_name}")
441
+ lines.append(f"{DIM}{'File':<{W}}{RESET} {session_file or 'in-memory'}")
442
+ lines.append(f"{DIM}{'ID':<{W}}{RESET} {session_id}")
443
+ lines.append("")
444
+ lines.append(f"{BOLD}Messages{RESET}")
445
+ lines.append(f"{DIM}{'User':<{W}}{RESET} {user_count}")
446
+ lines.append(f"{DIM}{'Assistant':<{W}}{RESET} {assistant_count}")
447
+ lines.append(f"{DIM}{'Tool calls':<{W}}{RESET} {tool_call_count}")
448
+ lines.append(f"{DIM}{'Tool results':<{W}}{RESET} {tool_result_count}")
449
+ lines.append(f"{DIM}{'Total':<{W}}{RESET} {total_messages}")
450
+ lines.append("")
451
+ lines.append(f"{BOLD}Tokens{RESET}")
452
+ lines.append(f"{DIM}{'Input':<{W}}{RESET} {input_tokens:,}")
453
+ lines.append(f"{DIM}{'Output':<{W}}{RESET} {output_tokens:,}")
454
+ if cache_read_tokens:
455
+ lines.append(f"{DIM}{'Cache read':<{W}}{RESET} {cache_read_tokens:,}")
456
+ if cache_write_tokens:
457
+ lines.append(f"{DIM}{'Cache write':<{W}}{RESET} {cache_write_tokens:,}")
458
+ lines.append(f"{DIM}{'Total':<{W}}{RESET} {total_tokens:,}")
459
+ if total_cost > 0:
460
+ lines.append("")
461
+ lines.append(f"{BOLD}Cost{RESET}")
462
+ lines.append(f"{DIM}{'Total':<{W}}{RESET} ${total_cost:.4f}")
463
+
464
+ ctx.notify("\n".join(lines))