@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.
- package/dist/cli.js +35 -4
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/interactive-mode-patch.d.ts +2 -0
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +82 -0
- package/dist/interactive-mode-patch.js.map +1 -1
- package/dist/sdk.d.ts +17 -0
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +68 -1
- package/dist/sdk.js.map +1 -1
- package/dist/workspace-transition-relay.d.ts +40 -7
- package/dist/workspace-transition-relay.d.ts.map +1 -1
- package/dist/workspace-transition-relay.js +81 -16
- package/dist/workspace-transition-relay.js.map +1 -1
- package/extensions/__integration__/background-task-widget-ownership.test.ts +216 -0
- package/extensions/__integration__/claude-hooks-compat.test.ts +156 -0
- package/extensions/__integration__/slash-command-bridge.test.ts +169 -23
- package/extensions/_shared/atomic-write.ts +1 -1
- package/extensions/_shared/bordered-box.ts +102 -0
- package/extensions/_shared/interop-events.ts +5 -0
- package/extensions/_shared/pid-registry.ts +1 -1
- package/extensions/agent-commands-tool/index.ts +4 -1
- package/extensions/background-task-tool/__tests__/lifecycle.test.ts +50 -25
- package/extensions/background-task-tool/index.ts +139 -221
- package/extensions/bash-tool-enhanced/index.ts +1 -75
- package/extensions/cd-tool/index.ts +2 -2
- package/extensions/context-fork/spawn.ts +4 -1
- package/extensions/health/index.ts +6 -6
- package/extensions/hooks/__tests__/claude-compat.test.ts +35 -0
- package/extensions/hooks/__tests__/subprocess-hardening.test.ts +73 -0
- package/extensions/hooks/index.ts +27 -4
- package/extensions/loop/__tests__/loop.test.ts +168 -4
- package/extensions/loop/extension.json +6 -5
- package/extensions/loop/index.ts +242 -31
- package/extensions/plan-mode-tool/__tests__/agent-end-execution.test.ts +373 -0
- package/extensions/plan-mode-tool/index.ts +103 -41
- package/extensions/prompt-suggestions/__tests__/editor-compatibility.test.ts +42 -0
- package/extensions/prompt-suggestions/index.ts +41 -6
- package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +267 -671
- package/extensions/slash-command-bridge/extension.json +1 -1
- package/extensions/slash-command-bridge/index.ts +230 -116
- package/extensions/subagent-tool/index.ts +2 -2
- package/extensions/subagent-tool/process.ts +4 -5
- package/extensions/tasks/commands/register-tasks-extension.ts +41 -0
- package/extensions/teams-tool/__tests__/peer-messaging.test.ts +29 -24
- package/extensions/teams-tool/dashboard.ts +3 -5
- package/extensions/teams-tool/dispatch/auto-dispatch.ts +18 -1
- package/extensions/teams-tool/tools/teammate-tools.ts +9 -6
- package/extensions/wezterm-pane-control/__tests__/index.test.ts +88 -4
- package/extensions/wezterm-pane-control/index.ts +113 -8
- package/package.json +6 -4
- package/packages/tallow-tui/README.md +51 -0
- package/packages/tallow-tui/dist/autocomplete.d.ts +48 -0
- package/packages/tallow-tui/dist/autocomplete.d.ts.map +1 -0
- package/packages/tallow-tui/dist/autocomplete.js +564 -0
- package/packages/tallow-tui/dist/autocomplete.js.map +1 -0
- package/packages/tallow-tui/dist/border-styles.d.ts +32 -0
- package/packages/tallow-tui/dist/border-styles.d.ts.map +1 -0
- package/packages/tallow-tui/dist/border-styles.js +46 -0
- package/packages/tallow-tui/dist/border-styles.js.map +1 -0
- package/packages/tallow-tui/dist/components/bordered-box.d.ts +52 -0
- package/packages/tallow-tui/dist/components/bordered-box.d.ts.map +1 -0
- package/packages/tallow-tui/dist/components/bordered-box.js +89 -0
- package/packages/tallow-tui/dist/components/bordered-box.js.map +1 -0
- package/packages/tallow-tui/dist/components/box.d.ts +22 -0
- package/packages/tallow-tui/dist/components/box.d.ts.map +1 -0
- package/packages/tallow-tui/dist/components/box.js +104 -0
- package/packages/tallow-tui/dist/components/box.js.map +1 -0
- package/packages/tallow-tui/dist/components/cancellable-loader.d.ts +22 -0
- package/packages/tallow-tui/dist/components/cancellable-loader.d.ts.map +1 -0
- package/packages/tallow-tui/dist/components/cancellable-loader.js +35 -0
- package/packages/tallow-tui/dist/components/cancellable-loader.js.map +1 -0
- package/packages/tallow-tui/dist/components/editor.d.ts +240 -0
- package/packages/tallow-tui/dist/components/editor.d.ts.map +1 -0
- package/packages/tallow-tui/dist/components/editor.js +1766 -0
- package/packages/tallow-tui/dist/components/editor.js.map +1 -0
- package/packages/tallow-tui/dist/components/image.d.ts +126 -0
- package/packages/tallow-tui/dist/components/image.d.ts.map +1 -0
- package/packages/tallow-tui/dist/components/image.js +245 -0
- package/packages/tallow-tui/dist/components/image.js.map +1 -0
- package/packages/tallow-tui/dist/components/input.d.ts +37 -0
- package/packages/tallow-tui/dist/components/input.d.ts.map +1 -0
- package/packages/tallow-tui/dist/components/input.js +439 -0
- package/packages/tallow-tui/dist/components/input.js.map +1 -0
- package/packages/tallow-tui/dist/components/loader.d.ts +88 -0
- package/packages/tallow-tui/dist/components/loader.d.ts.map +1 -0
- package/packages/tallow-tui/dist/components/loader.js +146 -0
- package/packages/tallow-tui/dist/components/loader.js.map +1 -0
- package/packages/tallow-tui/dist/components/markdown.d.ts +95 -0
- package/packages/tallow-tui/dist/components/markdown.d.ts.map +1 -0
- package/packages/tallow-tui/dist/components/markdown.js +633 -0
- package/packages/tallow-tui/dist/components/markdown.js.map +1 -0
- package/packages/tallow-tui/dist/components/select-list.d.ts +32 -0
- package/packages/tallow-tui/dist/components/select-list.d.ts.map +1 -0
- package/packages/tallow-tui/dist/components/select-list.js +156 -0
- package/packages/tallow-tui/dist/components/select-list.js.map +1 -0
- package/packages/tallow-tui/dist/components/settings-list.d.ts +50 -0
- package/packages/tallow-tui/dist/components/settings-list.d.ts.map +1 -0
- package/packages/tallow-tui/dist/components/settings-list.js +189 -0
- package/packages/tallow-tui/dist/components/settings-list.js.map +1 -0
- package/packages/tallow-tui/dist/components/spacer.d.ts +12 -0
- package/packages/tallow-tui/dist/components/spacer.d.ts.map +1 -0
- package/packages/tallow-tui/dist/components/spacer.js +23 -0
- package/packages/tallow-tui/dist/components/spacer.js.map +1 -0
- package/packages/tallow-tui/dist/components/text.d.ts +19 -0
- package/packages/tallow-tui/dist/components/text.d.ts.map +1 -0
- package/packages/tallow-tui/dist/components/text.js +91 -0
- package/packages/tallow-tui/dist/components/text.js.map +1 -0
- package/packages/tallow-tui/dist/components/truncated-text.d.ts +13 -0
- package/packages/tallow-tui/dist/components/truncated-text.d.ts.map +1 -0
- package/packages/tallow-tui/dist/components/truncated-text.js +51 -0
- package/packages/tallow-tui/dist/components/truncated-text.js.map +1 -0
- package/packages/tallow-tui/dist/editor-component.d.ts +50 -0
- package/packages/tallow-tui/dist/editor-component.d.ts.map +1 -0
- package/packages/tallow-tui/dist/editor-component.js +2 -0
- package/packages/tallow-tui/dist/editor-component.js.map +1 -0
- package/packages/tallow-tui/dist/fuzzy.d.ts +16 -0
- package/packages/tallow-tui/dist/fuzzy.d.ts.map +1 -0
- package/packages/tallow-tui/dist/fuzzy.js +107 -0
- package/packages/tallow-tui/dist/fuzzy.js.map +1 -0
- package/packages/tallow-tui/dist/index.d.ts +25 -0
- package/packages/tallow-tui/dist/index.d.ts.map +1 -0
- package/packages/tallow-tui/dist/index.js +35 -0
- package/packages/tallow-tui/dist/index.js.map +1 -0
- package/packages/tallow-tui/dist/keybindings.d.ts +39 -0
- package/packages/tallow-tui/dist/keybindings.d.ts.map +1 -0
- package/packages/tallow-tui/dist/keybindings.js +114 -0
- package/packages/tallow-tui/dist/keybindings.js.map +1 -0
- package/packages/tallow-tui/dist/keys.d.ts +168 -0
- package/packages/tallow-tui/dist/keys.d.ts.map +1 -0
- package/packages/tallow-tui/dist/keys.js +971 -0
- package/packages/tallow-tui/dist/keys.js.map +1 -0
- package/packages/tallow-tui/dist/kill-ring.d.ts +28 -0
- package/packages/tallow-tui/dist/kill-ring.d.ts.map +1 -0
- package/packages/tallow-tui/dist/kill-ring.js +44 -0
- package/packages/tallow-tui/dist/kill-ring.js.map +1 -0
- package/packages/tallow-tui/dist/stdin-buffer.d.ts +48 -0
- package/packages/tallow-tui/dist/stdin-buffer.d.ts.map +1 -0
- package/packages/tallow-tui/dist/stdin-buffer.js +317 -0
- package/packages/tallow-tui/dist/stdin-buffer.js.map +1 -0
- package/packages/tallow-tui/dist/terminal-image.d.ts +161 -0
- package/packages/tallow-tui/dist/terminal-image.d.ts.map +1 -0
- package/packages/tallow-tui/dist/terminal-image.js +460 -0
- package/packages/tallow-tui/dist/terminal-image.js.map +1 -0
- package/packages/tallow-tui/dist/terminal.d.ts +102 -0
- package/packages/tallow-tui/dist/terminal.d.ts.map +1 -0
- package/packages/tallow-tui/dist/terminal.js +263 -0
- package/packages/tallow-tui/dist/terminal.js.map +1 -0
- package/packages/tallow-tui/dist/test-utils/capability-env.d.ts +14 -0
- package/packages/tallow-tui/dist/test-utils/capability-env.d.ts.map +1 -0
- package/packages/tallow-tui/dist/test-utils/capability-env.js +55 -0
- package/packages/tallow-tui/dist/test-utils/capability-env.js.map +1 -0
- package/packages/tallow-tui/dist/tui.d.ts +239 -0
- package/packages/tallow-tui/dist/tui.d.ts.map +1 -0
- package/packages/tallow-tui/dist/tui.js +1058 -0
- package/packages/tallow-tui/dist/tui.js.map +1 -0
- package/packages/tallow-tui/dist/undo-stack.d.ts +17 -0
- package/packages/tallow-tui/dist/undo-stack.d.ts.map +1 -0
- package/packages/tallow-tui/dist/undo-stack.js +25 -0
- package/packages/tallow-tui/dist/undo-stack.js.map +1 -0
- package/packages/tallow-tui/dist/utils.d.ts +96 -0
- package/packages/tallow-tui/dist/utils.d.ts.map +1 -0
- package/packages/tallow-tui/dist/utils.js +843 -0
- package/packages/tallow-tui/dist/utils.js.map +1 -0
- package/packages/tallow-tui/package.json +24 -0
- package/packages/tallow-tui/src/__tests__/__snapshots__/render.test.ts.snap +121 -0
- package/packages/tallow-tui/src/__tests__/editor-border.test.ts +72 -0
- package/packages/tallow-tui/src/__tests__/editor-change-listener.test.ts +121 -0
- package/packages/tallow-tui/src/__tests__/editor-ghost-text.test.ts +112 -0
- package/packages/tallow-tui/src/__tests__/fuzzy.test.ts +91 -0
- package/packages/tallow-tui/src/__tests__/image-component.test.ts +113 -0
- package/packages/tallow-tui/src/__tests__/keys.test.ts +141 -0
- package/packages/tallow-tui/src/__tests__/render.test.ts +179 -0
- package/packages/tallow-tui/src/__tests__/stdin-buffer.test.ts +82 -0
- package/packages/tallow-tui/src/__tests__/terminal-image.test.ts +363 -0
- package/packages/tallow-tui/src/__tests__/tui-diff-regression.test.ts +454 -0
- package/packages/tallow-tui/src/__tests__/tui-render-scheduling.test.ts +256 -0
- package/packages/tallow-tui/src/__tests__/utils.test.ts +259 -0
- package/packages/tallow-tui/src/autocomplete.ts +716 -0
- package/packages/tallow-tui/src/border-styles.ts +60 -0
- package/packages/tallow-tui/src/components/bordered-box.ts +113 -0
- package/packages/tallow-tui/src/components/box.ts +137 -0
- package/packages/tallow-tui/src/components/cancellable-loader.ts +40 -0
- package/packages/tallow-tui/src/components/editor.ts +2143 -0
- package/packages/tallow-tui/src/components/image.ts +315 -0
- package/packages/tallow-tui/src/components/input.ts +522 -0
- package/packages/tallow-tui/src/components/loader.ts +187 -0
- package/packages/tallow-tui/src/components/markdown.ts +780 -0
- package/packages/tallow-tui/src/components/select-list.ts +197 -0
- package/packages/tallow-tui/src/components/settings-list.ts +264 -0
- package/packages/tallow-tui/src/components/spacer.ts +28 -0
- package/packages/tallow-tui/src/components/text.ts +113 -0
- package/packages/tallow-tui/src/components/truncated-text.ts +65 -0
- package/packages/tallow-tui/src/editor-component.ts +92 -0
- package/packages/tallow-tui/src/fuzzy.ts +133 -0
- package/packages/tallow-tui/src/index.ts +118 -0
- package/packages/tallow-tui/src/keybindings.ts +183 -0
- package/packages/tallow-tui/src/keys.ts +1189 -0
- package/packages/tallow-tui/src/kill-ring.ts +46 -0
- package/packages/tallow-tui/src/stdin-buffer.ts +386 -0
- package/packages/tallow-tui/src/terminal-image.ts +619 -0
- package/packages/tallow-tui/src/terminal.ts +350 -0
- package/packages/tallow-tui/src/test-utils/capability-env.ts +56 -0
- package/packages/tallow-tui/src/tui.ts +1336 -0
- package/packages/tallow-tui/src/undo-stack.ts +28 -0
- package/packages/tallow-tui/src/utils.ts +948 -0
- package/packages/tallow-tui/tsconfig.build.json +21 -0
- package/runtime/agent-runner.ts +20 -0
- package/runtime/atomic-write.ts +8 -0
- package/runtime/otel.ts +12 -0
- package/runtime/resolve-module.ts +23 -0
- package/runtime/runtime-path-provider.ts +12 -0
- package/runtime/runtime-provenance.ts +17 -0
- package/runtime/workspace-transition-relay.ts +21 -0
- 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
|
+
});
|