@dungle-scrubs/tallow 0.9.3 → 0.9.6

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 (207) hide show
  1. package/dist/cli.js +7 -4
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.js +1 -1
  5. package/dist/interactive-mode-patch.d.ts +24 -10
  6. package/dist/interactive-mode-patch.d.ts.map +1 -1
  7. package/dist/interactive-mode-patch.js +285 -148
  8. package/dist/interactive-mode-patch.js.map +1 -1
  9. package/dist/interactive-reset.d.ts +49 -0
  10. package/dist/interactive-reset.d.ts.map +1 -0
  11. package/dist/interactive-reset.js +40 -0
  12. package/dist/interactive-reset.js.map +1 -0
  13. package/dist/pi-tui-editor-patch.d.ts +10 -0
  14. package/dist/pi-tui-editor-patch.d.ts.map +1 -0
  15. package/dist/pi-tui-editor-patch.js +159 -0
  16. package/dist/pi-tui-editor-patch.js.map +1 -0
  17. package/dist/pi-tui-patch.d.ts +2 -0
  18. package/dist/pi-tui-patch.d.ts.map +1 -0
  19. package/dist/pi-tui-patch.js +563 -0
  20. package/dist/pi-tui-patch.js.map +1 -0
  21. package/dist/pi-tui-settings-list-patch.d.ts +11 -0
  22. package/dist/pi-tui-settings-list-patch.d.ts.map +1 -0
  23. package/dist/pi-tui-settings-list-patch.js +38 -0
  24. package/dist/pi-tui-settings-list-patch.js.map +1 -0
  25. package/dist/reset-diagnostics.d.ts +69 -0
  26. package/dist/reset-diagnostics.d.ts.map +1 -0
  27. package/dist/reset-diagnostics.js +41 -0
  28. package/dist/reset-diagnostics.js.map +1 -0
  29. package/dist/sdk.d.ts +5 -21
  30. package/dist/sdk.d.ts.map +1 -1
  31. package/dist/sdk.js +180 -149
  32. package/dist/sdk.js.map +1 -1
  33. package/dist/workspace-transition-interactive.d.ts +1 -0
  34. package/dist/workspace-transition-interactive.d.ts.map +1 -1
  35. package/dist/workspace-transition-interactive.js +7 -17
  36. package/dist/workspace-transition-interactive.js.map +1 -1
  37. package/extensions/__integration__/audit-findings.test.ts +6 -16
  38. package/extensions/__integration__/teams-runtime.test.ts +4 -1
  39. package/extensions/_icons/index.ts +2 -4
  40. package/extensions/_shared/__tests__/image-metadata.test.ts +33 -0
  41. package/extensions/_shared/__tests__/terminal-links.test.ts +18 -0
  42. package/extensions/_shared/image-metadata.ts +99 -0
  43. package/extensions/_shared/inline-preview.ts +1 -1
  44. package/extensions/_shared/pid-registry.ts +0 -1
  45. package/extensions/_shared/terminal-links.ts +22 -0
  46. package/extensions/ask-user-question-tool/index.ts +0 -3
  47. package/extensions/clear/__tests__/clear.test.ts +270 -3
  48. package/extensions/command-expansion/index.ts +1 -1
  49. package/extensions/context-files/index.ts +5 -1
  50. package/extensions/context-fork/__tests__/context-fork.test.ts +94 -1
  51. package/extensions/context-fork/extension.json +1 -1
  52. package/extensions/context-fork/index.ts +32 -0
  53. package/extensions/edit-tool-enhanced/index.ts +2 -1
  54. package/extensions/hooks/index.ts +33 -11
  55. package/extensions/loop/index.ts +14 -1
  56. package/extensions/lsp/index.ts +64 -13
  57. package/extensions/lsp/package.json +2 -2
  58. package/extensions/permissions/__tests__/permissions.test.ts +4 -4
  59. package/extensions/random-spinner/index.ts +7 -642
  60. package/extensions/read-tool-enhanced/index.ts +6 -8
  61. package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +4 -5
  62. package/extensions/render-stabilizer/index.ts +6 -6
  63. package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +1 -1
  64. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +26 -0
  65. package/extensions/slash-command-bridge/index.ts +14 -2
  66. package/extensions/subagent-tool/index.ts +1 -1
  67. package/extensions/subagent-tool/model-resolver.ts +274 -7
  68. package/extensions/tasks/__tests__/state-ui.test.ts +3 -3
  69. package/extensions/tasks/__tests__/widget-subagents.test.ts +2 -2
  70. package/extensions/tasks/commands/register-tasks-extension.ts +10 -10
  71. package/extensions/tasks/state/index.ts +1 -1
  72. package/extensions/tasks/ui/index.ts +2 -7
  73. package/extensions/teams-tool/tools/register-extension.ts +1 -3
  74. package/extensions/web-search-tool/index.ts +2 -1
  75. package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +21 -6
  76. package/extensions/write-tool-enhanced/index.ts +2 -1
  77. package/node_modules/@mariozechner/pi-tui/README.md +56 -34
  78. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts +18 -13
  79. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts.map +1 -1
  80. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js +182 -113
  81. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js.map +1 -1
  82. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js +3 -3
  83. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js.map +1 -1
  84. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts +45 -36
  85. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts.map +1 -1
  86. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js +489 -325
  87. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js.map +1 -1
  88. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts +1 -99
  89. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts.map +1 -1
  90. package/node_modules/@mariozechner/pi-tui/dist/components/image.js +17 -192
  91. package/node_modules/@mariozechner/pi-tui/dist/components/image.js.map +1 -1
  92. package/node_modules/@mariozechner/pi-tui/dist/components/input.d.ts.map +1 -1
  93. package/node_modules/@mariozechner/pi-tui/dist/components/input.js +57 -60
  94. package/node_modules/@mariozechner/pi-tui/dist/components/input.js.map +1 -1
  95. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts +2 -69
  96. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts.map +1 -1
  97. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js +5 -102
  98. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js.map +1 -1
  99. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.d.ts.map +1 -1
  100. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js +111 -53
  101. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js.map +1 -1
  102. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts +19 -1
  103. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts.map +1 -1
  104. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js +78 -67
  105. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js.map +1 -1
  106. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts +0 -2
  107. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts.map +1 -1
  108. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js +12 -23
  109. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js.map +1 -1
  110. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +8 -10
  111. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
  112. package/node_modules/@mariozechner/pi-tui/dist/index.js +6 -9
  113. package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
  114. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +108 -238
  115. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
  116. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +108 -365
  117. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
  118. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +33 -48
  119. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  120. package/node_modules/@mariozechner/pi-tui/dist/keys.js +239 -155
  121. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  122. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts +14 -94
  123. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts.map +1 -1
  124. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js +44 -186
  125. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js.map +1 -1
  126. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +13 -58
  127. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
  128. package/node_modules/@mariozechner/pi-tui/dist/terminal.js +78 -111
  129. package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
  130. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +24 -110
  131. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
  132. package/node_modules/@mariozechner/pi-tui/dist/tui.js +188 -435
  133. package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
  134. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts +0 -18
  135. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts.map +1 -1
  136. package/node_modules/@mariozechner/pi-tui/dist/utils.js +251 -119
  137. package/node_modules/@mariozechner/pi-tui/dist/utils.js.map +1 -1
  138. package/node_modules/@mariozechner/pi-tui/package.json +6 -6
  139. package/node_modules/@mariozechner/pi-tui/src/__tests__/__snapshots__/render.test.ts.snap +3 -40
  140. package/node_modules/@mariozechner/pi-tui/src/__tests__/image-component.test.ts +71 -81
  141. package/node_modules/@mariozechner/pi-tui/src/__tests__/render.test.ts +0 -33
  142. package/node_modules/@mariozechner/pi-tui/src/__tests__/terminal-image.test.ts +93 -334
  143. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +1 -1
  144. package/node_modules/@mariozechner/pi-tui/src/__tests__/utils.test.ts +11 -196
  145. package/node_modules/@mariozechner/pi-tui/src/autocomplete.ts +228 -142
  146. package/node_modules/@mariozechner/pi-tui/src/components/cancellable-loader.ts +3 -3
  147. package/node_modules/@mariozechner/pi-tui/src/components/editor.ts +624 -390
  148. package/node_modules/@mariozechner/pi-tui/src/components/image.ts +17 -227
  149. package/node_modules/@mariozechner/pi-tui/src/components/input.ts +71 -63
  150. package/node_modules/@mariozechner/pi-tui/src/components/loader.ts +5 -137
  151. package/node_modules/@mariozechner/pi-tui/src/components/markdown.ts +143 -52
  152. package/node_modules/@mariozechner/pi-tui/src/components/select-list.ts +136 -70
  153. package/node_modules/@mariozechner/pi-tui/src/components/settings-list.ts +11 -23
  154. package/node_modules/@mariozechner/pi-tui/src/index.ts +17 -36
  155. package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +148 -421
  156. package/node_modules/@mariozechner/pi-tui/src/keys.ts +253 -181
  157. package/node_modules/@mariozechner/pi-tui/src/terminal-image.ts +51 -252
  158. package/node_modules/@mariozechner/pi-tui/src/terminal.ts +78 -133
  159. package/node_modules/@mariozechner/pi-tui/src/tui.ts +202 -478
  160. package/node_modules/@mariozechner/pi-tui/src/utils.ts +289 -125
  161. package/node_modules/@mariozechner/pi-tui/tsconfig.build.json +1 -0
  162. package/package.json +13 -13
  163. package/packages/tallow-tui/node_modules/@types/mime-types/README.md +8 -2
  164. package/packages/tallow-tui/node_modules/@types/mime-types/index.d.ts +6 -0
  165. package/packages/tallow-tui/node_modules/@types/mime-types/package.json +9 -3
  166. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup-data.js +18 -0
  167. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup.js +116 -384
  168. package/packages/tallow-tui/node_modules/get-east-asian-width/package.json +5 -4
  169. package/packages/tallow-tui/node_modules/get-east-asian-width/utilities.js +24 -0
  170. package/packages/tallow-tui/node_modules/marked/README.md +5 -4
  171. package/packages/tallow-tui/node_modules/marked/bin/main.js +10 -8
  172. package/packages/tallow-tui/node_modules/marked/bin/marked.js +2 -1
  173. package/packages/tallow-tui/node_modules/marked/lib/marked.d.ts +156 -125
  174. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js +67 -2179
  175. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js.map +3 -3
  176. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js +67 -2201
  177. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js.map +3 -3
  178. package/packages/tallow-tui/node_modules/marked/man/marked.1 +4 -2
  179. package/packages/tallow-tui/node_modules/marked/man/marked.1.md +2 -1
  180. package/packages/tallow-tui/node_modules/marked/package.json +26 -34
  181. package/runtime/model-metadata-overrides.ts +10 -1
  182. package/runtime/pid-schema.ts +26 -6
  183. package/skills/tallow-expert/SKILL.md +1 -3
  184. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts +0 -32
  185. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts.map +0 -1
  186. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js +0 -46
  187. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js.map +0 -1
  188. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts +0 -52
  189. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts.map +0 -1
  190. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js +0 -89
  191. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js.map +0 -1
  192. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts +0 -14
  193. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts.map +0 -1
  194. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js +0 -55
  195. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js.map +0 -1
  196. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-change-listener.test.ts +0 -121
  197. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-ghost-text.test.ts +0 -112
  198. package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +0 -134
  199. package/node_modules/@mariozechner/pi-tui/src/__tests__/settings-list.test.ts +0 -49
  200. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +0 -555
  201. package/node_modules/@mariozechner/pi-tui/src/border-styles.ts +0 -60
  202. package/node_modules/@mariozechner/pi-tui/src/components/bordered-box.ts +0 -113
  203. package/node_modules/@mariozechner/pi-tui/src/test-utils/capability-env.ts +0 -56
  204. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs +0 -2211
  205. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs.map +0 -7
  206. package/packages/tallow-tui/node_modules/marked/lib/marked.d.cts +0 -728
  207. package/packages/tallow-tui/node_modules/marked/marked.min.js +0 -69
