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/renderer.py ADDED
@@ -0,0 +1,378 @@
1
+ from __future__ import annotations
2
+
3
+ from tau.tui.ansi import (
4
+ CURSOR_MARKER, RESET, visible_width, truncate,
5
+ _ANSI_RE, _char_width, # type: ignore[attr-defined]
6
+ )
7
+ from tau.tui.terminal import Terminal
8
+ from tau.tui.component import Component
9
+
10
+ _CURSOR_MARKER_LEN = len(CURSOR_MARKER)
11
+
12
+ # Blank columns reserved on the left/right edges of the terminal so content
13
+ # never touches the window border.
14
+ _LEFT_PAD = 1
15
+ _RIGHT_PAD = 1
16
+
17
+
18
+ class Renderer:
19
+ """
20
+ Scrollback-mode differential renderer.
21
+
22
+ Renders into the main terminal buffer — no alternate screen. Content
23
+ grows downward; old lines scroll into the terminal's native scrollback
24
+ buffer so the user can scroll back with the terminal's own scrollbar.
25
+
26
+ Positioning uses only relative cursor moves (ESC[NA / ESC[NB) so the
27
+ terminal's own scroll state is never disrupted. Overlays are composited
28
+ directly into the content lines before the diff, keeping a single
29
+ rendering pass.
30
+ """
31
+
32
+ def __init__(self, terminal: Terminal, show_hardware_cursor: bool = False) -> None:
33
+ self._terminal = terminal
34
+ self._show_hardware_cursor = show_hardware_cursor
35
+ self._prev_lines: list[str] = []
36
+ self._cursor_row: int = 0 # logical index of last rendered line
37
+ self._hw_cursor_row: int = 0 # where the terminal cursor actually is
38
+ self._viewport_top: int = 0 # first logical line visible on screen
39
+ self._max_lines: int = 0
40
+ self._prev_width: int = 0
41
+ self._prev_height: int = 0
42
+ self._resized: bool = False
43
+ # Memoizes the width-clamp of each line (line -> clamped line). Unchanged
44
+ # blocks emit stable string objects, so this skips the costly visible_width
45
+ # ANSI scan for every line that didn't change since the last frame.
46
+ self._clamp_cache: dict[str, str] = {}
47
+ self._clamp_cache_width: int = 0
48
+ terminal.on_resize(self._on_resize)
49
+
50
+ # -------------------------------------------------------------------------
51
+ # Public API
52
+ # -------------------------------------------------------------------------
53
+
54
+ def render(self, component: Component, overlays: list | None = None) -> None:
55
+ """Render component differentially into the terminal scrollback buffer."""
56
+ width = self._terminal.width - _LEFT_PAD - _RIGHT_PAD
57
+ height = self._terminal.height
58
+ width_changed = self._resized or (self._prev_width != 0 and self._prev_width != width)
59
+ self._resized = False
60
+
61
+ new_lines: list[str] = component.render(width)
62
+
63
+ # Clamp every line to the terminal width. A logical line wider than
64
+ # `width` causes the terminal to auto-wrap onto extra physical rows,
65
+ # which desynchronises _hw_cursor_row (a logical index) from the real
66
+ # hardware cursor row (counted in physical rows). That divergence makes
67
+ # the differential render write to the wrong rows, producing the
68
+ # duplicated-lines artifact that disappears on resize (full-clear redraw).
69
+ #
70
+ # `visible_width` runs an ANSI regex per line, which dominates frame cost
71
+ # on long transcripts (every line, every frame). Memoize the clamp by line
72
+ # value, rebuilt each frame so it stays bounded to the visible content —
73
+ # unchanged lines (stable cached-block strings) hit the cache and skip the
74
+ # scan entirely.
75
+ if width != self._clamp_cache_width:
76
+ self._clamp_cache = {}
77
+ self._clamp_cache_width = width
78
+ prev_clamp = self._clamp_cache
79
+ clamp: dict[str, str] = {}
80
+ clamped: list[str] = []
81
+ for line in new_lines:
82
+ out = prev_clamp.get(line)
83
+ if out is None:
84
+ out = truncate(line, width) if visible_width(line) > width else line
85
+ clamp[line] = out
86
+ clamped.append(out)
87
+ self._clamp_cache = clamp
88
+ new_lines = clamped
89
+
90
+ # Always have at least one line so index arithmetic stays valid.
91
+ if not new_lines:
92
+ new_lines = [""]
93
+
94
+ # Composite overlays into the visible viewport portion of new_lines.
95
+ if overlays:
96
+ new_lines = self._composite_overlays(new_lines, overlays, width, height)
97
+
98
+ # Locate and strip CURSOR_MARKER (TextInput IME cursor position).
99
+ cursor_pos: tuple[int, int] | None = None
100
+ for _r, _line in enumerate(new_lines):
101
+ if CURSOR_MARKER in _line:
102
+ _mi = _line.index(CURSOR_MARKER)
103
+ cursor_pos = (_r, visible_width(_line[:_mi]) + _LEFT_PAD)
104
+ new_lines[_r] = _line[:_mi] + _line[_mi + _CURSOR_MARKER_LEN:]
105
+ break
106
+
107
+ # Reserve the left/right margins on every line.
108
+ left_pad = " " * _LEFT_PAD
109
+ right_pad = " " * _RIGHT_PAD
110
+ new_lines = [left_pad + line + right_pad for line in new_lines]
111
+
112
+ # First render (or after reset()).
113
+ if not self._prev_lines and not width_changed:
114
+ self._full_render(new_lines, cursor_pos, width, height, clear=False)
115
+ return
116
+
117
+ # Width changed — wrapping changes, must fully redraw.
118
+ if width_changed:
119
+ self._full_render(new_lines, cursor_pos, width, height, clear=True)
120
+ return
121
+
122
+ prev = self._prev_lines
123
+
124
+ # Find first and last changed line.
125
+ max_len = max(len(new_lines), len(prev))
126
+ first_changed = -1
127
+ last_changed = -1
128
+ for i in range(max_len):
129
+ old_line = prev[i] if i < len(prev) else ""
130
+ new_line = new_lines[i] if i < len(new_lines) else ""
131
+ if old_line != new_line:
132
+ if first_changed == -1:
133
+ first_changed = i
134
+ last_changed = i
135
+
136
+ # No content changes — only reposition IME cursor if needed.
137
+ if first_changed == -1:
138
+ self._position_hw_cursor(cursor_pos, new_lines)
139
+ self._prev_height = height
140
+ return
141
+
142
+ # Changed line is above the visible viewport — full redraw.
143
+ if first_changed < self._viewport_top:
144
+ self._full_render(new_lines, cursor_pos, width, height, clear=True)
145
+ return
146
+
147
+ # === Differential render ===
148
+ buf = self._terminal.begin_sync()
149
+
150
+ viewport_top = self._viewport_top
151
+ hw_cursor = self._hw_cursor_row
152
+ viewport_bottom = viewport_top + height - 1
153
+
154
+ # If first changed row is beyond the viewport bottom, scroll down to it.
155
+ if first_changed > viewport_bottom:
156
+ current_screen_row = hw_cursor - viewport_top
157
+ move_to_bottom = max(0, (height - 1) - current_screen_row)
158
+ if move_to_bottom > 0:
159
+ buf += f"\x1b[{move_to_bottom}B"
160
+ scroll = first_changed - viewport_bottom
161
+ buf += "\r\n" * scroll
162
+ viewport_top += scroll
163
+ hw_cursor = first_changed
164
+ viewport_bottom = viewport_top + height - 1
165
+
166
+ # Move cursor up/down to the first changed line.
167
+ line_diff = first_changed - hw_cursor
168
+ if line_diff > 0:
169
+ buf += f"\x1b[{line_diff}B"
170
+ elif line_diff < 0:
171
+ buf += f"\x1b[{-line_diff}A"
172
+ buf += "\r"
173
+ hw_cursor = first_changed
174
+
175
+ render_end = min(last_changed, len(new_lines) - 1)
176
+ for i in range(first_changed, render_end + 1):
177
+ if i > first_changed:
178
+ buf += "\r\n"
179
+ hw_cursor += 1
180
+ buf += "\x1b[2K"
181
+ if i < len(new_lines) and new_lines[i]:
182
+ buf += new_lines[i]
183
+
184
+ final_cursor_row = render_end
185
+
186
+ # Clear extra lines if content shrank.
187
+ if len(prev) > len(new_lines):
188
+ if render_end < len(new_lines) - 1:
189
+ move_down = len(new_lines) - 1 - render_end
190
+ buf += f"\x1b[{move_down}B"
191
+ final_cursor_row = len(new_lines) - 1
192
+ extra = len(prev) - len(new_lines)
193
+ for _ in range(extra):
194
+ buf += "\r\n\x1b[2K"
195
+ buf += f"\x1b[{extra}A"
196
+
197
+ buf += self._terminal.end_sync()
198
+ self._terminal.write(buf)
199
+
200
+ self._cursor_row = max(0, len(new_lines) - 1)
201
+ self._hw_cursor_row = final_cursor_row
202
+ self._max_lines = max(self._max_lines, len(new_lines))
203
+ self._viewport_top = max(viewport_top, final_cursor_row - height + 1)
204
+ self._prev_lines = new_lines
205
+ self._prev_width = width
206
+ self._prev_height = height
207
+
208
+ self._position_hw_cursor(cursor_pos, new_lines)
209
+
210
+ def clear(self) -> None:
211
+ """Erase the entire screen and scrollback buffer."""
212
+ self._terminal.write_flush(
213
+ self._terminal.begin_sync()
214
+ + "\x1b[2J\x1b[H\x1b[3J"
215
+ + self._terminal.end_sync()
216
+ )
217
+ self._prev_lines = []
218
+ self._cursor_row = 0
219
+ self._hw_cursor_row = 0
220
+ self._viewport_top = 0
221
+ self._max_lines = 0
222
+
223
+ def reset(self) -> None:
224
+ """Force a full re-render on the next frame without clearing the screen."""
225
+ self._prev_lines = []
226
+ self._cursor_row = 0
227
+ self._hw_cursor_row = 0
228
+ self._viewport_top = 0
229
+
230
+ # -------------------------------------------------------------------------
231
+ # Internal helpers
232
+ # -------------------------------------------------------------------------
233
+
234
+ def _full_render(
235
+ self,
236
+ new_lines: list[str],
237
+ cursor_pos: tuple[int, int] | None,
238
+ width: int,
239
+ height: int,
240
+ *,
241
+ clear: bool,
242
+ ) -> None:
243
+ buf = self._terminal.begin_sync()
244
+ if clear:
245
+ buf += "\x1b[2J\x1b[H\x1b[3J" # clear screen + scrollback
246
+ else:
247
+ buf += "\r" # start from column 0 for first render
248
+ for i, line in enumerate(new_lines):
249
+ if i > 0:
250
+ buf += "\r\n"
251
+ buf += "\x1b[2K"
252
+ buf += line
253
+ buf += self._terminal.end_sync()
254
+ self._terminal.write(buf)
255
+
256
+ self._cursor_row = max(0, len(new_lines) - 1)
257
+ self._hw_cursor_row = self._cursor_row
258
+ self._max_lines = len(new_lines) if clear else max(self._max_lines, len(new_lines))
259
+ buf_len = max(height, len(new_lines))
260
+ self._viewport_top = max(0, buf_len - height)
261
+ self._prev_lines = new_lines
262
+ self._prev_width = width
263
+ self._prev_height = height
264
+
265
+ self._position_hw_cursor(cursor_pos, new_lines)
266
+
267
+ def _position_hw_cursor(
268
+ self, cursor_pos: tuple[int, int] | None, new_lines: list[str]
269
+ ) -> None:
270
+ """Move the hardware terminal cursor to the IME position and show/hide it."""
271
+ if cursor_pos is None or not new_lines:
272
+ self._terminal.write_flush("\x1b[?25l")
273
+ return
274
+
275
+ target_row, target_col = cursor_pos
276
+ target_row = max(0, min(target_row, len(new_lines) - 1))
277
+
278
+ row_delta = target_row - self._hw_cursor_row
279
+ buf = ""
280
+ if row_delta > 0:
281
+ buf += f"\x1b[{row_delta}B"
282
+ elif row_delta < 0:
283
+ buf += f"\x1b[{-row_delta}A"
284
+ buf += f"\x1b[{target_col + 1}G" # absolute column (1-indexed)
285
+ if self._show_hardware_cursor:
286
+ buf += "\x1b[?25h" # show cursor (if enabled)
287
+ self._terminal.write_flush(buf)
288
+ self._hw_cursor_row = target_row
289
+
290
+ def _composite_overlays(
291
+ self, lines: list[str], overlays: list, width: int, height: int
292
+ ) -> list[str]:
293
+ """Composite all visible overlays into the visible portion of lines."""
294
+ viewport_start = max(0, len(lines) - height)
295
+ result = list(lines)
296
+
297
+ for entry in overlays:
298
+ if not entry.is_visible(width, height):
299
+ continue
300
+ ov_w = entry.resolve_width(width)
301
+ all_ov_lines = entry.component.render(ov_w)
302
+ ov_w2, ov_h, ov_row, ov_col = entry.resolve(width, height, len(all_ov_lines))
303
+ ov_lines = all_ov_lines[:ov_h]
304
+
305
+ for i, ov_line in enumerate(ov_lines):
306
+ logical = viewport_start + ov_row + i
307
+ if logical < 0:
308
+ continue
309
+ while logical >= len(result):
310
+ result.append("")
311
+ result[logical] = _composite_line(
312
+ result[logical], _fit_line(ov_line, ov_w2), ov_col, ov_w2, width
313
+ )
314
+
315
+ return result
316
+
317
+ def _on_resize(self) -> None:
318
+ # Clear state; next render() call forces a full clear+redraw, even if
319
+ # the reported width didn't change (e.g. a height-only resize), so a
320
+ # stale frame is never left on screen for the new render to stack atop.
321
+ self._prev_lines = []
322
+ self._cursor_row = 0
323
+ self._hw_cursor_row = 0
324
+ self._viewport_top = 0
325
+ self._resized = True
326
+
327
+
328
+ # ── Line helpers ──────────────────────────────────────────────────────────────
329
+
330
+ def _fit_line(line: str, width: int) -> str:
331
+ """Pad or truncate a line to exactly width visible columns."""
332
+ vw = visible_width(line)
333
+ if vw > width:
334
+ return truncate(line, width, ellipsis="")
335
+ if vw < width:
336
+ return line + RESET + " " * (width - vw)
337
+ return line
338
+
339
+
340
+ def _split_at_column(text: str, col: int) -> tuple[str, str]:
341
+ """ANSI-safe split: returns (text_before_col, text_from_col_onwards)."""
342
+ vis = 0
343
+ i = 0
344
+ while i < len(text):
345
+ m = _ANSI_RE.match(text, i)
346
+ if m:
347
+ i += len(m.group(0))
348
+ continue
349
+ ch = text[i]
350
+ w = _char_width(ch)
351
+ if vis + w > col:
352
+ break
353
+ vis += w
354
+ i += 1
355
+ return text[:i], text[i:]
356
+
357
+
358
+ def _composite_line(base: str, overlay: str, col: int, ov_width: int, total_width: int) -> str:
359
+ """Splice overlay into base starting at visual column col."""
360
+ before, rest = _split_at_column(base, col)
361
+
362
+ # Pad before to exactly col visual columns if base is shorter.
363
+ before_vw = visible_width(before)
364
+ if before_vw < col:
365
+ before += " " * (col - before_vw)
366
+
367
+ # Skip the overlay zone in the base to get the "after" segment.
368
+ _, after = _split_at_column(rest, ov_width)
369
+
370
+ # Overlay is already padded to ov_width by _fit_line; just truncate if needed.
371
+ ov_vw = visible_width(overlay)
372
+ if ov_vw > ov_width:
373
+ overlay = truncate(overlay, ov_width, ellipsis="")
374
+
375
+ result = before + RESET + overlay + RESET + after
376
+ if visible_width(result) > total_width:
377
+ result = truncate(result, total_width, ellipsis="")
378
+ return result