@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
@@ -4,9 +4,10 @@
4
4
  import * as fs from "node:fs";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
- import { isKeyRelease, isMouseEvent, matchesKey, parseMouseEvent, } from "./keys.js";
7
+ import { performance } from "node:perf_hooks";
8
+ import { isKeyRelease, matchesKey } from "./keys.js";
8
9
  import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
9
- import { extractSegments, sliceByColumn, sliceWithWidth, truncateToWidth, visibleWidth, } from "./utils.js";
10
+ import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
10
11
  /** Type guard to check if a component implements Focusable */
11
12
  export function isFocusable(component) {
12
13
  return component !== null && "focused" in component;
@@ -32,6 +33,9 @@ function parseSizeValue(value, referenceSize) {
32
33
  }
33
34
  return undefined;
34
35
  }
36
+ function isTermuxSession() {
37
+ return Boolean(process.env.TERMUX_VERSION);
38
+ }
35
39
  /**
36
40
  * Container - a component that contains other components
37
41
  */
@@ -57,7 +61,10 @@ export class Container {
57
61
  render(width) {
58
62
  const lines = [];
59
63
  for (const child of this.children) {
60
- lines.push(...child.render(width));
64
+ const childLines = child.render(width);
65
+ for (const line of childLines) {
66
+ lines.push(line);
67
+ }
61
68
  }
62
69
  return lines;
63
70
  }
@@ -69,31 +76,25 @@ export class TUI extends Container {
69
76
  terminal;
70
77
  previousLines = [];
71
78
  previousWidth = 0;
79
+ previousHeight = 0;
72
80
  focusedComponent = null;
81
+ inputListeners = new Set();
73
82
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
74
83
  onDebug;
75
- /**
76
- * Callback for mouse events. Called when a mouse event is received.
77
- * Scroll events are the primary use case (scroll-up, scroll-down).
78
- * Return value is ignored — mouse events are always consumed and never
79
- * forwarded to focused components.
80
- */
81
- onMouse;
82
84
  renderRequested = false;
83
- pendingRenderHandle;
85
+ renderTimer;
86
+ lastRenderAt = 0;
87
+ static MIN_RENDER_INTERVAL_MS = 16;
84
88
  cursorRow = 0; // Logical cursor row (end of rendered content)
85
89
  hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
86
- inputBuffer = ""; // Buffer for parsing terminal responses
87
- cellSizeQueryPending = false;
88
90
  showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
89
91
  clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
90
92
  maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
91
93
  previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
92
94
  fullRedrawCount = 0;
93
- rollingShrinkPeak = 0; // Recent peak line count for gradual shrink detection
94
95
  stopped = false;
95
- pendingScrollbackClear = false; // Clear scrollback on next full render (session breaks)
96
96
  // Overlay stack for modal components rendered on top of base content
97
+ focusOrderCounter = 0;
97
98
  overlayStack = [];
98
99
  constructor(terminal, showHardwareCursor) {
99
100
  super();
@@ -128,32 +129,6 @@ export class TUI extends Container {
128
129
  setClearOnShrink(enabled) {
129
130
  this.clearOnShrink = enabled;
130
131
  }
131
- /**
132
- * Reset the startup grace period timer, suppressing screen-clearing full
133
- * redraws for another {@link STARTUP_GRACE_MS} milliseconds.
134
- *
135
- * Call this at the start of a session switch so the chatContainer.clear()
136
- * → renderInitialMessages() transition doesn't cause visible flicker.
137
- *
138
- * @returns {void}
139
- */
140
- resetRenderGrace() {
141
- this.startedAtMs = Date.now();
142
- }
143
- /**
144
- * Request that the next full render clears the terminal scrollback buffer.
145
- *
146
- * Use when the session content is being replaced wholesale (workspace
147
- * transitions, new sessions, session switches) so stale scrollback
148
- * doesn't visually flow into the new content.
149
- *
150
- * Has no effect on partial (differential) redraws — the flag is consumed
151
- * only when a full render is triggered by content shrink, width change,
152
- * or forced invalidation.
153
- */
154
- requestScrollbackClear() {
155
- this.pendingScrollbackClear = true;
156
- }
157
132
  setFocus(component) {
158
133
  // Clear focused flag on old component
159
134
  if (isFocusable(this.focusedComponent)) {
@@ -170,10 +145,16 @@ export class TUI extends Container {
170
145
  * Returns a handle to control the overlay's visibility.
171
146
  */
172
147
  showOverlay(component, options) {
173
- const entry = { component, options, preFocus: this.focusedComponent, hidden: false };
148
+ const entry = {
149
+ component,
150
+ options,
151
+ preFocus: this.focusedComponent,
152
+ hidden: false,
153
+ focusOrder: ++this.focusOrderCounter,
154
+ };
174
155
  this.overlayStack.push(entry);
175
156
  // Only focus if overlay is actually visible
176
- if (this.isOverlayVisible(entry)) {
157
+ if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
177
158
  this.setFocus(component);
178
159
  }
179
160
  this.terminal.hideCursor();
@@ -208,13 +189,31 @@ export class TUI extends Container {
208
189
  }
209
190
  else {
210
191
  // Restore focus to this overlay when showing (if it's actually visible)
211
- if (this.isOverlayVisible(entry)) {
192
+ if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
193
+ entry.focusOrder = ++this.focusOrderCounter;
212
194
  this.setFocus(component);
213
195
  }
214
196
  }
215
197
  this.requestRender();
216
198
  },
217
199
  isHidden: () => entry.hidden,
200
+ focus: () => {
201
+ if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
202
+ return;
203
+ if (this.focusedComponent !== component) {
204
+ this.setFocus(component);
205
+ }
206
+ entry.focusOrder = ++this.focusOrderCounter;
207
+ this.requestRender();
208
+ },
209
+ unfocus: () => {
210
+ if (this.focusedComponent !== component)
211
+ return;
212
+ const topVisible = this.getTopmostVisibleOverlay();
213
+ this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
214
+ this.requestRender();
215
+ },
216
+ isFocused: () => this.focusedComponent === component,
218
217
  };
219
218
  }
220
219
  /** Hide the topmost overlay and restore previous focus. */
@@ -222,9 +221,11 @@ export class TUI extends Container {
222
221
  const overlay = this.overlayStack.pop();
223
222
  if (!overlay)
224
223
  return;
225
- // Find topmost visible overlay, or fall back to preFocus
226
- const topVisible = this.getTopmostVisibleOverlay();
227
- this.setFocus(topVisible?.component ?? overlay.preFocus);
224
+ if (this.focusedComponent === overlay.component) {
225
+ // Find topmost visible overlay, or fall back to preFocus
226
+ const topVisible = this.getTopmostVisibleOverlay();
227
+ this.setFocus(topVisible?.component ?? overlay.preFocus);
228
+ }
228
229
  if (this.overlayStack.length === 0)
229
230
  this.terminal.hideCursor();
230
231
  this.requestRender();
@@ -242,9 +243,11 @@ export class TUI extends Container {
242
243
  }
243
244
  return true;
244
245
  }
245
- /** Find the topmost visible overlay, if any */
246
+ /** Find the topmost visible capturing overlay, if any */
246
247
  getTopmostVisibleOverlay() {
247
248
  for (let i = this.overlayStack.length - 1; i >= 0; i--) {
249
+ if (this.overlayStack[i].options?.nonCapturing)
250
+ continue;
248
251
  if (this.isOverlayVisible(this.overlayStack[i])) {
249
252
  return this.overlayStack[i];
250
253
  }
@@ -256,50 +259,36 @@ export class TUI extends Container {
256
259
  for (const overlay of this.overlayStack)
257
260
  overlay.component.invalidate?.();
258
261
  }
259
- /**
260
- * Timestamp when `start()` was called.
261
- * Used by startup grace period to suppress screen-clearing full redraws.
262
- */
263
- startedAtMs = 0;
264
- /**
265
- * Duration (ms) after `start()` during which shrink-triggered full redraws
266
- * use a gentler line-by-line overwrite instead of screen clear.
267
- *
268
- * This prevents the visual flicker that occurs when session resume causes
269
- * rapid content height changes (extension hooks, widget adds/removes) before
270
- * the full message history is rendered.
271
- */
272
- static STARTUP_GRACE_MS = 3000;
273
262
  start() {
274
263
  this.stopped = false;
275
- this.startedAtMs = Date.now();
276
264
  this.terminal.start((data) => this.handleInput(data), () => this.requestRender());
277
265
  this.terminal.hideCursor();
278
266
  this.queryCellSize();
279
267
  this.requestRender();
280
268
  }
269
+ addInputListener(listener) {
270
+ this.inputListeners.add(listener);
271
+ return () => {
272
+ this.inputListeners.delete(listener);
273
+ };
274
+ }
275
+ removeInputListener(listener) {
276
+ this.inputListeners.delete(listener);
277
+ }
281
278
  queryCellSize() {
282
279
  // Only query if terminal supports images (cell size is only used for image rendering)
283
280
  if (!getCapabilities().images) {
284
281
  return;
285
282
  }
286
- // Skip cell size query inside tmux — tmux doesn't forward CSI 16 t responses,
287
- // so cellSizeQueryPending would stay true and parseCellSizeResponse would eat
288
- // bare \x1b (Escape key) as a "partial response", breaking Escape handling.
289
- if (process.env.TMUX) {
290
- return;
291
- }
292
283
  // Query terminal for cell size in pixels: CSI 16 t
293
284
  // Response format: CSI 6 ; height ; width t
294
- this.cellSizeQueryPending = true;
295
285
  this.terminal.write("\x1b[16t");
296
286
  }
297
287
  stop() {
298
288
  this.stopped = true;
299
- if (this.pendingRenderHandle !== undefined) {
300
- clearTimeout(this.pendingRenderHandle);
301
- this.pendingRenderHandle = undefined;
302
- this.renderRequested = false;
289
+ if (this.renderTimer) {
290
+ clearTimeout(this.renderTimer);
291
+ this.renderTimer = undefined;
303
292
  }
304
293
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
305
294
  if (this.previousLines.length > 0) {
@@ -316,123 +305,73 @@ export class TUI extends Container {
316
305
  this.terminal.showCursor();
317
306
  this.terminal.stop();
318
307
  }
319
- /** When >0, scheduled renders are deferred until the batch completes. */
320
- renderBatchDepth = 0;
321
- /** Whether a render was requested while batching was active. */
322
- renderDeferredDuringBatch = false;
323
- /** Whether a forced render was requested while batching was active. */
324
- renderForceDeferredDuringBatch = false;
325
- /**
326
- * Begin a render batch — all `requestRender()` calls are coalesced and
327
- * deferred until the matching `endRenderBatch()`. Nestable.
328
- *
329
- * Use to prevent intermediate renders (and the screen clears they cause)
330
- * during multi-step UI mutations such as session resume.
331
- *
332
- * @returns {void}
333
- */
334
- beginRenderBatch() {
335
- this.renderBatchDepth++;
336
- }
337
- /**
338
- * End a render batch. When the outermost batch ends, a single render is
339
- * scheduled if any were deferred.
340
- *
341
- * @returns {void}
342
- */
343
- endRenderBatch() {
344
- if (this.renderBatchDepth <= 0)
345
- return;
346
- this.renderBatchDepth--;
347
- if (this.renderBatchDepth === 0 && this.renderDeferredDuringBatch) {
348
- const wasForce = this.renderForceDeferredDuringBatch;
349
- this.renderDeferredDuringBatch = false;
350
- this.renderForceDeferredDuringBatch = false;
351
- this.requestRender(wasForce);
352
- }
353
- }
354
308
  requestRender(force = false) {
355
309
  if (force) {
356
310
  this.previousLines = [];
357
311
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
312
+ this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
358
313
  this.cursorRow = 0;
359
314
  this.hardwareCursorRow = 0;
360
315
  this.maxLinesRendered = 0;
361
316
  this.previousViewportTop = 0;
362
- this.rollingShrinkPeak = 0;
363
- }
364
- if (this.renderBatchDepth > 0) {
365
- this.renderDeferredDuringBatch = true;
366
- if (force)
367
- this.renderForceDeferredDuringBatch = true;
317
+ if (this.renderTimer) {
318
+ clearTimeout(this.renderTimer);
319
+ this.renderTimer = undefined;
320
+ }
321
+ this.renderRequested = true;
322
+ process.nextTick(() => {
323
+ if (this.stopped || !this.renderRequested) {
324
+ return;
325
+ }
326
+ this.renderRequested = false;
327
+ this.lastRenderAt = performance.now();
328
+ this.doRender();
329
+ });
368
330
  return;
369
331
  }
370
332
  if (this.renderRequested)
371
333
  return;
372
- this.scheduleRender();
334
+ this.renderRequested = true;
335
+ process.nextTick(() => this.scheduleRender());
373
336
  }
374
- /**
375
- * Schedule a single coalesced render in the check phase.
376
- *
377
- * On Bun, `setImmediate` behaves like a microtask and never enters the I/O poll
378
- * phase, so stdin data callbacks are starved during streaming. `setTimeout(fn, 0)`
379
- * forces a real timer (~1ms) that guarantees I/O polling between renders.
380
- *
381
- * On Node.js, `setTimeout(0)` has a 1ms minimum delay — slightly slower than
382
- * `setImmediate` but still imperceptible (<13ms human threshold).
383
- *
384
- * @see Plan 177 — Bun setImmediate does not yield to I/O
385
- * @returns {void}
386
- */
387
337
  scheduleRender() {
388
- this.renderRequested = true;
389
- this.pendingRenderHandle = setTimeout(() => {
390
- this.pendingRenderHandle = undefined;
391
- this.renderRequested = false;
392
- if (this.stopped)
338
+ if (this.stopped || this.renderTimer || !this.renderRequested) {
339
+ return;
340
+ }
341
+ const elapsed = performance.now() - this.lastRenderAt;
342
+ const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);
343
+ this.renderTimer = setTimeout(() => {
344
+ this.renderTimer = undefined;
345
+ if (this.stopped || !this.renderRequested) {
393
346
  return;
347
+ }
348
+ this.renderRequested = false;
349
+ this.lastRenderAt = performance.now();
394
350
  this.doRender();
395
- }, 0);
396
- }
397
- /** Input listener functions — called before the focused component receives input. */
398
- inputListeners = new Set();
399
- /**
400
- * Register an input listener. Listeners run before the focused component and can
401
- * consume input (return `{consume: true}`) or transform it (return `{data: newData}`).
402
- *
403
- * @param listener - Listener function
404
- * @returns Unsubscribe function
405
- */
406
- addInputListener(listener) {
407
- this.inputListeners.add(listener);
408
- return () => {
409
- this.inputListeners.delete(listener);
410
- };
411
- }
412
- /**
413
- * Remove a previously registered input listener.
414
- *
415
- * @param listener - The listener function to remove
416
- */
417
- removeInputListener(listener) {
418
- this.inputListeners.delete(listener);
351
+ if (this.renderRequested) {
352
+ this.scheduleRender();
353
+ }
354
+ }, delay);
419
355
  }
420
356
  handleInput(data) {
421
- // If we're waiting for cell size response, buffer input and parse
422
- if (this.cellSizeQueryPending) {
423
- this.inputBuffer += data;
424
- const filtered = this.parseCellSizeResponse();
425
- if (filtered.length === 0)
357
+ if (this.inputListeners.size > 0) {
358
+ let current = data;
359
+ for (const listener of this.inputListeners) {
360
+ const result = listener(current);
361
+ if (result?.consume) {
362
+ return;
363
+ }
364
+ if (result?.data !== undefined) {
365
+ current = result.data;
366
+ }
367
+ }
368
+ if (current.length === 0) {
426
369
  return;
427
- data = filtered;
428
- }
429
- // Mouse events — intercept before any key handling.
430
- // Always consumed: mouse sequences must never reach components as text.
431
- if (isMouseEvent(data)) {
432
- const event = parseMouseEvent(data);
433
- if (event && this.onMouse) {
434
- this.onMouse(event);
435
370
  }
371
+ data = current;
372
+ }
373
+ // Consume terminal cell size responses without blocking unrelated input.
374
+ if (this.consumeCellSizeResponse(data)) {
436
375
  return;
437
376
  }
438
377
  // Global debug key handler (Shift+Ctrl+D)
@@ -454,23 +393,6 @@ export class TUI extends Container {
454
393
  this.setFocus(focusedOverlay.preFocus);
455
394
  }
456
395
  }
457
- // Run input listeners — can consume or transform input
458
- if (this.inputListeners.size > 0) {
459
- let current = data;
460
- for (const listener of this.inputListeners) {
461
- const result = listener(current);
462
- if (result?.consume) {
463
- return;
464
- }
465
- if (result?.data !== undefined) {
466
- current = result.data;
467
- }
468
- }
469
- if (current.length === 0) {
470
- return;
471
- }
472
- data = current;
473
- }
474
396
  // Pass input to focused component (including Ctrl+C)
475
397
  // The focused component can decide how to handle Ctrl+C
476
398
  if (this.focusedComponent?.handleInput) {
@@ -478,53 +400,26 @@ export class TUI extends Container {
478
400
  if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
479
401
  return;
480
402
  }
481
- if (process.env.TALLOW_KEY_DEBUG && (data === "\x1b" || data === "\x03")) {
482
- const escMatch = matchesKey(data, "escape");
483
- const ctrlcMatch = matchesKey(data, "ctrl+c");
484
- const comp = this.focusedComponent;
485
- const hasOnEscape = typeof comp.onEscape === "function";
486
- const actionCount = comp.actionHandlers instanceof Map ? comp.actionHandlers.size : -1;
487
- process.stderr.write(`[key] ${data === "\x1b" ? "ESC" : "C-C"} matchEsc=${escMatch} matchCC=${ctrlcMatch} hasOnEscape=${hasOnEscape} actions=${actionCount}\n`);
488
- }
489
403
  this.focusedComponent.handleInput(data);
490
404
  this.requestRender();
491
405
  }
492
406
  }
493
- parseCellSizeResponse() {
407
+ consumeCellSizeResponse(data) {
494
408
  // Response format: ESC [ 6 ; height ; width t
495
- // Match the response pattern
496
- const responsePattern = /\x1b\[6;(\d+);(\d+)t/;
497
- const match = this.inputBuffer.match(responsePattern);
498
- if (match) {
499
- const heightPx = parseInt(match[1], 10);
500
- const widthPx = parseInt(match[2], 10);
501
- if (heightPx > 0 && widthPx > 0) {
502
- setCellDimensions({ widthPx, heightPx });
503
- // Invalidate all components so images re-render with correct dimensions
504
- this.invalidate();
505
- this.requestRender();
506
- }
507
- // Remove the response from buffer
508
- this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
509
- this.cellSizeQueryPending = false;
409
+ const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
410
+ if (!match) {
411
+ return false;
510
412
  }
511
- // Check if we have a partial cell size response starting (wait for more data)
512
- // Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet)
513
- const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/;
514
- if (partialCellSizePattern.test(this.inputBuffer)) {
515
- // Check if it's actually a complete different escape sequence (ends with a letter)
516
- // Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
517
- const lastChar = this.inputBuffer[this.inputBuffer.length - 1];
518
- if (!/[a-zA-Z~]/.test(lastChar)) {
519
- // Doesn't end with a terminator, might be incomplete - wait for more
520
- return "";
521
- }
413
+ const heightPx = parseInt(match[1], 10);
414
+ const widthPx = parseInt(match[2], 10);
415
+ if (heightPx <= 0 || widthPx <= 0) {
416
+ return true;
522
417
  }
523
- // No cell size response found, return buffered data as user input
524
- const result = this.inputBuffer;
525
- this.inputBuffer = "";
526
- this.cellSizeQueryPending = false; // Give up waiting
527
- return result;
418
+ setCellDimensions({ widthPx, heightPx });
419
+ // Invalidate all components so images re-render with correct dimensions.
420
+ this.invalidate();
421
+ this.requestRender();
422
+ return true;
528
423
  }
529
424
  /**
530
425
  * Resolve overlay layout from options.
@@ -652,7 +547,7 @@ export class TUI extends Container {
652
547
  return marginLeft + Math.floor((availWidth - width) / 2);
653
548
  }
654
549
  }
655
- /** Composite all overlays into content lines (in stack order, later = on top). */
550
+ /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
656
551
  compositeOverlays(lines, termWidth, termHeight) {
657
552
  if (this.overlayStack.length === 0)
658
553
  return lines;
@@ -660,10 +555,9 @@ export class TUI extends Container {
660
555
  // Pre-render all visible overlays and calculate positions
661
556
  const rendered = [];
662
557
  let minLinesNeeded = result.length;
663
- for (const entry of this.overlayStack) {
664
- // Skip invisible overlays (hidden or visible() returns false)
665
- if (!this.isOverlayVisible(entry))
666
- continue;
558
+ const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
559
+ visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
560
+ for (const entry of visibleEntries) {
667
561
  const { component, options } = entry;
668
562
  // Get layout with height=0 first to determine width and maxHeight
669
563
  // (width and maxHeight don't depend on overlay height)
@@ -679,16 +573,15 @@ export class TUI extends Container {
679
573
  rendered.push({ overlayLines, row, col, w: width });
680
574
  minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
681
575
  }
682
- // Ensure result covers the terminal working area to keep overlay positioning stable across resizes.
683
- // maxLinesRendered can exceed current content length after a shrink; pad to keep viewportStart consistent.
684
- const workingHeight = Math.max(this.maxLinesRendered, minLinesNeeded);
576
+ // Pad to at least terminal height so overlays have screen-relative positions.
577
+ // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
578
+ // inflation that pushed content into scrollback on terminal widen.
579
+ const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);
685
580
  // Extend result with empty lines if content is too short for overlay placement or working area
686
581
  while (result.length < workingHeight) {
687
582
  result.push("");
688
583
  }
689
584
  const viewportStart = Math.max(0, workingHeight - termHeight);
690
- // Track which lines were modified for final verification
691
- const modifiedLines = new Set();
692
585
  // Composite each overlay
693
586
  for (const { overlayLines, row, col, w } of rendered) {
694
587
  for (let i = 0; i < overlayLines.length; i++) {
@@ -700,20 +593,9 @@ export class TUI extends Container {
700
593
  ? sliceByColumn(overlayLines[i], 0, w, true)
701
594
  : overlayLines[i];
702
595
  result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
703
- modifiedLines.add(idx);
704
596
  }
705
597
  }
706
598
  }
707
- // Final verification: ensure no composited line exceeds terminal width
708
- // This is a belt-and-suspenders safeguard - compositeLineAt should already
709
- // guarantee this, but we verify here to prevent crashes from any edge cases
710
- // Only check lines that were actually modified (optimization)
711
- for (const idx of modifiedLines) {
712
- const lineWidth = visibleWidth(result[idx]);
713
- if (lineWidth > termWidth) {
714
- result[idx] = sliceByColumn(result[idx], 0, termWidth, true);
715
- }
716
- }
717
599
  return result;
718
600
  }
719
601
  static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
@@ -796,8 +678,13 @@ export class TUI extends Container {
796
678
  return;
797
679
  const width = this.terminal.columns;
798
680
  const height = this.terminal.rows;
799
- let viewportTop = Math.max(0, this.maxLinesRendered - height);
800
- let prevViewportTop = this.previousViewportTop;
681
+ const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
682
+ const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
683
+ const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;
684
+ let prevViewportTop = heightChanged
685
+ ? Math.max(0, previousBufferLength - height)
686
+ : this.previousViewportTop;
687
+ let viewportTop = prevViewportTop;
801
688
  let hardwareCursorRow = this.hardwareCursorRow;
802
689
  const computeLineDiff = (targetRow) => {
803
690
  const currentScreenRow = hardwareCursorRow - prevViewportTop;
@@ -813,22 +700,12 @@ export class TUI extends Container {
813
700
  // Extract cursor position before applying line resets (marker must be found first)
814
701
  const cursorPos = this.extractCursorPosition(newLines, height);
815
702
  newLines = this.applyLineResets(newLines);
816
- // Width changed - need full re-render (line wrapping changes)
817
- const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
818
- // Whether we are within the startup grace period where screen-clearing
819
- // full redraws are softened to prevent flicker during session resume.
820
- const inStartupGrace = this.startedAtMs > 0 && Date.now() - this.startedAtMs < TUI.STARTUP_GRACE_MS;
821
- // Helper to clear viewport (and optionally scrollback) and render all new lines
703
+ // Helper to clear scrollback and viewport and render all new lines
822
704
  const fullRender = (clear) => {
823
705
  this.fullRedrawCount += 1;
824
706
  let buffer = "\x1b[?2026h"; // Begin synchronized output
825
- if (clear && this.pendingScrollbackClear) {
826
- buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
827
- this.pendingScrollbackClear = false;
828
- }
829
- else if (clear) {
830
- buffer += "\x1b[2J\x1b[H"; // Clear screen and home (preserve scrollback)
831
- }
707
+ if (clear)
708
+ buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
832
709
  for (let i = 0; i < newLines.length; i++) {
833
710
  if (i > 0)
834
711
  buffer += "\r\n";
@@ -845,49 +722,12 @@ export class TUI extends Container {
845
722
  else {
846
723
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
847
724
  }
848
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
849
- this.rollingShrinkPeak = newLines.length;
850
- this.positionHardwareCursor(cursorPos, newLines.length);
851
- this.previousLines = newLines;
852
- this.previousWidth = width;
853
- };
854
- /**
855
- * Gentle full redraw: home cursor + overwrite each line + clear below.
856
- *
857
- * Used during the startup grace period instead of fullRender(true) for
858
- * shrink-triggered redraws. Avoids the visible blank frame caused by
859
- * `\x1b[2J` (clear screen), which makes messages appear to flash in and
860
- * out when session resume triggers rapid content height changes.
861
- *
862
- * Unlike fullRender(true), this never clears the screen — it writes each
863
- * line with a preceding `\x1b[2K` (clear line) so stale content is
864
- * overwritten without a blank frame. Lines below the new content are
865
- * individually erased.
866
- */
867
- const gentleFullRender = () => {
868
- this.fullRedrawCount += 1;
869
- let buffer = "\x1b[?2026h\x1b[H"; // Begin synchronized output + home cursor
870
- for (let i = 0; i < newLines.length; i++) {
871
- buffer += "\x1b[2K"; // Clear current line
872
- buffer += newLines[i];
873
- if (i < newLines.length - 1)
874
- buffer += "\r\n";
875
- }
876
- // Erase lines that were previously rendered but are no longer needed
877
- const staleLines = Math.max(0, this.maxLinesRendered - newLines.length);
878
- for (let i = 0; i < staleLines; i++) {
879
- buffer += "\r\n\x1b[2K";
880
- }
881
- buffer += "\x1b[?2026l"; // End synchronized output
882
- this.terminal.write(buffer);
883
- this.cursorRow = Math.max(0, newLines.length + staleLines - 1);
884
- this.hardwareCursorRow = this.cursorRow;
885
- this.maxLinesRendered = newLines.length;
886
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
887
- this.rollingShrinkPeak = newLines.length;
725
+ const bufferLength = Math.max(height, newLines.length);
726
+ this.previousViewportTop = Math.max(0, bufferLength - height);
888
727
  this.positionHardwareCursor(cursorPos, newLines.length);
889
728
  this.previousLines = newLines;
890
729
  this.previousWidth = width;
730
+ this.previousHeight = height;
891
731
  };
892
732
  const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
893
733
  const logRedraw = (reason) => {
@@ -898,14 +738,22 @@ export class TUI extends Container {
898
738
  fs.appendFileSync(logPath, msg);
899
739
  };
900
740
  // First render - just output everything without clearing (assumes clean screen)
901
- if (this.previousLines.length === 0 && !widthChanged) {
741
+ if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
902
742
  logRedraw("first render");
903
743
  fullRender(false);
904
744
  return;
905
745
  }
906
- // Width changed - full re-render (line wrapping changes)
746
+ // Width changes always need a full re-render because wrapping changes.
907
747
  if (widthChanged) {
908
- logRedraw(`width changed (${this.previousWidth} -> ${width})`);
748
+ logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
749
+ fullRender(true);
750
+ return;
751
+ }
752
+ // Height changes normally need a full re-render to keep the visible viewport aligned,
753
+ // but Termux changes height when the software keyboard shows or hides.
754
+ // In that environment, a full redraw causes the entire history to replay on every toggle.
755
+ if (heightChanged && !isTermuxSession()) {
756
+ logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
909
757
  fullRender(true);
910
758
  return;
911
759
  }
@@ -916,69 +764,9 @@ export class TUI extends Container {
916
764
  newLines.length < this.maxLinesRendered &&
917
765
  this.overlayStack.length === 0) {
918
766
  logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
919
- if (inStartupGrace) {
920
- gentleFullRender();
921
- }
922
- else {
923
- fullRender(true);
924
- }
925
- return;
926
- }
927
- // Large content shrinks (e.g., tool output collapse) are hard to partially redraw
928
- // correctly — cursor positions drift when many lines disappear at once, causing
929
- // ghost copies of previous frames. Force a full redraw when the shrink exceeds a
930
- // threshold. More targeted than clearOnShrink (which fires on ANY shrink).
931
- const shrinkDelta = this.previousLines.length - newLines.length;
932
- if (shrinkDelta > 5 && this.overlayStack.length === 0) {
933
- logRedraw(`large shrink (${shrinkDelta} lines)`);
934
- if (inStartupGrace) {
935
- gentleFullRender();
936
- }
937
- else {
938
- fullRender(true);
939
- }
940
- return;
941
- }
942
- // Rolling shrink detection: catches gradual shrinks where each individual
943
- // frame-to-frame delta is ≤5 lines (below the large-shrink threshold) but the
944
- // accumulated shrink from a recent peak exceeds it. This happens when
945
- // pollStates.clear() collapses tool-result anchors across multiple render
946
- // cycles while animations (loader, widget spinners) keep triggering renders.
947
- if (newLines.length >= this.rollingShrinkPeak) {
948
- this.rollingShrinkPeak = newLines.length;
949
- }
950
- else if (this.overlayStack.length === 0 && this.rollingShrinkPeak - newLines.length > 5) {
951
- logRedraw(`rolling shrink (peak=${this.rollingShrinkPeak}, now=${newLines.length}, delta=${this.rollingShrinkPeak - newLines.length})`);
952
- if (inStartupGrace) {
953
- gentleFullRender();
954
- }
955
- else {
956
- fullRender(true);
957
- }
767
+ fullRender(true);
958
768
  return;
959
769
  }
960
- const previousContentViewportTop = Math.max(0, this.previousLines.length - height);
961
- // Detect viewport basis drift: maxLinesRendered exceeds actual content,
962
- // causing viewportTop to be computed from a stale high-water mark.
963
- // Compare against newLines.length (not previousLines.length) so the
964
- // correction fires on the same render cycle as a shrink, not one cycle late.
965
- const hasViewportBasisDrift = this.overlayStack.length === 0 &&
966
- this.previousLines.length > 0 &&
967
- this.maxLinesRendered > newLines.length &&
968
- prevViewportTop !== previousContentViewportTop;
969
- // After shrink-heavy updates with clearOnShrink disabled, maxLinesRendered can stay larger
970
- // than current content. Instead of a destructive full redraw (which clears the screen and
971
- // disrupts scrollback), realign the working-area coordinates so the partial-redraw path
972
- // can operate on a consistent viewport basis.
973
- // Use newLines.length (not previousLines.length) so the correction is exact for the
974
- // current frame — previousLines.length can still exceed newLines.length, leaving
975
- // residual drift for one more cycle and causing ghost lines.
976
- if (hasViewportBasisDrift) {
977
- this.maxLinesRendered = newLines.length;
978
- viewportTop = Math.max(0, this.maxLinesRendered - height);
979
- prevViewportTop = viewportTop;
980
- this.previousViewportTop = viewportTop;
981
- }
982
770
  // Find first and last changed lines
983
771
  let firstChanged = -1;
984
772
  let lastChanged = -1;
@@ -1004,7 +792,8 @@ export class TUI extends Container {
1004
792
  // No changes - but still need to update hardware cursor position if it moved
1005
793
  if (firstChanged === -1) {
1006
794
  this.positionHardwareCursor(cursorPos, newLines.length);
1007
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
795
+ this.previousViewportTop = prevViewportTop;
796
+ this.previousHeight = height;
1008
797
  return;
1009
798
  }
1010
799
  // All changes are in deleted lines (nothing to render, just clear)
@@ -1013,6 +802,11 @@ export class TUI extends Container {
1013
802
  let buffer = "\x1b[?2026h";
1014
803
  // Move to end of new content (clamp to 0 for empty content)
1015
804
  const targetRow = Math.max(0, newLines.length - 1);
805
+ if (targetRow < prevViewportTop) {
806
+ logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
807
+ fullRender(true);
808
+ return;
809
+ }
1016
810
  const lineDiff = computeLineDiff(targetRow);
1017
811
  if (lineDiff > 0)
1018
812
  buffer += `\x1b[${lineDiff}B`;
@@ -1023,12 +817,7 @@ export class TUI extends Container {
1023
817
  const extraLines = this.previousLines.length - newLines.length;
1024
818
  if (extraLines > height) {
1025
819
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
1026
- if (inStartupGrace) {
1027
- gentleFullRender();
1028
- }
1029
- else {
1030
- fullRender(true);
1031
- }
820
+ fullRender(true);
1032
821
  return;
1033
822
  }
1034
823
  if (extraLines > 0) {
@@ -1050,18 +839,15 @@ export class TUI extends Container {
1050
839
  this.positionHardwareCursor(cursorPos, newLines.length);
1051
840
  this.previousLines = newLines;
1052
841
  this.previousWidth = width;
1053
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
842
+ this.previousHeight = height;
843
+ this.previousViewportTop = prevViewportTop;
1054
844
  return;
1055
845
  }
1056
- // If first changed line is above the current viewport basis, partial redraw is unsafe.
846
+ // Differential rendering can only touch what was actually visible.
847
+ // If the first changed line is above the previous viewport, we need a full redraw.
1057
848
  if (firstChanged < prevViewportTop) {
1058
849
  logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1059
- if (inStartupGrace) {
1060
- gentleFullRender();
1061
- }
1062
- else {
1063
- fullRender(true);
1064
- }
850
+ fullRender(true);
1065
851
  return;
1066
852
  }
1067
853
  // Render from first changed line to end
@@ -1097,7 +883,7 @@ export class TUI extends Container {
1097
883
  if (i > firstChanged)
1098
884
  buffer += "\r\n";
1099
885
  buffer += "\x1b[2K"; // Clear current line
1100
- let line = newLines[i];
886
+ const line = newLines[i];
1101
887
  const isImage = isImageLine(line);
1102
888
  if (!isImage && visibleWidth(line) > width) {
1103
889
  // Log all lines to crash file for debugging
@@ -1113,22 +899,17 @@ export class TUI extends Container {
1113
899
  ].join("\n");
1114
900
  fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
1115
901
  fs.writeFileSync(crashLogPath, crashData);
1116
- if (process.env.TALLOW_DEBUG || process.env.PI_DEBUG) {
1117
- // In debug mode, throw to surface the problem for fixing
1118
- this.stop();
1119
- const errorMsg = [
1120
- `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
1121
- "",
1122
- "This is likely caused by a custom TUI component not truncating its output.",
1123
- "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
1124
- "",
1125
- `Debug log written to: ${crashLogPath}`,
1126
- ].join("\n");
1127
- throw new Error(errorMsg);
1128
- }
1129
- // Production: defensively clamp the line instead of crashing
1130
- line = truncateToWidth(line, width, "");
1131
- newLines[i] = line;
902
+ // Clean up terminal state before throwing
903
+ this.stop();
904
+ const errorMsg = [
905
+ `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
906
+ "",
907
+ "This is likely caused by a custom TUI component not truncating its output.",
908
+ "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
909
+ "",
910
+ `Debug log written to: ${crashLogPath}`,
911
+ ].join("\n");
912
+ throw new Error(errorMsg);
1132
913
  }
1133
914
  buffer += line;
1134
915
  }
@@ -1136,21 +917,13 @@ export class TUI extends Container {
1136
917
  let finalCursorRow = renderEnd;
1137
918
  // If we had more lines before, clear them and move cursor back
1138
919
  if (this.previousLines.length > newLines.length) {
1139
- const extraLines = this.previousLines.length - newLines.length;
1140
- // Safety guard: when extraLines exceeds terminal height, the \r\n
1141
- // sequence would scroll the viewport and desynchronize cursor tracking.
1142
- // Fall back to a full redraw instead (same guard as the deleted-lines-only path).
1143
- if (extraLines > height) {
1144
- logRedraw(`extraLines > height in diff path (${extraLines} > ${height})`);
1145
- fullRender(true);
1146
- return;
1147
- }
1148
920
  // Move to end of new content first if we stopped before it
1149
921
  if (renderEnd < newLines.length - 1) {
1150
922
  const moveDown = newLines.length - 1 - renderEnd;
1151
923
  buffer += `\x1b[${moveDown}B`;
1152
924
  finalCursorRow = newLines.length - 1;
1153
925
  }
926
+ const extraLines = this.previousLines.length - newLines.length;
1154
927
  for (let i = newLines.length; i < this.previousLines.length; i++) {
1155
928
  buffer += "\r\n\x1b[2K";
1156
929
  }
@@ -1161,16 +934,7 @@ export class TUI extends Container {
1161
934
  if (process.env.PI_TUI_DEBUG === "1") {
1162
935
  const debugDir = "/tmp/tui";
1163
936
  fs.mkdirSync(debugDir, { recursive: true });
1164
- // Log large height fluctuations for diagnosing intermittent ghost gaps
1165
- const heightDelta = newLines.length - this.previousLines.length;
1166
- if (Math.abs(heightDelta) > 5) {
1167
- const fluctPath = path.join(debugDir, "height-fluctuations.log");
1168
- const fluctMsg = `[${new Date().toISOString()}] heightFluctuation: ${heightDelta > 0 ? "+" : ""}${heightDelta} ` +
1169
- `(prev=${this.previousLines.length}, new=${newLines.length}, ` +
1170
- `maxLR=${this.maxLinesRendered}, viewportTop=${viewportTop})\n`;
1171
- fs.appendFileSync(fluctPath, fluctMsg);
1172
- }
1173
- const debugPath = path.join(debugDir, `render-${Date.now()}-${crypto.randomUUID().slice(0, 8)}.log`);
937
+ const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
1174
938
  const debugData = [
1175
939
  `firstChanged: ${firstChanged}`,
1176
940
  `viewportTop: ${viewportTop}`,
@@ -1183,7 +947,6 @@ export class TUI extends Container {
1183
947
  `cursorPos: ${JSON.stringify(cursorPos)}`,
1184
948
  `newLines.length: ${newLines.length}`,
1185
949
  `previousLines.length: ${this.previousLines.length}`,
1186
- `maxLinesRendered: ${this.maxLinesRendered}`,
1187
950
  "",
1188
951
  "=== newLines ===",
1189
952
  JSON.stringify(newLines, null, 2),
@@ -1194,7 +957,7 @@ export class TUI extends Container {
1194
957
  "=== buffer ===",
1195
958
  JSON.stringify(buffer),
1196
959
  ].join("\n");
1197
- fs.writeFileSync(debugPath, debugData, { mode: 0o600 });
960
+ fs.writeFileSync(debugPath, debugData);
1198
961
  }
1199
962
  // Write entire buffer at once
1200
963
  this.terminal.write(buffer);
@@ -1203,24 +966,14 @@ export class TUI extends Container {
1203
966
  // hardwareCursorRow tracks actual terminal cursor position (for movement)
1204
967
  this.cursorRow = Math.max(0, newLines.length - 1);
1205
968
  this.hardwareCursorRow = finalCursorRow;
1206
- // Track terminal's working area.
1207
- // When overlays are active, keep the high-water mark so overlay padding stays
1208
- // stable. When no overlays are active and content shrank, decay to actual
1209
- // content size — this prevents permanent ghost height from stale maxLinesRendered.
1210
- if (this.overlayStack.length === 0) {
1211
- this.maxLinesRendered = newLines.length;
1212
- }
1213
- else {
1214
- this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1215
- }
1216
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1217
- // Update rolling peak for gradual shrink detection (partial path only —
1218
- // fullRender paths reset it inside the fullRender closure).
1219
- this.rollingShrinkPeak = Math.max(this.rollingShrinkPeak, newLines.length);
969
+ // Track terminal's working area (grows but doesn't shrink unless cleared)
970
+ this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
971
+ this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
1220
972
  // Position hardware cursor for IME
1221
973
  this.positionHardwareCursor(cursorPos, newLines.length);
1222
974
  this.previousLines = newLines;
1223
975
  this.previousWidth = width;
976
+ this.previousHeight = height;
1224
977
  }
1225
978
  /**
1226
979
  * Position the hardware cursor for IME candidate window.