@@ -5,22 +5,11 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
- import {
9
- isKeyRelease,
10
- isMouseEvent,
11
- type MouseEvent,
12
- matchesKey,
13
- parseMouseEvent,
14
- } from "./keys.js";
8
+ import { performance } from "node:perf_hooks";
9
+ import { isKeyRelease, matchesKey } from "./keys.js";
15
10
  import type { Terminal } from "./terminal.js";
16
11
  import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
17
- import {
18
- extractSegments,
19
- sliceByColumn,
20
- sliceWithWidth,
21
- truncateToWidth,
22
- visibleWidth,
23
- } from "./utils.js";
12
+ import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
24
13
 
25
14
  /**
26
15
  * Component interface - all components must implement this
@@ -51,6 +40,9 @@ export interface Component {
51
40
  invalidate(): void;
52
41
  }
53
42
 
43
+ type InputListenerResult = { consume?: boolean; data?: string } | undefined;
44
+ type InputListener = (data: string) => InputListenerResult;
45
+
54
46
  /**
55
47
  * Interface for components that can receive focus and display a hardware cursor.
56
48
  * When focused, the component should emit CURSOR_MARKER at the cursor position
@@ -116,6 +108,10 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
116
108
  return undefined;
117
109
  }
118
110
 
111
+ function isTermuxSession(): boolean {
112
+ return Boolean(process.env.TERMUX_VERSION);
113
+ }
114
+
119
115
  /**
120
116
  * Options for overlay positioning and sizing.
121
117
  * Values can be absolute numbers or percentage strings (e.g., "50%").
@@ -154,6 +150,8 @@ export interface OverlayOptions {
154
150
  * Called each render cycle with current terminal dimensions.
155
151
  */
156
152
  visible?: (termWidth: number, termHeight: number) => boolean;
153
+ /** If true, don't capture keyboard focus when shown */
154
+ nonCapturing?: boolean;
157
155
  }
158
156
 
