@luan.sh/pi-libtui 0.3.8
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 +208 -0
- package/native-revision +1 -0
- package/package.json +58 -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,183 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
configureTuiAppearance,
|
|
4
|
+
DEFAULT_TUI_APPEARANCE,
|
|
5
|
+
getTuiAppearance,
|
|
6
|
+
isTuiActivityIndicatorStyle,
|
|
7
|
+
requestPhaseAnimation,
|
|
8
|
+
resolveActivityPresentation,
|
|
9
|
+
subscribeTuiAppearance,
|
|
10
|
+
TUI_ACTIVITY_INDICATOR_OPTIONS,
|
|
11
|
+
} from "../src/appearance.ts";
|
|
12
|
+
|
|
13
|
+
describe("shared TUI appearance", () => {
|
|
14
|
+
afterEach(() => configureTuiAppearance(DEFAULT_TUI_APPEARANCE));
|
|
15
|
+
|
|
16
|
+
test("starts with portable defaults", () => {
|
|
17
|
+
expect(DEFAULT_TUI_APPEARANCE).toEqual({
|
|
18
|
+
iconPack: "unicode",
|
|
19
|
+
activityIndicator: "spinner",
|
|
20
|
+
activityMessage: "phase",
|
|
21
|
+
textEffect: "off",
|
|
22
|
+
textEffectScope: "message",
|
|
23
|
+
pulseEffect: "off",
|
|
24
|
+
statusPresentation: "standard",
|
|
25
|
+
animationSpeed: "normal",
|
|
26
|
+
animationSmoothness: "balanced",
|
|
27
|
+
thinkingIndicator: "inherit",
|
|
28
|
+
thinkingMessage: "inherit",
|
|
29
|
+
thinkingTextEffect: "inherit",
|
|
30
|
+
thinkingPulseEffect: "inherit",
|
|
31
|
+
thinkingPresentation: "inherit",
|
|
32
|
+
workingIndicator: "inherit",
|
|
33
|
+
workingMessage: "inherit",
|
|
34
|
+
workingTextEffect: "inherit",
|
|
35
|
+
workingPulseEffect: "inherit",
|
|
36
|
+
workingPresentation: "inherit",
|
|
37
|
+
toolIndicator: "inherit",
|
|
38
|
+
toolMessage: "inherit",
|
|
39
|
+
toolTextEffect: "inherit",
|
|
40
|
+
toolPulseEffect: "inherit",
|
|
41
|
+
toolPresentation: "inherit",
|
|
42
|
+
powerline: false,
|
|
43
|
+
powerlineButtons: false,
|
|
44
|
+
softCursor: false,
|
|
45
|
+
insertionCursor: "virtual",
|
|
46
|
+
navigationCursor: "virtual",
|
|
47
|
+
selectionCursor: "virtual",
|
|
48
|
+
});
|
|
49
|
+
expect(getTuiAppearance()).toEqual(DEFAULT_TUI_APPEARANCE);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("notifies subscribers only for effective changes", () => {
|
|
53
|
+
let changes = 0;
|
|
54
|
+
const remove = subscribeTuiAppearance(() => {
|
|
55
|
+
changes += 1;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
configureTuiAppearance({ iconPack: "emoji" });
|
|
59
|
+
configureTuiAppearance({ iconPack: "emoji" });
|
|
60
|
+
expect(changes).toBe(1);
|
|
61
|
+
|
|
62
|
+
remove();
|
|
63
|
+
configureTuiAppearance(DEFAULT_TUI_APPEARANCE);
|
|
64
|
+
expect(changes).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("isolates subscriber failures", () => {
|
|
68
|
+
let changes = 0;
|
|
69
|
+
const removeBroken = subscribeTuiAppearance(() => {
|
|
70
|
+
throw new Error("broken renderer");
|
|
71
|
+
});
|
|
72
|
+
const removeWorking = subscribeTuiAppearance(() => {
|
|
73
|
+
changes += 1;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
configureTuiAppearance({ powerline: true });
|
|
77
|
+
expect(changes).toBe(1);
|
|
78
|
+
removeBroken();
|
|
79
|
+
removeWorking();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("resolves inline composition, mixed composition, and exclusive scenes", () => {
|
|
83
|
+
expect(resolveActivityPresentation("spinner", "typewriter", "aurora", "inline", "color", "standard")).toEqual({
|
|
84
|
+
kind: "inline",
|
|
85
|
+
indicatorStyle: "spinner",
|
|
86
|
+
messageStyle: "typewriter",
|
|
87
|
+
textEffectStyle: "aurora",
|
|
88
|
+
textEffectScope: "inline",
|
|
89
|
+
pulseEffectStyle: "color",
|
|
90
|
+
});
|
|
91
|
+
expect(resolveActivityPresentation("spinner", "phase", "off", "message", "color", "brainstorm")).toEqual({
|
|
92
|
+
kind: "composition",
|
|
93
|
+
style: "brainstorm",
|
|
94
|
+
});
|
|
95
|
+
expect(resolveActivityPresentation("spinner", "phase", "off", "message", "color", "starfield")).toEqual({
|
|
96
|
+
kind: "scene",
|
|
97
|
+
style: "starfield",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
configureTuiAppearance({
|
|
101
|
+
activityMessage: "typewriter",
|
|
102
|
+
textEffect: "glow",
|
|
103
|
+
textEffectScope: "inline",
|
|
104
|
+
thinkingIndicator: "off",
|
|
105
|
+
thinkingMessage: "phase",
|
|
106
|
+
thinkingPulseEffect: "color",
|
|
107
|
+
});
|
|
108
|
+
expect(requestPhaseAnimation("thinking")).toEqual({
|
|
109
|
+
kind: "inline",
|
|
110
|
+
indicatorStyle: "off",
|
|
111
|
+
messageStyle: "phase",
|
|
112
|
+
textEffectStyle: "glow",
|
|
113
|
+
textEffectScope: "inline",
|
|
114
|
+
pulseEffectStyle: "color",
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("ignores invalid values at the shared-state boundary", () => {
|
|
119
|
+
configureTuiAppearance({
|
|
120
|
+
iconPack: "emoji",
|
|
121
|
+
activityIndicator: "static",
|
|
122
|
+
pulseEffect: "color",
|
|
123
|
+
statusPresentation: "block-wave",
|
|
124
|
+
textEffect: "glow",
|
|
125
|
+
textEffectScope: "inline",
|
|
126
|
+
animationSpeed: "fast",
|
|
127
|
+
animationSmoothness: "smooth",
|
|
128
|
+
thinkingIndicator: "off",
|
|
129
|
+
thinkingPresentation: "pacman",
|
|
130
|
+
toolTextEffect: "rainbow",
|
|
131
|
+
powerlineButtons: true,
|
|
132
|
+
softCursor: true,
|
|
133
|
+
insertionCursor: "steady-bar",
|
|
134
|
+
});
|
|
135
|
+
configureTuiAppearance({
|
|
136
|
+
iconPack: "invalid" as never,
|
|
137
|
+
activityIndicator: "marquee" as never,
|
|
138
|
+
statusPresentation: "marquee" as never,
|
|
139
|
+
textEffect: "flash" as never,
|
|
140
|
+
textEffectScope: "yes" as never,
|
|
141
|
+
animationSpeed: "warp" as never,
|
|
142
|
+
animationSmoothness: "maximum" as never,
|
|
143
|
+
thinkingIndicator: "marquee" as never,
|
|
144
|
+
thinkingPresentation: "marquee" as never,
|
|
145
|
+
toolTextEffect: "flash" as never,
|
|
146
|
+
powerline: "yes" as never,
|
|
147
|
+
powerlineButtons: "yes" as never,
|
|
148
|
+
softCursor: "yes" as never,
|
|
149
|
+
insertionCursor: "beam" as never,
|
|
150
|
+
navigationCursor: "box" as never,
|
|
151
|
+
selectionCursor: "line" as never,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(getTuiAppearance()).toEqual({
|
|
155
|
+
...DEFAULT_TUI_APPEARANCE,
|
|
156
|
+
iconPack: "emoji",
|
|
157
|
+
activityIndicator: "static",
|
|
158
|
+
pulseEffect: "color",
|
|
159
|
+
statusPresentation: "block-wave",
|
|
160
|
+
textEffect: "glow",
|
|
161
|
+
textEffectScope: "inline",
|
|
162
|
+
animationSpeed: "fast",
|
|
163
|
+
animationSmoothness: "smooth",
|
|
164
|
+
thinkingIndicator: "off",
|
|
165
|
+
thinkingPresentation: "pacman",
|
|
166
|
+
toolTextEffect: "rainbow",
|
|
167
|
+
powerlineButtons: true,
|
|
168
|
+
softCursor: true,
|
|
169
|
+
insertionCursor: "steady-bar",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("accepts the dim-to-bright pulse independently from color pulse", () => {
|
|
174
|
+
configureTuiAppearance({ pulseEffect: "pulse", toolPulseEffect: "pulse" });
|
|
175
|
+
|
|
176
|
+
expect(getTuiAppearance().pulseEffect).toBe("pulse");
|
|
177
|
+
expect(requestPhaseAnimation("tool")).toMatchObject({ pulseEffectStyle: "pulse" });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("accepts every published activity indicator at the shared boundary", () => {
|
|
181
|
+
expect(TUI_ACTIVITY_INDICATOR_OPTIONS.every((option) => isTuiActivityIndicatorStyle(option.value))).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { BoundedStreamBuffer } from "../src/stream.ts";
|
|
3
|
+
|
|
4
|
+
describe("BoundedStreamBuffer", () => {
|
|
5
|
+
test("never exposes an incomplete ANSI control before truncation", () => {
|
|
6
|
+
const stream = new BoundedStreamBuffer({ maxBytes: 64 });
|
|
7
|
+
expect(stream.append("ready\x1b[3").text).toBe("ready");
|
|
8
|
+
expect(stream.append("1mred").text).toBe("ready\x1b[31mred");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("keeps exact output and the current partial line while under budget", () => {
|
|
12
|
+
const stream = new BoundedStreamBuffer({ maxBytes: 64 });
|
|
13
|
+
stream.append("one\ntw");
|
|
14
|
+
const snapshot = stream.append("o\npartial");
|
|
15
|
+
|
|
16
|
+
expect(snapshot).toMatchObject({
|
|
17
|
+
text: "one\ntwo\npartial",
|
|
18
|
+
head: "one\ntwo\npartial",
|
|
19
|
+
tail: "",
|
|
20
|
+
partialLine: "partial",
|
|
21
|
+
truncated: false,
|
|
22
|
+
totalBytes: 15,
|
|
23
|
+
lineCount: 3,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("decodes split UTF-8 and UTF-16 chunks without broken code points", () => {
|
|
28
|
+
const bytes = new TextEncoder().encode("A😀界Z");
|
|
29
|
+
const byteStream = new BoundedStreamBuffer({ maxBytes: 64 });
|
|
30
|
+
for (const byte of bytes) byteStream.append(Uint8Array.of(byte));
|
|
31
|
+
expect(byteStream.flush().text).toBe("A😀界Z");
|
|
32
|
+
|
|
33
|
+
const stringStream = new BoundedStreamBuffer({ maxBytes: 64 });
|
|
34
|
+
const emoji = "😀";
|
|
35
|
+
stringStream.append(emoji.slice(0, 1));
|
|
36
|
+
expect(stringStream.snapshot().text).toBe("");
|
|
37
|
+
stringStream.append(emoji.slice(1));
|
|
38
|
+
expect(stringStream.snapshot().text).toBe(emoji);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("retains code-point-safe head and tail edges", () => {
|
|
42
|
+
const stream = new BoundedStreamBuffer({
|
|
43
|
+
maxBytes: 12,
|
|
44
|
+
omissionMarker: (bytes) => `<${bytes}>`,
|
|
45
|
+
});
|
|
46
|
+
const snapshot = stream.append("A😀BCDEF😀Z");
|
|
47
|
+
|
|
48
|
+
expect(snapshot).toMatchObject({
|
|
49
|
+
head: "A😀B",
|
|
50
|
+
tail: "F😀Z",
|
|
51
|
+
truncated: true,
|
|
52
|
+
omittedBytes: 3,
|
|
53
|
+
totalBytes: 15,
|
|
54
|
+
});
|
|
55
|
+
expect(snapshot.text).toBe("A😀B<3>F😀Z");
|
|
56
|
+
expect(snapshot.text).not.toContain("�");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("never cuts an ANSI sequence and resets styling around an omission", () => {
|
|
60
|
+
const stream = new BoundedStreamBuffer({
|
|
61
|
+
maxBytes: 18,
|
|
62
|
+
omissionMarker: () => "<cut>",
|
|
63
|
+
});
|
|
64
|
+
const snapshot = stream.append("\x1b[31mred-red-red-red\x1b[39m tail");
|
|
65
|
+
|
|
66
|
+
expect(snapshot.truncated).toBe(true);
|
|
67
|
+
expect(snapshot.head.endsWith("\x1b[")).toBe(false);
|
|
68
|
+
expect(snapshot.tail.startsWith("31m")).toBe(false);
|
|
69
|
+
expect(snapshot.text).toContain("\x1b[0m<cut>\x1b[0m");
|
|
70
|
+
expect(snapshot.text.endsWith("\x1b[0m")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("never cuts an eight-bit CSI sequence at a retained edge", () => {
|
|
74
|
+
const head = new BoundedStreamBuffer({ maxBytes: 8, omissionMarker: () => "<cut>" });
|
|
75
|
+
const headSnapshot = head.append("\u009b31mred-red-red-red\u009b39m tail");
|
|
76
|
+
expect(headSnapshot.head).toBe("");
|
|
77
|
+
expect(headSnapshot.text).not.toContain("\u009b31");
|
|
78
|
+
|
|
79
|
+
const tail = new BoundedStreamBuffer({ maxBytes: 12, omissionMarker: () => "<cut>" });
|
|
80
|
+
const tailSnapshot = tail.append(`${"x".repeat(30)}\u009b31mred`);
|
|
81
|
+
expect(tailSnapshot.tail).toBe("red");
|
|
82
|
+
expect(tailSnapshot.text).not.toContain("31mred");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("retains an ANSI sequence split across chunks without exposing its partial bytes", () => {
|
|
86
|
+
const stream = new BoundedStreamBuffer({ maxBytes: 24, omissionMarker: () => "<cut>" });
|
|
87
|
+
stream.append(`${"x".repeat(50)}\x1b[3`);
|
|
88
|
+
expect(stream.snapshot().text).not.toContain("\x1b[3");
|
|
89
|
+
|
|
90
|
+
const completed = stream.append("1mred");
|
|
91
|
+
expect(completed.tail).toContain("\x1b[31mred");
|
|
92
|
+
expect(completed.text).toContain("\x1b[31mred\x1b[0m");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("retains an eight-bit CSI sequence split across chunks", () => {
|
|
96
|
+
const stream = new BoundedStreamBuffer({ maxBytes: 24, omissionMarker: () => "<cut>" });
|
|
97
|
+
stream.append(`${"x".repeat(50)}\u009b3`);
|
|
98
|
+
expect(stream.snapshot().text).not.toContain("\u009b3");
|
|
99
|
+
|
|
100
|
+
const completed = stream.append("1mred");
|
|
101
|
+
expect(completed.tail).toContain("\u009b31mred");
|
|
102
|
+
expect(completed.text).toContain("\u009b31mred\x1b[0m");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("keeps a split string-control boundary intact while retaining stream styling", () => {
|
|
106
|
+
const stream = new BoundedStreamBuffer({ maxBytes: 24, omissionMarker: () => "<cut>" });
|
|
107
|
+
stream.append(`${"x".repeat(50)}\x1b]52;c;secret`);
|
|
108
|
+
const completed = stream.append("\x9ctail");
|
|
109
|
+
|
|
110
|
+
expect(completed.tail).not.toStartWith("]52;");
|
|
111
|
+
expect(completed.tail).toEndWith("tail");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("lets an authoritative final value replace speculative chunks", () => {
|
|
115
|
+
const stream = new BoundedStreamBuffer({ maxBytes: 10 });
|
|
116
|
+
stream.append("speculative output that will disappear");
|
|
117
|
+
const final = stream.replaceFinal("final ✓");
|
|
118
|
+
|
|
119
|
+
expect(final).toMatchObject({
|
|
120
|
+
text: "final ✓",
|
|
121
|
+
head: "final ✓",
|
|
122
|
+
tail: "",
|
|
123
|
+
truncated: false,
|
|
124
|
+
authoritative: true,
|
|
125
|
+
totalBytes: 9,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("stays bounded across many tiny chunks", () => {
|
|
130
|
+
const stream = new BoundedStreamBuffer({ maxBytes: 128 });
|
|
131
|
+
for (let index = 0; index < 100_000; index += 1) stream.append(`line-${index}\n`);
|
|
132
|
+
const snapshot = stream.snapshot();
|
|
133
|
+
|
|
134
|
+
expect(snapshot.truncated).toBe(true);
|
|
135
|
+
expect(Buffer.byteLength(snapshot.head) + Buffer.byteLength(snapshot.tail)).toBeLessThanOrEqual(128);
|
|
136
|
+
expect(snapshot.tail).toEndWith("line-99999\n");
|
|
137
|
+
expect(snapshot.partialLine).toBe("");
|
|
138
|
+
expect(snapshot.lineCount).toBe(100_000);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("keeps lazy snapshots stable while later chunks arrive", () => {
|
|
142
|
+
const stream = new BoundedStreamBuffer({ maxBytes: 1_024 });
|
|
143
|
+
const before = stream.append("before");
|
|
144
|
+
stream.append(" after");
|
|
145
|
+
|
|
146
|
+
expect(before.text).toBe("before");
|
|
147
|
+
expect(before.partialLine).toBe("before");
|
|
148
|
+
expect(stream.snapshot().text).toBe("before after");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import { createTerminalBridgeClient, parseTerminalBridgeReadResponse } from "../src/terminal/bridge-client.ts";
|
|
4
|
+
|
|
5
|
+
test("rejects an invalid native response and replaces its bridge generation", async () => {
|
|
6
|
+
let generation = 0;
|
|
7
|
+
const bridge = createTerminalBridgeClient({
|
|
8
|
+
binaryPath: async () => process.execPath,
|
|
9
|
+
spawnBridge: () => {
|
|
10
|
+
generation += 1;
|
|
11
|
+
const source =
|
|
12
|
+
generation === 1
|
|
13
|
+
? 'process.stdin.once("data", () => process.stdout.write("bad\\n")); setInterval(() => {}, 1000)'
|
|
14
|
+
: 'process.stdin.once("data", data => { const r = JSON.parse(data); process.stdout.write(JSON.stringify({ request_id: r.request_id, ok: true, result: "replacement" }) + "\\n", () => process.exit(0)) })';
|
|
15
|
+
return spawn(process.execPath, ["-e", source], { stdio: "pipe" });
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
await expect(bridge.request({ op: "first" })).rejects.toThrow("invalid response");
|
|
20
|
+
await expect(bridge.request({ op: "second" })).resolves.toBe("replacement");
|
|
21
|
+
await bridge.shutdown();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("rejects non-contiguous terminal read sequences", () => {
|
|
25
|
+
const base = { exited: false, closed: false };
|
|
26
|
+
const parse = (chunks: object[], nextSeq: number) => parseTerminalBridgeReadResponse({ ...base, chunks, nextSeq });
|
|
27
|
+
expect(parse([{ startSeq: 1, seq: 3, stream: "pty", chunk: "YQ==" }], 4)).toBeDefined();
|
|
28
|
+
expect(
|
|
29
|
+
parse(
|
|
30
|
+
[
|
|
31
|
+
{ seq: 1, stream: "pty", chunk: "YQ==" },
|
|
32
|
+
{ seq: 3, stream: "pty", chunk: "Yg==" },
|
|
33
|
+
],
|
|
34
|
+
4,
|
|
35
|
+
),
|
|
36
|
+
).toBeUndefined();
|
|
37
|
+
expect(parse([{ seq: 1, stream: "pty", chunk: "not base64" }], 2)).toBeUndefined();
|
|
38
|
+
expect(parseTerminalBridgeReadResponse({ ...base, chunks: [], nextSeq: 2 }, 1)).toBeUndefined();
|
|
39
|
+
expect(
|
|
40
|
+
parseTerminalBridgeReadResponse({ ...base, chunks: [{ seq: 2, stream: "pty", chunk: "YQ==" }], nextSeq: 3 }, 1),
|
|
41
|
+
).toBeUndefined();
|
|
42
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { COLOR256_PREVIEW_WIDTH, renderColor256Preview } from "../src/color/preview.ts";
|
|
4
|
+
|
|
5
|
+
describe("color256 preview", () => {
|
|
6
|
+
test("matches the terminal grid dimensions and labels", () => {
|
|
7
|
+
const lines = renderColor256Preview("harmonious");
|
|
8
|
+
expect(lines).toHaveLength(20);
|
|
9
|
+
expect(lines.every((line) => visibleWidth(line) === COLOR256_PREVIEW_WIDTH)).toBe(true);
|
|
10
|
+
expect(stripAnsi(lines.at(-1)!)).toContain("harmonious");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("renders the active 256-color indexes instead of a second RGB palette", () => {
|
|
14
|
+
const output = renderColor256Preview("harmonious").join("\n");
|
|
15
|
+
expect(output).toContain("\x1b[38;5;231;7m");
|
|
16
|
+
expect(output).toContain("\x1b[48;5;52m");
|
|
17
|
+
expect(output).toContain("\x1b[38;5;15;7m");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function stripAnsi(value: string): string {
|
|
22
|
+
return value.replaceAll(/\x1b\[[0-9;]*m/gu, "");
|
|
23
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { ComponentStack } from "../src/component-stack.ts";
|
|
4
|
+
import type { TuiMouseEvent } from "../src/mouse.ts";
|
|
5
|
+
|
|
6
|
+
class TestComponent implements Component {
|
|
7
|
+
readonly inputs: string[] = [];
|
|
8
|
+
invalidations = 0;
|
|
9
|
+
|
|
10
|
+
constructor(readonly lines: string[]) {}
|
|
11
|
+
|
|
12
|
+
handleInput(data: string): void {
|
|
13
|
+
this.inputs.push(data);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
invalidate(): void {
|
|
17
|
+
this.invalidations += 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
render(): string[] {
|
|
21
|
+
return [...this.lines];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class MouseComponent extends TestComponent {
|
|
26
|
+
readonly events: TuiMouseEvent[] = [];
|
|
27
|
+
|
|
28
|
+
onMouse(event: TuiMouseEvent): boolean {
|
|
29
|
+
this.events.push(event);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class FocusableComponent extends TestComponent {
|
|
35
|
+
focused = false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class ViewportComponent extends TestComponent {
|
|
39
|
+
constructor(
|
|
40
|
+
lines: string[],
|
|
41
|
+
private readonly handled: boolean,
|
|
42
|
+
) {
|
|
43
|
+
super(lines);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
handleViewportInput(data: string): boolean {
|
|
47
|
+
this.inputs.push(data);
|
|
48
|
+
return this.handled;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mouseEvent(row: number, col = 0): TuiMouseEvent {
|
|
53
|
+
return {
|
|
54
|
+
type: "press",
|
|
55
|
+
row,
|
|
56
|
+
col,
|
|
57
|
+
screenRow: row,
|
|
58
|
+
screenCol: col,
|
|
59
|
+
button: 0,
|
|
60
|
+
wheel: undefined,
|
|
61
|
+
shift: false,
|
|
62
|
+
alt: false,
|
|
63
|
+
ctrl: false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("ComponentStack", () => {
|
|
68
|
+
test("renders children vertically and records exact spans", () => {
|
|
69
|
+
const first = new TestComponent(["one", "two"]);
|
|
70
|
+
const second = new TestComponent(["three"]);
|
|
71
|
+
const stack = new ComponentStack([first, second]);
|
|
72
|
+
|
|
73
|
+
expect(stack.render(20)).toEqual(["one", "two", "three"]);
|
|
74
|
+
expect(stack.getSpans()).toEqual([
|
|
75
|
+
{ component: first, index: 0, row: 0, height: 2, width: 20 },
|
|
76
|
+
{ component: second, index: 1, row: 2, height: 1, width: 20 },
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("routes input to the active child and keeps its identity across replacement", () => {
|
|
81
|
+
const first = new TestComponent(["one"]);
|
|
82
|
+
const active = new TestComponent(["active"]);
|
|
83
|
+
const stack = new ComponentStack([first, active], { activeChild: 1 });
|
|
84
|
+
|
|
85
|
+
stack.handleInput("a");
|
|
86
|
+
stack.setChildren([active, first]);
|
|
87
|
+
stack.handleInput("b");
|
|
88
|
+
|
|
89
|
+
expect(first.inputs).toEqual([]);
|
|
90
|
+
expect(active.inputs).toEqual(["a", "b"]);
|
|
91
|
+
expect(stack.getActiveChild()).toBe(active);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("propagates overlay focus to the active child", () => {
|
|
95
|
+
const first = new FocusableComponent(["first"]);
|
|
96
|
+
const second = new FocusableComponent(["second"]);
|
|
97
|
+
const stack = new ComponentStack([first, second]);
|
|
98
|
+
|
|
99
|
+
stack.focused = true;
|
|
100
|
+
expect([first.focused, second.focused]).toEqual([true, false]);
|
|
101
|
+
stack.setActiveChild(second);
|
|
102
|
+
expect([first.focused, second.focused]).toEqual([false, true]);
|
|
103
|
+
stack.focused = false;
|
|
104
|
+
expect(second.focused).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("can route input to every child in display order", () => {
|
|
108
|
+
const order: string[] = [];
|
|
109
|
+
const child = (name: string): Component => ({
|
|
110
|
+
render: () => [],
|
|
111
|
+
invalidate() {},
|
|
112
|
+
handleInput: (data) => order.push(`${name}:${data}`),
|
|
113
|
+
});
|
|
114
|
+
const stack = new ComponentStack([child("first"), child("second")], { inputMode: "all" });
|
|
115
|
+
|
|
116
|
+
stack.handleInput("x");
|
|
117
|
+
|
|
118
|
+
expect(order).toEqual(["first:x", "second:x"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("routes viewport input to the active child and falls back to legacy input", () => {
|
|
122
|
+
const viewport = new ViewportComponent(["viewport"], true);
|
|
123
|
+
const legacy = new TestComponent(["legacy"]);
|
|
124
|
+
const stack = new ComponentStack([viewport, legacy]);
|
|
125
|
+
|
|
126
|
+
expect(stack.handleViewportInput("right")).toBe(true);
|
|
127
|
+
stack.setActiveChild(legacy);
|
|
128
|
+
expect(stack.handleViewportInput("down")).toBe(false);
|
|
129
|
+
|
|
130
|
+
expect(viewport.inputs).toEqual(["right"]);
|
|
131
|
+
expect(legacy.inputs).toEqual(["down"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("anchors the final child and dispatches translated pointer input", () => {
|
|
135
|
+
const body = new MouseComponent(["body", "body"]);
|
|
136
|
+
const footer = new MouseComponent(["footer"]);
|
|
137
|
+
const stack = new ComponentStack([body, footer], { height: 6, anchorLastChild: true });
|
|
138
|
+
|
|
139
|
+
expect(stack.render(12)).toEqual(["body", "body", "", "", "", "footer"]);
|
|
140
|
+
expect(stack.getSpans().map(({ index, row, height }) => ({ index, row, height }))).toEqual([
|
|
141
|
+
{ index: 0, row: 0, height: 2 },
|
|
142
|
+
{ index: 1, row: 5, height: 1 },
|
|
143
|
+
]);
|
|
144
|
+
expect(stack.onMouse(mouseEvent(5, 4))).toBe(true);
|
|
145
|
+
expect(footer.events[0]).toMatchObject({ row: 0, col: 4, screenRow: 5, screenCol: 4 });
|
|
146
|
+
expect(body.events).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("dispatches recursively to the deepest nested stack child", () => {
|
|
150
|
+
const leaf = new MouseComponent(["leaf"]);
|
|
151
|
+
const inner = new ComponentStack([new TestComponent(["label"]), leaf]);
|
|
152
|
+
const outer = new ComponentStack([new TestComponent(["title"]), inner]);
|
|
153
|
+
outer.render(10);
|
|
154
|
+
|
|
155
|
+
expect(outer.onMouse(mouseEvent(2, 3))).toBe(true);
|
|
156
|
+
expect(leaf.events[0]).toMatchObject({ row: 0, col: 3, screenRow: 2, screenCol: 3 });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("owns hover transitions and capture across nested children", () => {
|
|
160
|
+
const first = new MouseComponent(["first"]);
|
|
161
|
+
const second = new MouseComponent(["second"]);
|
|
162
|
+
const stack = new ComponentStack([first, second]);
|
|
163
|
+
stack.render(12);
|
|
164
|
+
stack.onMouse({ ...mouseEvent(0), type: "move" });
|
|
165
|
+
stack.onMouse({ ...mouseEvent(1), type: "move" });
|
|
166
|
+
stack.onMouse(mouseEvent(0));
|
|
167
|
+
stack.onMouse({ ...mouseEvent(9), type: "release" });
|
|
168
|
+
|
|
169
|
+
expect(first.events.map((event) => event.type)).toEqual(["enter", "move", "leave", "press", "release"]);
|
|
170
|
+
expect(second.events.map((event) => event.type)).toEqual(["enter", "move", "leave"]);
|
|
171
|
+
expect(first.events.at(-1)).toMatchObject({ row: 9 });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("preserves a handled hover when its redraw invalidates nested geometry", () => {
|
|
175
|
+
let stack: ComponentStack;
|
|
176
|
+
const child = new MouseComponent(["child"]);
|
|
177
|
+
const original = child.onMouse.bind(child);
|
|
178
|
+
child.onMouse = (event) => {
|
|
179
|
+
const handled = original(event);
|
|
180
|
+
if (event.type === "enter") stack.invalidate();
|
|
181
|
+
return handled;
|
|
182
|
+
};
|
|
183
|
+
stack = new ComponentStack([child]);
|
|
184
|
+
stack.render(12);
|
|
185
|
+
|
|
186
|
+
expect(stack.onMouse({ ...mouseEvent(0), type: "move" })).toBe(true);
|
|
187
|
+
stack.render(12);
|
|
188
|
+
expect(stack.onMouse(mouseEvent(0))).toBe(true);
|
|
189
|
+
expect(stack.onMouse({ ...mouseEvent(0), type: "release" })).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("clips spans to a fixed height and invalidates every current child", () => {
|
|
193
|
+
const first = new TestComponent(["one", "two"]);
|
|
194
|
+
const second = new TestComponent(["three", "four"]);
|
|
195
|
+
const stack = new ComponentStack([first, second], { height: 3 });
|
|
196
|
+
|
|
197
|
+
expect(stack.render(8)).toEqual(["one", "two", "three"]);
|
|
198
|
+
expect(stack.getSpans().map(({ row, height }) => ({ row, height }))).toEqual([
|
|
199
|
+
{ row: 0, height: 2 },
|
|
200
|
+
{ row: 2, height: 1 },
|
|
201
|
+
]);
|
|
202
|
+
stack.invalidate();
|
|
203
|
+
expect([first.invalidations, second.invalidations]).toEqual([1, 1]);
|
|
204
|
+
expect(stack.getSpans()).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
});
|