@dungle-scrubs/tallow 0.8.21 → 0.8.23

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 (217) hide show
  1. package/dist/cli.js +35 -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 +2 -0
  6. package/dist/interactive-mode-patch.d.ts.map +1 -1
  7. package/dist/interactive-mode-patch.js +82 -0
  8. package/dist/interactive-mode-patch.js.map +1 -1
  9. package/dist/sdk.d.ts +17 -0
  10. package/dist/sdk.d.ts.map +1 -1
  11. package/dist/sdk.js +68 -1
  12. package/dist/sdk.js.map +1 -1
  13. package/dist/workspace-transition-relay.d.ts +40 -7
  14. package/dist/workspace-transition-relay.d.ts.map +1 -1
  15. package/dist/workspace-transition-relay.js +81 -16
  16. package/dist/workspace-transition-relay.js.map +1 -1
  17. package/extensions/__integration__/background-task-widget-ownership.test.ts +216 -0
  18. package/extensions/__integration__/claude-hooks-compat.test.ts +156 -0
  19. package/extensions/__integration__/slash-command-bridge.test.ts +169 -23
  20. package/extensions/_shared/atomic-write.ts +1 -1
  21. package/extensions/_shared/bordered-box.ts +102 -0
  22. package/extensions/_shared/interop-events.ts +5 -0
  23. package/extensions/_shared/pid-registry.ts +1 -1
  24. package/extensions/agent-commands-tool/index.ts +4 -1
  25. package/extensions/background-task-tool/__tests__/lifecycle.test.ts +50 -25
  26. package/extensions/background-task-tool/index.ts +139 -221
  27. package/extensions/bash-tool-enhanced/index.ts +1 -75
  28. package/extensions/cd-tool/index.ts +2 -2
  29. package/extensions/context-fork/spawn.ts +4 -1
  30. package/extensions/health/index.ts +6 -6
  31. package/extensions/hooks/__tests__/claude-compat.test.ts +35 -0
  32. package/extensions/hooks/__tests__/subprocess-hardening.test.ts +73 -0
  33. package/extensions/hooks/index.ts +27 -4
  34. package/extensions/loop/__tests__/loop.test.ts +168 -4
  35. package/extensions/loop/extension.json +6 -5
  36. package/extensions/loop/index.ts +242 -31
  37. package/extensions/plan-mode-tool/__tests__/agent-end-execution.test.ts +373 -0
  38. package/extensions/plan-mode-tool/index.ts +103 -41
  39. package/extensions/prompt-suggestions/__tests__/editor-compatibility.test.ts +42 -0
  40. package/extensions/prompt-suggestions/index.ts +41 -6
  41. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +267 -671
  42. package/extensions/slash-command-bridge/extension.json +1 -1
  43. package/extensions/slash-command-bridge/index.ts +230 -116
  44. package/extensions/subagent-tool/index.ts +2 -2
  45. package/extensions/subagent-tool/process.ts +4 -5
  46. package/extensions/tasks/commands/register-tasks-extension.ts +41 -0
  47. package/extensions/teams-tool/__tests__/peer-messaging.test.ts +29 -24
  48. package/extensions/teams-tool/dashboard.ts +3 -5
  49. package/extensions/teams-tool/dispatch/auto-dispatch.ts +18 -1
  50. package/extensions/teams-tool/tools/teammate-tools.ts +9 -6
  51. package/extensions/wezterm-pane-control/__tests__/index.test.ts +88 -4
  52. package/extensions/wezterm-pane-control/index.ts +113 -8
  53. package/package.json +6 -4
  54. package/packages/tallow-tui/README.md +51 -0
  55. package/packages/tallow-tui/dist/autocomplete.d.ts +48 -0
  56. package/packages/tallow-tui/dist/autocomplete.d.ts.map +1 -0
  57. package/packages/tallow-tui/dist/autocomplete.js +564 -0
  58. package/packages/tallow-tui/dist/autocomplete.js.map +1 -0
  59. package/packages/tallow-tui/dist/border-styles.d.ts +32 -0
  60. package/packages/tallow-tui/dist/border-styles.d.ts.map +1 -0
  61. package/packages/tallow-tui/dist/border-styles.js +46 -0
  62. package/packages/tallow-tui/dist/border-styles.js.map +1 -0
  63. package/packages/tallow-tui/dist/components/bordered-box.d.ts +52 -0
  64. package/packages/tallow-tui/dist/components/bordered-box.d.ts.map +1 -0
  65. package/packages/tallow-tui/dist/components/bordered-box.js +89 -0
  66. package/packages/tallow-tui/dist/components/bordered-box.js.map +1 -0
  67. package/packages/tallow-tui/dist/components/box.d.ts +22 -0
  68. package/packages/tallow-tui/dist/components/box.d.ts.map +1 -0
  69. package/packages/tallow-tui/dist/components/box.js +104 -0
  70. package/packages/tallow-tui/dist/components/box.js.map +1 -0
  71. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts +22 -0
  72. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts.map +1 -0
  73. package/packages/tallow-tui/dist/components/cancellable-loader.js +35 -0
  74. package/packages/tallow-tui/dist/components/cancellable-loader.js.map +1 -0
  75. package/packages/tallow-tui/dist/components/editor.d.ts +240 -0
  76. package/packages/tallow-tui/dist/components/editor.d.ts.map +1 -0
  77. package/packages/tallow-tui/dist/components/editor.js +1766 -0
  78. package/packages/tallow-tui/dist/components/editor.js.map +1 -0
  79. package/packages/tallow-tui/dist/components/image.d.ts +126 -0
  80. package/packages/tallow-tui/dist/components/image.d.ts.map +1 -0
  81. package/packages/tallow-tui/dist/components/image.js +245 -0
  82. package/packages/tallow-tui/dist/components/image.js.map +1 -0
  83. package/packages/tallow-tui/dist/components/input.d.ts +37 -0
  84. package/packages/tallow-tui/dist/components/input.d.ts.map +1 -0
  85. package/packages/tallow-tui/dist/components/input.js +439 -0
  86. package/packages/tallow-tui/dist/components/input.js.map +1 -0
  87. package/packages/tallow-tui/dist/components/loader.d.ts +88 -0
  88. package/packages/tallow-tui/dist/components/loader.d.ts.map +1 -0
  89. package/packages/tallow-tui/dist/components/loader.js +146 -0
  90. package/packages/tallow-tui/dist/components/loader.js.map +1 -0
  91. package/packages/tallow-tui/dist/components/markdown.d.ts +95 -0
  92. package/packages/tallow-tui/dist/components/markdown.d.ts.map +1 -0
  93. package/packages/tallow-tui/dist/components/markdown.js +633 -0
  94. package/packages/tallow-tui/dist/components/markdown.js.map +1 -0
  95. package/packages/tallow-tui/dist/components/select-list.d.ts +32 -0
  96. package/packages/tallow-tui/dist/components/select-list.d.ts.map +1 -0
  97. package/packages/tallow-tui/dist/components/select-list.js +156 -0
  98. package/packages/tallow-tui/dist/components/select-list.js.map +1 -0
  99. package/packages/tallow-tui/dist/components/settings-list.d.ts +50 -0
  100. package/packages/tallow-tui/dist/components/settings-list.d.ts.map +1 -0
  101. package/packages/tallow-tui/dist/components/settings-list.js +189 -0
  102. package/packages/tallow-tui/dist/components/settings-list.js.map +1 -0
  103. package/packages/tallow-tui/dist/components/spacer.d.ts +12 -0
  104. package/packages/tallow-tui/dist/components/spacer.d.ts.map +1 -0
  105. package/packages/tallow-tui/dist/components/spacer.js +23 -0
  106. package/packages/tallow-tui/dist/components/spacer.js.map +1 -0
  107. package/packages/tallow-tui/dist/components/text.d.ts +19 -0
  108. package/packages/tallow-tui/dist/components/text.d.ts.map +1 -0
  109. package/packages/tallow-tui/dist/components/text.js +91 -0
  110. package/packages/tallow-tui/dist/components/text.js.map +1 -0
  111. package/packages/tallow-tui/dist/components/truncated-text.d.ts +13 -0
  112. package/packages/tallow-tui/dist/components/truncated-text.d.ts.map +1 -0
  113. package/packages/tallow-tui/dist/components/truncated-text.js +51 -0
  114. package/packages/tallow-tui/dist/components/truncated-text.js.map +1 -0
  115. package/packages/tallow-tui/dist/editor-component.d.ts +50 -0
  116. package/packages/tallow-tui/dist/editor-component.d.ts.map +1 -0
  117. package/packages/tallow-tui/dist/editor-component.js +2 -0
  118. package/packages/tallow-tui/dist/editor-component.js.map +1 -0
  119. package/packages/tallow-tui/dist/fuzzy.d.ts +16 -0
  120. package/packages/tallow-tui/dist/fuzzy.d.ts.map +1 -0
  121. package/packages/tallow-tui/dist/fuzzy.js +107 -0
  122. package/packages/tallow-tui/dist/fuzzy.js.map +1 -0
  123. package/packages/tallow-tui/dist/index.d.ts +25 -0
  124. package/packages/tallow-tui/dist/index.d.ts.map +1 -0
  125. package/packages/tallow-tui/dist/index.js +35 -0
  126. package/packages/tallow-tui/dist/index.js.map +1 -0
  127. package/packages/tallow-tui/dist/keybindings.d.ts +39 -0
  128. package/packages/tallow-tui/dist/keybindings.d.ts.map +1 -0
  129. package/packages/tallow-tui/dist/keybindings.js +114 -0
  130. package/packages/tallow-tui/dist/keybindings.js.map +1 -0
  131. package/packages/tallow-tui/dist/keys.d.ts +168 -0
  132. package/packages/tallow-tui/dist/keys.d.ts.map +1 -0
  133. package/packages/tallow-tui/dist/keys.js +971 -0
  134. package/packages/tallow-tui/dist/keys.js.map +1 -0
  135. package/packages/tallow-tui/dist/kill-ring.d.ts +28 -0
  136. package/packages/tallow-tui/dist/kill-ring.d.ts.map +1 -0
  137. package/packages/tallow-tui/dist/kill-ring.js +44 -0
  138. package/packages/tallow-tui/dist/kill-ring.js.map +1 -0
  139. package/packages/tallow-tui/dist/stdin-buffer.d.ts +48 -0
  140. package/packages/tallow-tui/dist/stdin-buffer.d.ts.map +1 -0
  141. package/packages/tallow-tui/dist/stdin-buffer.js +317 -0
  142. package/packages/tallow-tui/dist/stdin-buffer.js.map +1 -0
  143. package/packages/tallow-tui/dist/terminal-image.d.ts +161 -0
  144. package/packages/tallow-tui/dist/terminal-image.d.ts.map +1 -0
  145. package/packages/tallow-tui/dist/terminal-image.js +460 -0
  146. package/packages/tallow-tui/dist/terminal-image.js.map +1 -0
  147. package/packages/tallow-tui/dist/terminal.d.ts +102 -0
  148. package/packages/tallow-tui/dist/terminal.d.ts.map +1 -0
  149. package/packages/tallow-tui/dist/terminal.js +263 -0
  150. package/packages/tallow-tui/dist/terminal.js.map +1 -0
  151. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts +14 -0
  152. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts.map +1 -0
  153. package/packages/tallow-tui/dist/test-utils/capability-env.js +55 -0
  154. package/packages/tallow-tui/dist/test-utils/capability-env.js.map +1 -0
  155. package/packages/tallow-tui/dist/tui.d.ts +239 -0
  156. package/packages/tallow-tui/dist/tui.d.ts.map +1 -0
  157. package/packages/tallow-tui/dist/tui.js +1058 -0
  158. package/packages/tallow-tui/dist/tui.js.map +1 -0
  159. package/packages/tallow-tui/dist/undo-stack.d.ts +17 -0
  160. package/packages/tallow-tui/dist/undo-stack.d.ts.map +1 -0
  161. package/packages/tallow-tui/dist/undo-stack.js +25 -0
  162. package/packages/tallow-tui/dist/undo-stack.js.map +1 -0
  163. package/packages/tallow-tui/dist/utils.d.ts +96 -0
  164. package/packages/tallow-tui/dist/utils.d.ts.map +1 -0
  165. package/packages/tallow-tui/dist/utils.js +843 -0
  166. package/packages/tallow-tui/dist/utils.js.map +1 -0
  167. package/packages/tallow-tui/package.json +24 -0
  168. package/packages/tallow-tui/src/__tests__/__snapshots__/render.test.ts.snap +121 -0
  169. package/packages/tallow-tui/src/__tests__/editor-border.test.ts +72 -0
  170. package/packages/tallow-tui/src/__tests__/editor-change-listener.test.ts +121 -0
  171. package/packages/tallow-tui/src/__tests__/editor-ghost-text.test.ts +112 -0
  172. package/packages/tallow-tui/src/__tests__/fuzzy.test.ts +91 -0
  173. package/packages/tallow-tui/src/__tests__/image-component.test.ts +113 -0
  174. package/packages/tallow-tui/src/__tests__/keys.test.ts +141 -0
  175. package/packages/tallow-tui/src/__tests__/render.test.ts +179 -0
  176. package/packages/tallow-tui/src/__tests__/stdin-buffer.test.ts +82 -0
  177. package/packages/tallow-tui/src/__tests__/terminal-image.test.ts +363 -0
  178. package/packages/tallow-tui/src/__tests__/tui-diff-regression.test.ts +454 -0
  179. package/packages/tallow-tui/src/__tests__/tui-render-scheduling.test.ts +256 -0
  180. package/packages/tallow-tui/src/__tests__/utils.test.ts +259 -0
  181. package/packages/tallow-tui/src/autocomplete.ts +716 -0
  182. package/packages/tallow-tui/src/border-styles.ts +60 -0
  183. package/packages/tallow-tui/src/components/bordered-box.ts +113 -0
  184. package/packages/tallow-tui/src/components/box.ts +137 -0
  185. package/packages/tallow-tui/src/components/cancellable-loader.ts +40 -0
  186. package/packages/tallow-tui/src/components/editor.ts +2143 -0
  187. package/packages/tallow-tui/src/components/image.ts +315 -0
  188. package/packages/tallow-tui/src/components/input.ts +522 -0
  189. package/packages/tallow-tui/src/components/loader.ts +187 -0
  190. package/packages/tallow-tui/src/components/markdown.ts +780 -0
  191. package/packages/tallow-tui/src/components/select-list.ts +197 -0
  192. package/packages/tallow-tui/src/components/settings-list.ts +264 -0
  193. package/packages/tallow-tui/src/components/spacer.ts +28 -0
  194. package/packages/tallow-tui/src/components/text.ts +113 -0
  195. package/packages/tallow-tui/src/components/truncated-text.ts +65 -0
  196. package/packages/tallow-tui/src/editor-component.ts +92 -0
  197. package/packages/tallow-tui/src/fuzzy.ts +133 -0
  198. package/packages/tallow-tui/src/index.ts +118 -0
  199. package/packages/tallow-tui/src/keybindings.ts +183 -0
  200. package/packages/tallow-tui/src/keys.ts +1189 -0
  201. package/packages/tallow-tui/src/kill-ring.ts +46 -0
  202. package/packages/tallow-tui/src/stdin-buffer.ts +386 -0
  203. package/packages/tallow-tui/src/terminal-image.ts +619 -0
  204. package/packages/tallow-tui/src/terminal.ts +350 -0
  205. package/packages/tallow-tui/src/test-utils/capability-env.ts +56 -0
  206. package/packages/tallow-tui/src/tui.ts +1336 -0
  207. package/packages/tallow-tui/src/undo-stack.ts +28 -0
  208. package/packages/tallow-tui/src/utils.ts +948 -0
  209. package/packages/tallow-tui/tsconfig.build.json +21 -0
  210. package/runtime/agent-runner.ts +20 -0
  211. package/runtime/atomic-write.ts +8 -0
  212. package/runtime/otel.ts +12 -0
  213. package/runtime/resolve-module.ts +23 -0
  214. package/runtime/runtime-path-provider.ts +12 -0
  215. package/runtime/runtime-provenance.ts +17 -0
  216. package/runtime/workspace-transition-relay.ts +21 -0
  217. package/runtime/workspace-transition.ts +29 -0
