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,134 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
ensureSplitPaneRegistry,
|
|
5
|
+
mountSplitPane,
|
|
6
|
+
SPLIT_PANE_PROTOCOL,
|
|
7
|
+
type SplitPaneDefinition,
|
|
8
|
+
} from "../src/split-pane.ts";
|
|
9
|
+
|
|
10
|
+
// type-boundary: Runtime-validation fixtures deliberately cross the split-pane contribution boundary.
|
|
11
|
+
type InvalidDefinitionBoundary = unknown;
|
|
12
|
+
|
|
13
|
+
function component(label: string): Component {
|
|
14
|
+
return {
|
|
15
|
+
render: (width) => [`${label}:${width}`],
|
|
16
|
+
invalidate() {},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function definition(overrides: Partial<SplitPaneDefinition> = {}): SplitPaneDefinition {
|
|
21
|
+
return {
|
|
22
|
+
id: "test.pane",
|
|
23
|
+
position: "right",
|
|
24
|
+
component: () => component("pane"),
|
|
25
|
+
size: 24,
|
|
26
|
+
minMainSize: 40,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("split-pane registry", () => {
|
|
32
|
+
test("is load-order independent and normalizes the active contribution", () => {
|
|
33
|
+
const scope = Object.create(null) as typeof globalThis;
|
|
34
|
+
const registry = ensureSplitPaneRegistry(scope);
|
|
35
|
+
|
|
36
|
+
expect(ensureSplitPaneRegistry(scope)).toBe(registry);
|
|
37
|
+
expect(registry.protocol).toBe(SPLIT_PANE_PROTOCOL);
|
|
38
|
+
expect(registry.version).toBe(2);
|
|
39
|
+
expect(registry.current()).toBeUndefined();
|
|
40
|
+
|
|
41
|
+
const source = definition();
|
|
42
|
+
const remove = mountSplitPane(source, scope);
|
|
43
|
+
expect(registry.current()).toEqual({ ...source, gap: 1, priority: 0 });
|
|
44
|
+
expect(registry.current()).not.toBe(source);
|
|
45
|
+
|
|
46
|
+
remove();
|
|
47
|
+
expect(registry.current()).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("selects the highest priority and the latest mount among ties", () => {
|
|
51
|
+
const registry = ensureSplitPaneRegistry(Object.create(null) as typeof globalThis);
|
|
52
|
+
const changes: string[] = [];
|
|
53
|
+
registry.subscribe(() => changes.push(registry.current()?.id ?? "none"));
|
|
54
|
+
const removeHigh = registry.mount(definition({ id: "high", priority: 10 }));
|
|
55
|
+
const removeLow = registry.mount(definition({ id: "low", priority: -1 }));
|
|
56
|
+
const removeHighTie = registry.mount(definition({ id: "high-tie", priority: 10 }));
|
|
57
|
+
|
|
58
|
+
expect(registry.current()?.id).toBe("high-tie");
|
|
59
|
+
expect(changes).toEqual(["high", "high-tie"]);
|
|
60
|
+
removeLow();
|
|
61
|
+
expect(changes).toEqual(["high", "high-tie"]);
|
|
62
|
+
removeHighTie();
|
|
63
|
+
expect(registry.current()?.id).toBe("high");
|
|
64
|
+
removeHigh();
|
|
65
|
+
expect(changes).toEqual(["high", "high-tie", "high", "none"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("uses the newest lease and reveals the previous pane when it is released", () => {
|
|
69
|
+
const registry = ensureSplitPaneRegistry(Object.create(null) as typeof globalThis);
|
|
70
|
+
const changes: Array<string | undefined> = [];
|
|
71
|
+
registry.subscribe(() => changes.push(registry.current()?.id));
|
|
72
|
+
const removeFirst = registry.mount(definition({ id: "first", position: "left", gap: 0 }));
|
|
73
|
+
const first = registry.current();
|
|
74
|
+
const removeSecond = registry.mount(definition({ id: "second", size: 30 }));
|
|
75
|
+
|
|
76
|
+
expect(registry.current()?.id).toBe("second");
|
|
77
|
+
removeFirst();
|
|
78
|
+
expect(registry.current()?.id).toBe("second");
|
|
79
|
+
expect(changes).toEqual(["first", "second"]);
|
|
80
|
+
|
|
81
|
+
removeSecond();
|
|
82
|
+
expect(registry.current()).toBeUndefined();
|
|
83
|
+
expect(changes).toEqual(["first", "second", undefined]);
|
|
84
|
+
|
|
85
|
+
// Disposers are identity-safe and idempotent even after another lease used the same id.
|
|
86
|
+
const removeReplacement = registry.mount(definition({ id: "first", size: 18 }));
|
|
87
|
+
removeFirst();
|
|
88
|
+
expect(registry.current()?.size).toBe(18);
|
|
89
|
+
removeReplacement();
|
|
90
|
+
expect(first?.id).toBe("first");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("notifies independent listeners and isolates listener failures", () => {
|
|
94
|
+
const registry = ensureSplitPaneRegistry(Object.create(null) as typeof globalThis);
|
|
95
|
+
const calls: string[] = [];
|
|
96
|
+
registry.subscribe(() => {
|
|
97
|
+
throw new Error("optional listener failed");
|
|
98
|
+
});
|
|
99
|
+
const unsubscribe = registry.subscribe(() => calls.push(registry.current()?.id ?? "none"));
|
|
100
|
+
|
|
101
|
+
const remove = registry.mount(definition({ id: "visible" }));
|
|
102
|
+
expect(calls).toEqual(["visible"]);
|
|
103
|
+
unsubscribe();
|
|
104
|
+
unsubscribe();
|
|
105
|
+
remove();
|
|
106
|
+
expect(calls).toEqual(["visible"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("rejects malformed identifiers, positions, factories, and geometry", () => {
|
|
110
|
+
const registry = ensureSplitPaneRegistry(Object.create(null) as typeof globalThis);
|
|
111
|
+
const invalid: InvalidDefinitionBoundary[] = [
|
|
112
|
+
definition({ id: " " }),
|
|
113
|
+
{ ...definition(), position: "top" },
|
|
114
|
+
{ ...definition(), component: "not a factory" },
|
|
115
|
+
definition({ size: 0 }),
|
|
116
|
+
definition({ size: 1.5 }),
|
|
117
|
+
definition({ initialRatio: 0 }),
|
|
118
|
+
definition({ initialRatio: 1 }),
|
|
119
|
+
definition({ initialRatio: Number.NaN }),
|
|
120
|
+
{ ...definition(), onResize: "not a callback" },
|
|
121
|
+
definition({ minMainSize: 0 }),
|
|
122
|
+
definition({ minMainSize: Number.POSITIVE_INFINITY }),
|
|
123
|
+
definition({ gap: -1 }),
|
|
124
|
+
definition({ gap: 0.5 }),
|
|
125
|
+
definition({ priority: 0.5 }),
|
|
126
|
+
definition({ priority: Number.POSITIVE_INFINITY }),
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
for (const candidate of invalid) {
|
|
130
|
+
expect(() => registry.mount(candidate as SplitPaneDefinition)).toThrow();
|
|
131
|
+
expect(registry.current()).toBeUndefined();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { statusPresentationFrame } from "../src/status-presentation.ts";
|
|
5
|
+
import { TUI_STATUS_PRESENTATION_OPTIONS, type TuiStatusPresentationStyle } from "../src/appearance.ts";
|
|
6
|
+
import { tuiTheme } from "../src/color/theme.ts";
|
|
7
|
+
|
|
8
|
+
const theme = {
|
|
9
|
+
name: "status-presentation-test",
|
|
10
|
+
getColorMode: () => "truecolor",
|
|
11
|
+
getFgAnsi: (token: string) => (token === "accent" ? "\x1b[38;2;80;160;240m" : "\x1b[38;2;180;180;180m"),
|
|
12
|
+
getBgAnsi: () => "\x1b[48;2;20;20;20m",
|
|
13
|
+
} as never as Theme;
|
|
14
|
+
|
|
15
|
+
const colors = tuiTheme(theme);
|
|
16
|
+
const activeStyles = TUI_STATUS_PRESENTATION_OPTIONS.map((option) => option.value).filter(
|
|
17
|
+
(style): style is Exclude<TuiStatusPresentationStyle, "standard"> => style !== "standard",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
describe("standalone status presentation", () => {
|
|
21
|
+
test.each(activeStyles)("renders %s within the requested row width", (style) => {
|
|
22
|
+
for (const width of [10, 24, 48]) {
|
|
23
|
+
const rendered = statusPresentationFrame(colors, style, 500, width);
|
|
24
|
+
expect(rendered).not.toBe("");
|
|
25
|
+
expect(visibleWidth(rendered)).toBeLessThanOrEqual(width);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test.each(activeStyles)("animates %s across real repaint samples", (style) => {
|
|
30
|
+
const frames = [0, 180, 420, 900, 1_800].map((elapsedMs) => statusPresentationFrame(colors, style, elapsedMs, 40));
|
|
31
|
+
expect(new Set(frames).size).toBeGreaterThan(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test.each(activeStyles)("holds %s still for reduced motion", (style) => {
|
|
35
|
+
const first = statusPresentationFrame(colors, style, 0, 40, { reducedMotion: true });
|
|
36
|
+
const later = statusPresentationFrame(colors, style, 4_000, 40, { reducedMotion: true });
|
|
37
|
+
expect(later).toBe(first);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { highlightSyntaxBlock, whenSyntaxReady } from "../src/syntax.ts";
|
|
3
|
+
|
|
4
|
+
beforeAll(async () => {
|
|
5
|
+
await new Promise<void>((resolve) => whenSyntaxReady(resolve));
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe("syntax highlighting", () => {
|
|
9
|
+
test("adapts Shiki tokens into semantic spans without changing source text", () => {
|
|
10
|
+
const source = "const Crab: usize = 42; // safe 🦀";
|
|
11
|
+
const [spans] = highlightSyntaxBlock(source, "src/main.rs");
|
|
12
|
+
|
|
13
|
+
expect(spans?.map((span) => span.text).join("")).toBe(source);
|
|
14
|
+
expect(spans?.filter((span) => span.foreground !== undefined).length).toBeGreaterThan(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("falls back to Shiki plain text for unsupported filenames", () => {
|
|
18
|
+
const source = "ordinary words # remain ordinary";
|
|
19
|
+
const [spans] = highlightSyntaxBlock(source, "notes.unknown-language");
|
|
20
|
+
|
|
21
|
+
expect(spans?.map((span) => span.text).join("")).toBe(source);
|
|
22
|
+
expect(spans?.some((span) => span.emphasized)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("keeps JSON punctuation and values on the shared syntax path", () => {
|
|
26
|
+
const source = '{"value": true}';
|
|
27
|
+
const [spans] = highlightSyntaxBlock(source, ".json");
|
|
28
|
+
|
|
29
|
+
expect(spans?.map((span) => span.text).join("")).toBe(source);
|
|
30
|
+
expect(spans?.some((span) => span.text.includes('"value"') && span.foreground !== undefined)).toBe(true);
|
|
31
|
+
expect(spans?.some((span) => span.text.includes("true") && span.foreground !== undefined)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("uses shared shell spans for command syntax", () => {
|
|
35
|
+
const source = `echo "$HOME" && printf '%s\\n' ok`;
|
|
36
|
+
const [spans] = highlightSyntaxBlock(source, "script.sh");
|
|
37
|
+
|
|
38
|
+
expect(spans?.map((span) => span.text).join("")).toBe(source);
|
|
39
|
+
expect(spans?.filter((span) => span.foreground !== undefined).length).toBeGreaterThan(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("keeps shell heredoc state across lines", () => {
|
|
43
|
+
const source = "cat <<EOF\nhello world\nEOF\necho done";
|
|
44
|
+
const lines = highlightSyntaxBlock(source, "script.sh");
|
|
45
|
+
|
|
46
|
+
expect(lines.map((line) => line.map((span) => span.text).join("")).join("\n")).toBe(source);
|
|
47
|
+
expect(lines[1]).toHaveLength(1);
|
|
48
|
+
expect(lines[1]?.[0]?.foreground).toEqual(lines[2]?.[0]?.foreground);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("keeps JavaScript template strings open across lines", () => {
|
|
52
|
+
const source = "const message = `first\nsecond`;\nconsole.log(message);";
|
|
53
|
+
const lines = highlightSyntaxBlock(source, "message.js");
|
|
54
|
+
|
|
55
|
+
expect(lines.map((line) => line.map((span) => span.text).join("")).join("\n")).toBe(source);
|
|
56
|
+
expect(lines[1]?.[0]?.text).toBe("second`");
|
|
57
|
+
expect(lines[1]?.[0]?.foreground).toEqual(lines[0]?.at(-1)?.foreground);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { TUI, TuiInputListener } from "@earendil-works/pi-tui";
|
|
3
|
+
import { rgb } from "../src/color/palette.ts";
|
|
4
|
+
import { measureTerminalColors, terminalColorsRegistry } from "../src/terminal-colors.ts";
|
|
5
|
+
|
|
6
|
+
afterEach(() => terminalColorsRegistry().publish(undefined));
|
|
7
|
+
|
|
8
|
+
describe("terminal color detection", () => {
|
|
9
|
+
test("rejects stale or malformed cross-realm profiles", () => {
|
|
10
|
+
expect(() => terminalColorsRegistry().publish({ scheme: "dark", harmonious: true } as never)).toThrow(
|
|
11
|
+
"Invalid pi-libtui terminal color measurements",
|
|
12
|
+
);
|
|
13
|
+
expect(() =>
|
|
14
|
+
terminalColorsRegistry().publish({
|
|
15
|
+
defaultBackground: { r: 1.5, g: 2, b: 3 },
|
|
16
|
+
indexedPalette: "custom",
|
|
17
|
+
scheme: "dark",
|
|
18
|
+
}),
|
|
19
|
+
).toThrow("Invalid pi-libtui terminal color measurements");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("publishes an immutable snapshot of measured colors", () => {
|
|
23
|
+
const defaultBackground = { r: 10, g: 20, b: 30 };
|
|
24
|
+
const ansiBase16 = Array.from({ length: 16 }, () => ({ r: 40, g: 50, b: 60 }));
|
|
25
|
+
terminalColorsRegistry().publish({
|
|
26
|
+
defaultBackground,
|
|
27
|
+
ansiBase16,
|
|
28
|
+
indexedPalette: "custom",
|
|
29
|
+
scheme: "dark",
|
|
30
|
+
});
|
|
31
|
+
defaultBackground.r = 200;
|
|
32
|
+
ansiBase16[0]!.g = 200;
|
|
33
|
+
expect(terminalColorsRegistry().current()?.defaultBackground).toEqual(rgb(10, 20, 30));
|
|
34
|
+
expect(terminalColorsRegistry().current()?.ansiBase16?.[0]).toEqual(rgb(40, 50, 60));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("detects a harmonious palette without consuming ordinary input", async () => {
|
|
38
|
+
let listener: TuiInputListener | undefined;
|
|
39
|
+
const writes: string[] = [];
|
|
40
|
+
const tui = {
|
|
41
|
+
terminal: {
|
|
42
|
+
write(data: string) {
|
|
43
|
+
writes.push(data);
|
|
44
|
+
if (!data.includes("]10;?")) return;
|
|
45
|
+
listener?.("\x1b]10;rgb:ee/ee/ee\x1b\\");
|
|
46
|
+
listener?.("\x1b]4;16;rgb:1111/1111/1111\x1b\\");
|
|
47
|
+
listener?.("\x1b]4;231;rgb:eeee/eeee/eeee\x1b\\");
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
addInputListener(next: TuiInputListener) {
|
|
51
|
+
listener = next;
|
|
52
|
+
return () => {
|
|
53
|
+
listener = undefined;
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
queryTerminalBackgroundColor: async () => rgb(17, 17, 17),
|
|
57
|
+
queryTerminalColorScheme: async () => "dark",
|
|
58
|
+
} as never as TUI;
|
|
59
|
+
const profile = await measureTerminalColors(tui, 10);
|
|
60
|
+
expect(profile.scheme).toBe("dark");
|
|
61
|
+
expect(writes.some((value) => value.includes("]4;231;?"))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("frames split and batched replies while preserving residual input and consuming DA1", async () => {
|
|
65
|
+
let listener: TuiInputListener | undefined;
|
|
66
|
+
const listenerResults: ReturnType<TuiInputListener>[] = [];
|
|
67
|
+
const tui = {
|
|
68
|
+
terminal: {
|
|
69
|
+
write(data: string) {
|
|
70
|
+
if (!data.includes("]10;?")) return;
|
|
71
|
+
listenerResults.push(listener?.("\x1b]10;rgb:aaaa/") as ReturnType<TuiInputListener>);
|
|
72
|
+
listenerResults.push(
|
|
73
|
+
listener?.(
|
|
74
|
+
"bbbb/cccc\x1b\\left\x1b]4;16;rgb:1111/2222/3333\x07\x1b]4;231;rgb:dddd/eeee/ffff\x1b\\\x1b[?1;2cright",
|
|
75
|
+
) as ReturnType<TuiInputListener>,
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
addInputListener(next: TuiInputListener) {
|
|
80
|
+
listener = next;
|
|
81
|
+
return () => {
|
|
82
|
+
listener = undefined;
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
queryTerminalBackgroundColor: async () => rgb(17, 17, 17),
|
|
86
|
+
queryTerminalColorScheme: async () => "dark",
|
|
87
|
+
} as never as TUI;
|
|
88
|
+
|
|
89
|
+
const profile = await measureTerminalColors(tui, 20);
|
|
90
|
+
expect(profile.defaultForeground).toEqual(rgb(170, 187, 204));
|
|
91
|
+
expect(profile.indexedPalette).toBe("custom");
|
|
92
|
+
expect(profile).not.toHaveProperty("indexed16");
|
|
93
|
+
expect(profile).not.toHaveProperty("indexed231");
|
|
94
|
+
expect(listenerResults).toEqual([{ consume: true }, { data: "leftright" }]);
|
|
95
|
+
expect(listener).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("quarantines late color replies after timeout without swallowing adjacent input", async () => {
|
|
99
|
+
let listener: TuiInputListener | undefined;
|
|
100
|
+
const tui = {
|
|
101
|
+
terminal: { write() {} },
|
|
102
|
+
addInputListener(next: TuiInputListener) {
|
|
103
|
+
listener = next;
|
|
104
|
+
return () => {
|
|
105
|
+
listener = undefined;
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
queryTerminalBackgroundColor: async () => undefined,
|
|
109
|
+
queryTerminalColorScheme: async () => undefined,
|
|
110
|
+
} as never as TUI;
|
|
111
|
+
|
|
112
|
+
await measureTerminalColors(tui, 10);
|
|
113
|
+
expect(listener?.("\x1b]10;rgb:11/22/33\x1b\\typed")).toEqual({ data: "typed" });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { stripTopLevelZoneMarkers } from "../src/terminal/embedding.ts";
|
|
3
|
+
|
|
4
|
+
test("removes transcript zones embedded after another column", () => {
|
|
5
|
+
expect(
|
|
6
|
+
stripTopLevelZoneMarkers(["agent │ \x1b]133;A\x07\x1b[48;2;40;46;68mmessage\x1b]133;B\x1b\\\x1b]133;C\x07"]),
|
|
7
|
+
).toEqual(["agent │ \x1b[48;2;40;46;68mmessage"]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("preserves renderer styling and content", () => {
|
|
11
|
+
expect(stripTopLevelZoneMarkers(["\x1b[32mtool output\x1b[0m"])).toEqual(["\x1b[32mtool output\x1b[0m"]);
|
|
12
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CURSOR_MARKER, stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
3
|
+
import { TerminalOutput } from "../src/terminal/output.ts";
|
|
4
|
+
import { type ProjectionScheduler, type ProjectionTimer, TerminalProjection } from "../src/terminal/projection.ts";
|
|
5
|
+
import { findCursorPresentation } from "../src/cursor.ts";
|
|
6
|
+
|
|
7
|
+
interface ScheduledTask {
|
|
8
|
+
at: number;
|
|
9
|
+
callback: () => void;
|
|
10
|
+
cancelled: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class FakeScheduler implements ProjectionScheduler {
|
|
14
|
+
currentTime = 1_000;
|
|
15
|
+
readonly tasks: ScheduledTask[] = [];
|
|
16
|
+
|
|
17
|
+
now(): number {
|
|
18
|
+
return this.currentTime;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
schedule(callback: () => void, delayMs: number): ProjectionTimer {
|
|
22
|
+
const task = { at: this.currentTime + delayMs, callback, cancelled: false };
|
|
23
|
+
this.tasks.push(task);
|
|
24
|
+
return { dispose: () => (task.cancelled = true) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
advance(milliseconds = 0): void {
|
|
28
|
+
this.currentTime += milliseconds;
|
|
29
|
+
for (;;) {
|
|
30
|
+
const task = this.tasks.find((candidate) => !candidate.cancelled && candidate.at <= this.currentTime);
|
|
31
|
+
if (!task) return;
|
|
32
|
+
task.cancelled = true;
|
|
33
|
+
task.callback();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get pending(): number {
|
|
38
|
+
return this.tasks.filter((task) => !task.cancelled).length;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function write(projection: TerminalProjection, data: string): Promise<void> {
|
|
43
|
+
projection.write(data);
|
|
44
|
+
await projection.drain();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("TerminalOutput consumes cumulative append snapshots without retaining or replaying their prefix", async () => {
|
|
48
|
+
const output = new TerminalOutput({ requestRender() {}, cols: 40, rows: 4, maxRows: 4 });
|
|
49
|
+
output.appendCumulative("progress 10%\r");
|
|
50
|
+
output.appendCumulative("progress 10%\rprogress 100%\n");
|
|
51
|
+
await output.drain();
|
|
52
|
+
expect(output.render(40)).toEqual(["progress 100%"]);
|
|
53
|
+
output.dispose();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("TerminalOutput renders the initial PTY snapshot during a synchronous repaint", () => {
|
|
57
|
+
const output = new TerminalOutput({ requestRender() {}, cols: 40, rows: 4, maxRows: 4 });
|
|
58
|
+
output.setText("progress 10%\rprogress 100%\n");
|
|
59
|
+
|
|
60
|
+
expect(output.render(40).map((line) => stripTerminalSequences(line))).toEqual(["progress 100%"]);
|
|
61
|
+
output.dispose();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("TerminalOutput preserves PTY state when a caller selects cumulative-tail mode", async () => {
|
|
65
|
+
const output = new TerminalOutput({ requestRender() {}, cols: 40, rows: 4, maxRows: 4 });
|
|
66
|
+
const cumulative = `\x1b[31m${"red ".repeat(80)}`;
|
|
67
|
+
output.appendCumulative(cumulative);
|
|
68
|
+
await output.drain();
|
|
69
|
+
output.appendCumulativeTail(cumulative.slice(-100));
|
|
70
|
+
await output.drain();
|
|
71
|
+
|
|
72
|
+
const rendered = output.render(40);
|
|
73
|
+
expect(rendered.some((line) => line.includes("31m"))).toBe(true);
|
|
74
|
+
output.dispose();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("TerminalOutput always replaces stale output even when replacement starts with an old tail", async () => {
|
|
78
|
+
const output = new TerminalOutput({ requestRender() {}, cols: 40, rows: 4, maxRows: 4 });
|
|
79
|
+
output.setText(`stale output\nshared-prefix-123456`);
|
|
80
|
+
output.setText("shared-prefix-123456\nauthoritative output");
|
|
81
|
+
await output.drain();
|
|
82
|
+
|
|
83
|
+
const rendered = output.render(40).join("\n");
|
|
84
|
+
expect(rendered).toContain("authoritative output");
|
|
85
|
+
expect(rendered).not.toContain("stale output");
|
|
86
|
+
output.dispose();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("TerminalOutput appends a complete cumulative replacement without replaying its prefix", async () => {
|
|
90
|
+
const output = new TerminalOutput({ requestRender() {}, cols: 40, rows: 4, maxRows: 4 });
|
|
91
|
+
output.appendCumulative("one\r\n");
|
|
92
|
+
output.setText("one\r\ntwo");
|
|
93
|
+
await output.drain();
|
|
94
|
+
|
|
95
|
+
expect(output.render(40).join("\n")).toContain("one\ntwo");
|
|
96
|
+
output.dispose();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("TerminalOutput exposes an omitted-prefix row for bounded history", async () => {
|
|
100
|
+
const output = new TerminalOutput({ requestRender() {}, cols: 40, rows: 8, maxRows: 3 });
|
|
101
|
+
output.setText("one\r\ntwo\r\nthree\r\nfour\r\nfive");
|
|
102
|
+
await output.drain();
|
|
103
|
+
|
|
104
|
+
expect(output.render(40).map((line) => stripTerminalSequences(line).trimEnd())).toEqual([
|
|
105
|
+
"… 3 rows omitted …",
|
|
106
|
+
"four",
|
|
107
|
+
"five",
|
|
108
|
+
]);
|
|
109
|
+
expect(output.getOmissionRow()).toBe(0);
|
|
110
|
+
output.dispose();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("TerminalOutput delegates burst repaint throttling to its projection", async () => {
|
|
114
|
+
let renders = 0;
|
|
115
|
+
const output = new TerminalOutput({ requestRender: () => renders++, cols: 40, rows: 4, maxRows: 4 });
|
|
116
|
+
let cumulative = "";
|
|
117
|
+
for (let index = 0; index < 100; index++) {
|
|
118
|
+
cumulative += "x";
|
|
119
|
+
output.appendCumulative(cumulative);
|
|
120
|
+
}
|
|
121
|
+
await output.drain();
|
|
122
|
+
|
|
123
|
+
// The burst reaches the host in one microtask-coalesced repaint.
|
|
124
|
+
expect(renders).toBe(1);
|
|
125
|
+
expect(output.render(40).join("\n")).toContain("x".repeat(40));
|
|
126
|
+
output.dispose();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("TerminalOutput never repaints after disposal", async () => {
|
|
130
|
+
let renders = 0;
|
|
131
|
+
const output = new TerminalOutput({ requestRender: () => renders++, cols: 40, rows: 4 });
|
|
132
|
+
output.setText("queued");
|
|
133
|
+
output.dispose();
|
|
134
|
+
await Bun.sleep(0);
|
|
135
|
+
expect(renders).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("TerminalProjection", () => {
|
|
139
|
+
test("hard-bounds dimensions, scrollback, and rendered history", async () => {
|
|
140
|
+
const projection = new TerminalProjection({
|
|
141
|
+
requestRender() {},
|
|
142
|
+
cols: 1_000_000,
|
|
143
|
+
rows: 1_000_000,
|
|
144
|
+
scrollback: 1_000_000,
|
|
145
|
+
});
|
|
146
|
+
expect(projection.cols).toBe(500);
|
|
147
|
+
expect(projection.rows).toBe(200);
|
|
148
|
+
projection.write("one\ntwo\nthree");
|
|
149
|
+
await projection.drain();
|
|
150
|
+
expect(projection.renderLines({ includeScrollback: true, maxRows: 2 })).toHaveLength(2);
|
|
151
|
+
projection.dispose();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("projects carriage return, backspace, erase, and cursor movement", async () => {
|
|
155
|
+
const projection = new TerminalProjection({ requestRender() {}, cols: 8, rows: 3 });
|
|
156
|
+
await write(projection, "abc\rZ\bY");
|
|
157
|
+
expect(projection.renderLines()[0]).toBe(`Y${CURSOR_MARKER}bc`);
|
|
158
|
+
|
|
159
|
+
await write(projection, "\r\x1b[3C\x1b[K");
|
|
160
|
+
expect(projection.renderLines()[0]).toBe(`Ybc${CURSOR_MARKER}`);
|
|
161
|
+
|
|
162
|
+
await write(projection, "\x1b[2;3HX");
|
|
163
|
+
expect(projection.renderLines()[1]).toBe(` X${CURSOR_MARKER}`);
|
|
164
|
+
projection.dispose();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("tracks whether the projected application requested terminal focus events", async () => {
|
|
168
|
+
const projection = new TerminalProjection({ requestRender() {} });
|
|
169
|
+
expect(projection.acceptsFocusEvents).toBe(false);
|
|
170
|
+
await write(projection, "\x1b[?1004h");
|
|
171
|
+
expect(projection.acceptsFocusEvents).toBe(true);
|
|
172
|
+
await write(projection, "\x1b[?1004l");
|
|
173
|
+
expect(projection.acceptsFocusEvents).toBe(false);
|
|
174
|
+
projection.dispose();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("projects the embedded application's cursor visibility and native shape", async () => {
|
|
178
|
+
const projection = new TerminalProjection({ requestRender() {}, cols: 8, rows: 2 });
|
|
179
|
+
await write(projection, "copy\x1b[1 q");
|
|
180
|
+
expect(findCursorPresentation(projection.renderLines(), 2)).toEqual({ style: "blinking-block" });
|
|
181
|
+
|
|
182
|
+
await write(projection, "\x1b[?25l");
|
|
183
|
+
expect(projection.renderLines().join("\n")).not.toContain(CURSOR_MARKER);
|
|
184
|
+
|
|
185
|
+
await write(projection, "\x1b[5 q\x1b[?25h");
|
|
186
|
+
expect(findCursorPresentation(projection.renderLines(), 2)).toEqual({ style: "blinking-bar" });
|
|
187
|
+
projection.dispose();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("preserves wide glyph cell geometry", async () => {
|
|
191
|
+
const projection = new TerminalProjection({ requestRender() {}, cols: 8, rows: 2 });
|
|
192
|
+
await write(projection, "界x");
|
|
193
|
+
expect(projection.renderLines()[0]).toBe(`界x${CURSOR_MARKER}`);
|
|
194
|
+
projection.dispose();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("replays only cell SGR and drops terminal side effects", async () => {
|
|
198
|
+
const projection = new TerminalProjection({ requestRender() {}, cols: 12, rows: 2 });
|
|
199
|
+
await write(projection, "\x1b]2;secret title\x07\x1b]52;c;c2VjcmV0\x07\x1b[31;1mred\x1b[0m");
|
|
200
|
+
const rendered = projection.renderLines({ cursor: false })[0] ?? "";
|
|
201
|
+
|
|
202
|
+
expect(rendered).toContain("\x1b[0;1;31mred\x1b[0m");
|
|
203
|
+
expect(rendered).not.toContain("secret");
|
|
204
|
+
expect(rendered).not.toContain("\x1b]");
|
|
205
|
+
projection.dispose();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("bounds scrollback and reflows on resize", async () => {
|
|
209
|
+
const projection = new TerminalProjection({ requestRender() {}, cols: 6, rows: 2, scrollback: 2 });
|
|
210
|
+
await write(projection, "1\r\n2\r\n3\r\n4\r\n5");
|
|
211
|
+
const history = projection.renderLines({ includeScrollback: true, cursor: false });
|
|
212
|
+
|
|
213
|
+
expect(history.length).toBeLessThanOrEqual(4);
|
|
214
|
+
expect(history.at(-1)).toBe("5");
|
|
215
|
+
expect(projection.resize(3, 3)).toBe(true);
|
|
216
|
+
expect(projection.resize(3, 3)).toBe(false);
|
|
217
|
+
expect(projection).toMatchObject({ cols: 3, rows: 3 });
|
|
218
|
+
projection.dispose();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("reuses projected cells until terminal state or dimensions change", async () => {
|
|
222
|
+
const projection = new TerminalProjection({ requestRender() {}, cols: 20, rows: 2 });
|
|
223
|
+
await write(projection, "stable");
|
|
224
|
+
const first = projection.renderLines({ includeScrollback: true, cursor: false });
|
|
225
|
+
expect(projection.renderLines({ includeScrollback: true, cursor: false })).toBe(first);
|
|
226
|
+
expect(projection.renderLines({ includeScrollback: false, cursor: false })).not.toBe(first);
|
|
227
|
+
projection.resize(10, 2);
|
|
228
|
+
expect(projection.renderLines({ includeScrollback: true, cursor: false })).not.toBe(first);
|
|
229
|
+
projection.dispose();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("coalesces burst repaint requests without sleeping", async () => {
|
|
233
|
+
const scheduler = new FakeScheduler();
|
|
234
|
+
let renders = 0;
|
|
235
|
+
const projection = new TerminalProjection({
|
|
236
|
+
requestRender: () => (renders += 1),
|
|
237
|
+
cols: 20,
|
|
238
|
+
rows: 2,
|
|
239
|
+
repaintIntervalMs: 40,
|
|
240
|
+
scheduler,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
for (let index = 0; index < 1_000; index += 1) projection.write(String(index % 10));
|
|
244
|
+
await projection.drain();
|
|
245
|
+
expect(scheduler.pending).toBe(1);
|
|
246
|
+
scheduler.advance();
|
|
247
|
+
expect(renders).toBe(1);
|
|
248
|
+
|
|
249
|
+
await write(projection, "next");
|
|
250
|
+
await write(projection, "frame");
|
|
251
|
+
expect(scheduler.pending).toBe(1);
|
|
252
|
+
scheduler.advance(39);
|
|
253
|
+
expect(renders).toBe(1);
|
|
254
|
+
scheduler.advance(1);
|
|
255
|
+
expect(renders).toBe(2);
|
|
256
|
+
projection.dispose();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("publishes synchronized output as one complete frame", async () => {
|
|
260
|
+
const scheduler = new FakeScheduler();
|
|
261
|
+
let renders = 0;
|
|
262
|
+
const projection = new TerminalProjection({
|
|
263
|
+
requestRender: () => (renders += 1),
|
|
264
|
+
cols: 20,
|
|
265
|
+
rows: 2,
|
|
266
|
+
repaintIntervalMs: 0,
|
|
267
|
+
scheduler,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await write(projection, "\x1b[?2026h");
|
|
271
|
+
await write(projection, "complete frame");
|
|
272
|
+
scheduler.advance();
|
|
273
|
+
expect(renders).toBe(0);
|
|
274
|
+
|
|
275
|
+
await write(projection, "\x1b[?2026l");
|
|
276
|
+
scheduler.advance();
|
|
277
|
+
expect(renders).toBe(1);
|
|
278
|
+
expect(projection.renderLines({ cursor: false })[0]).toContain("complete frame");
|
|
279
|
+
projection.dispose();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("cancels repaint work and ignores writes after disposal", async () => {
|
|
283
|
+
const scheduler = new FakeScheduler();
|
|
284
|
+
let renders = 0;
|
|
285
|
+
const projection = new TerminalProjection({
|
|
286
|
+
requestRender: () => (renders += 1),
|
|
287
|
+
scheduler,
|
|
288
|
+
});
|
|
289
|
+
await write(projection, "queued");
|
|
290
|
+
expect(scheduler.pending).toBe(1);
|
|
291
|
+
|
|
292
|
+
projection.dispose();
|
|
293
|
+
expect(scheduler.pending).toBe(0);
|
|
294
|
+
projection.write("ignored");
|
|
295
|
+
scheduler.advance(1_000);
|
|
296
|
+
expect(renders).toBe(0);
|
|
297
|
+
expect(projection.renderLines()).toEqual([]);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("contains consumer repaint failures", async () => {
|
|
301
|
+
const scheduler = new FakeScheduler();
|
|
302
|
+
const projection = new TerminalProjection({
|
|
303
|
+
requestRender() {
|
|
304
|
+
throw new Error("host repaint failed");
|
|
305
|
+
},
|
|
306
|
+
scheduler,
|
|
307
|
+
});
|
|
308
|
+
await write(projection, "queued");
|
|
309
|
+
expect(() => scheduler.advance()).not.toThrow();
|
|
310
|
+
projection.dispose();
|
|
311
|
+
});
|
|
312
|
+
});
|