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,1194 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Callable, TYPE_CHECKING
5
+
6
+ from tau.tui.components.autocomplete_manager import AutocompleteManager
7
+ from tau.tui.components.command_palette import CommandPalette
8
+ from tau.tui.components.file_picker import FilePicker
9
+ from tau.tui.components.model_palette import ModelPalette # noqa: F401 (kept for compat)
10
+ from tau.tui.components.inline_selector import InlineSelector
11
+ from tau.tui.components.text_prompt import TextPrompt
12
+ from tau.tui.component import Component, Container
13
+ from tau.tui.input import InputEvent, KeyEvent, MouseEvent, PasteEvent # MouseEvent kept for type narrowing
14
+ from tau.tui.components.message_list import MessageList, MessageBlock
15
+ from tau.tui.components.select_list import SelectItem, SelectList
16
+ from tau.tui.components.spinner import Spinner
17
+ from tau.tui.components.tree_select_list import TreeRow, TreeSelectList
18
+ from tau.tui.components.text_input import TextInput
19
+ from tau.tui.theme import LayoutTheme
20
+
21
+ if TYPE_CHECKING:
22
+ from tau.commands.types import CommandInfo
23
+ from tau.tui.tui import TUI
24
+ from tau.tui.overlay import OverlayHandle, CustomOptions
25
+ from tau.tui.autocomplete import AutocompleteRegistration
26
+
27
+
28
+ class _PendingLines(Component):
29
+ """Mutable list of pre-rendered lines for the pending-queue display."""
30
+
31
+ def __init__(self) -> None:
32
+ self.lines: list[str] = []
33
+
34
+ def render(self, width: int) -> list[str]: # noqa: ARG002
35
+ return list(self.lines)
36
+
37
+ def invalidate(self) -> None:
38
+ pass
39
+
40
+
41
+ class Layout(Component):
42
+ """
43
+ The editor zone — dividers, text input, pickers, palette, and footer.
44
+
45
+ Layout registers itself as a TUI child in ``__init__`` and also manages
46
+ the named Container zones (``header``, ``status``, ``widgets_above``,
47
+ ``widgets_below``) that appear as sibling children above/around it.
48
+ TUI owns viewport scroll; Layout returns its full content stream without
49
+ any clipping.
50
+
51
+ Component assembly happens in ``__init__``:
52
+
53
+ ┌────────────────────────────┐
54
+ │ header zone (Container) │ ← tui.children[0]
55
+ │ messages │ ← tui.children[1] (MessageList)
56
+ │ spinner │ ← tui.children[2]
57
+ │ pending queue │ ← tui.children[3]
58
+ │ status zone (Container) │ ← tui.children[4]
59
+ │ widgets_above (Container) │ ← tui.children[5]
60
+ │ ────────────────────── │ ─┐
61
+ │ TextInput │ │ tui.children[6] = this Layout
62
+ │ ────────────────────── │ │
63
+ │ pickers / palette │ │
64
+ │ footer │ ─┘
65
+ │ widgets_below (Container) │ ← tui.children[7]
66
+ └────────────────────────────┘
67
+
68
+ Scroll (page_up / mouse-wheel) is handled by TUI._dispatch so
69
+ handle_input() deals only with editor and picker events.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ tui: TUI,
75
+ theme: LayoutTheme | None = None,
76
+ picker_max_visible: int = 8,
77
+ autocomplete_max_visible: int = 5,
78
+ editor_padding_x: int = 0,
79
+ ) -> None:
80
+ """Initialize layout with TUI instance and default theme."""
81
+ self._tui = tui
82
+ self._theme = theme or LayoutTheme()
83
+ self._picker_max_visible = picker_max_visible
84
+
85
+ self.messages = MessageList(
86
+ theme=self._theme.message,
87
+ user_prefix=self._theme.input.prefix,
88
+ )
89
+ self.spinner = Spinner(tui, theme=self._theme.spinner)
90
+ self.footer: Container = Container()
91
+ self.palette = CommandPalette()
92
+ self.input = TextInput(
93
+ prefix=self._theme.input.prefix,
94
+ placeholder=self._theme.input.placeholder,
95
+ padding_x=editor_padding_x,
96
+ )
97
+
98
+ # ── Public Container zones ──────────────────────────────────────────
99
+ # Add children directly or use the helper APIs below.
100
+ #
101
+ # layout.header.add_child(Banner()) # above messages
102
+ # layout.footer.add_child(my_widget) # inside the footer (above status bar)
103
+ # layout.status.add_child(my_status) # above editor
104
+ # layout.widgets_above.add_child(widget) # between status & editor
105
+ # layout.widgets_below.add_child(widget) # below editor
106
+ self.header: Container = Container()
107
+ self.status: Container = Container()
108
+ self.widgets_above: Container = Container()
109
+ self.widgets_below: Container = Container()
110
+
111
+ self._all_commands: list[CommandInfo] = []
112
+
113
+ # Returns True while the agent is streaming; lets handle_input distinguish
114
+ # "ESC clears the editor" (idle) from "ESC aborts the run" (busy — must
115
+ # fall through to the global key handler). Defaults to never-busy.
116
+ self._is_busy: Callable[[], bool] = lambda: False
117
+
118
+ # Pending queue display — steering/followup messages waiting to be consumed
119
+ self._pending_lines: _PendingLines = _PendingLines()
120
+
121
+ # Key-tracked widget maps — set_widget/remove_widget manage these;
122
+ # the components are also held in the Container zones above.
123
+ self._widgets_above_map: dict[str, Component] = {}
124
+ self._widgets_below_map: dict[str, Component] = {}
125
+
126
+ # Key-tracked status lines — set_status() manages this;
127
+ # rendered as dim text immediately above the editor.
128
+ self._status_map: dict[str, str] = {}
129
+
130
+ # Cached submit/followup/dequeue callbacks — re-wired after editor replacement
131
+ self._stored_submit_cb: Callable[[str], None] | None = None
132
+ self._stored_followup_cb: Callable[[str], None] | None = None
133
+ self._stored_dequeue_cb: Callable[[], None] | None = None
134
+ self._custom_input_factory: Callable[[Any, Any], Any] | None = None
135
+
136
+ # File picker — shown when user types '@'
137
+ self.file_picker = FilePicker()
138
+ self._at_pos: int = 0 # char index of '@' in input text when picker opened
139
+
140
+ # Inline pickers — rendered in the content stream below the input
141
+ self._active_selector: InlineSelector | None = None
142
+ self._settings_panel: list[str] | None = None
143
+ self._prompt = TextPrompt()
144
+ self._oauth_status_lines: list[str] | None = None
145
+
146
+ # Model palette — inline filtered model list for '/model <name> <provider>'
147
+
148
+ # Extension autocomplete + command argument completions
149
+ self._autocomplete = AutocompleteManager(
150
+ max_visible=autocomplete_max_visible,
151
+ request_render=tui.request_render,
152
+ )
153
+
154
+ # Register zones with TUI in render order. widgets_above, widgets_below,
155
+ # and _status_map are rendered by Layout itself (they sit between the
156
+ # dividers and pickers); the remaining zones become TUI children.
157
+ tui.add_child(self.header)
158
+ tui.add_child(self.messages)
159
+ tui.add_child(self.spinner)
160
+ tui.add_child(self._pending_lines)
161
+ tui.add_child(self.status)
162
+ tui.add_child(self) # Layout = EditorZone (status_map + dividers + input + pickers + footer)
163
+
164
+ # -------------------------------------------------------------------------
165
+ # Attach / detach (for full-screen takeovers and TrustScreen)
166
+ # -------------------------------------------------------------------------
167
+
168
+ def attach(self, tui: TUI) -> None:
169
+ """Re-add all zones to ``tui`` in the correct render order."""
170
+ tui.add_child(self.header)
171
+ tui.add_child(self.messages)
172
+ tui.add_child(self.spinner)
173
+ tui.add_child(self._pending_lines)
174
+ tui.add_child(self.status)
175
+ tui.add_child(self)
176
+
177
+ def detach(self, tui: TUI) -> None:
178
+ """Remove all zones from ``tui`` (used before installing a full-screen takeover)."""
179
+ tui.clear()
180
+
181
+ # -------------------------------------------------------------------------
182
+ # Component
183
+ # -------------------------------------------------------------------------
184
+
185
+ def render(self, width: int) -> list[str]:
186
+ """Render the editor zone: status-map, dividers, input, pickers, footer."""
187
+ # ── Build the editor content stream ───────────────────────────────
188
+ content: list[str] = []
189
+
190
+ # Status zone — keyed status lines above the editor
191
+ if self._status_map:
192
+ from tau.tui.ansi import DIM, RESET
193
+ for text in self._status_map.values():
194
+ content.append(f" {DIM}{text}{RESET}")
195
+
196
+ # Widgets above editor
197
+ if self.widgets_above.children:
198
+ content.extend(self.widgets_above.render(width))
199
+
200
+ # Modals that replace the input box (input disappears when a modal is active).
201
+ # The file picker is NOT a modal — like the '/' palette it renders below the
202
+ # input while the editor stays visible and receives the query keystrokes.
203
+ any_modal = (
204
+ self._active_selector is not None
205
+ or self._settings_panel is not None
206
+ or self._prompt.active
207
+ or self._oauth_status_lines is not None
208
+ )
209
+
210
+ # Divider color: when a modal is active use plain divider; otherwise reflect input state
211
+ _text = self.input.text
212
+ if any_modal:
213
+ _divider = self._theme.divider
214
+ elif _text.startswith("!"):
215
+ _divider = self._theme.divider_execute
216
+ elif _text.startswith("/"):
217
+ _divider = self._theme.divider_command
218
+ else:
219
+ _divider = self._theme.divider
220
+
221
+ content.append(_divider("─" * width))
222
+
223
+ if any_modal:
224
+ # Modal replaces the input between the two dividers
225
+ if self._active_selector is not None:
226
+ content.extend(self._active_selector.render(width))
227
+ elif self._settings_panel is not None:
228
+ content.extend(self._settings_panel)
229
+ elif self._prompt.active:
230
+ content.extend(self._prompt.render(width))
231
+ elif self._oauth_status_lines is not None:
232
+ content.extend(self._oauth_status_lines)
233
+ else:
234
+ # Normal: show the input editor
235
+ content.extend(self.input.render(width))
236
+
237
+ content.append(_divider("─" * width))
238
+
239
+ # Widgets below editor (only when no modal)
240
+ if not any_modal and self.widgets_below.children:
241
+ content.extend(self.widgets_below.render(width))
242
+
243
+ # Palette, file picker, and autocomplete — always below (driven by live input
244
+ # text); hidden during modals. The file picker renders here so it appears under
245
+ # the input, the same way the '/' command palette does.
246
+ any_inline = any_modal or self.palette.active or self.file_picker.active
247
+ if not any_modal:
248
+ content.extend(self.palette.render(width))
249
+ content.extend(self.file_picker.render(width))
250
+ content.extend(self._autocomplete.render(width))
251
+
252
+ # Footer — hidden when any picker/modal is active
253
+ if not any_inline:
254
+ content.extend(self.footer.render(width))
255
+
256
+ return content
257
+
258
+ def handle_input(self, event: InputEvent) -> bool:
259
+ """Process input event and return True if consumed.
260
+
261
+ Scroll and mouse events are handled by TUI._dispatch before reaching here,
262
+ so this method deals only with editor and picker navigation.
263
+ """
264
+ if isinstance(event, MouseEvent):
265
+ return False
266
+
267
+ # Bracketed paste while a modal is open: route the text into the active
268
+ # selector's search/edit buffer (e.g. pasting an API key into a settings
269
+ # text field) instead of letting it fall through to the unfocused editor.
270
+ if isinstance(event, PasteEvent) and self._active_selector is not None:
271
+ appender = getattr(self._active_selector.selector, "append_search", None)
272
+ if appender is not None:
273
+ appender(event.text.replace("\r", ""))
274
+ self._tui.request_render()
275
+ return True
276
+
277
+ if isinstance(event, KeyEvent):
278
+ # Generic inline selector (model, theme, effort, resume, tree) — modal
279
+ if self._active_selector is not None:
280
+ sel = self._active_selector
281
+ tree_sel = sel.selector if sel.kind == "tree" else None
282
+ model_sel = sel.selector if sel.kind == "model" else None
283
+ simple_sel = sel.selector if sel.kind in ("theme", "effort") else None
284
+
285
+ # Tree label-editing sub-mode: route all keys directly
286
+ if tree_sel is not None and getattr(tree_sel, "label_editing", False):
287
+ tree_sel.label_edit_key(event)
288
+ self._tui.request_render()
289
+ return True
290
+
291
+ # Simple list modal (theme, effort): owns navigation
292
+ if simple_sel is not None:
293
+ match event.key:
294
+ case "up":
295
+ simple_sel.move_up()
296
+ case "down":
297
+ simple_sel.move_down()
298
+ case "enter":
299
+ val = simple_sel.selected_value()
300
+ cb, cancel_cb = sel.on_commit, sel.on_cancel
301
+ self._active_selector = None
302
+ if val is not None:
303
+ cb(val)
304
+ else:
305
+ cancel_cb()
306
+ case "escape":
307
+ cb = sel.on_cancel
308
+ self._active_selector = None
309
+ cb()
310
+ self._tui.request_render()
311
+ return True
312
+
313
+ # Model selector: owns search, scope, navigation
314
+ if model_sel is not None:
315
+ match event.key:
316
+ case "up":
317
+ model_sel.move_up()
318
+ case "down":
319
+ model_sel.move_down()
320
+ case "tab":
321
+ model_sel.toggle_scope()
322
+ case "enter":
323
+ val = model_sel.selected_value()
324
+ cb, cancel_cb = sel.on_commit, sel.on_cancel
325
+ self._active_selector = None
326
+ if val is not None:
327
+ cb(val)
328
+ else:
329
+ cancel_cb()
330
+ case "escape":
331
+ cb = sel.on_cancel
332
+ self._active_selector = None
333
+ cb()
334
+ case "backspace":
335
+ model_sel.backspace_search()
336
+ case ch if len(ch) == 1 and ch.isprintable():
337
+ model_sel.append_search(ch)
338
+ self._tui.request_render()
339
+ return True
340
+
341
+ # Settings modal: interactive settings list
342
+ settings_sel = sel.selector if sel.kind == "settings" else None
343
+ if settings_sel is not None:
344
+ match event.key:
345
+ case "up":
346
+ settings_sel.move_up()
347
+ case "down":
348
+ settings_sel.move_down()
349
+ case "enter" | " ":
350
+ settings_sel.activate()
351
+ case "escape":
352
+ if settings_sel.in_submenu:
353
+ settings_sel.cancel_submenu()
354
+ else:
355
+ cb = sel.on_cancel
356
+ self._active_selector = None
357
+ cb()
358
+ case "backspace":
359
+ settings_sel.backspace_search()
360
+ case ch if len(ch) == 1 and ch.isprintable():
361
+ # Prefer event.char so shifted keys keep their case
362
+ # (e.g. capitals in a case-sensitive API key).
363
+ settings_sel.append_search(event.char or ch)
364
+ self._tui.request_render()
365
+ return True
366
+
367
+ # Resume modal: session picker with search/scope/delete
368
+ resume_sel = sel.selector if sel.kind == "resume" else None
369
+ if resume_sel is not None:
370
+ match event.key:
371
+ case "up":
372
+ resume_sel.move_up()
373
+ case "down":
374
+ resume_sel.move_down()
375
+ case "tab":
376
+ resume_sel.toggle_scope()
377
+ case "r" if event.ctrl:
378
+ resume_sel.cycle_sort()
379
+ case "d" if event.ctrl:
380
+ resume_sel.start_delete()
381
+ case "enter":
382
+ if resume_sel.confirming_delete:
383
+ resume_sel.confirm_delete()
384
+ else:
385
+ path = resume_sel.selected_path()
386
+ cb, cancel_cb = sel.on_commit, sel.on_cancel
387
+ self._active_selector = None
388
+ if path is not None:
389
+ cb(path)
390
+ else:
391
+ cancel_cb()
392
+ case "escape":
393
+ if resume_sel.confirming_delete:
394
+ resume_sel.cancel_delete()
395
+ else:
396
+ cb = sel.on_cancel
397
+ self._active_selector = None
398
+ cb()
399
+ case "backspace":
400
+ resume_sel.backspace_search()
401
+ case ch if len(ch) == 1 and ch.isprintable():
402
+ resume_sel.append_search(ch)
403
+ self._tui.request_render()
404
+ return True
405
+
406
+ match event.key:
407
+ case "up":
408
+ sel.nav(-1)
409
+ case "down":
410
+ sel.nav(1)
411
+ # Page up/down for tree (page_up/page_down keys, or ctrl+←/→ as alias)
412
+ case "page_up" if tree_sel is not None:
413
+ tree_sel.page_up()
414
+ case "page_down" if tree_sel is not None:
415
+ tree_sel.page_down()
416
+ case "left" if tree_sel is not None and (event.ctrl or event.alt):
417
+ tree_sel.page_up()
418
+ case "right" if tree_sel is not None and (event.ctrl or event.alt):
419
+ tree_sel.page_down()
420
+ # ←/→ (plain) fold/unfold — standard tree behavior; alt+←/→ as alias
421
+ case "left" if tree_sel is not None:
422
+ tree_sel.fold_or_up()
423
+ case "right" if tree_sel is not None:
424
+ tree_sel.unfold_or_down()
425
+ case "enter" | "tab":
426
+ val = sel.selected_value()
427
+ cb, cancel_cb = sel.on_commit, sel.on_cancel
428
+ self._active_selector = None
429
+ if val is not None:
430
+ cb(val)
431
+ else:
432
+ cancel_cb()
433
+ case "escape":
434
+ cb = sel.on_cancel
435
+ self._active_selector = None
436
+ cb()
437
+ # Tree filter shortcuts (Ctrl+D/T/U/L/A)
438
+ case "d" if event.ctrl and tree_sel is not None:
439
+ tree_sel.set_filter("default")
440
+ case "t" if event.ctrl and tree_sel is not None:
441
+ tree_sel.toggle_filter("no-tools")
442
+ case "u" if event.ctrl and tree_sel is not None:
443
+ tree_sel.toggle_filter("user-only")
444
+ case "l" if event.ctrl and tree_sel is not None:
445
+ tree_sel.toggle_filter("labeled-only")
446
+ case "a" if event.ctrl and tree_sel is not None:
447
+ tree_sel.toggle_filter("all")
448
+ # Cycle filter (ctrl+f = forward, ctrl+o kept for compat)
449
+ case "f" if event.ctrl and tree_sel is not None:
450
+ tree_sel.cycle_filter()
451
+ # Label editing (shift+L)
452
+ case "l" if event.shift and tree_sel is not None:
453
+ tree_sel.start_label_edit()
454
+ # Label timestamp toggle (shift+T)
455
+ case "t" if event.shift and tree_sel is not None:
456
+ tree_sel.toggle_label_timestamps()
457
+ case "backspace" if sel.searchable:
458
+ sel.backspace_search()
459
+ case ch if sel.searchable and len(ch) == 1 and ch.isprintable():
460
+ sel.append_search(ch)
461
+ self._tui.request_render()
462
+ return True
463
+
464
+ # Settings panel: Esc closes
465
+ if self._settings_panel is not None:
466
+ if event.key == "escape":
467
+ self._settings_panel = None
468
+ self._tui.request_render()
469
+ return True
470
+
471
+ # Text prompt
472
+ if self._prompt.active:
473
+ self._prompt.handle_input(event)
474
+ self._tui.request_render()
475
+ return True
476
+
477
+ # File picker navigation (active when '@' is in progress)
478
+ if self.file_picker.active:
479
+ if event.key in ("up",):
480
+ self.file_picker.move_up()
481
+ return True
482
+ if event.key in ("down",):
483
+ self.file_picker.move_down()
484
+ return True
485
+ if event.key == "tab":
486
+ self._accept_file_or_descend()
487
+ return True
488
+ if event.key == "escape":
489
+ self.file_picker.close()
490
+ self._tui.request_render()
491
+ return True
492
+
493
+ # Autocomplete navigation (extension triggers + command arg completions)
494
+ consumed, new_text = self._autocomplete.handle_input(
495
+ event, self.input.text, self.input._cursor
496
+ )
497
+ if consumed:
498
+ if new_text is not None:
499
+ self.input.set_text(new_text)
500
+ self._tui.request_render()
501
+ return True
502
+
503
+ # Palette navigation
504
+ if self.palette.active:
505
+ if event.key == "up":
506
+ self.palette.move_up()
507
+ return True
508
+ if event.key == "down":
509
+ self.palette.move_down()
510
+ return True
511
+ if event.key == "escape":
512
+ self.input.clear()
513
+ self._sync_pickers()
514
+ return True
515
+ if event.key in ("tab", "right"):
516
+ self._accept_palette_selection()
517
+ return True
518
+ if event.key == "enter":
519
+ self._execute_palette_selection()
520
+ return True
521
+
522
+ result = self.input.handle_input(event)
523
+ # Escape in normal mode (no modal open, agent idle): clear the editor.
524
+ # While the agent is streaming, leave ESC unconsumed so it reaches the
525
+ # global key handler, which turns it into an abort.
526
+ if (
527
+ isinstance(event, KeyEvent)
528
+ and event.key == "escape"
529
+ and not result
530
+ and not self._is_busy()
531
+ ):
532
+ self.input.clear()
533
+ result = True
534
+ self._sync_pickers()
535
+ return result
536
+
537
+ def invalidate(self) -> None:
538
+ """Invalidate message rendering cache."""
539
+ self.messages.invalidate()
540
+
541
+ # -------------------------------------------------------------------------
542
+ # Command palette + file picker sync
543
+ # -------------------------------------------------------------------------
544
+
545
+ def set_commands(self, commands: list[CommandInfo]) -> None:
546
+ """Update the list of available commands for the palette."""
547
+ self._all_commands = list(commands)
548
+
549
+ def set_busy_check(self, is_busy: Callable[[], bool]) -> None:
550
+ """Wire a predicate reporting whether the agent is currently streaming.
551
+
552
+ Used so ESC clears the editor only when idle, and otherwise propagates
553
+ to the global key handler to abort the run.
554
+ """
555
+ self._is_busy = is_busy
556
+
557
+ def set_cwd(self, cwd: Path) -> None:
558
+ """Update the current working directory for file picker."""
559
+ self.file_picker._root = cwd
560
+ self.file_picker._cwd = cwd
561
+
562
+ def set_model_callbacks(
563
+ self,
564
+ commit_cb: Callable[[str, str], None], # noqa: ARG002
565
+ current_key_cb: Callable[[], str], # noqa: ARG002
566
+ ) -> None:
567
+ pass # inline model palette removed; /model now opens a modal
568
+
569
+ def _update_arg_hint(self, text: str) -> None:
570
+ """Show per-placeholder ghost text after the cursor, dropping each token as the user types."""
571
+ import re
572
+ import shlex
573
+ if not text.startswith("/") or " " not in text:
574
+ self.input._arg_hint = ""
575
+ return
576
+ space_idx = text.index(" ")
577
+ cmd_name = text[1:space_idx]
578
+ args_part = text[space_idx + 1:]
579
+ cmd = next((c for c in self._all_commands if c.name == cmd_name or cmd_name in (c.aliases or [])), None)
580
+ if cmd is None or not cmd.argument_hint:
581
+ self.input._arg_hint = ""
582
+ return
583
+ hint_raw = cmd.argument_hint.strip()
584
+ placeholders = re.findall(r"<[^>]+>", hint_raw)
585
+ if not placeholders:
586
+ self.input._arg_hint = "" if args_part.strip() else hint_raw
587
+ return
588
+ # Use shlex so quoted "multi word arg" counts as one token (matches expand.py)
589
+ try:
590
+ n_started = len(shlex.split(args_part))
591
+ except ValueError:
592
+ n_started = len(args_part.split())
593
+ # A token being actively typed is already "consumed" — only hint what's still needed
594
+ remaining = placeholders[n_started:]
595
+ if remaining:
596
+ mid_token = bool(args_part) and not args_part[-1].isspace()
597
+ self.input._arg_hint = (" " if mid_token else "") + " ".join(remaining)
598
+ else:
599
+ self.input._arg_hint = ""
600
+
601
+ def _sync_pickers(self) -> None:
602
+ """After every keystroke, decide which picker (if any) should be visible."""
603
+ text = self.input.text
604
+ self._update_arg_hint(text)
605
+
606
+ # File picker: activated by '@' with no space between '@' and cursor.
607
+ # Suppressed while browsing history so past @mentions don't hijack focus.
608
+ at_info = self._find_at_query(text) if self.input._history_idx == -1 else None
609
+ if at_info is not None:
610
+ at_pos, query = at_info
611
+ if not self.file_picker.active:
612
+ self.file_picker.open()
613
+ self._at_pos = at_pos
614
+ self.file_picker.set_query(query)
615
+ if len(query) >= 2 and not self.file_picker._entries:
616
+ self.file_picker.close()
617
+ self.palette.set_commands([])
618
+ self._autocomplete.clear()
619
+ return
620
+
621
+ if self.file_picker.active:
622
+ self.file_picker.close()
623
+
624
+ # Command palette: activated by leading '/' with no space.
625
+ # Suppressed while the user is browsing history so navigating past
626
+ # a previously-run slash command doesn't hijack focus.
627
+ if text.startswith("/") and " " not in text and self.input._history_idx == -1:
628
+ self.palette.set_commands(self._all_commands)
629
+ self.palette.set_query(text[1:])
630
+ self._autocomplete.clear()
631
+ return
632
+
633
+ self.palette.set_commands([])
634
+
635
+ # Delegate all autocomplete logic (cmd args + extension triggers) to the manager
636
+ self._autocomplete.sync(text, self.input._cursor, self._all_commands)
637
+
638
+ def _find_at_query(self, text: str) -> tuple[int, str] | None:
639
+ """
640
+ Find the rightmost '@' before the cursor whose following text has no spaces.
641
+ Returns (at_pos, query_after_@) or None.
642
+ """
643
+ cursor = self.input._cursor
644
+ before = text[:cursor]
645
+ at_pos = before.rfind("@")
646
+ if at_pos == -1:
647
+ return None
648
+ if at_pos > 0 and before[at_pos - 1] not in (" ", "\n"):
649
+ return None
650
+ after_at = before[at_pos + 1:]
651
+ if " " in after_at or ":" in after_at:
652
+ return None
653
+ return at_pos, after_at
654
+
655
+ def _accept_file_or_descend(self) -> None:
656
+ """Tab pressed while file picker is active."""
657
+ entry = self.file_picker.enter_selected()
658
+ text = self.input.text
659
+ cursor = self.input._cursor
660
+
661
+ if entry is None:
662
+ rel = self.file_picker.cwd_relative_path
663
+ new_text = text[: self._at_pos] + "@" + rel + "/" + text[cursor:]
664
+ self.input.set_text(new_text)
665
+ self._at_pos = self._at_pos
666
+ else:
667
+ rel_path = self.file_picker.relative_path(entry)
668
+ new_text = text[: self._at_pos] + "@" + rel_path + " " + text[cursor:]
669
+ self.input.set_text(new_text)
670
+ self.file_picker.close()
671
+
672
+ def _accept_palette_selection(self) -> None:
673
+ """Insert selected command into input with trailing space."""
674
+ sel = self.palette.selected
675
+ if sel:
676
+ self.input.set_text(f"/{sel.name} ")
677
+ self.palette.set_commands([])
678
+ self._sync_pickers()
679
+
680
+ def _execute_palette_selection(self) -> None:
681
+ """Execute selected command immediately."""
682
+ sel = self.palette.selected
683
+ if sel:
684
+ self.input.set_text(f"/{sel.name}")
685
+ self.palette.set_commands([])
686
+ self.input._submit()
687
+
688
+ # -------------------------------------------------------------------------
689
+ # Helpers for the app layer
690
+ # -------------------------------------------------------------------------
691
+
692
+ def add_message(self, message: object, streaming: bool = False) -> MessageBlock:
693
+ """Add a message to the message list."""
694
+ block = self.messages.add_message(message, streaming=streaming)
695
+ self._tui.notify_content_added()
696
+ return block
697
+
698
+ def clear_messages(self) -> None:
699
+ """Clear all messages and pending lines."""
700
+ self.messages.clear()
701
+ self._pending_lines.lines = []
702
+
703
+ def on_submit(self, callback: Callable[[str], None]) -> None:
704
+ """Register callback for user submission."""
705
+ self._stored_submit_cb = callback
706
+ self.input._on_submit = callback
707
+
708
+ def on_followup(self, callback: Callable[[str], None]) -> None:
709
+ """Register callback for follow-up messages."""
710
+ self._stored_followup_cb = callback
711
+ self.input._on_followup = callback
712
+
713
+ def on_dequeue(self, callback: Callable[[], None]) -> None:
714
+ """Register callback for dequeue action."""
715
+ self._stored_dequeue_cb = callback
716
+ self.input._on_dequeue = callback
717
+
718
+ # -------------------------------------------------------------------------
719
+ # Extension customization
720
+ # -------------------------------------------------------------------------
721
+
722
+ def set_widget(self, id: str, widget: Any, placement: str = "above_editor") -> None:
723
+ """Add or replace a keyed widget in the layout.
724
+
725
+ ``widget`` can be a ``Component`` instance or a list of strings
726
+ (rendered as a StaticComponent). Use ``placement="below_editor"``
727
+ to place the widget below the text input instead of above it.
728
+
729
+ Alternatively you can add components directly to the Container zones::
730
+
731
+ layout.widgets_above.add_child(MyWidget())
732
+ layout.widgets_below.add_child(MyOtherWidget())
733
+ """
734
+ from tau.tui.component import StaticComponent
735
+ component = widget if isinstance(widget, Component) else StaticComponent(widget)
736
+ if placement == "below_editor":
737
+ old = self._widgets_below_map.pop(id, None)
738
+ if old is not None:
739
+ self.widgets_below.remove_child(old)
740
+ self._widgets_below_map[id] = component
741
+ self.widgets_below.add_child(component)
742
+ else:
743
+ old = self._widgets_above_map.pop(id, None)
744
+ if old is not None:
745
+ self.widgets_above.remove_child(old)
746
+ self._widgets_above_map[id] = component
747
+ self.widgets_above.add_child(component)
748
+ self._tui.request_render()
749
+
750
+ def remove_widget(self, id: str) -> None:
751
+ """Remove a keyed widget from the layout."""
752
+ above = self._widgets_above_map.pop(id, None)
753
+ if above is not None:
754
+ self.widgets_above.remove_child(above)
755
+ below = self._widgets_below_map.pop(id, None)
756
+ if below is not None:
757
+ self.widgets_below.remove_child(below)
758
+ self._tui.request_render()
759
+
760
+ def set_footer(self, component_or_factory: "Component | Callable[[], Component] | None") -> None:
761
+ """
762
+ Replace the footer contents with a custom component, or clear it.
763
+
764
+ Accepts a ``Component`` instance, a zero-argument factory, or ``None``
765
+ to clear the footer entirely::
766
+
767
+ layout.set_footer(MyFooter()) # component instance
768
+ layout.set_footer(lambda: MyFooter()) # factory
769
+ layout.set_footer(None) # clear footer
770
+ """
771
+ if component_or_factory is None:
772
+ self.footer.clear()
773
+ self._tui.request_render()
774
+ return
775
+ elif callable(component_or_factory) and not isinstance(component_or_factory, Component):
776
+ replacement = component_or_factory()
777
+ else:
778
+ replacement = component_or_factory # type: ignore[assignment]
779
+
780
+ # Replace the entire footer Container child (the Row) with the custom component
781
+ self.footer.clear()
782
+ self.footer.add_child(replacement)
783
+ self._tui.request_render()
784
+
785
+ def set_custom_footer(self, component: "Component | None") -> None:
786
+ """Backwards-compatible alias for set_footer()."""
787
+ self.set_footer(component)
788
+
789
+ def register_autocomplete_provider(self, registration: "AutocompleteRegistration") -> None:
790
+ """Wire an extension autocomplete provider into the layout."""
791
+ self._autocomplete.register_provider(registration)
792
+
793
+ def set_header(self, component_or_factory: "Component | Callable[[], Component] | None") -> None:
794
+ """
795
+ Set the header component rendered above the message list.
796
+
797
+ Replaces whatever is currently in the header Container with a single
798
+ component. For more control (multiple items, spacers…) access
799
+ ``layout.header`` directly::
800
+
801
+ layout.header.add_child(Banner())
802
+ layout.header.add_child(Spacer(1))
803
+
804
+ Pass ``None`` to clear the header entirely.
805
+ """
806
+ self.header.clear()
807
+ if component_or_factory is None:
808
+ pass
809
+ elif callable(component_or_factory) and not isinstance(component_or_factory, Component):
810
+ self.header.add_child(component_or_factory())
811
+ else:
812
+ self.header.add_child(component_or_factory) # type: ignore[arg-type]
813
+ self._tui.request_render()
814
+
815
+ def set_status(self, key: str, text: "str | None") -> None:
816
+ """
817
+ Set or clear a keyed status line shown above the editor.
818
+
819
+ Status lines are rendered as dim text in the zone between the chat
820
+ area and the editor — a dedicated slot separate from the footer.
821
+ They persist until explicitly cleared::
822
+
823
+ layout.set_status("git", "main (3 commits ahead)")
824
+ layout.set_status("git", None) # clear
825
+ """
826
+ if text is None:
827
+ self._status_map.pop(key, None)
828
+ else:
829
+ self._status_map[key] = text
830
+ self._tui.request_render()
831
+
832
+ def set_title(self, title: str) -> None:
833
+ """Set the terminal window title bar text."""
834
+ self._tui.terminal.set_title(title)
835
+
836
+ async def custom(
837
+ self,
838
+ factory: "Callable[[TUI, Callable[[Any], None]], Component]",
839
+ options: "CustomOptions | None" = None,
840
+ ) -> "Any":
841
+ """
842
+ Show a custom component with keyboard focus.
843
+
844
+ Show a custom component with keyboard focus.
845
+
846
+ Without ``options.overlay=True`` (default) the TUI root is swapped
847
+ to the custom component for a full-screen takeover; when ``done(result)``
848
+ is called the layout is restored and the awaited value is returned::
849
+
850
+ result = await layout.custom(
851
+ lambda tui, done: MyScreen(on_close=done)
852
+ )
853
+
854
+ With ``options.overlay=True`` the component is shown as a floating
855
+ overlay on top of the existing layout::
856
+
857
+ result = await layout.custom(
858
+ lambda tui, done: MyDialog(on_close=done),
859
+ CustomOptions(overlay=True, overlay_options=OverlayOptions(width="60%")),
860
+ )
861
+ """
862
+ import asyncio
863
+ from tau.tui.overlay import CustomOptions as _CustomOptions
864
+
865
+ opts = options or _CustomOptions()
866
+ loop = asyncio.get_event_loop()
867
+ future: asyncio.Future = loop.create_future()
868
+
869
+ def _done(result: Any = None) -> None:
870
+ if not future.done():
871
+ future.set_result(result)
872
+
873
+ component = factory(self._tui, _done)
874
+
875
+ if opts.overlay:
876
+ handle = self._tui.show_overlay(component, opts.overlay_options)
877
+ if opts.on_handle:
878
+ opts.on_handle(handle)
879
+ try:
880
+ return await future
881
+ finally:
882
+ handle.close()
883
+ else:
884
+ # Full-screen takeover: detach all layout zones and install component
885
+ self.detach(self._tui)
886
+ self._tui.add_child(component)
887
+ self._tui.set_focus(component)
888
+ try:
889
+ return await future
890
+ finally:
891
+ self.detach(self._tui)
892
+ self.attach(self._tui)
893
+ self._tui.set_focus(self)
894
+
895
+ def get_editor_text(self) -> str:
896
+ """Get the current text in the input editor."""
897
+ return self.input.text
898
+
899
+ def set_editor_text(self, text: str) -> None:
900
+ """Replace the input editor text."""
901
+ self.input.set_text(text)
902
+ self._tui.request_render()
903
+
904
+ def paste_to_editor(self, text: str) -> None:
905
+ """Insert text at the cursor position in the editor."""
906
+ self.input.insert_at_cursor(text)
907
+ self._tui.request_render()
908
+
909
+ def set_custom_input(self, factory: Callable[[Any, Any], Any] | None) -> None:
910
+ """Replace the input widget with a custom implementation."""
911
+ from tau.tui.keybindings import get_keybindings
912
+ self._custom_input_factory = factory
913
+ if factory is None:
914
+ from tau.tui.components.text_input import TextInput
915
+ new_input: Any = TextInput(
916
+ prefix=self._theme.input.prefix,
917
+ placeholder=self._theme.input.placeholder,
918
+ )
919
+ else:
920
+ new_input = factory(self._theme.input, get_keybindings())
921
+ self.input = new_input
922
+ if self._stored_submit_cb is not None:
923
+ self.input._on_submit = self._stored_submit_cb
924
+ if self._stored_followup_cb is not None:
925
+ self.input._on_followup = self._stored_followup_cb
926
+ if self._stored_dequeue_cb is not None:
927
+ self.input._on_dequeue = self._stored_dequeue_cb
928
+ self._tui.request_render()
929
+
930
+ def set_pending_queue(
931
+ self,
932
+ steering: list[str],
933
+ followup: list[str],
934
+ dequeue_hint: str = "Alt+↑ to edit queued",
935
+ ) -> None:
936
+ """Rebuild the pending-messages display between spinner and input."""
937
+ from tau.tui.ansi import DIM, RESET
938
+ lines: list[str] = []
939
+ for msg in steering:
940
+ preview = msg.replace("\n", " ")[:80]
941
+ lines.append(f" {DIM}Steering:{RESET} {DIM}{preview}{RESET}")
942
+ for msg in followup:
943
+ preview = msg.replace("\n", " ")[:80]
944
+ lines.append(f" {DIM}Follow-up:{RESET} {DIM}{preview}{RESET}")
945
+ if lines:
946
+ lines.append(f" {DIM}↳ {dequeue_hint}{RESET}")
947
+ self._pending_lines.lines = lines
948
+
949
+ def restore_queued_to_editor(self, messages: list[str]) -> None:
950
+ """Put all queued message text back into the editor (joined by blank lines)."""
951
+ combined = "\n\n".join(messages)
952
+ current = self.input.text.strip()
953
+ merged = "\n\n".join(filter(None, [combined, current]))
954
+ self.input.set_text(merged)
955
+
956
+ # -------------------------------------------------------------------------
957
+ # Theme
958
+ # -------------------------------------------------------------------------
959
+
960
+ def set_theme(self, theme: LayoutTheme) -> None:
961
+ """Swap the active theme and propagate it to every child component."""
962
+ self._theme = theme
963
+ self.messages.set_theme(theme.message)
964
+ self.messages.set_user_prefix(theme.input.prefix)
965
+ self.spinner.set_theme(theme.spinner)
966
+ self.input._prefix = theme.input.prefix
967
+ self.input._placeholder = theme.input.placeholder
968
+ self._tui.request_render()
969
+
970
+ # -------------------------------------------------------------------------
971
+ # Inline pickers — rendered in the content stream, no floating windows
972
+ # -------------------------------------------------------------------------
973
+
974
+ def _make_select_list(
975
+ self, items: list[SelectItem], current_label: str | None = None
976
+ ) -> SelectList:
977
+ """Build a SelectList with theme/visibility settings and optional initial selection."""
978
+ selector = SelectList(items, max_visible=self._picker_max_visible, theme=self._theme.select_list)
979
+ if current_label is not None:
980
+ labels = [item.label for item in items]
981
+ if current_label in labels:
982
+ selector._selected = labels.index(current_label)
983
+ return selector
984
+
985
+ def open_model_selector(
986
+ self,
987
+ models: list,
988
+ current_key: str,
989
+ on_commit: Callable[[tuple[str, str]], None],
990
+ on_cancel: Callable[[], None],
991
+ ) -> None:
992
+ """Open the model selector modal."""
993
+ from tau.tui.components.model_palette import ModelSelectorModal
994
+ modal = ModelSelectorModal(models, current_key)
995
+ self._active_selector = InlineSelector(
996
+ kind="model", selector=modal,
997
+ on_commit=on_commit, on_cancel=on_cancel,
998
+ )
999
+ self._tui.request_render()
1000
+
1001
+ @property
1002
+ def theme_selector_active(self) -> bool:
1003
+ return self._active_selector is not None and self._active_selector.kind == "theme"
1004
+
1005
+ def open_theme_selector(
1006
+ self,
1007
+ names: list[str],
1008
+ current: str,
1009
+ on_preview: Callable[[str], None],
1010
+ on_commit: Callable[[str], None],
1011
+ on_cancel: Callable[[], None],
1012
+ ) -> None:
1013
+ """Open a theme selector with live preview support."""
1014
+ from tau.tui.components.modal import ListModal
1015
+ modal = ListModal(names, current, "Theme", "Select color theme", on_preview=on_preview)
1016
+ self._active_selector = InlineSelector(
1017
+ kind="theme", selector=modal,
1018
+ on_commit=on_commit, on_cancel=on_cancel,
1019
+ )
1020
+ self._tui.request_render()
1021
+
1022
+ def open_effort_selector(
1023
+ self,
1024
+ levels: list[str],
1025
+ current: str,
1026
+ on_commit: Callable[[str], None],
1027
+ on_cancel: Callable[[], None],
1028
+ ) -> None:
1029
+ """Open an effort/thinking level selector modal."""
1030
+ from tau.tui.components.modal import ListModal
1031
+ modal = ListModal(levels, current, "Thinking Effort", "Select effort level")
1032
+ self._active_selector = InlineSelector(
1033
+ kind="effort", selector=modal,
1034
+ on_commit=on_commit, on_cancel=on_cancel,
1035
+ )
1036
+ self._tui.request_render()
1037
+
1038
+ def open_settings_selector(
1039
+ self,
1040
+ modal: object,
1041
+ on_cancel: Callable[[], None],
1042
+ ) -> None:
1043
+ """Open the interactive settings modal."""
1044
+ self._active_selector = InlineSelector(
1045
+ kind="settings", selector=modal,
1046
+ on_commit=lambda _: None, on_cancel=on_cancel,
1047
+ )
1048
+ self._tui.request_render()
1049
+
1050
+ def open_resume_selector(
1051
+ self,
1052
+ sessions: list,
1053
+ on_commit: Callable[[Path], None],
1054
+ on_cancel: Callable[[], None],
1055
+ all_sessions_loader: Callable[[], list] | None = None,
1056
+ current_session_path: Path | None = None,
1057
+ ) -> None:
1058
+ """Open the session resume selector with search, scope toggle, and delete."""
1059
+ from tau.tui.components.resume_modal import ResumeModal
1060
+ modal = ResumeModal(
1061
+ current_sessions=sessions,
1062
+ all_sessions_loader=all_sessions_loader or (lambda: []),
1063
+ current_session_path=current_session_path,
1064
+ max_visible=self._picker_max_visible,
1065
+ )
1066
+ self._active_selector = InlineSelector(
1067
+ kind="resume", selector=modal,
1068
+ on_commit=on_commit, on_cancel=on_cancel,
1069
+ )
1070
+ self._tui.request_render()
1071
+
1072
+ def open_tree_selector(
1073
+ self,
1074
+ items: list[SelectItem[str]],
1075
+ on_commit: Callable[[str], None],
1076
+ on_cancel: Callable[[], None],
1077
+ ) -> None:
1078
+ """Open a tree/list selector modal."""
1079
+ self._active_selector = InlineSelector(
1080
+ kind="tree", selector=self._make_select_list(items),
1081
+ on_commit=on_commit, on_cancel=on_cancel,
1082
+ )
1083
+ self._tui.request_render()
1084
+
1085
+ def open_branch_tree_selector(
1086
+ self,
1087
+ rows: list[TreeRow[str]],
1088
+ on_commit: Callable[[str], None],
1089
+ on_cancel: Callable[[], None],
1090
+ ) -> None:
1091
+ """Open the session branch-history tree selector (role-colored, tree-connector rows)."""
1092
+ m = self._theme.message
1093
+
1094
+ def role_color(role: str, text: str) -> Callable[[str], str]:
1095
+ if role == "user":
1096
+ return m.you_label
1097
+ if role == "assistant":
1098
+ return m.assistant_label
1099
+ if role == "branch_summary":
1100
+ return m.tool_arrow
1101
+ if role == "tool":
1102
+ return m.tool_result_err if text.startswith("[error]") else m.tool_result_ok
1103
+ return m.dim
1104
+
1105
+ # Size the tree to half the terminal height (min 5), not a fixed picker size.
1106
+ tree_max_visible = max(5, self._tui.terminal.height // 2)
1107
+ selector = TreeSelectList(
1108
+ rows,
1109
+ role_color=role_color,
1110
+ accent_color=m.you_label,
1111
+ dim_color=m.dim,
1112
+ max_visible=tree_max_visible,
1113
+ selected_bg=self._theme.select_list.selected_bg,
1114
+ )
1115
+ self._active_selector = InlineSelector(
1116
+ kind="tree", selector=selector,
1117
+ on_commit=on_commit, on_cancel=on_cancel,
1118
+ searchable=True,
1119
+ )
1120
+ self._tui.request_render()
1121
+
1122
+ # ── Text prompt ───────────────────────────────────────────────────────────
1123
+
1124
+ def open_prompt(
1125
+ self,
1126
+ label: str,
1127
+ on_commit: Callable[[str], None],
1128
+ on_cancel: Callable[[], None],
1129
+ *,
1130
+ secret: bool = False,
1131
+ ) -> None:
1132
+ """Open a text input prompt modal."""
1133
+ self._prompt.open(label, on_commit, on_cancel, secret=secret)
1134
+ self._tui.request_render()
1135
+
1136
+ # ── Multi-line editor overlay ─────────────────────────────────────────────
1137
+
1138
+ def open_editor(
1139
+ self,
1140
+ title: str,
1141
+ prefill: str,
1142
+ on_commit: Callable[[str], None],
1143
+ on_cancel: Callable[[], None],
1144
+ ) -> None:
1145
+ """Open a floating multi-line text editor overlay."""
1146
+ from tau.tui.components.prompt_overlay import EditorOverlay
1147
+ from tau.tui.overlay import OverlayOptions
1148
+
1149
+ handle_ref: list[OverlayHandle] = []
1150
+
1151
+ def _commit(value: str) -> None:
1152
+ if handle_ref:
1153
+ handle_ref[0].close()
1154
+ on_commit(value)
1155
+
1156
+ def _cancel() -> None:
1157
+ if handle_ref:
1158
+ handle_ref[0].close()
1159
+ on_cancel()
1160
+
1161
+ editor = EditorOverlay(title, prefill=prefill, on_commit=_commit, on_cancel=_cancel)
1162
+ opts = OverlayOptions(width="80%", max_height="70%", anchor="center")
1163
+ handle = self._tui.show_overlay(editor, opts)
1164
+ handle_ref.append(handle)
1165
+
1166
+ # ── Settings panel ────────────────────────────────────────────────────────
1167
+
1168
+ def open_settings_panel(self, lines: list[str]) -> None:
1169
+ """Show a read-only settings panel inline. Esc closes it."""
1170
+ self._settings_panel = lines
1171
+ self._tui.request_render()
1172
+
1173
+ def close_settings_panel(self) -> None:
1174
+ """Close the settings panel."""
1175
+ self._settings_panel = None
1176
+ self._tui.request_render()
1177
+
1178
+ # ── OAuth status (inline) ─────────────────────────────────────────────────
1179
+
1180
+ def open_oauth_status(self, lines: list[str]) -> None:
1181
+ """Show OAuth progress inline in the content stream."""
1182
+ self._oauth_status_lines = list(lines)
1183
+ self._tui.request_render()
1184
+
1185
+ def update_oauth_status(self, line: str) -> None:
1186
+ """Append a progress line to the OAuth status display."""
1187
+ if self._oauth_status_lines is not None:
1188
+ self._oauth_status_lines.append(line)
1189
+ self._tui.request_render()
1190
+
1191
+ def close_oauth_status(self) -> None:
1192
+ """Clear the OAuth status display."""
1193
+ self._oauth_status_lines = None
1194
+ self._tui.request_render()