pi-libtui 0.3.2
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/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +55 -0
- package/src/activity-presentation.ts +75 -0
- package/src/appearance.ts +632 -0
- package/src/background-surface.ts +125 -0
- package/src/color/palette.ts +186 -0
- package/src/color/preview.ts +157 -0
- package/src/color/resolver.ts +75 -0
- package/src/color/theme.ts +531 -0
- package/src/component-stack.ts +247 -0
- package/src/content/markdown-code.ts +60 -0
- package/src/content/terminal-text.ts +135 -0
- package/src/content/text.ts +105 -0
- package/src/controls/action-panel.ts +420 -0
- package/src/controls/dialog-button-bar.ts +287 -0
- package/src/controls/multi-select.ts +467 -0
- package/src/controls/picker-panel.ts +447 -0
- package/src/controls/screen-icon-actions.ts +134 -0
- package/src/controls/searchable-select.ts +394 -0
- package/src/controls/select-box.ts +224 -0
- package/src/controls/selectable-list.ts +433 -0
- package/src/controls/selection-action-bar.ts +382 -0
- package/src/controls/semantic-input.ts +23 -0
- package/src/controls/tab.ts +189 -0
- package/src/cursor.ts +288 -0
- package/src/decoration/editor-pills.ts +156 -0
- package/src/decoration/glyphs.ts +326 -0
- package/src/decoration/pointer-interaction.ts +123 -0
- package/src/decoration/powerline-pill.ts +104 -0
- package/src/decoration/status.ts +185 -0
- package/src/decoration/transient-pill.ts +176 -0
- package/src/diff/index.ts +21 -0
- package/src/diff/model.ts +76 -0
- package/src/diff/parse.ts +310 -0
- package/src/diff/pierre-syntax.ts +86 -0
- package/src/diff/render.ts +504 -0
- package/src/diff/structured.ts +264 -0
- package/src/diff/view.ts +75 -0
- package/src/editor/chrome.ts +341 -0
- package/src/editor/composition.ts +53 -0
- package/src/editor/layout.ts +81 -0
- package/src/editor/presentation.ts +35 -0
- package/src/editor/protocol.ts +188 -0
- package/src/editor.ts +41 -0
- package/src/extension.ts +30 -0
- package/src/folding.ts +192 -0
- package/src/host/cursor-bridge.ts +210 -0
- package/src/host/editor-bridge.ts +148 -0
- package/src/host/extension-host.ts +231 -0
- package/src/host/mouse-bridge.ts +212 -0
- package/src/host/mouse-dispatch.ts +400 -0
- package/src/host/mouse-input.ts +85 -0
- package/src/host/mouse-targets.ts +238 -0
- package/src/host/pi-layout-adapter.ts +298 -0
- package/src/host/pi-native-selection.ts +227 -0
- package/src/host/split-pane-bridge.ts +720 -0
- package/src/host/transcript-bridge.ts +236 -0
- package/src/index.ts +334 -0
- package/src/line-layout.ts +6 -0
- package/src/motion.ts +1409 -0
- package/src/mouse/events.ts +55 -0
- package/src/mouse/fullscreen-layout.ts +260 -0
- package/src/mouse/registry.ts +348 -0
- package/src/mouse.ts +37 -0
- package/src/native-binary.ts +189 -0
- package/src/overlay/anchored.ts +91 -0
- package/src/overlay/detail-card.ts +158 -0
- package/src/overlay/dialog.ts +266 -0
- package/src/overlay/floating.ts +106 -0
- package/src/overlay/framed-editor.ts +236 -0
- package/src/overlay/fullscreen.ts +170 -0
- package/src/overlay/hover-tooltip.ts +109 -0
- package/src/overlay/modal-mount.ts +118 -0
- package/src/panels.ts +207 -0
- package/src/render-cache.ts +80 -0
- package/src/render-epoch.ts +48 -0
- package/src/request-animation.ts +160 -0
- package/src/scrollbar.ts +27 -0
- package/src/selection.ts +148 -0
- package/src/split-pane.ts +232 -0
- package/src/status-presentation.ts +411 -0
- package/src/stream.ts +543 -0
- package/src/syntax.ts +316 -0
- package/src/terminal/bridge-client.ts +243 -0
- package/src/terminal/embedding.ts +6 -0
- package/src/terminal/output.ts +151 -0
- package/src/terminal/projection.ts +362 -0
- package/src/terminal/pty-host.ts +263 -0
- package/src/terminal/pty-pane.ts +308 -0
- package/src/terminal-colors.ts +334 -0
- package/src/tool/action.ts +170 -0
- package/src/tool/activity.ts +500 -0
- package/src/tool/call-preview.ts +44 -0
- package/src/tool/disclosure-action.ts +89 -0
- package/src/tool/expanded-region-viewport.ts +103 -0
- package/src/tool/index.ts +26 -0
- package/src/tool/output.ts +271 -0
- package/src/tool/transcript.ts +150 -0
- package/src/tool/view-region.ts +581 -0
- package/test/appearance.test.ts +183 -0
- package/test/bounded-stream.test.ts +150 -0
- package/test/bridge-client.test.ts +42 -0
- package/test/color-preview.test.ts +23 -0
- package/test/component-stack.test.ts +206 -0
- package/test/components.test.ts +1165 -0
- package/test/content-text.test.ts +21 -0
- package/test/cursor-bridge.test.ts +151 -0
- package/test/cursor.test.ts +147 -0
- package/test/editor-chrome.test.ts +119 -0
- package/test/editor-layout.test.ts +30 -0
- package/test/editor-registry.test.ts +203 -0
- package/test/editor-theme.test.ts +33 -0
- package/test/editor-token-pill.test.ts +72 -0
- package/test/floating-overlay.test.ts +57 -0
- package/test/folding.test.ts +147 -0
- package/test/harmonious-theme.test.ts +23 -0
- package/test/interaction.test.ts +104 -0
- package/test/key-icons.test.ts +62 -0
- package/test/markdown-code.test.ts +24 -0
- package/test/modal-overlay-mount.test.ts +94 -0
- package/test/motion.test.ts +649 -0
- package/test/mouse/bridge.test.ts +1302 -0
- package/test/mouse/extension.test.ts +205 -0
- package/test/mouse/registry.test.ts +140 -0
- package/test/mouse/screen-decoration.test.ts +167 -0
- package/test/native-binary.test.ts +52 -0
- package/test/palette.test.ts +32 -0
- package/test/panels.test.ts +74 -0
- package/test/pointer-components.test.ts +151 -0
- package/test/progress.test.ts +54 -0
- package/test/pty-host.test.ts +126 -0
- package/test/pty-pane.test.ts +62 -0
- package/test/public-surface.test.ts +75 -0
- package/test/render-cache.test.ts +25 -0
- package/test/render-epoch.test.ts +119 -0
- package/test/request-animation.test.ts +193 -0
- package/test/screen-icon-actions.test.ts +69 -0
- package/test/select-box.test.ts +86 -0
- package/test/select-pointer.test.ts +369 -0
- package/test/selectable-list.test.ts +258 -0
- package/test/selection/registry.test.ts +44 -0
- package/test/selection-action-bar.test.ts +204 -0
- package/test/split-pane-bridge.test.ts +944 -0
- package/test/split-pane.test.ts +134 -0
- package/test/status-presentation.test.ts +39 -0
- package/test/syntax.test.ts +59 -0
- package/test/terminal-colors.test.ts +115 -0
- package/test/terminal-embedding.test.ts +12 -0
- package/test/terminal-projection.test.ts +312 -0
- package/test/theme-colors.test.ts +175 -0
- package/test/tool-output.test.ts +160 -0
- package/test/tool-transcript.test.ts +1190 -0
- package/test/unified-diff.test.ts +691 -0
- package/themes/harmonious.json +61 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type Component, stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { configureTuiAppearance, DEFAULT_TUI_APPEARANCE } from "../src/appearance.ts";
|
|
5
|
+
import { BackgroundSurface, halfBlockSurfaceEdge } from "../src/background-surface.ts";
|
|
6
|
+
import { tuiTheme } from "../src/color/theme.ts";
|
|
7
|
+
import { icon } from "../src/decoration/glyphs.ts";
|
|
8
|
+
import { ProgressBar, progressFrame } from "../src/decoration/status.ts";
|
|
9
|
+
import { parseUnifiedDiff, UnifiedDiffView } from "../src/diff/index.ts";
|
|
10
|
+
import { ensureFoldingRegistry, foldTargetAt } from "../src/folding.ts";
|
|
11
|
+
import type { TuiMouseEvent } from "../src/mouse.ts";
|
|
12
|
+
import { SyntaxText } from "../src/syntax.ts";
|
|
13
|
+
import { LiveToolAction, ToolAction, type ToolActionView } from "../src/tool/action.ts";
|
|
14
|
+
import { ToolActivity } from "../src/tool/activity.ts";
|
|
15
|
+
import { settleToolCallPreview, toolCallPreview } from "../src/tool/call-preview.ts";
|
|
16
|
+
import { ToolDisclosureAction } from "../src/tool/disclosure-action.ts";
|
|
17
|
+
import { ToolTranscript } from "../src/tool/transcript.ts";
|
|
18
|
+
import { ToolViewRegion } from "../src/tool/view-region.ts";
|
|
19
|
+
|
|
20
|
+
const theme = {
|
|
21
|
+
name: "transcript-test",
|
|
22
|
+
bold: (text: string) => text,
|
|
23
|
+
getColorMode: () => "truecolor",
|
|
24
|
+
getFgAnsi: (token: string) => (token === "text" ? "\x1b[38;2;240;240;240m" : "\x1b[38;2;100;140;200m"),
|
|
25
|
+
getBgAnsi: () => "\x1b[48;2;30;34;40m",
|
|
26
|
+
} as never as Theme;
|
|
27
|
+
|
|
28
|
+
describe("progress", () => {
|
|
29
|
+
test("renders deterministic bounded frames", () => {
|
|
30
|
+
const colors = tuiTheme(theme);
|
|
31
|
+
const frame = progressFrame(colors, { width: 20, value: 0.5 });
|
|
32
|
+
expect(visibleWidth(frame)).toBe(20);
|
|
33
|
+
expect(progressFrame(colors, { width: 20, value: 0.5 })).toBe(frame);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("returns stable line arrays until invalidated", () => {
|
|
37
|
+
const progress = new ProgressBar({ theme, value: 0.42, label: "Indexing", showPercentage: true });
|
|
38
|
+
const first = progress.render(40);
|
|
39
|
+
expect(progress.render(40)).toBe(first);
|
|
40
|
+
progress.invalidate();
|
|
41
|
+
expect(progress.render(40)).not.toBe(first);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("tool transcript grammar", () => {
|
|
46
|
+
afterEach(() => configureTuiAppearance(DEFAULT_TUI_APPEARANCE));
|
|
47
|
+
|
|
48
|
+
test("resolves failed and warning lifecycle markers through the active icon pack", () => {
|
|
49
|
+
for (const iconPack of ["nerd-fonts", "unicode", "emoji"] as const) {
|
|
50
|
+
configureTuiAppearance({ iconPack });
|
|
51
|
+
expect(renderAction({ verb: "Failed", status: "failed" })).toBe(`${icon("error")} Failed`);
|
|
52
|
+
expect(renderAction({ verb: "Warning", status: "warning" })).toBe(`${icon("warning")} Warning`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("background-only surfaces preserve width and restored syntax fields never crash", () => {
|
|
57
|
+
const syntax = new SyntaxText({ theme, text: undefined as never, path: ".md" });
|
|
58
|
+
const surface = new BackgroundSurface({ theme, component: component("one\ntwo") });
|
|
59
|
+
expect(syntax.render(40)).toEqual([""]);
|
|
60
|
+
expect(surface.render(12).every((line) => visibleWidth(line) === 12)).toBe(true);
|
|
61
|
+
expect(surface.render(12).join("\n")).toContain("\x1b[48;");
|
|
62
|
+
expect(surface.render(12)).toBe(surface.render(12));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("surface padding cannot inherit a child style", () => {
|
|
66
|
+
const surface = new BackgroundSurface({ theme, component: component("\x1b[4munderlined") });
|
|
67
|
+
const [line] = surface.render(20);
|
|
68
|
+
expect(line).toMatch(/\x1b\[0m\x1b\[48;[^m]+m {10}/u);
|
|
69
|
+
expect(visibleWidth(line!)).toBe(20);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("half-block surface edges leave a soft cell boundary", () => {
|
|
73
|
+
const top = halfBlockSurfaceEdge(theme, "surface.selected", "top", 4);
|
|
74
|
+
const bottom = halfBlockSurfaceEdge(theme, "surface.selected", "bottom", 4);
|
|
75
|
+
expect(stripTerminalSequences(top)).toBe("▄▄▄▄");
|
|
76
|
+
expect(stripTerminalSequences(bottom)).toBe("▀▀▀▀");
|
|
77
|
+
expect(top).toContain(tuiTheme(theme).fgAnsi(tuiTheme(theme).color("surface.selected")));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("tool surfaces start after one output row and include the action row", () => {
|
|
81
|
+
const compact = new ToolActivity({
|
|
82
|
+
theme,
|
|
83
|
+
requestRender() {},
|
|
84
|
+
view: { action: { verb: "Waited", status: "succeeded", marker: "◌" } },
|
|
85
|
+
});
|
|
86
|
+
const singleOutput = new ToolActivity({
|
|
87
|
+
theme,
|
|
88
|
+
requestRender() {},
|
|
89
|
+
view: {
|
|
90
|
+
action: { verb: "Edited", status: "succeeded", marker: "✎" },
|
|
91
|
+
payload: { kind: "text", text: "changed", revision: 1 },
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const multiline = new ToolActivity({
|
|
95
|
+
theme,
|
|
96
|
+
requestRender() {},
|
|
97
|
+
view: {
|
|
98
|
+
action: { verb: "Edited", status: "succeeded", marker: "✎" },
|
|
99
|
+
payload: { kind: "text", text: "first\nsecond", revision: 1 },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
expect(compact.render(40)[0]).not.toContain("\x1b[48;2;");
|
|
103
|
+
expect(singleOutput.render(40)).toHaveLength(2);
|
|
104
|
+
expect(singleOutput.render(40).every((line) => !line.includes("\x1b[48;2;"))).toBe(true);
|
|
105
|
+
expect(multiline.render(40)).toHaveLength(3);
|
|
106
|
+
expect(multiline.render(40).every((line) => /\x1b\[48;(?:2|5);/u.test(line))).toBe(true);
|
|
107
|
+
compact.dispose();
|
|
108
|
+
singleOutput.dispose();
|
|
109
|
+
multiline.dispose();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("replaces custom action rows and disposes replaced components", () => {
|
|
113
|
+
let oldActionDisposals = 0;
|
|
114
|
+
let oldPayloadDisposals = 0;
|
|
115
|
+
let currentPayloadDisposals = 0;
|
|
116
|
+
const oldPayload = disposableComponent("old payload", () => oldPayloadDisposals++);
|
|
117
|
+
const currentPayload = disposableComponent("current payload", () => currentPayloadDisposals++);
|
|
118
|
+
const activity = new ToolActivity({
|
|
119
|
+
theme,
|
|
120
|
+
requestRender() {},
|
|
121
|
+
action: disposableComponent("old action", () => oldActionDisposals++),
|
|
122
|
+
view: {
|
|
123
|
+
action: { verb: "ignored", status: "succeeded" },
|
|
124
|
+
payload: { kind: "component", preview: oldPayload },
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const reused = ToolActivity.reuse(activity, {
|
|
128
|
+
theme,
|
|
129
|
+
requestRender() {},
|
|
130
|
+
action: component("new action"),
|
|
131
|
+
view: {
|
|
132
|
+
action: { verb: "ignored", status: "succeeded" },
|
|
133
|
+
payload: { kind: "component", preview: currentPayload },
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
expect(reused).toBe(activity);
|
|
137
|
+
expect(stripTerminalSequences(reused.render(40).join("\n"))).toContain("new action");
|
|
138
|
+
expect(oldActionDisposals).toBe(1);
|
|
139
|
+
expect(oldPayloadDisposals).toBe(1);
|
|
140
|
+
expect(currentPayloadDisposals).toBe(0);
|
|
141
|
+
reused.dispose();
|
|
142
|
+
expect(currentPayloadDisposals).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("recreates an activity when its structural rendering options change", () => {
|
|
146
|
+
const requestRender = () => {};
|
|
147
|
+
const activity = new ToolActivity({
|
|
148
|
+
theme,
|
|
149
|
+
requestRender,
|
|
150
|
+
view: { action: { verb: "Read", status: "succeeded" } },
|
|
151
|
+
});
|
|
152
|
+
const surfaced = ToolActivity.reuse(activity, {
|
|
153
|
+
theme,
|
|
154
|
+
requestRender,
|
|
155
|
+
surface: "surface.inset",
|
|
156
|
+
view: {
|
|
157
|
+
action: { verb: "Read", status: "succeeded" },
|
|
158
|
+
payload: { kind: "text", text: "one\ntwo", revision: 1 },
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
expect(surfaced).not.toBe(activity);
|
|
162
|
+
expect(surfaced.render(40).every((line) => /\x1b\[48;(?:2|5);/u.test(line))).toBe(true);
|
|
163
|
+
surfaced.dispose();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("view regions dispose removed mode components once", () => {
|
|
167
|
+
let removedDisposals = 0;
|
|
168
|
+
let retainedDisposals = 0;
|
|
169
|
+
const removed = disposableComponent("removed", () => removedDisposals++);
|
|
170
|
+
const retained = disposableComponent("retained", () => retainedDisposals++);
|
|
171
|
+
const region = new ToolViewRegion({
|
|
172
|
+
theme,
|
|
173
|
+
modes: [
|
|
174
|
+
{ id: "preview", component: retained },
|
|
175
|
+
{ id: "full", component: removed },
|
|
176
|
+
],
|
|
177
|
+
requestRender() {},
|
|
178
|
+
});
|
|
179
|
+
region.updateModes([{ id: "preview", component: retained }]);
|
|
180
|
+
expect(removedDisposals).toBe(1);
|
|
181
|
+
expect(retainedDisposals).toBe(0);
|
|
182
|
+
region.dispose();
|
|
183
|
+
expect(retainedDisposals).toBe(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("registers only foldable regions as global fold targets", () => {
|
|
187
|
+
const registry = ensureFoldingRegistry();
|
|
188
|
+
registry.setCurrent(undefined);
|
|
189
|
+
const region = new ToolViewRegion({
|
|
190
|
+
theme,
|
|
191
|
+
modes: [{ id: "preview", component: component("preview") }],
|
|
192
|
+
requestRender() {},
|
|
193
|
+
});
|
|
194
|
+
region.render(30);
|
|
195
|
+
region.onMouse(mouse("move", 0, 1));
|
|
196
|
+
expect(registry.current).toBeUndefined();
|
|
197
|
+
|
|
198
|
+
region.updateModes([
|
|
199
|
+
{ id: "preview", component: component("preview") },
|
|
200
|
+
{ id: "full", component: component("full") },
|
|
201
|
+
]);
|
|
202
|
+
region.render(30);
|
|
203
|
+
region.onMouse(mouse("move", 0, 1));
|
|
204
|
+
expect(registry.current).toBe(region);
|
|
205
|
+
|
|
206
|
+
region.updateModes([{ id: "preview", component: component("preview") }]);
|
|
207
|
+
expect(registry.current).toBeUndefined();
|
|
208
|
+
region.dispose();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("exposes the rendered body fold at its structural transcript row", () => {
|
|
212
|
+
const region = new ToolViewRegion({
|
|
213
|
+
theme,
|
|
214
|
+
modes: [
|
|
215
|
+
{ id: "preview", component: component("preview") },
|
|
216
|
+
{ id: "full", component: component("full") },
|
|
217
|
+
],
|
|
218
|
+
requestRender() {},
|
|
219
|
+
});
|
|
220
|
+
const transcript = new ToolTranscript({ theme, action: component("action"), body: [region] });
|
|
221
|
+
transcript.render(30);
|
|
222
|
+
const body = transcript.getSpans()[1]?.component;
|
|
223
|
+
expect(body && foldTargetAt(body, 0)).toBe(region);
|
|
224
|
+
transcript.dispose();
|
|
225
|
+
});
|
|
226
|
+
test("supports shell, sentence, custom-marker, and markerless rows", () => {
|
|
227
|
+
const views = [
|
|
228
|
+
{ verb: "pwd", status: "succeeded" as const, marker: "$" },
|
|
229
|
+
{ verb: "Read", status: "succeeded" as const, marker: "•", detail: "src/a.ts" },
|
|
230
|
+
{ verb: "Edited", status: "succeeded" as const, marker: "✎", detail: "src/a.ts" },
|
|
231
|
+
{ verb: "Indexing", status: "running" as const, marker: false },
|
|
232
|
+
] satisfies ToolActionView[];
|
|
233
|
+
const rendered = views.map((view) => stripTerminalSequences(new ToolAction({ theme, view }).render(80)[0]!));
|
|
234
|
+
expect(rendered).toEqual(["$ pwd", "• Read · src/a.ts", "✎ Edited · src/a.ts", "Indexing"]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("strips terminal controls from ordinary action fields", () => {
|
|
238
|
+
const rendered = renderAction({
|
|
239
|
+
verb: "Read\x1b]52;c;secret\x07",
|
|
240
|
+
detail: "file\x1b[2J.ts",
|
|
241
|
+
status: "succeeded",
|
|
242
|
+
marker: "•",
|
|
243
|
+
});
|
|
244
|
+
expect(rendered).toContain("Read");
|
|
245
|
+
expect(rendered).toContain("file.ts");
|
|
246
|
+
expect(rendered).not.toContain("secret");
|
|
247
|
+
expect(rendered).not.toContain("\x1b]52");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("settles provisional call rows through shared renderer state", () => {
|
|
251
|
+
const state = {};
|
|
252
|
+
const preview = toolCallPreview(state, component("queued"));
|
|
253
|
+
expect(preview.render(20)).toEqual(["queued"]);
|
|
254
|
+
settleToolCallPreview(state);
|
|
255
|
+
expect(preview.render(20)).toEqual([]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("renders copy-friendly payload rows without an imposed gutter", () => {
|
|
259
|
+
const transcript = new ToolTranscript({
|
|
260
|
+
theme,
|
|
261
|
+
view: { verb: "bun test", status: "succeeded", marker: "$" },
|
|
262
|
+
body: [component("pass\nsecond")],
|
|
263
|
+
});
|
|
264
|
+
const rendered = stripTerminalSequences(transcript.render(40).join("\n"));
|
|
265
|
+
expect(rendered).toBe("$ bun test\npass\nsecond");
|
|
266
|
+
expect(rendered).not.toMatch(/[╭╮╰╯]/u);
|
|
267
|
+
expect(rendered).not.toContain("Output");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("disposes transcript bodies removed by replacement", () => {
|
|
271
|
+
let disposed = 0;
|
|
272
|
+
const removed = disposableComponent("old", () => disposed++);
|
|
273
|
+
const transcript = new ToolTranscript({
|
|
274
|
+
theme,
|
|
275
|
+
view: { verb: "Read", status: "succeeded" },
|
|
276
|
+
body: [removed],
|
|
277
|
+
});
|
|
278
|
+
transcript.setBody([component("new")]);
|
|
279
|
+
expect(disposed).toBe(1);
|
|
280
|
+
transcript.dispose();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("multi-mode regions handle hover, click, focus, and keyboard independently", () => {
|
|
284
|
+
let renders = 0;
|
|
285
|
+
const region = new ToolViewRegion({
|
|
286
|
+
theme,
|
|
287
|
+
modes: [
|
|
288
|
+
{ id: "preview", component: component("one"), nextHint: "show all" },
|
|
289
|
+
{ id: "detail", component: component("one\ntwo"), nextHint: "show diagnostics" },
|
|
290
|
+
{ id: "full", component: component("one\ntwo\nthree"), nextHint: "collapse", activate: "preview" },
|
|
291
|
+
],
|
|
292
|
+
requestRender: () => renders++,
|
|
293
|
+
});
|
|
294
|
+
region.render(30);
|
|
295
|
+
expect(region.onMouse(mouse("move", 0, 1))).toBe(true);
|
|
296
|
+
region.onMouse(mouse("press", 0, 1, 0));
|
|
297
|
+
region.onMouse(mouse("release", 0, 1, 0));
|
|
298
|
+
expect(region.getMode()).toBe("detail");
|
|
299
|
+
expect(region.render(30).join("\n")).toContain("\x1b[48;");
|
|
300
|
+
region.onMouse(mouse("leave", 0, 0));
|
|
301
|
+
region.setViewportFocus(true);
|
|
302
|
+
expect(region.render(30).join("\n")).toContain("\x1b[48;");
|
|
303
|
+
expect(region.handleViewportInput("\r")).toBe(false);
|
|
304
|
+
expect(region.handleViewportInput("\x1b[C")).toBe(true);
|
|
305
|
+
expect(region.getMode()).toBe("full");
|
|
306
|
+
region.render(30);
|
|
307
|
+
region.onMouse(mouse("press", 0, 1, 0));
|
|
308
|
+
region.onMouse(mouse("release", 0, 1, 0));
|
|
309
|
+
// Expanded content is not a collapse control; the owning header handles refolding.
|
|
310
|
+
expect(region.getMode()).toBe("full");
|
|
311
|
+
region.setMode("full");
|
|
312
|
+
region.render(30);
|
|
313
|
+
region.onMouse(mouse("press", 2, 20, 2));
|
|
314
|
+
region.onMouse(mouse("release", 2, 20, 2));
|
|
315
|
+
expect(region.getMode()).toBe("preview");
|
|
316
|
+
expect(renders).toBeGreaterThan(0);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("disclosure headers delegate keyboard and pointer folding to the region", () => {
|
|
320
|
+
const region = new ToolViewRegion({
|
|
321
|
+
theme,
|
|
322
|
+
modes: [
|
|
323
|
+
{ id: "preview", component: component("preview"), activate: "full" },
|
|
324
|
+
{ id: "full", component: component("full"), activate: "preview" },
|
|
325
|
+
],
|
|
326
|
+
requestRender() {},
|
|
327
|
+
});
|
|
328
|
+
const disclosure = new ToolDisclosureAction(theme, component("action"), region, () => {});
|
|
329
|
+
disclosure.render(30);
|
|
330
|
+
region.open();
|
|
331
|
+
disclosure.render(30);
|
|
332
|
+
expect(disclosure.handleViewportInput("\r")).toBe(true);
|
|
333
|
+
expect(region.isFolded()).toBe(true);
|
|
334
|
+
region.open();
|
|
335
|
+
disclosure.render(30);
|
|
336
|
+
expect(disclosure.onMouse(mouse("press", 0, 4, 0))).toBe(true);
|
|
337
|
+
expect(disclosure.onMouse(mouse("release", 0, 4, 0))).toBe(true);
|
|
338
|
+
expect(region.isFolded()).toBe(true);
|
|
339
|
+
disclosure.dispose();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("keeps a disclosure header hovered across streamed mode replacement", () => {
|
|
343
|
+
const region = new ToolViewRegion({
|
|
344
|
+
theme,
|
|
345
|
+
modes: [
|
|
346
|
+
{ id: "preview", component: component("preview"), activate: "full" },
|
|
347
|
+
{ id: "full", component: component("full"), activate: "preview" },
|
|
348
|
+
],
|
|
349
|
+
requestRender() {},
|
|
350
|
+
});
|
|
351
|
+
const disclosure = new ToolDisclosureAction(theme, component("action"), region, () => {});
|
|
352
|
+
const compact = disclosure.render(30);
|
|
353
|
+
expect(disclosure.onMouse(mouse("move", 0, 4))).toBe(true);
|
|
354
|
+
expect(disclosure.render(30)).not.toEqual(compact);
|
|
355
|
+
|
|
356
|
+
region.updateModes([
|
|
357
|
+
{ id: "preview", component: component("next preview"), activate: "full" },
|
|
358
|
+
{ id: "full", component: component("next full"), activate: "preview" },
|
|
359
|
+
]);
|
|
360
|
+
expect(region.isHeaderHovered()).toBe(true);
|
|
361
|
+
expect(disclosure.render(30)).not.toEqual(compact);
|
|
362
|
+
disclosure.dispose();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("repaints pre-styled disclosure text for hover contrast", () => {
|
|
366
|
+
const colors = tuiTheme(theme);
|
|
367
|
+
const region = new ToolViewRegion({
|
|
368
|
+
theme,
|
|
369
|
+
modes: [
|
|
370
|
+
{ id: "preview", component: component("preview"), activate: "full" },
|
|
371
|
+
{ id: "full", component: component("full"), activate: "preview" },
|
|
372
|
+
],
|
|
373
|
+
requestRender() {},
|
|
374
|
+
});
|
|
375
|
+
const disclosure = new ToolDisclosureAction(theme, component("\x1b[38;5;1mdim action"), region, () => {});
|
|
376
|
+
disclosure.render(30);
|
|
377
|
+
disclosure.onMouse(mouse("move", 0, 4));
|
|
378
|
+
const hovered = disclosure.render(30).join("\n");
|
|
379
|
+
expect(hovered).toContain(colors.bgAnsi("surface.hover"));
|
|
380
|
+
expect(hovered).toContain(colors.fgAnsi(colors.contrastBackground(colors.color("surface.hover"))));
|
|
381
|
+
expect(hovered).not.toContain("\x1b[38;5;1m");
|
|
382
|
+
disclosure.dispose();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("right-click toggles a disclosure header in either direction", () => {
|
|
386
|
+
const region = new ToolViewRegion({
|
|
387
|
+
theme,
|
|
388
|
+
modes: [
|
|
389
|
+
{ id: "preview", component: component("preview"), activate: "full" },
|
|
390
|
+
{ id: "full", component: component("full"), activate: "preview" },
|
|
391
|
+
],
|
|
392
|
+
requestRender() {},
|
|
393
|
+
});
|
|
394
|
+
const disclosure = new ToolDisclosureAction(theme, component("action"), region, () => {});
|
|
395
|
+
disclosure.render(30);
|
|
396
|
+
|
|
397
|
+
// Use the far edge of the full header row to keep the hit target whole-row,
|
|
398
|
+
// not just the visible action text.
|
|
399
|
+
expect(disclosure.onMouse(mouse("press", 0, 29, 2))).toBe(true);
|
|
400
|
+
expect(disclosure.onMouse(mouse("release", 0, 29, 2))).toBe(true);
|
|
401
|
+
expect(region.isExpanded()).toBe(true);
|
|
402
|
+
|
|
403
|
+
disclosure.render(30);
|
|
404
|
+
expect(disclosure.onMouse(mouse("press", 0, 29, 2))).toBe(true);
|
|
405
|
+
expect(disclosure.onMouse(mouse("release", 0, 29, 2))).toBe(true);
|
|
406
|
+
expect(region.isFolded()).toBe(true);
|
|
407
|
+
disclosure.dispose();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("folds from headers and expanded-body secondary clicks while primary body clicks stay payload-owned", () => {
|
|
411
|
+
const region = new ToolViewRegion({
|
|
412
|
+
theme,
|
|
413
|
+
modes: [
|
|
414
|
+
{ id: "preview", component: component("preview"), activate: "full" },
|
|
415
|
+
{ id: "full", component: component("full\nsecond") },
|
|
416
|
+
],
|
|
417
|
+
requestRender() {},
|
|
418
|
+
});
|
|
419
|
+
const disclosure = new ToolDisclosureAction(theme, component("action\ncontinuation"), region, () => {});
|
|
420
|
+
disclosure.render(30);
|
|
421
|
+
region.open();
|
|
422
|
+
disclosure.render(30);
|
|
423
|
+
expect(disclosure.onMouse(mouse("press", 1, 4, 0))).toBe(true);
|
|
424
|
+
expect(disclosure.onMouse(mouse("release", 1, 4, 0))).toBe(true);
|
|
425
|
+
expect(region.isFolded()).toBe(true);
|
|
426
|
+
region.open();
|
|
427
|
+
region.render(30);
|
|
428
|
+
expect(region.onMouse(mouse("press", 0, 4, 0))).toBe(false);
|
|
429
|
+
expect(region.onMouse(mouse("release", 0, 4, 0))).toBe(false);
|
|
430
|
+
expect(region.onMouse(mouse("press", 0, 4, 2))).toBe(true);
|
|
431
|
+
expect(region.onMouse(mouse("release", 0, 4, 2))).toBe(true);
|
|
432
|
+
expect(region.isFolded()).toBe(true);
|
|
433
|
+
disclosure.dispose();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("headers open folds even when the payload has its own disclosure control", () => {
|
|
437
|
+
const region = new ToolViewRegion({
|
|
438
|
+
theme,
|
|
439
|
+
modes: [
|
|
440
|
+
{ id: "preview", component: component("before\nshow full\nafter"), activationRow: 1 },
|
|
441
|
+
{ id: "full", component: component("before\nmiddle\nafter") },
|
|
442
|
+
],
|
|
443
|
+
requestRender() {},
|
|
444
|
+
});
|
|
445
|
+
const disclosure = new ToolDisclosureAction(theme, component("action"), region, () => {});
|
|
446
|
+
disclosure.render(30);
|
|
447
|
+
region.render(30);
|
|
448
|
+
|
|
449
|
+
expect(disclosure.onMouse(mouse("press", 0, 4, 0))).toBe(true);
|
|
450
|
+
expect(disclosure.onMouse(mouse("release", 0, 4, 0))).toBe(true);
|
|
451
|
+
expect(region.isExpanded()).toBe(true);
|
|
452
|
+
disclosure.dispose();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("foldable payloads without an explicit action keep ordinary body rows inert", () => {
|
|
456
|
+
const region = new ToolViewRegion({
|
|
457
|
+
theme,
|
|
458
|
+
modes: [
|
|
459
|
+
{ id: "preview", component: component("ordinary body") },
|
|
460
|
+
{ id: "full", component: component("ordinary body\nwarning details") },
|
|
461
|
+
],
|
|
462
|
+
requestRender() {},
|
|
463
|
+
});
|
|
464
|
+
const rendered = stripTerminalSequences(region.render(30).join("\n"));
|
|
465
|
+
|
|
466
|
+
expect(rendered).toBe("ordinary body");
|
|
467
|
+
expect(region.onMouse(mouse("press", 0, 4, 0))).toBe(false);
|
|
468
|
+
expect(region.onMouse(mouse("release", 0, 4, 0))).toBe(false);
|
|
469
|
+
expect(region.isFolded()).toBe(true);
|
|
470
|
+
region.dispose();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("mode advancement invalidates content and resets the expanded viewport", () => {
|
|
474
|
+
const region = new ToolViewRegion({
|
|
475
|
+
theme,
|
|
476
|
+
maxHeight: 2,
|
|
477
|
+
modes: [
|
|
478
|
+
{ id: "preview", component: component("preview") },
|
|
479
|
+
{ id: "detail", component: component("detail 0\ndetail 1\ndetail 2") },
|
|
480
|
+
{ id: "full", component: component("full 0\nfull 1\nfull 2") },
|
|
481
|
+
],
|
|
482
|
+
requestRender() {},
|
|
483
|
+
});
|
|
484
|
+
region.setMode("detail");
|
|
485
|
+
region.render(30);
|
|
486
|
+
region.onMouse({ ...mouse("wheel", 1, 1), wheel: 1 });
|
|
487
|
+
expect(stripTerminalSequences(region.render(30).join("\n"))).toContain("detail 2");
|
|
488
|
+
expect(region.handleViewportInput("\x1b[C")).toBe(true);
|
|
489
|
+
const full = stripTerminalSequences(region.render(30).join("\n"));
|
|
490
|
+
expect(full).toContain("full 0");
|
|
491
|
+
expect(full).not.toContain("detail");
|
|
492
|
+
expect(region.handleViewportInput("\x1b[D")).toBe(true);
|
|
493
|
+
expect(stripTerminalSequences(region.render(30).join("\n"))).toContain("detail 0");
|
|
494
|
+
region.dispose();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("all expanded regions use the shared surface and bounded payload viewport", () => {
|
|
498
|
+
const region = new ToolViewRegion({
|
|
499
|
+
theme,
|
|
500
|
+
maxHeight: 4,
|
|
501
|
+
modes: [
|
|
502
|
+
{ id: "preview", component: component("preview"), nextHint: "show details" },
|
|
503
|
+
{ id: "full", component: component(Array.from({ length: 8 }, (_, index) => `line ${index}`).join("\n")) },
|
|
504
|
+
],
|
|
505
|
+
requestRender() {},
|
|
506
|
+
});
|
|
507
|
+
region.render(32);
|
|
508
|
+
region.onMouse(mouse("press", 0, 0, 0));
|
|
509
|
+
region.onMouse(mouse("release", 0, 0, 0));
|
|
510
|
+
const expanded = region.render(32);
|
|
511
|
+
expect(expanded).toHaveLength(4);
|
|
512
|
+
expect(expanded.every((line) => line.includes("\x1b[48;"))).toBe(true);
|
|
513
|
+
expect(stripTerminalSequences(expanded[0]!).trimEnd()).toMatch(/^line 0\s+[│█]$/u);
|
|
514
|
+
expect(stripTerminalSequences(expanded.at(-1)!)).toMatch(/[│█]$/u);
|
|
515
|
+
region.onMouse(mouse("press", 2, 2, 0));
|
|
516
|
+
region.onMouse(mouse("release", 2, 2, 0));
|
|
517
|
+
expect(region.getMode()).toBe("full");
|
|
518
|
+
region.onMouse(mouse("press", 2, 2, 2));
|
|
519
|
+
region.onMouse(mouse("release", 2, 2, 2));
|
|
520
|
+
expect(region.getMode()).toBe("preview");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("keeps a bounded fold threshold visible and never shortens on expansion", () => {
|
|
524
|
+
const activity = new ToolActivity({
|
|
525
|
+
theme,
|
|
526
|
+
maxHeight: 4,
|
|
527
|
+
previewRows: 6,
|
|
528
|
+
requestRender() {},
|
|
529
|
+
view: {
|
|
530
|
+
action: { verb: "Working", status: "succeeded", marker: false },
|
|
531
|
+
payload: {
|
|
532
|
+
kind: "text",
|
|
533
|
+
text: Array.from({ length: 10 }, (_, index) => `line ${index}`).join("\n"),
|
|
534
|
+
revision: 1,
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
const body = activity.children[1] as Component & { onMouse(event: TuiMouseEvent): boolean };
|
|
539
|
+
const collapsed = stripTerminalSequences(activity.render(40).join("\n"));
|
|
540
|
+
expect(collapsed).toContain("rows omitted");
|
|
541
|
+
const collapsedRows = collapsed.split("\n").length;
|
|
542
|
+
expect(body.onMouse(mouse("press", 1, 39, 0))).toBe(true);
|
|
543
|
+
expect(body.onMouse(mouse("release", 1, 39, 0))).toBe(true);
|
|
544
|
+
const expandedRows = activity.render(40).length;
|
|
545
|
+
expect(expandedRows).toBeGreaterThanOrEqual(collapsedRows);
|
|
546
|
+
activity.dispose();
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("keeps fold geometry bounded across wide-narrow-wide reflow", () => {
|
|
550
|
+
const activity = new ToolActivity({
|
|
551
|
+
theme,
|
|
552
|
+
previewRows: 3,
|
|
553
|
+
maxHeight: 6,
|
|
554
|
+
requestRender() {},
|
|
555
|
+
view: {
|
|
556
|
+
action: { verb: "Working", status: "succeeded", marker: false },
|
|
557
|
+
payload: {
|
|
558
|
+
kind: "text",
|
|
559
|
+
text: Array.from({ length: 10 }, (_, index) => `line ${index} ${"x".repeat(24)}`).join("\n"),
|
|
560
|
+
revision: 1,
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
const body = activity.children[1] as Component & { onMouse(event: TuiMouseEvent): boolean };
|
|
565
|
+
|
|
566
|
+
const foldedWide = activity.render(80);
|
|
567
|
+
const foldedNarrow = activity.render(20);
|
|
568
|
+
expect(foldedWide.length).toBeLessThanOrEqual(6);
|
|
569
|
+
expect(foldedNarrow.length).toBeLessThanOrEqual(6);
|
|
570
|
+
|
|
571
|
+
const omissionRow = foldedNarrow.findIndex((line) => stripTerminalSequences(line).includes("rows omitted"));
|
|
572
|
+
expect(omissionRow).toBeGreaterThan(0);
|
|
573
|
+
expect(body.onMouse(mouse("press", omissionRow - 1, 2, 0))).toBe(true);
|
|
574
|
+
expect(body.onMouse(mouse("release", omissionRow - 1, 2, 0))).toBe(true);
|
|
575
|
+
|
|
576
|
+
const expandedNarrow = activity.render(20);
|
|
577
|
+
expect(expandedNarrow.length).toBeGreaterThanOrEqual(foldedNarrow.length);
|
|
578
|
+
expect(expandedNarrow.length).toBeLessThanOrEqual(6);
|
|
579
|
+
const firstNarrowBodyRow = stripTerminalSequences(expandedNarrow[1]!).trim();
|
|
580
|
+
expect(body.onMouse({ ...mouse("wheel", 1, 2), wheel: 1 })).toBe(true);
|
|
581
|
+
const scrolledNarrow = activity.render(20);
|
|
582
|
+
expect(stripTerminalSequences(scrolledNarrow[1]!).trim()).not.toBe(firstNarrowBodyRow);
|
|
583
|
+
|
|
584
|
+
const expandedWide = activity.render(80);
|
|
585
|
+
expect(expandedWide.length).toBeGreaterThanOrEqual(foldedWide.length);
|
|
586
|
+
expect(expandedWide.length).toBeLessThanOrEqual(6);
|
|
587
|
+
const firstWideBodyRow = stripTerminalSequences(expandedWide[1]!).trim();
|
|
588
|
+
expect(body.onMouse({ ...mouse("wheel", 1, 2), wheel: 1 })).toBe(true);
|
|
589
|
+
expect(stripTerminalSequences(activity.render(80)[1]!).trim()).not.toBe(firstWideBodyRow);
|
|
590
|
+
activity.dispose();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("places component show-full controls in the omitted middle", () => {
|
|
594
|
+
const region = new ToolViewRegion({
|
|
595
|
+
theme,
|
|
596
|
+
modes: [
|
|
597
|
+
{
|
|
598
|
+
id: "preview",
|
|
599
|
+
component: component("line 0\nline 1\nline 2\nline 3"),
|
|
600
|
+
nextHint: "show full",
|
|
601
|
+
activate: "full",
|
|
602
|
+
},
|
|
603
|
+
{ id: "full", component: component("full") },
|
|
604
|
+
],
|
|
605
|
+
requestRender() {},
|
|
606
|
+
});
|
|
607
|
+
const rendered = stripTerminalSequences(region.render(30).join("\n")).split("\n");
|
|
608
|
+
const hintRow = rendered.findIndex((line) => line.includes("show full"));
|
|
609
|
+
expect(hintRow).toBeGreaterThan(0);
|
|
610
|
+
expect(hintRow).toBeLessThan(rendered.length - 1);
|
|
611
|
+
expect(region.onMouse(mouse("press", hintRow, 2, 0))).toBe(true);
|
|
612
|
+
expect(region.onMouse(mouse("release", hintRow, 2, 0))).toBe(true);
|
|
613
|
+
expect(region.getMode()).toBe("full");
|
|
614
|
+
region.dispose();
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("canceled and replaced controls clear pointer state without stealing inert keys", () => {
|
|
618
|
+
let renders = 0;
|
|
619
|
+
const preview = component("one");
|
|
620
|
+
const region = new ToolViewRegion({
|
|
621
|
+
theme,
|
|
622
|
+
modes: [
|
|
623
|
+
{ id: "preview", component: preview, nextHint: "show all" },
|
|
624
|
+
{ id: "full", component: component("one\ntwo") },
|
|
625
|
+
],
|
|
626
|
+
requestRender: () => renders++,
|
|
627
|
+
});
|
|
628
|
+
region.render(30);
|
|
629
|
+
region.onMouse(mouse("move", 0, 1));
|
|
630
|
+
region.onMouse(mouse("press", 0, 1, 0));
|
|
631
|
+
expect(region.onMouse(mouse("release", 5, 20, 0))).toBe(false);
|
|
632
|
+
expect(region.render(30).join("\n")).not.toContain("\x1b[48;");
|
|
633
|
+
|
|
634
|
+
region.onMouse(mouse("move", 1, 1));
|
|
635
|
+
region.updateModes([{ id: "preview", component: component("replacement") }]);
|
|
636
|
+
expect(region.render(30).join("\n")).not.toContain("\x1b[48;");
|
|
637
|
+
expect(region.handleViewportInput("\x1b[C")).toBe(false);
|
|
638
|
+
expect(region.handleViewportInput("\x1b[D")).toBe(false);
|
|
639
|
+
expect(renders).toBeGreaterThan(0);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("streaming activity accepts text, terminal, diff, and custom visual payloads", () => {
|
|
643
|
+
const action = { verb: "Working", status: "running" as const, marker: false as const };
|
|
644
|
+
const activity = new ToolActivity({
|
|
645
|
+
theme,
|
|
646
|
+
requestRender() {},
|
|
647
|
+
view: { action, running: false, payload: { kind: "text", text: "hello", revision: 1 } },
|
|
648
|
+
});
|
|
649
|
+
expect(stripTerminalSequences(activity.render(40).join("\n"))).toContain("hello");
|
|
650
|
+
expect(stripTerminalSequences(activity.render(40).join("\n"))).not.toContain("expand transcript");
|
|
651
|
+
activity.update({
|
|
652
|
+
action,
|
|
653
|
+
running: false,
|
|
654
|
+
payload: { kind: "text", text: Array.from({ length: 9 }, (_, index) => `line ${index}`).join("\n"), revision: 2 },
|
|
655
|
+
});
|
|
656
|
+
expect(stripTerminalSequences(activity.render(40).join("\n"))).toContain("… 4 rows omitted …");
|
|
657
|
+
expect(stripTerminalSequences(activity.render(40).join("\n"))).not.toContain("expand transcript");
|
|
658
|
+
expect(stripTerminalSequences(activity.render(40).join("\n"))).not.toContain("show full output");
|
|
659
|
+
activity.update({ action, running: false, payload: { kind: "terminal", text: "progress 1%\rprogress 2%" } });
|
|
660
|
+
activity.update({ action, running: false, payload: { kind: "component", preview: component("custom") } });
|
|
661
|
+
const custom = stripTerminalSequences(activity.render(40).join("\n"));
|
|
662
|
+
expect(custom).toContain("custom");
|
|
663
|
+
expect(custom).not.toContain("expand");
|
|
664
|
+
activity.dispose();
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("expands a diff from its fold row without an appended tail control", () => {
|
|
668
|
+
const model = parseUnifiedDiff(
|
|
669
|
+
["--- a/x", "+++ b/x", "@@ -1,30 +1,30 @@", ...Array.from({ length: 30 }, (_, index) => ` line ${index}`)].join(
|
|
670
|
+
"\n",
|
|
671
|
+
),
|
|
672
|
+
);
|
|
673
|
+
const activity = new ToolActivity({
|
|
674
|
+
theme,
|
|
675
|
+
fullRows: 40,
|
|
676
|
+
requestRender() {},
|
|
677
|
+
view: { action: { verb: "Diff", status: "succeeded", marker: false }, payload: { kind: "diff", model } },
|
|
678
|
+
});
|
|
679
|
+
const collapsed = stripTerminalSequences(activity.render(80).join("\n"));
|
|
680
|
+
expect(collapsed).toContain("@@");
|
|
681
|
+
expect(collapsed).not.toContain("show full diff");
|
|
682
|
+
const body = activity.children[1] as Component & { onMouse(event: TuiMouseEvent): boolean };
|
|
683
|
+
expect(body.onMouse(mouse("press", 12, 10, 0))).toBe(true);
|
|
684
|
+
expect(body.onMouse(mouse("release", 12, 10, 0))).toBe(true);
|
|
685
|
+
const expandedLines = activity.render(80);
|
|
686
|
+
const expanded = stripTerminalSequences(expandedLines.join("\n"));
|
|
687
|
+
expect(expandedLines.length).toBeGreaterThanOrEqual(collapsed.split("\n").length);
|
|
688
|
+
expect(expandedLines.at(-1)).toMatch(/[│█]/u);
|
|
689
|
+
expect(expanded).toContain("line 21");
|
|
690
|
+
expect(expanded).not.toContain("line 29");
|
|
691
|
+
expect(expanded).not.toContain("show full diff");
|
|
692
|
+
const header = expandedLines[0];
|
|
693
|
+
const firstBodyRow = expandedLines[1];
|
|
694
|
+
for (let index = 0; index < 3; index += 1) expect(body.onMouse({ ...mouse("wheel", 1, 10), wheel: 1 })).toBe(true);
|
|
695
|
+
const scrolledLines = activity.render(80);
|
|
696
|
+
expect(scrolledLines[0]).toBe(header);
|
|
697
|
+
expect(scrolledLines[1]).not.toBe(firstBodyRow);
|
|
698
|
+
expect(stripTerminalSequences(scrolledLines.join("\n"))).toContain("line 29");
|
|
699
|
+
activity.dispose();
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("diff omission hover paints semantic gutter and content backgrounds", () => {
|
|
703
|
+
const colors = tuiTheme(theme);
|
|
704
|
+
const view = new UnifiedDiffView({
|
|
705
|
+
model: parseUnifiedDiff(
|
|
706
|
+
["--- a/x", "+++ b/x", "@@ -1,5 +1,5 @@", " one", " two", " three", " four", " five"].join("\n"),
|
|
707
|
+
),
|
|
708
|
+
theme: colors,
|
|
709
|
+
viewport: { maxRows: 3, selection: "head-tail" },
|
|
710
|
+
});
|
|
711
|
+
const region = new ToolViewRegion({
|
|
712
|
+
theme,
|
|
713
|
+
modes: [
|
|
714
|
+
{ id: "preview", component: view, activationRow: "omission" },
|
|
715
|
+
{ id: "full", component: component("full") },
|
|
716
|
+
],
|
|
717
|
+
requestRender() {},
|
|
718
|
+
});
|
|
719
|
+
const rest = region.render(60);
|
|
720
|
+
const omissionRow = view.getOmissionRow();
|
|
721
|
+
expect(omissionRow).toBeDefined();
|
|
722
|
+
expect(rest[omissionRow!]).toContain(colors.bgAnsi("diff.hunk"));
|
|
723
|
+
expect(rest[omissionRow!]).toContain(colors.bgAnsi("diff.hunkGutter"));
|
|
724
|
+
expect(region.onMouse(mouse("move", omissionRow!, 0))).toBe(true);
|
|
725
|
+
const gutterHovered = region.render(60);
|
|
726
|
+
expect(gutterHovered[omissionRow!]).toContain(colors.bgAnsi("diff.hunkHover"));
|
|
727
|
+
expect(gutterHovered[omissionRow!]).toContain(colors.bgAnsi("diff.hunkGutterHover"));
|
|
728
|
+
region.onMouse(mouse("leave", omissionRow!, 5));
|
|
729
|
+
expect(region.onMouse(mouse("move", omissionRow!, 59))).toBe(true);
|
|
730
|
+
const hovered = region.render(60);
|
|
731
|
+
expect(hovered[omissionRow!]).toContain(colors.bgAnsi("diff.hunkHover"));
|
|
732
|
+
expect(hovered[omissionRow!]).toContain(colors.bgAnsi("diff.hunkGutterHover"));
|
|
733
|
+
expect(hovered[omissionRow!]?.split(colors.bgAnsi("diff.hunkHover")).length).toBe(2);
|
|
734
|
+
expect(hovered[omissionRow!]?.split(colors.bgAnsi("diff.hunkGutterHover")).length).toBe(2);
|
|
735
|
+
expect(hovered[omissionRow!]).not.toBe(rest[omissionRow!]);
|
|
736
|
+
region.onMouse(mouse("leave", omissionRow!, 5));
|
|
737
|
+
expect(region.render(60)[omissionRow!]).toBe(rest[omissionRow!]);
|
|
738
|
+
region.dispose();
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("keeps wrapped diff previews bounded without removing expanded scrolling", () => {
|
|
742
|
+
const model = parseUnifiedDiff(
|
|
743
|
+
[
|
|
744
|
+
"--- a/x",
|
|
745
|
+
"+++ b/x",
|
|
746
|
+
"@@ -1,30 +1,30 @@",
|
|
747
|
+
...Array.from({ length: 30 }, (_, index) => ` ${index} ${"wrapped ".repeat(30)}`),
|
|
748
|
+
].join("\n"),
|
|
749
|
+
);
|
|
750
|
+
const activity = new ToolActivity({
|
|
751
|
+
theme,
|
|
752
|
+
requestRender() {},
|
|
753
|
+
view: { action: { verb: "Diff", status: "succeeded", marker: false }, payload: { kind: "diff", model } },
|
|
754
|
+
});
|
|
755
|
+
const collapsed = activity.render(20);
|
|
756
|
+
expect(collapsed.length).toBeLessThanOrEqual(26);
|
|
757
|
+
const body = activity.children[1] as Component & { onMouse(event: TuiMouseEvent): boolean };
|
|
758
|
+
let omissionRow = -1;
|
|
759
|
+
for (const [index, line] of collapsed.entries()) {
|
|
760
|
+
if (stripTerminalSequences(line).includes("@@")) omissionRow = index;
|
|
761
|
+
}
|
|
762
|
+
expect(omissionRow).toBeGreaterThan(0);
|
|
763
|
+
expect(body.onMouse(mouse("press", omissionRow - 1, 10, 0))).toBe(true);
|
|
764
|
+
expect(body.onMouse(mouse("release", omissionRow - 1, 10, 0))).toBe(true);
|
|
765
|
+
const expanded = activity.render(20);
|
|
766
|
+
expect(expanded.length).toBeGreaterThanOrEqual(collapsed.length);
|
|
767
|
+
expect(expanded.length).toBeLessThanOrEqual(27);
|
|
768
|
+
const firstBodyRow = expanded[1];
|
|
769
|
+
expect(body.onMouse({ ...mouse("wheel", 1, 10), wheel: 1 })).toBe(true);
|
|
770
|
+
expect(activity.render(20)[1]).not.toBe(firstBodyRow);
|
|
771
|
+
activity.dispose();
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test("derives diff disclosure from rendered wrapping instead of source rows", () => {
|
|
775
|
+
const model = parseUnifiedDiff(["--- a/x", "+++ b/x", "@@ -1 +1 @@", ` ${"wrapped ".repeat(80)}`].join("\n"));
|
|
776
|
+
expect(model.sourceRows).toBe(4);
|
|
777
|
+
const activity = new ToolActivity({
|
|
778
|
+
theme,
|
|
779
|
+
previewRows: 6,
|
|
780
|
+
requestRender() {},
|
|
781
|
+
view: { action: { verb: "Diff", status: "succeeded", marker: false }, payload: { kind: "diff", model } },
|
|
782
|
+
});
|
|
783
|
+
const collapsed = activity.render(20);
|
|
784
|
+
const omissionRow = collapsed.findIndex((line) => stripTerminalSequences(line).includes("wrapped diff"));
|
|
785
|
+
expect(omissionRow).toBeGreaterThan(0);
|
|
786
|
+
const body = activity.children[1] as Component & { onMouse(event: TuiMouseEvent): boolean };
|
|
787
|
+
expect(body.onMouse(mouse("press", omissionRow - 1, 10, 0))).toBe(true);
|
|
788
|
+
expect(body.onMouse(mouse("release", omissionRow - 1, 10, 0))).toBe(true);
|
|
789
|
+
expect(activity.render(20).length).toBeGreaterThan(collapsed.length);
|
|
790
|
+
activity.dispose();
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test("renders a first text stream whose revision starts at zero", () => {
|
|
794
|
+
const activity = new ToolActivity({
|
|
795
|
+
theme,
|
|
796
|
+
requestRender() {},
|
|
797
|
+
view: {
|
|
798
|
+
action: { verb: "Working", status: "running", marker: false },
|
|
799
|
+
payload: { kind: "text", text: "visible", revision: 0 },
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
expect(stripTerminalSequences(activity.render(40).join("\n"))).toContain("visible");
|
|
803
|
+
activity.dispose();
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("adds a disclosure when preview output wraps past the row budget", () => {
|
|
807
|
+
const activity = new ToolActivity({
|
|
808
|
+
theme,
|
|
809
|
+
previewRows: 2,
|
|
810
|
+
requestRender() {},
|
|
811
|
+
view: {
|
|
812
|
+
action: { verb: "Working", status: "succeeded", marker: false },
|
|
813
|
+
payload: { kind: "text", text: "a very long line that wraps", revision: 1 },
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
const rendered = stripTerminalSequences(activity.render(8).join("\n"));
|
|
817
|
+
expect(rendered).toContain("›");
|
|
818
|
+
const body = activity.children[1] as Component & { onMouse(event: TuiMouseEvent): boolean };
|
|
819
|
+
body.onMouse(mouse("press", 0, 5, 0));
|
|
820
|
+
body.onMouse(mouse("release", 0, 5, 0));
|
|
821
|
+
const expanded = stripTerminalSequences(activity.render(8).join("\n"));
|
|
822
|
+
expect(expanded.replace(/\s/gu, "")).toContain("averylonglinethatwraps");
|
|
823
|
+
expect(expanded).not.toContain("rows omitted");
|
|
824
|
+
activity.dispose();
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("resets the region after payload removal before its first render", () => {
|
|
828
|
+
const action = { verb: "Working", status: "succeeded" as const, marker: false as const };
|
|
829
|
+
const activity = new ToolActivity({
|
|
830
|
+
theme,
|
|
831
|
+
requestRender() {},
|
|
832
|
+
view: { action, payload: { kind: "text", text: "first", revision: 1 } },
|
|
833
|
+
});
|
|
834
|
+
const originalBody = activity.children[1];
|
|
835
|
+
activity.update({ action });
|
|
836
|
+
activity.update({ action, payload: { kind: "text", text: "second", revision: 2 } });
|
|
837
|
+
expect(activity.children[1]).not.toBe(originalBody);
|
|
838
|
+
expect(stripTerminalSequences(activity.render(30).join("\n"))).toContain("second");
|
|
839
|
+
activity.dispose();
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("focuses nested children and gives them input before outer scrolling", () => {
|
|
843
|
+
const focus: boolean[] = [];
|
|
844
|
+
const input: string[] = [];
|
|
845
|
+
const nested: Component & {
|
|
846
|
+
setViewportFocus(focused: boolean): void;
|
|
847
|
+
handleViewportInput(data: string): boolean;
|
|
848
|
+
} = {
|
|
849
|
+
render: () => ["nested"],
|
|
850
|
+
invalidate() {},
|
|
851
|
+
setViewportFocus(focused) {
|
|
852
|
+
focus.push(focused);
|
|
853
|
+
},
|
|
854
|
+
handleViewportInput(data) {
|
|
855
|
+
input.push(data);
|
|
856
|
+
return true;
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
const region = new ToolViewRegion({
|
|
860
|
+
theme,
|
|
861
|
+
maxHeight: 2,
|
|
862
|
+
modes: [
|
|
863
|
+
{ id: "preview", component: component("preview") },
|
|
864
|
+
{ id: "full", component: nested },
|
|
865
|
+
],
|
|
866
|
+
requestRender() {},
|
|
867
|
+
});
|
|
868
|
+
region.setViewportFocus(true);
|
|
869
|
+
region.setMode("full");
|
|
870
|
+
region.render(30);
|
|
871
|
+
expect(focus).toEqual([true]);
|
|
872
|
+
expect(region.handleViewportInput("\x1b[B")).toBe(true);
|
|
873
|
+
expect(input).toEqual(["\x1b[B"]);
|
|
874
|
+
region.dispose();
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test("counts the action row in a bounded activity fold", () => {
|
|
878
|
+
const activity = new ToolActivity({
|
|
879
|
+
theme,
|
|
880
|
+
maxHeight: 4,
|
|
881
|
+
requestRender() {},
|
|
882
|
+
view: {
|
|
883
|
+
action: { verb: "Working", status: "succeeded", marker: false },
|
|
884
|
+
mode: "full",
|
|
885
|
+
payload: { kind: "text", text: "one\ntwo\nthree\nfour\nfive", revision: 1 },
|
|
886
|
+
},
|
|
887
|
+
});
|
|
888
|
+
expect(activity.render(30)).toHaveLength(4);
|
|
889
|
+
activity.dispose();
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("routes an explicit cumulative terminal tail without treating replacements as append-only", async () => {
|
|
893
|
+
const action = { verb: "Working", status: "running" as const, marker: false as const };
|
|
894
|
+
const cumulative = `\x1b[31m${"red ".repeat(80)}`;
|
|
895
|
+
const activity = new ToolActivity({
|
|
896
|
+
theme,
|
|
897
|
+
requestRender() {},
|
|
898
|
+
view: { action, payload: { kind: "terminal", text: cumulative, revision: 1, update: "cumulative" } },
|
|
899
|
+
});
|
|
900
|
+
activity.update({
|
|
901
|
+
action,
|
|
902
|
+
payload: {
|
|
903
|
+
kind: "terminal",
|
|
904
|
+
text: cumulative.slice(-100),
|
|
905
|
+
revision: 2,
|
|
906
|
+
update: "cumulative-tail",
|
|
907
|
+
},
|
|
908
|
+
});
|
|
909
|
+
await Bun.sleep(0);
|
|
910
|
+
expect(activity.render(40).join("\n")).toContain("31m");
|
|
911
|
+
activity.dispose();
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test("append-only stream state resets when switching from terminal to text and back", async () => {
|
|
915
|
+
let wake: (() => void) | undefined;
|
|
916
|
+
const rendered = () => new Promise<void>((resolve) => (wake = resolve));
|
|
917
|
+
let nextRender = rendered();
|
|
918
|
+
const action = { verb: "Working", status: "running" as const, marker: false as const };
|
|
919
|
+
const activity = new ToolActivity({
|
|
920
|
+
theme,
|
|
921
|
+
requestRender() {
|
|
922
|
+
wake?.();
|
|
923
|
+
wake = undefined;
|
|
924
|
+
},
|
|
925
|
+
view: { action, payload: { kind: "terminal", text: "first!", revision: 1, update: "cumulative" } },
|
|
926
|
+
});
|
|
927
|
+
await nextRender;
|
|
928
|
+
expect(stripTerminalSequences(activity.render(40).join("\n"))).toContain("first!");
|
|
929
|
+
activity.update({ action, payload: { kind: "text", text: "middle", revision: 1, update: "cumulative" } });
|
|
930
|
+
nextRender = rendered();
|
|
931
|
+
activity.update({ action, payload: { kind: "terminal", text: "second", revision: 1, update: "cumulative" } });
|
|
932
|
+
await nextRender;
|
|
933
|
+
const final = stripTerminalSequences(activity.render(40).join("\n"));
|
|
934
|
+
expect(final).toContain("second");
|
|
935
|
+
expect(final).not.toContain("first");
|
|
936
|
+
activity.dispose();
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
test("append-only stream state resets when switching from text to terminal and back", async () => {
|
|
940
|
+
let wake: (() => void) | undefined;
|
|
941
|
+
const rendered = () => new Promise<void>((resolve) => (wake = resolve));
|
|
942
|
+
let nextRender = rendered();
|
|
943
|
+
const action = { verb: "Working", status: "running" as const, marker: false as const };
|
|
944
|
+
const activity = new ToolActivity({
|
|
945
|
+
theme,
|
|
946
|
+
requestRender() {
|
|
947
|
+
wake?.();
|
|
948
|
+
wake = undefined;
|
|
949
|
+
},
|
|
950
|
+
view: { action, payload: { kind: "text", text: "first!", revision: 1, update: "cumulative" } },
|
|
951
|
+
});
|
|
952
|
+
nextRender = rendered();
|
|
953
|
+
activity.update({ action, payload: { kind: "terminal", text: "middle", revision: 1, update: "cumulative" } });
|
|
954
|
+
await nextRender;
|
|
955
|
+
activity.update({ action, payload: { kind: "text", text: "second", revision: 1, update: "cumulative" } });
|
|
956
|
+
const final = stripTerminalSequences(activity.render(40).join("\n"));
|
|
957
|
+
expect(final).toContain("second");
|
|
958
|
+
expect(final).not.toContain("middle");
|
|
959
|
+
expect(final).not.toContain("first!");
|
|
960
|
+
activity.dispose();
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("stream updates preserve a click already captured by the payload region", () => {
|
|
964
|
+
const action = { verb: "Working", status: "running" as const, marker: false as const };
|
|
965
|
+
const initial = Array.from({ length: 9 }, (_, index) => `line ${index}`).join("\n");
|
|
966
|
+
const activity = new ToolActivity({
|
|
967
|
+
theme,
|
|
968
|
+
requestRender() {},
|
|
969
|
+
view: { action, running: false, payload: { kind: "text", text: initial, revision: 1 } },
|
|
970
|
+
});
|
|
971
|
+
activity.render(40);
|
|
972
|
+
const body = activity.children[1]!;
|
|
973
|
+
const interactive = body as Component & { onMouse(event: TuiMouseEvent): boolean };
|
|
974
|
+
expect(interactive.onMouse(mouse("press", 2, 5, 0))).toBe(true);
|
|
975
|
+
|
|
976
|
+
activity.update({
|
|
977
|
+
action,
|
|
978
|
+
running: false,
|
|
979
|
+
payload: { kind: "text", text: `${initial}\nline 9`, revision: 2 },
|
|
980
|
+
});
|
|
981
|
+
expect(activity.children[1]).toBe(body);
|
|
982
|
+
expect(interactive.onMouse(mouse("release", 2, 5, 0))).toBe(true);
|
|
983
|
+
expect(stripTerminalSequences(activity.render(40).join("\n"))).not.toContain("collapse");
|
|
984
|
+
activity.dispose();
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
test("recreates a fold region after its payload disappears", () => {
|
|
988
|
+
const action = { verb: "Working", status: "succeeded" as const, marker: false as const };
|
|
989
|
+
const activity = new ToolActivity({
|
|
990
|
+
theme,
|
|
991
|
+
requestRender() {},
|
|
992
|
+
view: {
|
|
993
|
+
action,
|
|
994
|
+
payload: { kind: "text", text: Array.from({ length: 9 }, (_, row) => `first ${row}`).join("\n"), revision: 1 },
|
|
995
|
+
},
|
|
996
|
+
});
|
|
997
|
+
activity.render(40);
|
|
998
|
+
activity.update({ action });
|
|
999
|
+
activity.render(40);
|
|
1000
|
+
activity.update({
|
|
1001
|
+
action,
|
|
1002
|
+
payload: { kind: "text", text: Array.from({ length: 9 }, (_, row) => `second ${row}`).join("\n"), revision: 2 },
|
|
1003
|
+
});
|
|
1004
|
+
activity.render(40);
|
|
1005
|
+
const body = activity.children[1] as Component & { onMouse(event: TuiMouseEvent): boolean };
|
|
1006
|
+
body.onMouse(mouse("move", 2, 5));
|
|
1007
|
+
expect(ensureFoldingRegistry().current).toBeDefined();
|
|
1008
|
+
expect(body.onMouse(mouse("press", 2, 5, 0))).toBe(true);
|
|
1009
|
+
expect(body.onMouse(mouse("release", 2, 5, 0))).toBe(true);
|
|
1010
|
+
expect(stripTerminalSequences(activity.render(40).join("\n"))).toContain("second 8");
|
|
1011
|
+
activity.dispose();
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test("keeps failure details collapsed until their own region is activated", () => {
|
|
1015
|
+
const activity = new ToolActivity({
|
|
1016
|
+
theme,
|
|
1017
|
+
requestRender() {},
|
|
1018
|
+
view: {
|
|
1019
|
+
action: { verb: "Skill failed", detail: "missing", status: "failed" },
|
|
1020
|
+
failure: 'Unknown skill "missing"',
|
|
1021
|
+
},
|
|
1022
|
+
});
|
|
1023
|
+
const compact = stripTerminalSequences(activity.render(60).join("\n"));
|
|
1024
|
+
expect(compact).toMatch(
|
|
1025
|
+
new RegExp(
|
|
1026
|
+
`^${escapeRegExp(icon("error"))} Skill failed · missing\\s+${escapeRegExp(icon("expand-closed"))}$`,
|
|
1027
|
+
"u",
|
|
1028
|
+
),
|
|
1029
|
+
);
|
|
1030
|
+
expect(compact).not.toContain("Unknown skill");
|
|
1031
|
+
const action = activity.children[0] as Component & { onMouse(event: TuiMouseEvent): boolean };
|
|
1032
|
+
activity.render(60);
|
|
1033
|
+
action.onMouse(mouse("move", 0, 4));
|
|
1034
|
+
action.onMouse(mouse("press", 0, 4, 0));
|
|
1035
|
+
expect(action.onMouse(mouse("release", 2, 80, 0))).toBe(false);
|
|
1036
|
+
expect(activity.render(60).join("\n")).not.toContain("\x1b[48;");
|
|
1037
|
+
expect(action.onMouse(mouse("press", 0, 4, 0))).toBe(true);
|
|
1038
|
+
expect(action.onMouse(mouse("release", 0, 4, 0))).toBe(true);
|
|
1039
|
+
expect(stripTerminalSequences(activity.render(60).join("\n"))).toContain('Unknown skill "missing"');
|
|
1040
|
+
action.onMouse(mouse("press", 0, 4, 2));
|
|
1041
|
+
action.onMouse(mouse("release", 0, 4, 2));
|
|
1042
|
+
expect(stripTerminalSequences(activity.render(60).join("\n"))).not.toContain('Unknown skill "missing"');
|
|
1043
|
+
activity.update({
|
|
1044
|
+
action: { verb: "Skill failed", detail: "missing", status: "failed" },
|
|
1045
|
+
failure: 'Unknown skill "missing"\x1b]52;c;secret\x07',
|
|
1046
|
+
mode: "full",
|
|
1047
|
+
});
|
|
1048
|
+
const expanded = activity.render(60).join("\n");
|
|
1049
|
+
expect(expanded).not.toContain("\x1b]52");
|
|
1050
|
+
expect(stripTerminalSequences(expanded)).toContain('Unknown skill "missing"');
|
|
1051
|
+
expect(stripTerminalSequences(activity.render(60).join("\n"))).not.toContain("show error");
|
|
1052
|
+
action.onMouse(mouse("move", 0, 4));
|
|
1053
|
+
expect(activity.render(60).join("\n")).not.toBe(expanded);
|
|
1054
|
+
action.onMouse(mouse("press", 0, 4, 0));
|
|
1055
|
+
action.onMouse(mouse("release", 0, 4, 0));
|
|
1056
|
+
expect(stripTerminalSequences(activity.render(60).join("\n"))).not.toContain('Unknown skill "missing"');
|
|
1057
|
+
activity.dispose();
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
test("live actions share a disposable animation capability", () => {
|
|
1061
|
+
const live = new LiveToolAction({
|
|
1062
|
+
theme,
|
|
1063
|
+
view: { verb: "Streaming", status: "running", marker: false },
|
|
1064
|
+
requestRender() {},
|
|
1065
|
+
reducedMotion: true,
|
|
1066
|
+
});
|
|
1067
|
+
expect(stripTerminalSequences(live.render(40)[0]!)).toContain("Streaming");
|
|
1068
|
+
live.dispose();
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
test("live actions render the configured shared activity style", () => {
|
|
1072
|
+
configureTuiAppearance({ activityIndicator: "off", textEffect: "glow" });
|
|
1073
|
+
const live = new LiveToolAction({
|
|
1074
|
+
theme,
|
|
1075
|
+
view: { verb: "Streaming", status: "running", marker: false },
|
|
1076
|
+
requestRender() {},
|
|
1077
|
+
});
|
|
1078
|
+
expect(stripTerminalSequences(live.render(40)[0]!)).toStartWith("Streaming");
|
|
1079
|
+
configureTuiAppearance({ activityIndicator: "static", pulseEffect: "color" });
|
|
1080
|
+
expect(stripTerminalSequences(live.render(40)[0]!)).toStartWith("● Streaming");
|
|
1081
|
+
live.dispose();
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test("bounds hidden text in animated actions and output disclosure", () => {
|
|
1085
|
+
const activity = new ToolActivity({
|
|
1086
|
+
theme,
|
|
1087
|
+
requestRender() {},
|
|
1088
|
+
view: {
|
|
1089
|
+
action: { verb: `Run ${"🙂\u0301".repeat(50_000)}`, status: "running" },
|
|
1090
|
+
running: true,
|
|
1091
|
+
payload: { kind: "text", text: "x".repeat(100_000), revision: 1 },
|
|
1092
|
+
},
|
|
1093
|
+
});
|
|
1094
|
+
const rendered = activity.render(40);
|
|
1095
|
+
|
|
1096
|
+
expect(rendered.every((line) => visibleWidth(line) <= 40)).toBe(true);
|
|
1097
|
+
expect(stripTerminalSequences(rendered[0] ?? "")).toContain("Run");
|
|
1098
|
+
expect(stripTerminalSequences(rendered.join("\n"))).toContain("omitted");
|
|
1099
|
+
activity.dispose();
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
function escapeRegExp(value: string): string {
|
|
1104
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function component(text: string) {
|
|
1108
|
+
return { render: () => text.split("\n"), invalidate() {} };
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function disposableComponent(text: string, dispose: () => void) {
|
|
1112
|
+
return { ...component(text), dispose };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function renderAction(view: ToolActionView): string {
|
|
1116
|
+
return stripTerminalSequences(new ToolAction({ theme, view }).render(80)[0]!);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function mouse(type: TuiMouseEvent["type"], row: number, col: number, button?: 0 | 1 | 2): TuiMouseEvent {
|
|
1120
|
+
return {
|
|
1121
|
+
type,
|
|
1122
|
+
row,
|
|
1123
|
+
col,
|
|
1124
|
+
screenRow: row,
|
|
1125
|
+
screenCol: col,
|
|
1126
|
+
button,
|
|
1127
|
+
wheel: undefined,
|
|
1128
|
+
shift: false,
|
|
1129
|
+
alt: false,
|
|
1130
|
+
ctrl: false,
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
describe("tool transcript body indent", () => {
|
|
1135
|
+
afterEach(() => configureTuiAppearance(DEFAULT_TUI_APPEARANCE));
|
|
1136
|
+
|
|
1137
|
+
test("indents payload rows, narrows their width, and translates pointer columns", () => {
|
|
1138
|
+
const events: TuiMouseEvent[] = [];
|
|
1139
|
+
const body: Component & { onMouse(event: TuiMouseEvent): boolean } = {
|
|
1140
|
+
render: (width) => ["x".repeat(width)],
|
|
1141
|
+
invalidate() {},
|
|
1142
|
+
onMouse(event) {
|
|
1143
|
+
events.push(event);
|
|
1144
|
+
return true;
|
|
1145
|
+
},
|
|
1146
|
+
};
|
|
1147
|
+
const transcript = new ToolTranscript({
|
|
1148
|
+
theme,
|
|
1149
|
+
view: { verb: "Ran", status: "succeeded" },
|
|
1150
|
+
body: [body],
|
|
1151
|
+
bodyIndent: 4,
|
|
1152
|
+
});
|
|
1153
|
+
const rendered = transcript.render(20).map((line) => stripTerminalSequences(line));
|
|
1154
|
+
expect(rendered[1]).toBe(` ${"x".repeat(16)}`);
|
|
1155
|
+
|
|
1156
|
+
transcript.onMouse(mouse("press", 1, 9, 0));
|
|
1157
|
+
expect(events.at(-1)).toMatchObject({ type: "press", row: 0, col: 5 });
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
test("activity payloads wrap at the indented width and fold from the indented omission row", () => {
|
|
1161
|
+
const activity = new ToolActivity({
|
|
1162
|
+
theme,
|
|
1163
|
+
requestRender() {},
|
|
1164
|
+
bodyIndent: 4,
|
|
1165
|
+
view: {
|
|
1166
|
+
action: { verb: "Ran", status: "succeeded" },
|
|
1167
|
+
payload: {
|
|
1168
|
+
kind: "text",
|
|
1169
|
+
text: Array.from({ length: 12 }, (_, index) => `row ${index}`).join("\n"),
|
|
1170
|
+
revision: 1,
|
|
1171
|
+
},
|
|
1172
|
+
},
|
|
1173
|
+
});
|
|
1174
|
+
const rendered = activity.render(40).map((line) => stripTerminalSequences(line).trimEnd());
|
|
1175
|
+
expect(rendered.slice(1).every((line) => line.startsWith(" "))).toBe(true);
|
|
1176
|
+
expect(rendered.slice(1).every((line) => visibleWidth(line) <= 40)).toBe(true);
|
|
1177
|
+
const omissionRow = rendered.findIndex((line) => line.includes("rows omitted"));
|
|
1178
|
+
expect(omissionRow).toBeGreaterThan(0);
|
|
1179
|
+
|
|
1180
|
+
expect(activity.onMouse(mouse("press", omissionRow, 6, 0))).toBe(true);
|
|
1181
|
+
activity.onMouse(mouse("release", omissionRow, 6, 0));
|
|
1182
|
+
expect(
|
|
1183
|
+
activity
|
|
1184
|
+
.render(40)
|
|
1185
|
+
.map((line) => stripTerminalSequences(line))
|
|
1186
|
+
.join("\n"),
|
|
1187
|
+
).not.toContain("rows omitted");
|
|
1188
|
+
activity.dispose();
|
|
1189
|
+
});
|
|
1190
|
+
});
|