159
157
  /**
@@ -166,6 +164,12 @@ export interface OverlayHandle {
166
164
  setHidden(hidden: boolean): void;
167
165
  /** Check if overlay is temporarily hidden */
168
166
  isHidden(): boolean;
167
+ /** Focus this overlay and bring it to the visual front */
168
+ focus(): void;
169
+ /** Release focus to the previous target */
170
+ unfocus(): void;
171
+ /** Check if this overlay currently has focus */
172
+ isFocused(): boolean;
169
173
  }
170
174
 
171
175
  /**
@@ -198,7 +202,10 @@ export class Container implements Component {
198
202
  render(width: number): string[] {
199
203
  const lines: string[] = [];
200
204
  for (const child of this.children) {
201
- lines.push(...child.render(width));
205
+ const childLines = child.render(width);
206
+ for (const line of childLines) {
207
+ lines.push(line);
208
+ }
202
209
  }
203
210
  return lines;
204
211
  }
@@ -211,38 +218,33 @@ export class TUI extends Container {
211
218
  public terminal: Terminal;
212
219
  private previousLines: string[] = [];
213
220
  private previousWidth = 0;
221
+ private previousHeight = 0;
214
222
  private focusedComponent: Component | null = null;
223
+ private inputListeners = new Set<InputListener>();
215
224
 
216
225
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
217
226
  public onDebug?: () => void;
218
- /**
219
- * Callback for mouse events. Called when a mouse event is received.
220
- * Scroll events are the primary use case (scroll-up, scroll-down).
221
- * Return value is ignored — mouse events are always consumed and never
222
- * forwarded to focused components.
223
- */
224
- public onMouse?: (event: MouseEvent) => void;
225
227
  private renderRequested = false;
226
- private pendingRenderHandle?: ReturnType<typeof setTimeout>;
228
+ private renderTimer: NodeJS.Timeout | undefined;
229
+ private lastRenderAt = 0;
230
+ private static readonly MIN_RENDER_INTERVAL_MS = 16;
227
231
  private cursorRow = 0; // Logical cursor row (end of rendered content)
228
232
  private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
229
- private inputBuffer = ""; // Buffer for parsing terminal responses
230
- private cellSizeQueryPending = false;
231
233
  private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
232
234
  private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
233
235
  private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
234
236
  private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
235
237
  private fullRedrawCount = 0;
236
- private rollingShrinkPeak = 0; // Recent peak line count for gradual shrink detection
237
238
  private stopped = false;
238
- private pendingScrollbackClear = false; // Clear scrollback on next full render (session breaks)
239
239
 
240
240
  // Overlay stack for modal components rendered on top of base content
241
+ private focusOrderCounter = 0;
241
242
  private overlayStack: {
242
243
  component: Component;
243
244
  options?: OverlayOptions;
244
245
  preFocus: Component | null;
245
246
  hidden: boolean;
247
+ focusOrder: number;
246
248
  }[] = [];
247
249
 
248
250
  constructor(terminal: Terminal, showHardwareCursor?: boolean) {
@@ -283,34 +285,6 @@ export class TUI extends Container {
283
285
  this.clearOnShrink = enabled;
284
286
  }
285
287
 
286
- /**
287
- * Reset the startup grace period timer, suppressing screen-clearing full
288
- * redraws for another {@link STARTUP_GRACE_MS} milliseconds.
289
- *
290
- * Call this at the start of a session switch so the chatContainer.clear()
291
- * → renderInitialMessages() transition doesn't cause visible flicker.
292
- *
293
- * @returns {void}
294
- */
295
- resetRenderGrace(): void {
296
- this.startedAtMs = Date.now();
297
- }
298
-
299
- /**
300
- * Request that the next full render clears the terminal scrollback buffer.
301
- *
302
- * Use when the session content is being replaced wholesale (workspace
303
- * transitions, new sessions, session switches) so stale scrollback
304
- * doesn't visually flow into the new content.
305
- *
306
- * Has no effect on partial (differential) redraws — the flag is consumed
307
- * only when a full render is triggered by content shrink, width change,
308
- * or forced invalidation.
309
- */
310
- requestScrollbackClear(): void {
311
- this.pendingScrollbackClear = true;
312
- }
313
-
314
288
  setFocus(component: Component | null): void {
315
289
  // Clear focused flag on old component
316
290
  if (isFocusable(this.focusedComponent)) {
@@ -330,10 +304,16 @@ export class TUI extends Container {
330
304
  * Returns a handle to control the overlay's visibility.
331
305
  */
332
306
  showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
333
- const entry = { component, options, preFocus: this.focusedComponent, hidden: false };
307
+ const entry = {
308
+ component,
309
+ options,
310
+ preFocus: this.focusedComponent,
311
+ hidden: false,
312
+ focusOrder: ++this.focusOrderCounter,
313
+ };
334
314
  this.overlayStack.push(entry);
335
315
  // Only focus if overlay is actually visible
336
- if (this.isOverlayVisible(entry)) {
316
+ if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
337
317
  this.setFocus(component);
338
318
  }
339
319
  this.terminal.hideCursor();
@@ -366,13 +346,29 @@ export class TUI extends Container {
366
346
  }
367
347
  } else {
368
348
  // Restore focus to this overlay when showing (if it's actually visible)
369
- if (this.isOverlayVisible(entry)) {
349
+ if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
350
+ entry.focusOrder = ++this.focusOrderCounter;
370
351
  this.setFocus(component);
371
352
  }
372
353
  }
373
354
  this.requestRender();
374
355
  },
375
356
  isHidden: () => entry.hidden,
357
+ focus: () => {
358
+ if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return;
359
+ if (this.focusedComponent !== component) {
360
+ this.setFocus(component);
361
+ }
362
+ entry.focusOrder = ++this.focusOrderCounter;
363
+ this.requestRender();
364
+ },
365
+ unfocus: () => {
366
+ if (this.focusedComponent !== component) return;
367
+ const topVisible = this.getTopmostVisibleOverlay();
368
+ this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
369
+ this.requestRender();
370
+ },
371
+ isFocused: () => this.focusedComponent === component,
376
372
  };
377
373
  }
378
374
 