@@ -0,0 +1,454 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { stripAnsi } from "../../../../test-utils/virtual-terminal.js";
3
+ import type { Terminal } from "../terminal.js";
4
+ import { type Component, TUI } from "../tui.js";
5
+
6
+ /** Terminal test double that records all writes for assertion. */
7
+ class MockTerminal implements Terminal {
8
+ private readonly width: number;
9
+ private readonly height: number;
10
+ public readonly writes: string[] = [];
11
+
12
+ constructor(width: number, height: number) {
13
+ this.width = width;
14
+ this.height = height;
15
+ }
16
+
17
+ start(_onInput: (data: string) => void, _onResize: () => void): void {}
18
+
19
+ stop(): void {}
20
+
21
+ async drainInput(): Promise<void> {}
22
+
23
+ write(data: string): void {
24
+ this.writes.push(data);
25
+ }
26
+
27
+ get columns(): number {
28
+ return this.width;
29
+ }
30
+
31
+ get rows(): number {
32
+ return this.height;
33
+ }
34
+
35
+ get kittyProtocolActive(): boolean {
36
+ return false;
37
+ }
38
+
39
+ moveBy(_lines: number): void {}
40
+
41
+ hideCursor(): void {}
42
+
43
+ showCursor(): void {}
44
+
45
+ clearLine(): void {}
46
+
47
+ clearFromCursor(): void {}
48
+
49
+ clearScreen(): void {}
50
+
51
+ enterAlternateScreen(): void {}
52
+
53
+ leaveAlternateScreen(): void {}
54
+
55
+ setTitle(_title: string): void {}
56
+
57
+ setProgress(_percent: number): void {}
58
+
59
+ clearProgress(): void {}
60
+ }
61
+
62
+ /** Mutable component that lets tests drive exact rendered line sequences. */
63
+ class MutableLinesComponent implements Component {
64
+ private lines: string[];
65
+
66
+ constructor(lines: string[]) {
67
+ this.lines = lines;
68
+ }
69
+
70
+ setLines(lines: string[]): void {
71
+ this.lines = lines;
72
+ }
73
+
74
+ render(_width: number): string[] {
75
+ return this.lines;
76
+ }
77
+
78
+ invalidate(): void {}
79
+ }
80
+
81
+ interface ScenarioResult {
82
+ border: string;
83
+ finalWrite: string;
84
+ redrawsBeforeUpdate: number;
85
+ redrawsAfterUpdate: number;
86
+ }
87
+
88
+ /**
89
+ * Invoke TUI's internal render synchronously for deterministic testing.
90
+ *
91
+ * @param tui - TUI instance under test
92
+ */
93
+ function renderNow(tui: TUI): void {
94
+ const renderer = tui as unknown as { doRender: () => void };
95
+ renderer.doRender();
96
+ }
97
+
98
+ /**
99
+ * Build a frame with stable lines, editor-like borders, and optional trailing lines.
100
+ *
101
+ * @param stableLines - Number of unchanged lines before editor
102
+ * @param inputText - Editor content line between borders
103
+ * @param trailingLines - Number of transient lines after editor
104
+ * @param width - Terminal width in columns
105
+ * @returns Frame lines for the component render output
106
+ */
107
+ function createFrame(
108
+ stableLines: number,
109
+ inputText: string,
110
+ trailingLines: number,
111
+ width: number
112
+ ): string[] {
113
+ const stable = Array.from({ length: stableLines }, (_, index) => `stable ${index}`);
114
+ const border = "─".repeat(width);
115
+ const inputLine = inputText.padEnd(width, " ").slice(0, width);
116
+ const trailing = Array.from({ length: trailingLines }, (_, index) => `tail ${index}`);
117
+ return [...stable, border, inputLine, border, ...trailing];
118
+ }
119
+
120
+ /**
121
+ * Reproduce grow -> shrink -> update sequence that previously risked row drift.
122
+ *
123
+ * @returns Scenario outputs used by assertions
124
+ */
125
+ function runGrowShrinkUpdateScenario(): ScenarioResult {
126
+ const width = 32;
127
+ const height = 10;
128
+ const stableLines = 18;
129
+ const border = "─".repeat(width);
130
+ const terminal = new MockTerminal(width, height);
131
+ const tui = new TUI(terminal);
132
+ const component = new MutableLinesComponent(createFrame(stableLines, "input A", 9, width));
133
+ tui.addChild(component);
134
+
135
+ // Grow to establish a large working area.
136
+ renderNow(tui);
137
+
138
+ // Shrink without changing earlier viewport lines. This keeps maxLinesRendered > previousLines.length.
139
+ component.setLines(createFrame(stableLines, "input A", 0, width));
140
+ renderNow(tui);
141
+
142
+ const redrawsBeforeUpdate = tui.fullRedraws;
143
+
144
+ // Trigger a regular update in the editor band after shrink.
145
+ component.setLines(createFrame(stableLines, "input B", 0, width));
146
+ renderNow(tui);
147
+
148
+ return {
149
+ border,
150
+ finalWrite: terminal.writes[terminal.writes.length - 1] ?? "",
151
+ redrawsBeforeUpdate,
152
+ redrawsAfterUpdate: tui.fullRedraws,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Reproduce grow -> shrink -> grow -> update cycle that simulates agent turn
158
+ * content fluctuations (loader appears, content shrinks, new content arrives).
159
+ *
160
+ * @returns All terminal writes and final redraw count
161
+ */
162
+ function runHeightFluctuationScenario(): { allWrites: string[]; fullRedraws: number } {
163
+ const width = 32;
164
+ const height = 10;
165
+ const stableLines = 18;
166
+ const terminal = new MockTerminal(width, height);
167
+ const tui = new TUI(terminal);
168
+ const component = new MutableLinesComponent(createFrame(stableLines, "input A", 9, width));
169
+ tui.addChild(component);
170
+
171
+ // Phase 1: Grow to establish a large working area (simulates loader + streaming).
172
+ renderNow(tui);
173
+
174
+ // Phase 2: Shrink (simulates loader stopping or tool result replacing progress).
175
+ component.setLines(createFrame(stableLines, "input A", 0, width));
176
+ renderNow(tui);
177
+
178
+ // Phase 3: Grow again (simulates new streaming content arriving).
179
+ component.setLines(createFrame(stableLines, "input A", 5, width));
180
+ renderNow(tui);
181
+
182
+ // Phase 4: Update within content (simulates editor input change).
183
+ component.setLines(createFrame(stableLines, "input B", 5, width));
184
+ renderNow(tui);
185
+
186
+ return {
187
+ allWrites: [...terminal.writes],
188
+ fullRedraws: tui.fullRedraws,
189
+ };
190
+ }
191
+
192
+ describe("TUI differential rendering shrink regression", () => {
193
+ test("realigns viewport basis on drift instead of full redraw", () => {
194
+ const result = runGrowShrinkUpdateScenario();
195
+
196
+ // Viewport basis drift is now handled by realignment, not full redraw.
197
+ // The update render should use a partial redraw (no increase in fullRedraws).
198
+ expect(result.redrawsAfterUpdate).toBe(result.redrawsBeforeUpdate);
199
+ });
200
+
201
+ test("keeps editor content correct after grow->shrink->update", () => {
202
+ const result = runGrowShrinkUpdateScenario();
203
+ const plain = stripAnsi(result.finalWrite);
204
+
205
+ // The partial redraw only writes the changed line, not the full content.
206
+ // Borders are already on screen from the prior render and don't need
207
+ // to be redrawn — their absence in the final write proves partial redraw worked.
208
+ expect(plain).toContain("input B");
209
+ });
210
+
211
+ test("never clears scrollback during content height fluctuation", () => {
212
+ const result = runHeightFluctuationScenario();
213
+
214
+ // No write should ever contain \x1b[3J (clear scrollback).
215
+ // This sequence destroys the user's scroll position and reading context.
216
+ for (const write of result.allWrites) {
217
+ expect(write).not.toContain("\x1b[3J");
218
+ }
219
+ });
220
+
221
+ test("never clears scrollback during grow->shrink->update", () => {
222
+ const width = 32;
223
+ const height = 10;
224
+ const stableLines = 18;
225
+ const terminal = new MockTerminal(width, height);
226
+ const tui = new TUI(terminal);
227
+ const component = new MutableLinesComponent(createFrame(stableLines, "input A", 9, width));
228
+ tui.addChild(component);
229
+
230
+ renderNow(tui);
231
+
232
+ component.setLines(createFrame(stableLines, "input A", 0, width));
233
+ renderNow(tui);
234
+
235
+ component.setLines(createFrame(stableLines, "input B", 0, width));
236
+ renderNow(tui);
237
+
238
+ for (const write of terminal.writes) {
239
+ expect(write).not.toContain("\x1b[3J");
240
+ }
241
+ });
242
+
243
+ test("extraLines > height in diff path triggers safety full redraw", () => {
244
+ const width = 40;
245
+ const height = 10;
246
+ const terminal = new MockTerminal(width, height);
247
+ const tui = new TUI(terminal);
248
+ // Start with 30 lines (extraLines on shrink = 25, which exceeds height = 10)
249
+ const component = new MutableLinesComponent(Array.from({ length: 30 }, (_, i) => `line ${i}`));
250
+ tui.addChild(component);
251
+ renderNow(tui);
252
+
253
+ // Shrink to 5 lines with changed content so we don't hit the "all deleted" path
254
+ component.setLines(Array.from({ length: 5 }, (_, i) => `changed ${i}`));
255
+ const redrawsBefore = tui.fullRedraws;
256
+ renderNow(tui);
257
+
258
+ // Should have triggered safety full redraw (extraLines = 25 > height = 10)
259
+ expect(tui.fullRedraws).toBe(redrawsBefore + 1);
260
+ });
261
+
262
+ test("maxLinesRendered decays after shrink without overlays", () => {
263
+ const width = 40;
264
+ const height = 20;
265
+ const terminal = new MockTerminal(width, height);
266
+ const tui = new TUI(terminal);
267
+ const component = new MutableLinesComponent(Array.from({ length: 15 }, (_, i) => `line ${i}`));
268
+ tui.addChild(component);
269
+ renderNow(tui);
270
+
271
+ component.setLines(Array.from({ length: 8 }, (_, i) => `short ${i}`));
272
+ renderNow(tui);
273
+
274
+ // maxLinesRendered should have decayed to actual content size
275
+ const tuiInternal = tui as unknown as { maxLinesRendered: number };
276
+ expect(tuiInternal.maxLinesRendered).toBe(8);
277
+ });
278
+
279
+ test("grow-shrink-grow does not leave ghost gap", () => {
280
+ const width = 40;
281
+ const height = 10;
282
+ const terminal = new MockTerminal(width, height);
283
+ const tui = new TUI(terminal);
284
+ const component = new MutableLinesComponent(Array.from({ length: 25 }, (_, i) => `line ${i}`));
285
+ tui.addChild(component);
286
+ renderNow(tui); // 25 lines
287
+
288
+ component.setLines(Array.from({ length: 8 }, (_, i) => `shrunk ${i}`));
289
+ renderNow(tui); // Shrink to 8
290
+
291
+ component.setLines(Array.from({ length: 12 }, (_, i) => `grown ${i}`));
292
+ renderNow(tui); // Grow to 12
293
+
294
+ // maxLinesRendered should track actual content, not stale high-water mark
295
+ const tuiInternal = tui as unknown as { maxLinesRendered: number };
296
+ expect(tuiInternal.maxLinesRendered).toBe(12);
297
+
298
+ // Verify no blank-line ghost: writes should not contain scrollback destruction
299
+ const lastWrite = terminal.writes[terminal.writes.length - 1] ?? "";
300
+ expect(lastWrite).not.toContain("\x1b[3J");
301
+ });
302
+
303
+ test("viewport drift corrected on same render as shrink", () => {
304
+ const width = 40;
305
+ const height = 10;
306
+ const terminal = new MockTerminal(width, height);
307
+ const tui = new TUI(terminal);
308
+ // Start with 80 lines to establish large working area
309
+ const component = new MutableLinesComponent(Array.from({ length: 80 }, (_, i) => `line ${i}`));
310
+ tui.addChild(component);
311
+ renderNow(tui); // maxLinesRendered = 80
312
+
313
+ // Shrink to 30 — drift should be corrected immediately (same cycle)
314
+ component.setLines(Array.from({ length: 30 }, (_, i) => `shrunk ${i}`));
315
+ renderNow(tui);
316
+
317
+ const tuiInternal = tui as unknown as {
318
+ maxLinesRendered: number;
319
+ previousViewportTop: number;
320
+ };
321
+
322
+ // maxLinesRendered should have decayed to 30 (not stuck at 80)
323
+ expect(tuiInternal.maxLinesRendered).toBe(30);
324
+ // previousViewportTop should be consistent with the decayed maxLinesRendered
325
+ expect(tuiInternal.previousViewportTop).toBe(Math.max(0, 30 - height));
326
+
327
+ // Grow to 40 — should NOT require full redraw since drift was already corrected
328
+ component.setLines(Array.from({ length: 40 }, (_, i) => `grown ${i}`));
329
+ renderNow(tui);
330
+
331
+ // The key assertion is maxLinesRendered is correct
332
+ expect(tuiInternal.maxLinesRendered).toBe(40);
333
+ });
334
+
335
+ test("large shrink (>5 lines) triggers full redraw to prevent ghosting", () => {
336
+ const width = 40;
337
+ const height = 20;
338
+ const terminal = new MockTerminal(width, height);
339
+ const tui = new TUI(terminal);
340
+ // Start with 18 lines
341
+ const component = new MutableLinesComponent(Array.from({ length: 18 }, (_, i) => `line ${i}`));
342
+ tui.addChild(component);
343
+ renderNow(tui); // First render (fullRedraw #1)
344
+
345
+ // Shrink by 6 lines (above the 5-line threshold) — should trigger full redraw
346
+ component.setLines(Array.from({ length: 12 }, (_, i) => `shrunk ${i}`));
347
+ const redrawsBefore = tui.fullRedraws;
348
+ renderNow(tui);
349
+
350
+ expect(tui.fullRedraws).toBe(redrawsBefore + 1);
351
+ const tuiInternal = tui as unknown as { maxLinesRendered: number };
352
+ expect(tuiInternal.maxLinesRendered).toBe(12);
353
+ });
354
+
355
+ test("small shrink (<=5 lines) uses partial diff, not full redraw", () => {
356
+ const width = 40;
357
+ const height = 20;
358
+ const terminal = new MockTerminal(width, height);
359
+ const tui = new TUI(terminal);
360
+ // Start with 12 lines
361
+ const component = new MutableLinesComponent(Array.from({ length: 12 }, (_, i) => `line ${i}`));
362
+ tui.addChild(component);
363
+ renderNow(tui); // First render (fullRedraw #1)
364
+
365
+ // Shrink by exactly 5 lines (at threshold, NOT over) — should NOT trigger
366
+ // the large-shrink full redraw path
367
+ component.setLines(Array.from({ length: 7 }, (_, i) => `shrunk ${i}`));
368
+ const redrawsBefore = tui.fullRedraws;
369
+ renderNow(tui);
370
+
371
+ // Should use partial diff (no increase in fullRedraws)
372
+ expect(tui.fullRedraws).toBe(redrawsBefore);
373
+ const tuiInternal = tui as unknown as { maxLinesRendered: number };
374
+ expect(tuiInternal.maxLinesRendered).toBe(7);
375
+ });
376
+
377
+ test("rapid grow-shrink-grow cycles don't accumulate ghost state", () => {
378
+ const width = 40;
379
+ const height = 15;
380
+ const terminal = new MockTerminal(width, height);
381
+ const tui = new TUI(terminal);
382
+ const component = new MutableLinesComponent(
383
+ Array.from({ length: 10 }, (_, i) => `initial ${i}`)
384
+ );
385
+ tui.addChild(component);
386
+ renderNow(tui);
387
+
388
+ const tuiInternal = tui as unknown as {
389
+ maxLinesRendered: number;
390
+ previousViewportTop: number;
391
+ };
392
+
393
+ // Simulate 5 rapid grow-shrink cycles (like streaming tool output + collapse)
394
+ for (let cycle = 0; cycle < 5; cycle++) {
395
+ // Grow: simulate streaming output
396
+ component.setLines(Array.from({ length: 40 }, (_, i) => `stream-${cycle}-${i}`));
397
+ renderNow(tui);
398
+
399
+ // Shrink: simulate tool result collapse
400
+ component.setLines(Array.from({ length: 8 }, (_, i) => `result-${cycle}-${i}`));
401
+ renderNow(tui);
402
+ }
403
+
404
+ // After all cycles, maxLinesRendered should match actual content (8), not
405
+ // any stale high-water mark from the streaming phases
406
+ expect(tuiInternal.maxLinesRendered).toBe(8);
407
+ expect(tuiInternal.previousViewportTop).toBe(0); // 8 < height(15), so 0
408
+
409
+ // A subsequent update should work correctly via partial diff
410
+ component.setLines(Array.from({ length: 8 }, (_, i) => `final ${i}`));
411
+ const redrawsBefore = tui.fullRedraws;
412
+ renderNow(tui);
413
+
414
+ // No full redraw needed — content didn't shrink
415
+ expect(tui.fullRedraws).toBe(redrawsBefore);
416
+ });
417
+
418
+ test("drift correction uses newLines.length, not previousLines.length", () => {
419
+ const width = 40;
420
+ const height = 10;
421
+ const terminal = new MockTerminal(width, height);
422
+ const tui = new TUI(terminal);
423
+
424
+ // Use an overlay to keep maxLinesRendered inflated (overlay padding).
425
+ // With overlays, maxLinesRendered = Math.max(old, new) — only grows.
426
+ const component = new MutableLinesComponent(Array.from({ length: 50 }, (_, i) => `line ${i}`));
427
+ tui.addChild(component);
428
+
429
+ const overlayComponent: Component = {
430
+ render: () => ["overlay line"],
431
+ invalidate: () => {},
432
+ };
433
+ tui.showOverlay(overlayComponent);
434
+ renderNow(tui); // maxLinesRendered >= 50 (padded by overlay)
435
+
436
+ // Remove overlay and shrink content simultaneously — maxLinesRendered is
437
+ // still inflated from overlay padding, but newLines.length will be much
438
+ // smaller. The large-shrink guard (>5 lines) triggers a full redraw,
439
+ // which correctly resets maxLinesRendered to newLines.length.
440
+ tui.hideOverlay();
441
+ component.setLines(Array.from({ length: 20 }, (_, i) => `shrunk ${i}`));
442
+ renderNow(tui);
443
+
444
+ const tuiInternal = tui as unknown as {
445
+ maxLinesRendered: number;
446
+ previousViewportTop: number;
447
+ };
448
+
449
+ // maxLinesRendered should be 20 (newLines.length), not whatever
450
+ // previousLines.length was (which includes overlay padding)
451
+ expect(tuiInternal.maxLinesRendered).toBe(20);
452
+ expect(tuiInternal.previousViewportTop).toBe(Math.max(0, 20 - height));
453
+ });
454
+ });
@@ -0,0 +1,256 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Terminal } from "../terminal.js";
3
+ import { type Component, TUI } from "../tui.js";
4
+
5
+ /** Terminal test double that captures writes and lets tests inject input. */
6
+ class ControlledTerminal implements Terminal {
7
+ private readonly width: number;
8
+ private readonly height: number;
9
+ private onInput?: (data: string) => void;
10
+ public readonly writes: string[] = [];
11
+
12
+ constructor(width: number, height: number) {
13
+ this.width = width;
14
+ this.height = height;
15
+ }
16
+
17
+ /**
18
+ * Store input callback so tests can inject terminal input later.
19
+ *
20
+ * @param onInput - TUI input handler
21
+ * @param _onResize - Unused resize handler
22
+ * @returns {void}
23
+ */
24
+ start(onInput: (data: string) => void, _onResize: () => void): void {
25
+ this.onInput = onInput;
26
+ }
27
+
28
+ /**
29
+ * Remove captured input handler.
30
+ *
31
+ * @returns {void}
32
+ */
33
+ stop(): void {
34
+ this.onInput = undefined;
35
+ }
36
+
37
+ /**
38
+ * No-op drain for the test terminal.
39
+ *
40
+ * @returns {Promise<void>}
41
+ */
42
+ async drainInput(): Promise<void> {}
43
+
44
+ /**
45
+ * Record terminal writes for assertions.
46
+ *
47
+ * @param data - Terminal escape sequences and rendered text
48
+ * @returns {void}
49
+ */
50
+ write(data: string): void {
51
+ this.writes.push(data);
52
+ }
53
+
54
+ /**
55
+ * Inject input as if it came from stdin.
56
+ *
57
+ * @param data - Input sequence to deliver
58
+ * @returns {void}
59
+ */
60
+ emitInput(data: string): void {
61
+ this.onInput?.(data);
62
+ }
63
+
64
+ get columns(): number {
65
+ return this.width;
66
+ }
67
+
68
+ get rows(): number {
69
+ return this.height;
70
+ }
71
+
72
+ get kittyProtocolActive(): boolean {
73
+ return false;
74
+ }
75
+
76
+ moveBy(_lines: number): void {}
77
+
78
+ hideCursor(): void {}
79
+
80
+ showCursor(): void {}
81
+
82
+ clearLine(): void {}
83
+
84
+ clearFromCursor(): void {}
85
+
86
+ clearScreen(): void {}
87
+
88
+ enterAlternateScreen(): void {}
89
+
90
+ leaveAlternateScreen(): void {}
91
+
92
+ setTitle(_title: string): void {}
93
+
94
+ setProgress(_percent: number): void {}
95
+
96
+ clearProgress(): void {}
97
+ }
98
+
99
+ /**
100
+ * Component that keeps requesting renders to simulate a chatty streaming turn.
101
+ */
102
+ class StreamingBurstComponent implements Component {
103
+ public text = "";
104
+ public renderCount = 0;
105
+ public inputHandledAtRenderCount: number | null = null;
106
+ public inputVisibleAtRenderCount: number | null = null;
107
+
108
+ constructor(
109
+ private readonly tui: TUI,
110
+ private readonly targetRenderCount: number
111
+ ) {}
112
+
113
+ /**
114
+ * Render current editor text and request another frame until the burst finishes.
115
+ *
116
+ * @returns {string[]} Visible lines for the frame
117
+ */
118
+ render(): string[] {
119
+ this.renderCount += 1;
120
+ if (this.text.length > 0 && this.inputVisibleAtRenderCount === null) {
121
+ this.inputVisibleAtRenderCount = this.renderCount;
122
+ }
123
+ if (this.renderCount < this.targetRenderCount) {
124
+ this.tui.requestRender();
125
+ }
126
+ return ["assistant: streaming", `editor: ${this.text}`];
127
+ }
128
+
129
+ /**
130
+ * Apply typed input and record the render count at which input became interactive.
131
+ *
132
+ * @param data - Typed character sequence
133
+ * @returns {void}
134
+ */
135
+ handleInput(data: string): void {
136
+ this.text += data;
137
+ if (this.inputHandledAtRenderCount === null) {
138
+ this.inputHandledAtRenderCount = this.renderCount;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * No-op invalidation hook required by Component.
144
+ *
145
+ * @returns {void}
146
+ */
147
+ invalidate(): void {}
148
+ }
149
+
150
+ /** Simple component used to count coalesced render executions. */
151
+ class CountingComponent implements Component {
152
+ public renderCount = 0;
153
+
154
+ /**
155
+ * Increment render counter for each frame.
156
+ *
157
+ * @returns {string[]} Rendered lines
158
+ */
159
+ render(): string[] {
160
+ this.renderCount += 1;
161
+ return [`renders: ${this.renderCount}`];
162
+ }
163
+
164
+ /**
165
+ * No-op invalidation hook required by Component.
166
+ *
167
+ * @returns {void}
168
+ */
169
+ invalidate(): void {}
170
+ }
171
+
172
+ /**
173
+ * Yield until the next I/O phase.
174
+ *
175
+ * Uses `setTimeout(0)` because on Bun `setImmediate` never enters the
176
+ * I/O poll phase. This matches the `setTimeout(0)` used in `scheduleRender`.
177
+ *
178
+ * @returns {Promise<void>} Promise that resolves after I/O polling
179
+ */
180
+ function flushIO(): Promise<void> {
181
+ return new Promise((resolve) => setTimeout(resolve, 0));
182
+ }
183
+
184
+ /**
185
+ * Wait until a condition becomes true, failing if it never does.
186
+ *
187
+ * @param condition - Predicate checked after each event-loop turn
188
+ * @param timeoutMs - Maximum wait time in milliseconds
189
+ * @returns {Promise<void>} Promise that resolves once the condition passes
190
+ * @throws {Error} When the condition does not pass before timeout
191
+ */
192
+ async function waitFor(condition: () => boolean, timeoutMs = 1_000): Promise<void> {
193
+ const deadline = Date.now() + timeoutMs;
194
+ while (!condition()) {
195
+ if (Date.now() > deadline) {
196
+ throw new Error("Condition not met before timeout");
197
+ }
198
+ await flushIO();
199
+ }
200
+ }
201
+
202
+ describe("TUI render scheduling", () => {
203
+ test("requestRender yields to input under streaming pressure", async () => {
204
+ const terminal = new ControlledTerminal(80, 24);
205
+ const tui = new TUI(terminal);
206
+ const component = new StreamingBurstComponent(tui, 40);
207
+ tui.addChild(component);
208
+ tui.setFocus(component);
209
+ tui.start();
210
+
211
+ setTimeout(() => {
212
+ terminal.emitInput("x");
213
+ }, 0);
214
+
215
+ await waitFor(() => component.renderCount >= 40);
216
+ expect(component.inputHandledAtRenderCount).not.toBeNull();
217
+ expect(component.inputHandledAtRenderCount).toBeLessThan(40);
218
+
219
+ tui.stop();
220
+ });
221
+
222
+ test("typing remains visible during streaming burst", async () => {
223
+ const terminal = new ControlledTerminal(80, 24);
224
+ const tui = new TUI(terminal);
225
+ const component = new StreamingBurstComponent(tui, 40);
226
+ tui.addChild(component);
227
+ tui.setFocus(component);
228
+ tui.start();
229
+
230
+ setTimeout(() => {
231
+ terminal.emitInput("x");
232
+ }, 0);
233
+
234
+ await waitFor(() => component.inputVisibleAtRenderCount !== null);
235
+ expect(component.inputVisibleAtRenderCount).toBeLessThan(40);
236
+ await waitFor(() => component.renderCount >= 40);
237
+ expect(terminal.writes.some((write) => write.includes("editor: x"))).toBe(true);
238
+
239
+ tui.stop();
240
+ });
241
+
242
+ test("render requests still coalesce", async () => {
243
+ const terminal = new ControlledTerminal(80, 24);
244
+ const tui = new TUI(terminal);
245
+ const component = new CountingComponent();
246
+ tui.addChild(component);
247
+
248
+ for (let index = 0; index < 25; index += 1) {
249
+ tui.requestRender();
250
+ }
251
+ await flushIO();
252
+
253
+ expect(component.renderCount).toBe(1);
254
+ expect(terminal.writes).toHaveLength(1);
255
+ });
256
+ });