@@ -380,9 +376,11 @@ export class TUI extends Container {
380
376
  hideOverlay(): void {
381
377
  const overlay = this.overlayStack.pop();
382
378
  if (!overlay) return;
383
- // Find topmost visible overlay, or fall back to preFocus
384
- const topVisible = this.getTopmostVisibleOverlay();
385
- this.setFocus(topVisible?.component ?? overlay.preFocus);
379
+ if (this.focusedComponent === overlay.component) {
380
+ // Find topmost visible overlay, or fall back to preFocus
381
+ const topVisible = this.getTopmostVisibleOverlay();
382
+ this.setFocus(topVisible?.component ?? overlay.preFocus);
383
+ }
386
384
  if (this.overlayStack.length === 0) this.terminal.hideCursor();
387
385
  this.requestRender();
388
386
  }
@@ -401,9 +399,10 @@ export class TUI extends Container {
401
399
  return true;
402
400
  }
403
401
 
404
- /** Find the topmost visible overlay, if any */
402
+ /** Find the topmost visible capturing overlay, if any */
405
403
  private getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined {
406
404
  for (let i = this.overlayStack.length - 1; i >= 0; i--) {
405
+ if (this.overlayStack[i].options?.nonCapturing) continue;
407
406
  if (this.isOverlayVisible(this.overlayStack[i])) {
408
407
  return this.overlayStack[i];
409
408
  }
@@ -416,25 +415,8 @@ export class TUI extends Container {
416
415
  for (const overlay of this.overlayStack) overlay.component.invalidate?.();
417
416
  }
418
417
 
419
- /**
420
- * Timestamp when `start()` was called.
421
- * Used by startup grace period to suppress screen-clearing full redraws.
422
- */
423
- private startedAtMs = 0;
424
-
425
- /**
426
- * Duration (ms) after `start()` during which shrink-triggered full redraws
427
- * use a gentler line-by-line overwrite instead of screen clear.
428
- *
429
- * This prevents the visual flicker that occurs when session resume causes
430
- * rapid content height changes (extension hooks, widget adds/removes) before
431
- * the full message history is rendered.
432
- */
433
- private static readonly STARTUP_GRACE_MS = 3000;
434
-
435
418
  start(): void {
436
419
  this.stopped = false;
437
- this.startedAtMs = Date.now();
438
420
  this.terminal.start(
439
421
  (data) => this.handleInput(data),
440
422
  () => this.requestRender()
@@ -444,29 +426,32 @@ export class TUI extends Container {
444
426
  this.requestRender();
445
427
  }
446
428
 
429
+ addInputListener(listener: InputListener): () => void {
430
+ this.inputListeners.add(listener);
431
+ return () => {
432
+ this.inputListeners.delete(listener);
433
+ };
434
+ }
435
+
436
+ removeInputListener(listener: InputListener): void {
437
+ this.inputListeners.delete(listener);
438
+ }
439
+
447
440
  private queryCellSize(): void {
448
441
  // Only query if terminal supports images (cell size is only used for image rendering)
449
442
  if (!getCapabilities().images) {
450
443
  return;
451
444
  }
452
- // Skip cell size query inside tmux — tmux doesn't forward CSI 16 t responses,
453
- // so cellSizeQueryPending would stay true and parseCellSizeResponse would eat
454
- // bare \x1b (Escape key) as a "partial response", breaking Escape handling.
455
- if (process.env.TMUX) {
456
- return;
457
- }
458
445
  // Query terminal for cell size in pixels: CSI 16 t
459
446
  // Response format: CSI 6 ; height ; width t
460
- this.cellSizeQueryPending = true;
461
447
  this.terminal.write("\x1b[16t");
462
448
  }
463
449
 
464
450
  stop(): void {
465
451
  this.stopped = true;
466
- if (this.pendingRenderHandle !== undefined) {
467
- clearTimeout(this.pendingRenderHandle);
468
- this.pendingRenderHandle = undefined;
469
- this.renderRequested = false;
452
+ if (this.renderTimer) {
453
+ clearTimeout(this.renderTimer);
454
+ this.renderTimer = undefined;
470
455
  }
471
456
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
472
457
  if (this.previousLines.length > 0) {
@@ -484,135 +469,75 @@ export class TUI extends Container {
484
469
  this.terminal.stop();
485
470
  }
486
471
 
487
- /** When >0, scheduled renders are deferred until the batch completes. */
488
- private renderBatchDepth = 0;
489
-
490
- /** Whether a render was requested while batching was active. */
491
- private renderDeferredDuringBatch = false;
492
-
493
- /** Whether a forced render was requested while batching was active. */
494
- private renderForceDeferredDuringBatch = false;
495
-
496
- /**
497
- * Begin a render batch — all `requestRender()` calls are coalesced and
498
- * deferred until the matching `endRenderBatch()`. Nestable.
499
- *
500
- * Use to prevent intermediate renders (and the screen clears they cause)
501
- * during multi-step UI mutations such as session resume.
502
- *
503
- * @returns {void}
504
- */
505
- beginRenderBatch(): void {
506
- this.renderBatchDepth++;
507
- }
508
-
509
- /**
510
- * End a render batch. When the outermost batch ends, a single render is
511
- * scheduled if any were deferred.
512
- *
513
- * @returns {void}
514
- */
515
- endRenderBatch(): void {
516
- if (this.renderBatchDepth <= 0) return;
517
- this.renderBatchDepth--;
518
- if (this.renderBatchDepth === 0 && this.renderDeferredDuringBatch) {
519
- const wasForce = this.renderForceDeferredDuringBatch;
520
- this.renderDeferredDuringBatch = false;
521
- this.renderForceDeferredDuringBatch = false;
522
- this.requestRender(wasForce);
523
- }
524
- }
525
-
526
472
  requestRender(force = false): void {
527
473
  if (force) {
528
474
  this.previousLines = [];
529
475
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
476
+ this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
530
477
  this.cursorRow = 0;
531
478
  this.hardwareCursorRow = 0;
532
479
  this.maxLinesRendered = 0;
533
480
  this.previousViewportTop = 0;
534
- this.rollingShrinkPeak = 0;
535
- }
536
- if (this.renderBatchDepth > 0) {
537
- this.renderDeferredDuringBatch = true;
538
- if (force) this.renderForceDeferredDuringBatch = true;
481
+ if (this.renderTimer) {
482
+ clearTimeout(this.renderTimer);
483
+ this.renderTimer = undefined;
484
+ }
485
+ this.renderRequested = true;
486
+ process.nextTick(() => {
487
+ if (this.stopped || !this.renderRequested) {
488
+ return;
489
+ }
490
+ this.renderRequested = false;
491
+ this.lastRenderAt = performance.now();
492
+ this.doRender();
493
+ });
539
494
  return;
540
495
  }
541
496
  if (this.renderRequested) return;
542
- this.scheduleRender();
497
+ this.renderRequested = true;
498
+ process.nextTick(() => this.scheduleRender());
543
499
  }
544
500
 
545
- /**
546
- * Schedule a single coalesced render in the check phase.
547
- *
548
- * On Bun, `setImmediate` behaves like a microtask and never enters the I/O poll
549
- * phase, so stdin data callbacks are starved during streaming. `setTimeout(fn, 0)`
550
- * forces a real timer (~1ms) that guarantees I/O polling between renders.
551
- *
552
- * On Node.js, `setTimeout(0)` has a 1ms minimum delay — slightly slower than
553
- * `setImmediate` but still imperceptible (<13ms human threshold).
554
- *
555
- * @see Plan 177 — Bun setImmediate does not yield to I/O
556
- * @returns {void}
557
- */
558
501
  private scheduleRender(): void {
559
- this.renderRequested = true;
560
- this.pendingRenderHandle = setTimeout(() => {
561
- this.pendingRenderHandle = undefined;
502
+ if (this.stopped || this.renderTimer || !this.renderRequested) {
503
+ return;
504
+ }
505
+ const elapsed = performance.now() - this.lastRenderAt;
506
+ const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);
507
+ this.renderTimer = setTimeout(() => {
508
+ this.renderTimer = undefined;
509
+ if (this.stopped || !this.renderRequested) {
510
+ return;
511
+ }
562
512
  this.renderRequested = false;
563
- if (this.stopped) return;
513
+ this.lastRenderAt = performance.now();
564
514
  this.doRender();
565
- }, 0);
566
- }
567
-
568
- /** Input listener functions — called before the focused component receives input. */
569
- private inputListeners = new Set<
570
- (data: string) => { consume?: boolean; data?: string } | undefined
571
- >();
572
-
573
- /**
574
- * Register an input listener. Listeners run before the focused component and can
575
- * consume input (return `{consume: true}`) or transform it (return `{data: newData}`).
576
- *
577
- * @param listener - Listener function
578
- * @returns Unsubscribe function
579
- */
580
- addInputListener(
581
- listener: (data: string) => { consume?: boolean; data?: string } | undefined
582
- ): () => void {
583
- this.inputListeners.add(listener);
584
- return () => {
585
- this.inputListeners.delete(listener);
586
- };
587
- }
588
-
589
- /**
590
- * Remove a previously registered input listener.
591
- *
592
- * @param listener - The listener function to remove
593
- */
594
- removeInputListener(
595
- listener: (data: string) => { consume?: boolean; data?: string } | undefined
596
- ): void {
597
- this.inputListeners.delete(listener);
515
+ if (this.renderRequested) {
516
+ this.scheduleRender();
517
+ }
518
+ }, delay);
598
519
  }
599
520
 
600
521
  private handleInput(data: string): void {
601
- // If we're waiting for cell size response, buffer input and parse
602
- if (this.cellSizeQueryPending) {
603
- this.inputBuffer += data;
604
- const filtered = this.parseCellSizeResponse();
605
- if (filtered.length === 0) return;
606
- data = filtered;
522
+ if (this.inputListeners.size > 0) {
523
+ let current = data;
524
+ for (const listener of this.inputListeners) {
525
+ const result = listener(current);
526
+ if (result?.consume) {
527
+ return;
528
+ }
529
+ if (result?.data !== undefined) {
530
+ current = result.data;
531
+ }
532
+ }
533
+ if (current.length === 0) {
534
+ return;
535
+ }
536
+ data = current;
607
537
  }
608
538
 
609
- // Mouse events intercept before any key handling.
610
- // Always consumed: mouse sequences must never reach components as text.
611
- if (isMouseEvent(data)) {
612
- const event = parseMouseEvent(data);
613
- if (event && this.onMouse) {
614
- this.onMouse(event);
615
- }
539
+ // Consume terminal cell size responses without blocking unrelated input.
540
+ if (this.consumeCellSizeResponse(data)) {
616
541
  return;
617
542
  }
618
543
 
@@ -636,24 +561,6 @@ export class TUI extends Container {
636
561
  }
637
562
  }
638
563
 
639
- // Run input listeners — can consume or transform input
640
- if (this.inputListeners.size > 0) {
641
- let current = data;
642
- for (const listener of this.inputListeners) {
643
- const result = listener(current);
644
- if (result?.consume) {
645
- return;
646
- }
647
- if (result?.data !== undefined) {
648
- current = result.data;
649
- }
650
- }
651
- if (current.length === 0) {
652
- return;
653
- }
654
- data = current;
655
- }
656
-
657
564
  // Pass input to focused component (including Ctrl+C)
658
565
  // The focused component can decide how to handle Ctrl+C
659
566
  if (this.focusedComponent?.handleInput) {
@@ -661,64 +568,29 @@ export class TUI extends Container {
661
568
  if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
662
569
  return;
663
570
  }
664
- if (process.env.TALLOW_KEY_DEBUG && (data === "\x1b" || data === "\x03")) {
665
- const escMatch = matchesKey(data, "escape");
666
- const ctrlcMatch = matchesKey(data, "ctrl+c");
667
- const comp = this.focusedComponent as unknown as {
668
- onEscape?: () => void;
669
- actionHandlers?: Map<string, unknown>;
670
- };
671
- const hasOnEscape = typeof comp.onEscape === "function";
672
- const actionCount = comp.actionHandlers instanceof Map ? comp.actionHandlers.size : -1;
673
- process.stderr.write(
674
- `[key] ${data === "\x1b" ? "ESC" : "C-C"} matchEsc=${escMatch} matchCC=${ctrlcMatch} hasOnEscape=${hasOnEscape} actions=${actionCount}\n`
675
- );
676
- }
677
571
  this.focusedComponent.handleInput(data);
678
572
  this.requestRender();
679
573
  }
680
574
  }
681
575
 
682
- private parseCellSizeResponse(): string {
576
+ private consumeCellSizeResponse(data: string): boolean {
683
577
  // Response format: ESC [ 6 ; height ; width t
684
- // Match the response pattern
685
- const responsePattern = /\x1b\[6;(\d+);(\d+)t/;
686
- const match = this.inputBuffer.match(responsePattern);
687
-
688
- if (match) {
689
- const heightPx = parseInt(match[1], 10);
690
- const widthPx = parseInt(match[2], 10);
691
-
692
- if (heightPx > 0 && widthPx > 0) {
693
- setCellDimensions({ widthPx, heightPx });
694
- // Invalidate all components so images re-render with correct dimensions
695
- this.invalidate();
696
- this.requestRender();
697
- }
698
-
699
- // Remove the response from buffer
700
- this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
701
- this.cellSizeQueryPending = false;
578
+ const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
579
+ if (!match) {
580
+ return false;
702
581
  }
703
582
 
704
- // Check if we have a partial cell size response starting (wait for more data)
705
- // Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet)
706
- const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/;
707
- if (partialCellSizePattern.test(this.inputBuffer)) {
708
- // Check if it's actually a complete different escape sequence (ends with a letter)
709
- // Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
710
- const lastChar = this.inputBuffer[this.inputBuffer.length - 1];
711
- if (!/[a-zA-Z~]/.test(lastChar)) {
712
- // Doesn't end with a terminator, might be incomplete - wait for more
713
- return "";
714
- }
583
+ const heightPx = parseInt(match[1], 10);
584
+ const widthPx = parseInt(match[2], 10);
585
+ if (heightPx <= 0 || widthPx <= 0) {
586
+ return true;
715
587
  }
716
588
 
717
- // No cell size response found, return buffered data as user input
718
- const result = this.inputBuffer;
719
- this.inputBuffer = "";
720
- this.cellSizeQueryPending = false; // Give up waiting
721
- return result;
589
+ setCellDimensions({ widthPx, heightPx });
590
+ // Invalidate all components so images re-render with correct dimensions.
591
+ this.invalidate();
592
+ this.requestRender();
593
+ return true;
722
594
  }
723
595
 
724
596
  /**
@@ -870,7 +742,7 @@ export class TUI extends Container {
870
742
  }
871
743
  }
872
744
 
873
- /** Composite all overlays into content lines (in stack order, later = on top). */
745
+ /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
874
746
  private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
875
747
  if (this.overlayStack.length === 0) return lines;
876
748
  const result = [...lines];
@@ -879,10 +751,9 @@ export class TUI extends Container {
879
751
  const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
880
752
  let minLinesNeeded = result.length;
881
753
 
882
- for (const entry of this.overlayStack) {
883
- // Skip invisible overlays (hidden or visible() returns false)
884
- if (!this.isOverlayVisible(entry)) continue;
885
-
754
+ const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
755
+ visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
756
+ for (const entry of visibleEntries) {
886
757
  const { component, options } = entry;
887
758
 
888
759
  // Get layout with height=0 first to determine width and maxHeight
@@ -909,9 +780,10 @@ export class TUI extends Container {
909
780
  minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
910
781
  }
911
782
 
912
- // Ensure result covers the terminal working area to keep overlay positioning stable across resizes.
913
- // maxLinesRendered can exceed current content length after a shrink; pad to keep viewportStart consistent.
914
- const workingHeight = Math.max(this.maxLinesRendered, minLinesNeeded);
783
+ // Pad to at least terminal height so overlays have screen-relative positions.
784
+ // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
785
+ // inflation that pushed content into scrollback on terminal widen.
786
+ const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);
915
787
 
916
788
  // Extend result with empty lines if content is too short for overlay placement or working area
917
789
  while (result.length < workingHeight) {
@@ -920,9 +792,6 @@ export class TUI extends Container {
920
792
 
921
793
  const viewportStart = Math.max(0, workingHeight - termHeight);
922
794
 
923
- // Track which lines were modified for final verification
924
- const modifiedLines = new Set<number>();
925
-
926
795
  // Composite each overlay
927
796
  for (const { overlayLines, row, col, w } of rendered) {
928
797
  for (let i = 0; i < overlayLines.length; i++) {
@@ -935,22 +804,10 @@ export class TUI extends Container {
935
804
  ? sliceByColumn(overlayLines[i], 0, w, true)
936
805
  : overlayLines[i];
937
806
  result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
938
- modifiedLines.add(idx);
939
807
  }
940
808
  }
941
809
  }
942
810
 
943
- // Final verification: ensure no composited line exceeds terminal width
944
- // This is a belt-and-suspenders safeguard - compositeLineAt should already
945
- // guarantee this, but we verify here to prevent crashes from any edge cases
946
- // Only check lines that were actually modified (optimization)
947
- for (const idx of modifiedLines) {
948
- const lineWidth = visibleWidth(result[idx]);
949
- if (lineWidth > termWidth) {
950
- result[idx] = sliceByColumn(result[idx], 0, termWidth, true);
951
- }
952
- }
953
-
954
811
  return result;
955
812
  }
956
813
 
@@ -1053,8 +910,14 @@ export class TUI extends Container {
1053
910
  if (this.stopped) return;
1054
911
  const width = this.terminal.columns;
1055
912
  const height = this.terminal.rows;
1056
- let viewportTop = Math.max(0, this.maxLinesRendered - height);
1057
- let prevViewportTop = this.previousViewportTop;
913
+ const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
914
+ const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
915
+ const previousBufferLength =
916
+ this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;
917
+ let prevViewportTop = heightChanged
918
+ ? Math.max(0, previousBufferLength - height)
919
+ : this.previousViewportTop;
920
+ let viewportTop = prevViewportTop;
1058
921
  let hardwareCursorRow = this.hardwareCursorRow;
1059
922
  const computeLineDiff = (targetRow: number): number => {
1060
923
  const currentScreenRow = hardwareCursorRow - prevViewportTop;
@@ -1075,24 +938,11 @@ export class TUI extends Container {
1075
938
 
1076
939
  newLines = this.applyLineResets(newLines);
1077
940
 
1078
- // Width changed - need full re-render (line wrapping changes)
1079
- const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
1080
-
1081
- // Whether we are within the startup grace period where screen-clearing
1082
- // full redraws are softened to prevent flicker during session resume.
1083
- const inStartupGrace =
1084
- this.startedAtMs > 0 && Date.now() - this.startedAtMs < TUI.STARTUP_GRACE_MS;
1085
-
1086
- // Helper to clear viewport (and optionally scrollback) and render all new lines
941
+ // Helper to clear scrollback and viewport and render all new lines
1087
942
  const fullRender = (clear: boolean): void => {
1088
943
  this.fullRedrawCount += 1;
1089
944
  let buffer = "\x1b[?2026h"; // Begin synchronized output
1090
- if (clear && this.pendingScrollbackClear) {
1091
- buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
1092
- this.pendingScrollbackClear = false;
1093
- } else if (clear) {
1094
- buffer += "\x1b[2J\x1b[H"; // Clear screen and home (preserve scrollback)
1095
- }
945
+ if (clear) buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
1096
946
  for (let i = 0; i < newLines.length; i++) {
1097
947
  if (i > 0) buffer += "\r\n";
1098
948
  buffer += newLines[i];
@@ -1107,49 +957,12 @@ export class TUI extends Container {
1107
957
  } else {
1108
958
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1109
959
  }
1110
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1111
- this.rollingShrinkPeak = newLines.length;
1112
- this.positionHardwareCursor(cursorPos, newLines.length);
1113
- this.previousLines = newLines;
1114
- this.previousWidth = width;
1115
- };
1116
-
1117
- /**
1118
- * Gentle full redraw: home cursor + overwrite each line + clear below.
1119
- *
1120
- * Used during the startup grace period instead of fullRender(true) for
1121
- * shrink-triggered redraws. Avoids the visible blank frame caused by
1122
- * `\x1b[2J` (clear screen), which makes messages appear to flash in and
1123
- * out when session resume triggers rapid content height changes.
1124
- *
1125
- * Unlike fullRender(true), this never clears the screen — it writes each
1126
- * line with a preceding `\x1b[2K` (clear line) so stale content is
1127
- * overwritten without a blank frame. Lines below the new content are
1128
- * individually erased.
1129
- */
1130
- const gentleFullRender = (): void => {
1131
- this.fullRedrawCount += 1;
1132
- let buffer = "\x1b[?2026h\x1b[H"; // Begin synchronized output + home cursor
1133
- for (let i = 0; i < newLines.length; i++) {
1134
- buffer += "\x1b[2K"; // Clear current line
1135
- buffer += newLines[i];
1136
- if (i < newLines.length - 1) buffer += "\r\n";
1137
- }
1138
- // Erase lines that were previously rendered but are no longer needed
1139
- const staleLines = Math.max(0, this.maxLinesRendered - newLines.length);
1140
- for (let i = 0; i < staleLines; i++) {
1141
- buffer += "\r\n\x1b[2K";
1142
- }
1143
- buffer += "\x1b[?2026l"; // End synchronized output
1144
- this.terminal.write(buffer);
1145
- this.cursorRow = Math.max(0, newLines.length + staleLines - 1);
1146
- this.hardwareCursorRow = this.cursorRow;
1147
- this.maxLinesRendered = newLines.length;
1148
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1149
- this.rollingShrinkPeak = newLines.length;
960
+ const bufferLength = Math.max(height, newLines.length);
961
+ this.previousViewportTop = Math.max(0, bufferLength - height);
1150
962
  this.positionHardwareCursor(cursorPos, newLines.length);
1151
963
  this.previousLines = newLines;
1152
964
  this.previousWidth = width;
965
+ this.previousHeight = height;
1153
966
  };
1154
967
 
1155
968
  const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
@@ -1161,15 +974,24 @@ export class TUI extends Container {
1161
974
  };
1162
975
 
1163
976
  // First render - just output everything without clearing (assumes clean screen)
1164
- if (this.previousLines.length === 0 && !widthChanged) {
977
+ if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
1165
978
  logRedraw("first render");
1166
979
  fullRender(false);
1167
980
  return;
1168
981
  }
1169
982
 
1170
- // Width changed - full re-render (line wrapping changes)
983
+ // Width changes always need a full re-render because wrapping changes.
1171
984
  if (widthChanged) {
1172
- logRedraw(`width changed (${this.previousWidth} -> ${width})`);
985
+ logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
986
+ fullRender(true);
987
+ return;
988
+ }
989
+
990
+ // Height changes normally need a full re-render to keep the visible viewport aligned,
991
+ // but Termux changes height when the software keyboard shows or hides.
992
+ // In that environment, a full redraw causes the entire history to replay on every toggle.
993
+ if (heightChanged && !isTermuxSession()) {
994
+ logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
1173
995
  fullRender(true);
1174
996
  return;
1175
997
  }
@@ -1183,73 +1005,10 @@ export class TUI extends Container {
1183
1005
  this.overlayStack.length === 0
1184
1006
  ) {
1185
1007
  logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
1186
- if (inStartupGrace) {
1187
- gentleFullRender();
1188
- } else {
1189
- fullRender(true);
1190
- }
1191
- return;
1192
- }
1193
-
1194
- // Large content shrinks (e.g., tool output collapse) are hard to partially redraw
1195
- // correctly — cursor positions drift when many lines disappear at once, causing
1196
- // ghost copies of previous frames. Force a full redraw when the shrink exceeds a
1197
- // threshold. More targeted than clearOnShrink (which fires on ANY shrink).
1198
- const shrinkDelta = this.previousLines.length - newLines.length;
1199
- if (shrinkDelta > 5 && this.overlayStack.length === 0) {
1200
- logRedraw(`large shrink (${shrinkDelta} lines)`);
1201
- if (inStartupGrace) {
1202
- gentleFullRender();
1203
- } else {
1204
- fullRender(true);
1205
- }
1206
- return;
1207
- }
1208
-
1209
- // Rolling shrink detection: catches gradual shrinks where each individual
1210
- // frame-to-frame delta is ≤5 lines (below the large-shrink threshold) but the
1211
- // accumulated shrink from a recent peak exceeds it. This happens when
1212
- // pollStates.clear() collapses tool-result anchors across multiple render
1213
- // cycles while animations (loader, widget spinners) keep triggering renders.
1214
- if (newLines.length >= this.rollingShrinkPeak) {
1215
- this.rollingShrinkPeak = newLines.length;
1216
- } else if (this.overlayStack.length === 0 && this.rollingShrinkPeak - newLines.length > 5) {
1217
- logRedraw(
1218
- `rolling shrink (peak=${this.rollingShrinkPeak}, now=${newLines.length}, delta=${this.rollingShrinkPeak - newLines.length})`
1219
- );
1220
- if (inStartupGrace) {
1221
- gentleFullRender();
1222
- } else {
1223
- fullRender(true);
1224
- }
1008
+ fullRender(true);
1225
1009
  return;
1226
1010
  }
1227
1011
 
1228
- const previousContentViewportTop = Math.max(0, this.previousLines.length - height);
1229
- // Detect viewport basis drift: maxLinesRendered exceeds actual content,
1230
- // causing viewportTop to be computed from a stale high-water mark.
1231
- // Compare against newLines.length (not previousLines.length) so the
1232
- // correction fires on the same render cycle as a shrink, not one cycle late.
1233
- const hasViewportBasisDrift =
1234
- this.overlayStack.length === 0 &&
1235
- this.previousLines.length > 0 &&
1236
- this.maxLinesRendered > newLines.length &&
1237
- prevViewportTop !== previousContentViewportTop;
1238
-
1239
- // After shrink-heavy updates with clearOnShrink disabled, maxLinesRendered can stay larger
1240
- // than current content. Instead of a destructive full redraw (which clears the screen and
1241
- // disrupts scrollback), realign the working-area coordinates so the partial-redraw path
1242
- // can operate on a consistent viewport basis.
1243
- // Use newLines.length (not previousLines.length) so the correction is exact for the
1244
- // current frame — previousLines.length can still exceed newLines.length, leaving
1245
- // residual drift for one more cycle and causing ghost lines.
1246
- if (hasViewportBasisDrift) {
1247
- this.maxLinesRendered = newLines.length;
1248
- viewportTop = Math.max(0, this.maxLinesRendered - height);
1249
- prevViewportTop = viewportTop;
1250
- this.previousViewportTop = viewportTop;
1251
- }
1252
-
1253
1012
  // Find first and last changed lines
1254
1013
  let firstChanged = -1;
1255
1014
  let lastChanged = -1;
@@ -1278,7 +1037,8 @@ export class TUI extends Container {
1278
1037
  // No changes - but still need to update hardware cursor position if it moved
1279
1038
  if (firstChanged === -1) {
1280
1039
  this.positionHardwareCursor(cursorPos, newLines.length);
1281
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1040
+ this.previousViewportTop = prevViewportTop;
1041
+ this.previousHeight = height;
1282
1042
  return;
1283
1043
  }
1284
1044
 
@@ -1288,6 +1048,11 @@ export class TUI extends Container {
1288
1048
  let buffer = "\x1b[?2026h";
1289
1049
  // Move to end of new content (clamp to 0 for empty content)
1290
1050
  const targetRow = Math.max(0, newLines.length - 1);
1051
+ if (targetRow < prevViewportTop) {
1052
+ logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
1053
+ fullRender(true);
1054
+ return;
1055
+ }
1291
1056
  const lineDiff = computeLineDiff(targetRow);
1292
1057
  if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
1293
1058
  else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
@@ -1296,11 +1061,7 @@ export class TUI extends Container {
1296
1061
  const extraLines = this.previousLines.length - newLines.length;
1297
1062
  if (extraLines > height) {
1298
1063
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
1299
- if (inStartupGrace) {
1300
- gentleFullRender();
1301
- } else {
1302
- fullRender(true);
1303
- }
1064
+ fullRender(true);
1304
1065
  return;
1305
1066
  }
1306
1067
  if (extraLines > 0) {
@@ -1321,18 +1082,16 @@ export class TUI extends Container {
1321
1082
  this.positionHardwareCursor(cursorPos, newLines.length);
1322
1083
  this.previousLines = newLines;
1323
1084
  this.previousWidth = width;
1324
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1085
+ this.previousHeight = height;
1086
+ this.previousViewportTop = prevViewportTop;
1325
1087
  return;
1326
1088
  }
1327
1089
 
1328
- // If first changed line is above the current viewport basis, partial redraw is unsafe.
1090
+ // Differential rendering can only touch what was actually visible.
1091
+ // If the first changed line is above the previous viewport, we need a full redraw.
1329
1092
  if (firstChanged < prevViewportTop) {
1330
1093
  logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1331
- if (inStartupGrace) {
1332
- gentleFullRender();
1333
- } else {
1334
- fullRender(true);
1335
- }
1094
+ fullRender(true);
1336
1095
  return;
1337
1096
  }
1338
1097
 
@@ -1373,7 +1132,7 @@ export class TUI extends Container {
1373
1132
  for (let i = firstChanged; i <= renderEnd; i++) {
1374
1133
  if (i > firstChanged) buffer += "\r\n";
1375
1134
  buffer += "\x1b[2K"; // Clear current line
1376
- let line = newLines[i];
1135
+ const line = newLines[i];
1377
1136
  const isImage = isImageLine(line);
1378
1137
  if (!isImage && visibleWidth(line) > width) {
1379
1138
  // Log all lines to crash file for debugging
@@ -1390,23 +1149,18 @@ export class TUI extends Container {
1390
1149
  fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
1391
1150
  fs.writeFileSync(crashLogPath, crashData);
1392
1151
 
1393
- if (process.env.TALLOW_DEBUG || process.env.PI_DEBUG) {
1394
- // In debug mode, throw to surface the problem for fixing
1395
- this.stop();
1396
- const errorMsg = [
1397
- `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
1398
- "",
1399
- "This is likely caused by a custom TUI component not truncating its output.",
1400
- "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
1401
- "",
1402
- `Debug log written to: ${crashLogPath}`,
1403
- ].join("\n");
1404
- throw new Error(errorMsg);
1405
- }
1152
+ // Clean up terminal state before throwing
1153
+ this.stop();
1406
1154
 
1407
- // Production: defensively clamp the line instead of crashing
1408
- line = truncateToWidth(line, width, "");
1409
- newLines[i] = line;
1155
+ const errorMsg = [
1156
+ `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
1157
+ "",
1158
+ "This is likely caused by a custom TUI component not truncating its output.",
1159
+ "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
1160
+ "",
1161
+ `Debug log written to: ${crashLogPath}`,
1162
+ ].join("\n");
1163
+ throw new Error(errorMsg);
1410
1164
  }
1411
1165
  buffer += line;
1412
1166
  }
@@ -1416,21 +1170,13 @@ export class TUI extends Container {
1416
1170
 
1417
1171
  // If we had more lines before, clear them and move cursor back
1418
1172
  if (this.previousLines.length > newLines.length) {
1419
- const extraLines = this.previousLines.length - newLines.length;
1420
- // Safety guard: when extraLines exceeds terminal height, the \r\n
1421
- // sequence would scroll the viewport and desynchronize cursor tracking.
1422
- // Fall back to a full redraw instead (same guard as the deleted-lines-only path).
1423
- if (extraLines > height) {
1424
- logRedraw(`extraLines > height in diff path (${extraLines} > ${height})`);
1425
- fullRender(true);
1426
- return;
1427
- }
1428
1173
  // Move to end of new content first if we stopped before it
1429
1174
  if (renderEnd < newLines.length - 1) {
1430
1175
  const moveDown = newLines.length - 1 - renderEnd;
1431
1176
  buffer += `\x1b[${moveDown}B`;
1432
1177
  finalCursorRow = newLines.length - 1;
1433
1178
  }
1179
+ const extraLines = this.previousLines.length - newLines.length;
1434
1180
  for (let i = newLines.length; i < this.previousLines.length; i++) {
1435
1181
  buffer += "\r\n\x1b[2K";
1436
1182
  }
@@ -1443,21 +1189,9 @@ export class TUI extends Container {
1443
1189
  if (process.env.PI_TUI_DEBUG === "1") {
1444
1190
  const debugDir = "/tmp/tui";
1445
1191
  fs.mkdirSync(debugDir, { recursive: true });
1446
-
1447
- // Log large height fluctuations for diagnosing intermittent ghost gaps
1448
- const heightDelta = newLines.length - this.previousLines.length;
1449
- if (Math.abs(heightDelta) > 5) {
1450
- const fluctPath = path.join(debugDir, "height-fluctuations.log");
1451
- const fluctMsg =
1452
- `[${new Date().toISOString()}] heightFluctuation: ${heightDelta > 0 ? "+" : ""}${heightDelta} ` +
1453
- `(prev=${this.previousLines.length}, new=${newLines.length}, ` +
1454
- `maxLR=${this.maxLinesRendered}, viewportTop=${viewportTop})\n`;
1455
- fs.appendFileSync(fluctPath, fluctMsg);
1456
- }
1457
-
1458
1192
  const debugPath = path.join(
1459
1193
  debugDir,
1460
- `render-${Date.now()}-${crypto.randomUUID().slice(0, 8)}.log`
1194
+ `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`
1461
1195
  );
1462
1196
  const debugData = [
1463
1197
  `firstChanged: ${firstChanged}`,
@@ -1471,7 +1205,6 @@ export class TUI extends Container {
1471
1205
  `cursorPos: ${JSON.stringify(cursorPos)}`,
1472
1206
  `newLines.length: ${newLines.length}`,
1473
1207
  `previousLines.length: ${this.previousLines.length}`,
1474
- `maxLinesRendered: ${this.maxLinesRendered}`,
1475
1208
  "",
1476
1209
  "=== newLines ===",
1477
1210
  JSON.stringify(newLines, null, 2),
@@ -1482,7 +1215,7 @@ export class TUI extends Container {
1482
1215
  "=== buffer ===",
1483
1216
  JSON.stringify(buffer),
1484
1217
  ].join("\n");
1485
- fs.writeFileSync(debugPath, debugData, { mode: 0o600 });
1218
+ fs.writeFileSync(debugPath, debugData);
1486
1219
  }
1487
1220
 
1488
1221
  // Write entire buffer at once
@@ -1493,25 +1226,16 @@ export class TUI extends Container {
1493
1226
  // hardwareCursorRow tracks actual terminal cursor position (for movement)
1494
1227
  this.cursorRow = Math.max(0, newLines.length - 1);
1495
1228
  this.hardwareCursorRow = finalCursorRow;
1496
- // Track terminal's working area.
1497
- // When overlays are active, keep the high-water mark so overlay padding stays
1498
- // stable. When no overlays are active and content shrank, decay to actual
1499
- // content size — this prevents permanent ghost height from stale maxLinesRendered.
1500
- if (this.overlayStack.length === 0) {
1501
- this.maxLinesRendered = newLines.length;
1502
- } else {
1503
- this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1504
- }
1505
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1506
- // Update rolling peak for gradual shrink detection (partial path only —
1507
- // fullRender paths reset it inside the fullRender closure).
1508
- this.rollingShrinkPeak = Math.max(this.rollingShrinkPeak, newLines.length);
1229
+ // Track terminal's working area (grows but doesn't shrink unless cleared)
1230
+ this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1231
+ this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
1509
1232
 
1510
1233
  // Position hardware cursor for IME
1511
1234
  this.positionHardwareCursor(cursorPos, newLines.length);
1512
1235
 
1513
1236
  this.previousLines = newLines;
1514
1237
  this.previousWidth = width;
1238
+ this.previousHeight = height;
1515
1239
  }
1516
1240
 
1517
1241
  /**