@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.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/native-revision +1 -0
  4. package/package.json +58 -0
  5. package/src/activity-presentation.ts +75 -0
  6. package/src/appearance.ts +632 -0
  7. package/src/background-surface.ts +125 -0
  8. package/src/color/palette.ts +186 -0
  9. package/src/color/preview.ts +157 -0
  10. package/src/color/resolver.ts +75 -0
  11. package/src/color/theme.ts +531 -0
  12. package/src/component-stack.ts +247 -0
  13. package/src/content/markdown-code.ts +60 -0
  14. package/src/content/terminal-text.ts +135 -0
  15. package/src/content/text.ts +105 -0
  16. package/src/controls/action-panel.ts +420 -0
  17. package/src/controls/dialog-button-bar.ts +287 -0
  18. package/src/controls/multi-select.ts +467 -0
  19. package/src/controls/picker-panel.ts +447 -0
  20. package/src/controls/screen-icon-actions.ts +134 -0
  21. package/src/controls/searchable-select.ts +394 -0
  22. package/src/controls/select-box.ts +224 -0
  23. package/src/controls/selectable-list.ts +433 -0
  24. package/src/controls/selection-action-bar.ts +382 -0
  25. package/src/controls/semantic-input.ts +23 -0
  26. package/src/controls/tab.ts +189 -0
  27. package/src/cursor.ts +288 -0
  28. package/src/decoration/editor-pills.ts +156 -0
  29. package/src/decoration/glyphs.ts +326 -0
  30. package/src/decoration/pointer-interaction.ts +123 -0
  31. package/src/decoration/powerline-pill.ts +104 -0
  32. package/src/decoration/status.ts +185 -0
  33. package/src/decoration/transient-pill.ts +176 -0
  34. package/src/diff/index.ts +21 -0
  35. package/src/diff/model.ts +76 -0
  36. package/src/diff/parse.ts +310 -0
  37. package/src/diff/pierre-syntax.ts +86 -0
  38. package/src/diff/render.ts +504 -0
  39. package/src/diff/structured.ts +264 -0
  40. package/src/diff/view.ts +75 -0
  41. package/src/editor/chrome.ts +341 -0
  42. package/src/editor/composition.ts +53 -0
  43. package/src/editor/layout.ts +81 -0
  44. package/src/editor/presentation.ts +35 -0
  45. package/src/editor/protocol.ts +188 -0
  46. package/src/editor.ts +41 -0
  47. package/src/extension.ts +30 -0
  48. package/src/folding.ts +192 -0
  49. package/src/host/cursor-bridge.ts +210 -0
  50. package/src/host/editor-bridge.ts +148 -0
  51. package/src/host/extension-host.ts +231 -0
  52. package/src/host/mouse-bridge.ts +212 -0
  53. package/src/host/mouse-dispatch.ts +400 -0
  54. package/src/host/mouse-input.ts +85 -0
  55. package/src/host/mouse-targets.ts +238 -0
  56. package/src/host/pi-layout-adapter.ts +298 -0
  57. package/src/host/pi-native-selection.ts +227 -0
  58. package/src/host/split-pane-bridge.ts +720 -0
  59. package/src/host/transcript-bridge.ts +236 -0
  60. package/src/index.ts +334 -0
  61. package/src/line-layout.ts +6 -0
  62. package/src/motion.ts +1409 -0
  63. package/src/mouse/events.ts +55 -0
  64. package/src/mouse/fullscreen-layout.ts +260 -0
  65. package/src/mouse/registry.ts +348 -0
  66. package/src/mouse.ts +37 -0
  67. package/src/native-binary.ts +189 -0
  68. package/src/overlay/anchored.ts +91 -0
  69. package/src/overlay/detail-card.ts +158 -0
  70. package/src/overlay/dialog.ts +266 -0
  71. package/src/overlay/floating.ts +106 -0
  72. package/src/overlay/framed-editor.ts +236 -0
  73. package/src/overlay/fullscreen.ts +170 -0
  74. package/src/overlay/hover-tooltip.ts +109 -0
  75. package/src/overlay/modal-mount.ts +118 -0
  76. package/src/panels.ts +207 -0
  77. package/src/render-cache.ts +80 -0
  78. package/src/render-epoch.ts +48 -0
  79. package/src/request-animation.ts +160 -0
  80. package/src/scrollbar.ts +27 -0
  81. package/src/selection.ts +148 -0
  82. package/src/split-pane.ts +232 -0
  83. package/src/status-presentation.ts +411 -0
  84. package/src/stream.ts +543 -0
  85. package/src/syntax.ts +316 -0
  86. package/src/terminal/bridge-client.ts +243 -0
  87. package/src/terminal/embedding.ts +6 -0
  88. package/src/terminal/output.ts +151 -0
  89. package/src/terminal/projection.ts +362 -0
  90. package/src/terminal/pty-host.ts +263 -0
  91. package/src/terminal/pty-pane.ts +308 -0
  92. package/src/terminal-colors.ts +334 -0
  93. package/src/tool/action.ts +170 -0
  94. package/src/tool/activity.ts +500 -0
  95. package/src/tool/call-preview.ts +44 -0
  96. package/src/tool/disclosure-action.ts +89 -0
  97. package/src/tool/expanded-region-viewport.ts +103 -0
  98. package/src/tool/index.ts +26 -0
  99. package/src/tool/output.ts +271 -0
  100. package/src/tool/transcript.ts +150 -0
  101. package/src/tool/view-region.ts +581 -0
  102. package/test/appearance.test.ts +183 -0
  103. package/test/bounded-stream.test.ts +150 -0
  104. package/test/bridge-client.test.ts +42 -0
  105. package/test/color-preview.test.ts +23 -0
  106. package/test/component-stack.test.ts +206 -0
  107. package/test/components.test.ts +1165 -0
  108. package/test/content-text.test.ts +21 -0
  109. package/test/cursor-bridge.test.ts +151 -0
  110. package/test/cursor.test.ts +147 -0
  111. package/test/editor-chrome.test.ts +119 -0
  112. package/test/editor-layout.test.ts +30 -0
  113. package/test/editor-registry.test.ts +203 -0
  114. package/test/editor-theme.test.ts +33 -0
  115. package/test/editor-token-pill.test.ts +72 -0
  116. package/test/floating-overlay.test.ts +57 -0
  117. package/test/folding.test.ts +147 -0
  118. package/test/harmonious-theme.test.ts +23 -0
  119. package/test/interaction.test.ts +104 -0
  120. package/test/key-icons.test.ts +62 -0
  121. package/test/markdown-code.test.ts +24 -0
  122. package/test/modal-overlay-mount.test.ts +94 -0
  123. package/test/motion.test.ts +649 -0
  124. package/test/mouse/bridge.test.ts +1302 -0
  125. package/test/mouse/extension.test.ts +205 -0
  126. package/test/mouse/registry.test.ts +140 -0
  127. package/test/mouse/screen-decoration.test.ts +167 -0
  128. package/test/native-binary.test.ts +52 -0
  129. package/test/palette.test.ts +32 -0
  130. package/test/panels.test.ts +74 -0
  131. package/test/pointer-components.test.ts +151 -0
  132. package/test/progress.test.ts +54 -0
  133. package/test/pty-host.test.ts +126 -0
  134. package/test/pty-pane.test.ts +62 -0
  135. package/test/public-surface.test.ts +75 -0
  136. package/test/render-cache.test.ts +25 -0
  137. package/test/render-epoch.test.ts +119 -0
  138. package/test/request-animation.test.ts +193 -0
  139. package/test/screen-icon-actions.test.ts +69 -0
  140. package/test/select-box.test.ts +86 -0
  141. package/test/select-pointer.test.ts +369 -0
  142. package/test/selectable-list.test.ts +258 -0
  143. package/test/selection/registry.test.ts +44 -0
  144. package/test/selection-action-bar.test.ts +204 -0
  145. package/test/split-pane-bridge.test.ts +944 -0
  146. package/test/split-pane.test.ts +134 -0
  147. package/test/status-presentation.test.ts +39 -0
  148. package/test/syntax.test.ts +59 -0
  149. package/test/terminal-colors.test.ts +115 -0
  150. package/test/terminal-embedding.test.ts +12 -0
  151. package/test/terminal-projection.test.ts +312 -0
  152. package/test/theme-colors.test.ts +175 -0
  153. package/test/tool-output.test.ts +160 -0
  154. package/test/tool-transcript.test.ts +1190 -0
  155. package/test/unified-diff.test.ts +691 -0
  156. package/themes/harmonious.json +61 -0
  157. package/tsconfig.json +16 -0
@@ -0,0 +1,151 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { TabBar } from "../src/controls/tab.ts";
5
+ import type { TuiMouseEvent } from "../src/mouse.ts";
6
+ import { DialogOverlay } from "../src/overlay/dialog.ts";
7
+ import { FullscreenOverlay } from "../src/overlay/fullscreen.ts";
8
+
9
+ const theme = {
10
+ fg: (_color: string, text: string) => text,
11
+ bg: (_color: string, text: string) => `\x1b[7m${text}\x1b[27m`,
12
+ } as Theme;
13
+
14
+ function mouse(type: TuiMouseEvent["type"], row: number, col: number, button?: 0 | 1 | 2): TuiMouseEvent {
15
+ return {
16
+ type,
17
+ row,
18
+ col,
19
+ screenRow: row + 10,
20
+ screenCol: col + 20,
21
+ button,
22
+ wheel: undefined,
23
+ shift: false,
24
+ alt: false,
25
+ ctrl: false,
26
+ };
27
+ }
28
+
29
+ describe("structural pointer components", () => {
30
+ test("fullscreen overlay translates through its border and safely delegates to its child", () => {
31
+ const events: TuiMouseEvent[] = [];
32
+ const child = {
33
+ render: () => ["content"],
34
+ invalidate() {},
35
+ onMouse(event: TuiMouseEvent) {
36
+ events.push(event);
37
+ return true;
38
+ },
39
+ };
40
+ const overlay = new FullscreenOverlay(
41
+ { terminal: { rows: 5 }, requestRender() {} } as never,
42
+ theme,
43
+ child,
44
+ "Title",
45
+ );
46
+ overlay.render(10);
47
+
48
+ expect(overlay.onMouse(mouse("move", 2, 3))).toBe(true);
49
+ expect(events[0]).toMatchObject({ row: 1, col: 2, screenRow: 12, screenCol: 23 });
50
+ expect(overlay.onMouse(mouse("move", 0, 3))).toBe(false);
51
+ expect(overlay.onMouse(mouse("press", 0, 3, 0))).toBe(true);
52
+ expect(events).toHaveLength(1);
53
+ expect(overlay.onMouse(mouse("leave", 0, 0))).toBe(true);
54
+ expect(events[1]).toMatchObject({ type: "leave", row: -1, col: -1 });
55
+
56
+ const brokenChild = {
57
+ render: () => [],
58
+ invalidate() {},
59
+ onMouse: () => {
60
+ throw new Error("boom");
61
+ },
62
+ };
63
+ const broken = new FullscreenOverlay({ terminal: { rows: 5 }, requestRender() {} } as never, theme, brokenChild);
64
+ broken.render(10);
65
+ expect(broken.onMouse(mouse("move", 1, 1))).toBe(false);
66
+ expect(broken.onMouse(mouse("press", 1, 1, 0))).toBe(true);
67
+ overlay.dispose();
68
+ broken.dispose();
69
+ });
70
+
71
+ test("dialog overlay translates pointer input through its frame", () => {
72
+ const events: TuiMouseEvent[] = [];
73
+ const child = {
74
+ render: () => ["one", "two"],
75
+ invalidate() {},
76
+ onMouse(event: TuiMouseEvent) {
77
+ events.push(event);
78
+ return true;
79
+ },
80
+ };
81
+ const dialog = new DialogOverlay(theme, child);
82
+ dialog.render(12);
83
+
84
+ expect(dialog.onMouse(mouse("move", 2, 4))).toBe(true);
85
+ expect(events[0]).toMatchObject({ row: 1, col: 3 });
86
+ expect(dialog.onMouse(mouse("move", 0, 4))).toBe(false);
87
+ expect(dialog.onMouse(mouse("move", 3, 4))).toBe(false);
88
+ });
89
+
90
+ test("tab bar uses exact visible spans for hover and primary-button release", () => {
91
+ const selected: string[] = [];
92
+ const tabs = new TabBar(
93
+ [
94
+ { id: "a", label: "A" },
95
+ { id: "b", label: "Longer" },
96
+ ],
97
+ theme,
98
+ );
99
+ tabs.onChange = (tab) => selected.push(tab.id);
100
+ const initial = tabs.render(9)[0]!;
101
+
102
+ expect(visibleWidth(initial)).toBe(9);
103
+ expect(stripTerminalSequences(initial)).toContain("A");
104
+ expect(stripTerminalSequences(initial)).toContain("Long");
105
+ expect(tabs.onMouse(mouse("move", 0, 8))).toBe(true);
106
+ expect(tabs.render(9)[0]).not.toBe(initial);
107
+ expect(tabs.onMouse(mouse("release", 0, 8, 1))).toBe(true);
108
+ expect(selected).toEqual([]);
109
+ expect(tabs.onMouse(mouse("release", 0, 8, 0))).toBe(true);
110
+ expect(selected).toEqual(["b"]);
111
+ expect(tabs.onMouse(mouse("release", 0, 9, 0))).toBe(false);
112
+ expect(selected).toEqual(["b"]);
113
+ expect(tabs.onMouse(mouse("leave", 0, 0))).toBe(false);
114
+ });
115
+
116
+ test("tab bar closes tabs without confusing close with tab selection", () => {
117
+ const selected: string[] = [];
118
+ const closed: string[] = [];
119
+ const tabs = new TabBar([{ id: "side", label: "Side" }], theme);
120
+ tabs.onChange = (tab) => selected.push(tab.id);
121
+ tabs.onClose = (tab) => closed.push(tab.id);
122
+ tabs.render(20);
123
+
124
+ expect(tabs.onMouse(mouse("release", 0, 1, 0))).toBe(true);
125
+ expect(tabs.onMouse(mouse("release", 0, 6, 0))).toBe(true);
126
+ expect(selected).toEqual([]);
127
+ expect(closed).toEqual(["side"]);
128
+ });
129
+
130
+ test("tab bar drags tabs into a new order and highlights only the close foreground", () => {
131
+ const moved: Array<{ id: string; from: number; to: number }> = [];
132
+ const tabs = new TabBar(
133
+ [
134
+ { id: "one", label: "One" },
135
+ { id: "two", label: "Two" },
136
+ ],
137
+ theme,
138
+ );
139
+ tabs.onMove = (tab, from, to) => moved.push({ id: tab.id, from, to });
140
+ tabs.onClose = () => {};
141
+ const normal = tabs.render(30)[0]!;
142
+ tabs.onMouse(mouse("move", 0, 5));
143
+ const closeHovered = tabs.render(30)[0]!;
144
+ expect(Bun.stripANSI(closeHovered)).toBe(Bun.stripANSI(normal));
145
+ expect(closeHovered).not.toBe(normal);
146
+ tabs.onMouse(mouse("press", 0, 2, 0));
147
+ tabs.onMouse({ ...mouse("drag", 0, 10, 0), button: 0 });
148
+ tabs.onMouse(mouse("release", 0, 10, 0));
149
+ expect(moved).toEqual([{ id: "one", from: 0, to: 1 }]);
150
+ });
151
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { tuiTheme } from "../src/color/theme.ts";
5
+ import { ActivityIndicator, ProgressBar, progressFrame } from "../src/decoration/status.ts";
6
+
7
+ const theme = {
8
+ name: "progress-test",
9
+ getColorMode: () => "truecolor",
10
+ getFgAnsi: () => "\x1b[38;2;180;180;180m",
11
+ getBgAnsi: () => "\x1b[48;2;20;20;20m",
12
+ } as never as Theme;
13
+
14
+ describe("bounded progress rendering", () => {
15
+ test("rejects control and wide custom glyphs that break the cell contract", () => {
16
+ const frame = progressFrame(tuiTheme(theme), {
17
+ width: 4,
18
+ value: 0.5,
19
+ filled: "界",
20
+ empty: "\n",
21
+ });
22
+
23
+ expect(visibleWidth(frame)).toBe(4);
24
+ expect(stripTerminalSequences(frame)).toBe("━━──");
25
+ });
26
+
27
+ test("applies global animation speed to indeterminate progress", () => {
28
+ const colors = tuiTheme(theme);
29
+ const slow = progressFrame(colors, { width: 8, elapsedMs: 140, animationSpeed: "slow" });
30
+ const fast = progressFrame(colors, { width: 8, elapsedMs: 140, animationSpeed: "fast" });
31
+
32
+ expect(stripTerminalSequences(slow)).not.toBe(stripTerminalSequences(fast));
33
+ expect(visibleWidth(slow)).toBe(8);
34
+ expect(visibleWidth(fast)).toBe(8);
35
+ });
36
+
37
+ test("sanitizes activity and progress labels into one component row", () => {
38
+ const activity = new ActivityIndicator({
39
+ theme,
40
+ label: "build\nnext\x1b]52;c;secret\x07",
41
+ detail: "phase\r2",
42
+ spinnerFrames: ["界"],
43
+ requestRender() {},
44
+ reducedMotion: true,
45
+ });
46
+ const progress = new ProgressBar({ theme, value: 0.5, label: "one\ntwo" });
47
+
48
+ for (const line of [...activity.render(80), ...progress.render(80)]) {
49
+ expect(stripTerminalSequences(line)).not.toMatch(/[\n\r]/u);
50
+ expect(visibleWidth(line)).toBeLessThanOrEqual(80);
51
+ }
52
+ activity.dispose();
53
+ });
54
+ });
@@ -0,0 +1,126 @@
1
+ import { expect, test } from "bun:test";
2
+ import type { TerminalBridgeClient } from "../src/terminal/bridge-client.ts";
3
+ import { createPtyHost } from "../src/terminal/pty-host.ts";
4
+
5
+ test("owns PTY execution without a feature-extension provider", async () => {
6
+ const requests: Record<string, unknown>[] = [];
7
+ let finishRead: ((response: unknown) => void) | undefined;
8
+ const bridge: TerminalBridgeClient = {
9
+ request<T>(request: Record<string, unknown>): Promise<T> {
10
+ requests.push(request);
11
+ if (request.op === "read")
12
+ return new Promise<T>((resolve) => {
13
+ finishRead = resolve as (response: unknown) => void;
14
+ });
15
+ if (request.op === "write") return Promise.resolve({ status: "accepted" } as T);
16
+ return Promise.resolve({} as T);
17
+ },
18
+ shutdown: async () => {},
19
+ };
20
+ const host = createPtyHost({
21
+ bridge,
22
+ createProcessId: () => "process-1",
23
+ environment: { PATH: "/test/bin" },
24
+ });
25
+ const processId = await host.spawn({ command: "pi --tui-mode fullscreen", cwd: "/tmp/project" });
26
+
27
+ expect(requests[0]).toMatchObject({
28
+ op: "exec",
29
+ process_id: processId,
30
+ argv: ["/bin/sh", "-c", "pi --tui-mode fullscreen"],
31
+ cwd: "/tmp/project",
32
+ tty: true,
33
+ pipe_stdin: true,
34
+ env: { PATH: "/test/bin", TERM: "xterm-256color", COLORTERM: "truecolor" },
35
+ });
36
+ expect(requests.find((request) => request.op === "read")?.wait_ms).toBe(30_000);
37
+ expect(await host.resize(processId, 120, 40)).toBe(true);
38
+ expect(await host.resize(processId, 120, 40)).toBe(true);
39
+ expect(await host.sendInput(processId, "hello")).toBe(true);
40
+ expect(requests).toContainEqual({ op: "resize", process_id: processId, rows: 40, cols: 120 });
41
+ expect(requests.filter((request) => request.op === "resize")).toHaveLength(1);
42
+ expect(requests).toContainEqual({
43
+ op: "write",
44
+ process_id: processId,
45
+ chunk: "hello",
46
+ });
47
+
48
+ const exited = new Promise<void>((resolve) => {
49
+ const unsubscribe = host.subscribe(processId, () => {
50
+ if (host.isRunning(processId)) return;
51
+ unsubscribe();
52
+ resolve();
53
+ });
54
+ });
55
+ finishRead?.({ chunks: [], nextSeq: 1, exited: true, closed: true });
56
+ await exited;
57
+ expect(host.isRunning(processId)).toBe(false);
58
+ await host.shutdown();
59
+ });
60
+
61
+ test("does not publish process completion before the projected tail is parsed", async () => {
62
+ const reads: Array<(response: unknown) => void> = [];
63
+ const bridge: TerminalBridgeClient = {
64
+ request<T>(request: Record<string, unknown>): Promise<T> {
65
+ if (request.op !== "read") return Promise.resolve({} as T);
66
+ return new Promise<T>((resolve) => reads.push(resolve as (response: unknown) => void));
67
+ },
68
+ shutdown: async () => {},
69
+ };
70
+ const host = createPtyHost({ bridge, createProcessId: () => "process-1", environment: {} });
71
+ const processId = await host.spawn({ command: "output", cwd: "/tmp" });
72
+ reads.shift()?.({
73
+ chunks: [{ seq: 1, stream: "pty", chunk: Buffer.from("prefix").toString("base64") }],
74
+ nextSeq: 2,
75
+ exited: false,
76
+ closed: false,
77
+ });
78
+ await Bun.sleep(0);
79
+ let completedTail: string | undefined;
80
+ const completed = new Promise<void>((resolve) => {
81
+ host.subscribe(processId, () => {
82
+ if (host.isRunning(processId)) return;
83
+ completedTail = host.render(processId, 24, false).join("\n");
84
+ resolve();
85
+ });
86
+ });
87
+ reads.shift()?.({
88
+ chunks: [{ seq: 2, stream: "pty", chunk: Buffer.from("-FINAL-MARKER").toString("base64") }],
89
+ nextSeq: 3,
90
+ exited: true,
91
+ closed: true,
92
+ });
93
+ await completed;
94
+
95
+ expect(completedTail).toContain("FINAL-MARKER");
96
+ await host.shutdown();
97
+ });
98
+
99
+ test("drains process closure before reaping an explicitly terminated PTY", async () => {
100
+ let finishRead: ((response: unknown) => void) | undefined;
101
+ const requests: Record<string, unknown>[] = [];
102
+ const host = createPtyHost({
103
+ bridge: {
104
+ request<T>(request: Record<string, unknown>): Promise<T> {
105
+ requests.push(request);
106
+ if (request.op === "read")
107
+ return new Promise<T>((resolve) => {
108
+ finishRead = resolve as (response: unknown) => void;
109
+ });
110
+ return Promise.resolve({} as T);
111
+ },
112
+ shutdown: async () => {},
113
+ },
114
+ createProcessId: () => "process-1",
115
+ environment: {},
116
+ });
117
+ const processId = await host.spawn({ command: "interactive", cwd: "/tmp" });
118
+ const terminating = host.terminate(processId);
119
+ await Promise.resolve();
120
+ expect(requests.some((request) => request.op === "reap")).toBe(false);
121
+
122
+ finishRead?.({ chunks: [], nextSeq: 1, exited: true, closed: true });
123
+ expect(await terminating).toBe(true);
124
+ expect(requests.map((request) => request.op)).toEqual(["exec", "read", "terminate", "reap"]);
125
+ await host.shutdown();
126
+ });
@@ -0,0 +1,62 @@
1
+ import { expect, test } from "bun:test";
2
+ import type { TUI } from "@earendil-works/pi-tui";
3
+ import type { TerminalBridgeClient } from "../src/terminal/bridge-client.ts";
4
+ import { createPtyHost } from "../src/terminal/pty-host.ts";
5
+ import { PtyPane, PtyProcess } from "../src/terminal/pty-pane.ts";
6
+
7
+ test("runs an interactive PTY at its rendered size without redundant work or frames", async () => {
8
+ const requests: Record<string, unknown>[] = [];
9
+ let finishRead: ((response: unknown) => void) | undefined;
10
+ const bridge: TerminalBridgeClient = {
11
+ request: <T>(request: Record<string, unknown>) => {
12
+ requests.push(request);
13
+ if (request.op === "read")
14
+ return new Promise<T>((resolve) => {
15
+ finishRead = resolve as (response: unknown) => void;
16
+ });
17
+ if (request.op === "write") return Promise.resolve({ status: "accepted" } as T);
18
+ return Promise.resolve({} as T);
19
+ },
20
+ shutdown: async () => {},
21
+ };
22
+ const host = createPtyHost({ bridge, createProcessId: () => "process-1", environment: {} });
23
+ const scope = Object.create(null) as typeof globalThis;
24
+ Reflect.set(scope, Symbol.for("pi-libtui/pty-host/v2"), host);
25
+ const process = new PtyProcess({
26
+ label: "test",
27
+ command: "pi",
28
+ context: { cwd: "/tmp", ui: { notify() {} } } as never,
29
+ onExit() {},
30
+ scope,
31
+ });
32
+
33
+ process.render(120, 40, false);
34
+ await Promise.resolve();
35
+ expect(requests.find((request) => request.op === "exec")).toMatchObject({ rows: 40, cols: 120 });
36
+ process.render(100, 30, false);
37
+ process.render(110, 35, false);
38
+ for (const data of "abc") process.sendInput(data);
39
+ await Bun.sleep(0);
40
+ expect(requests.filter((request) => request.op === "resize" || request.op === "write")).toEqual([
41
+ { op: "resize", process_id: "process-1", rows: 35, cols: 110 },
42
+ { op: "write", process_id: "process-1", chunk: "abc" },
43
+ ]);
44
+
45
+ let immediateRenders = 0;
46
+ const tui = {
47
+ requestImmediateRender: () => (immediateRenders += 1),
48
+ } as never as TUI;
49
+ const pane = new PtyPane(process, { tui, rows: () => 35, requestRender() {} });
50
+ finishRead?.({
51
+ chunks: [{ seq: 1, stream: "pty", chunk: Buffer.from("abc").toString("base64") }],
52
+ nextSeq: 2,
53
+ exited: false,
54
+ closed: false,
55
+ });
56
+ await Bun.sleep(0);
57
+ expect(immediateRenders).toBe(1);
58
+
59
+ pane.dispose();
60
+ process.dispose();
61
+ await host.shutdown();
62
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ const PUBLIC_ENTRYPOINTS = [
4
+ "@luan.sh/pi-libtui",
5
+ "@luan.sh/pi-libtui/diff",
6
+ "@luan.sh/pi-libtui/editor",
7
+ "@luan.sh/pi-libtui/folding",
8
+ "@luan.sh/pi-libtui/mouse",
9
+ "@luan.sh/pi-libtui/selection",
10
+ "@luan.sh/pi-libtui/stream",
11
+ "@luan.sh/pi-libtui/terminal",
12
+ "@luan.sh/pi-libtui/tool",
13
+ ] as const;
14
+
15
+ const CAPABILITY_KEYS = [
16
+ Symbol.for("pi-libtui/editor/registry/v1"),
17
+ Symbol.for("pi-libtui/folding/registry/v2"),
18
+ Symbol.for("pi-libtui/mouse/registry/v1"),
19
+ Symbol.for("pi-libtui/selection/v1"),
20
+ Symbol.for("pi-libtui/split-panes/v2"),
21
+ Symbol.for("pi-libtui.motionScheduler.v1"),
22
+ ] as const;
23
+
24
+ describe("public module boundaries", () => {
25
+ test("loads every documented public entrypoint without loading the host extension", async () => {
26
+ const before = CAPABILITY_KEYS.map((key) => Object.hasOwn(globalThis, key));
27
+ for (const entrypoint of PUBLIC_ENTRYPOINTS) {
28
+ const module = await import(entrypoint);
29
+ expect(typeof module).toBe("object");
30
+ }
31
+ const after = CAPABILITY_KEYS.map((key) => Object.hasOwn(globalThis, key));
32
+ expect(after).toEqual(before);
33
+ const mouse = await import("@luan.sh/pi-libtui/mouse");
34
+ expect(Object.keys(mouse).sort()).toEqual([
35
+ "FULLSCREEN_LAYOUT_CAPABILITY_KEY",
36
+ "FULLSCREEN_LAYOUT_PROTOCOL",
37
+ "MOUSE_PROTOCOL",
38
+ "MOUSE_REGISTRY_KEY",
39
+ "TEXT_INTERACTION_TARGET",
40
+ "ensureMouseRegistry",
41
+ "getFullscreenLayoutCapability",
42
+ "preserveViewportOnResize",
43
+ "publishFullscreenLayoutCapability",
44
+ "registerModalPointerShield",
45
+ "resolveFullscreenLayout",
46
+ ]);
47
+ const editor = await import("@luan.sh/pi-libtui/editor");
48
+ expect(Object.keys(editor).sort()).toEqual([
49
+ "EDITOR_PROTOCOL",
50
+ "EDITOR_REGISTRY_KEY",
51
+ "SemanticEditor",
52
+ "composeEditorStatus",
53
+ "dispatchEditorPaste",
54
+ "dispatchEditorRender",
55
+ "editorCompositionCadenceMs",
56
+ "editorCompositionContentWidth",
57
+ "editorStatusSeparator",
58
+ "ensureEditorRegistry",
59
+ "installEditorLayer",
60
+ "installEditorMinimumRows",
61
+ "renderEditorComposition",
62
+ "renderEditorCompositionPreview",
63
+ "renderEditorCompositionStatus",
64
+ "semanticEditorTheme",
65
+ ]);
66
+ });
67
+
68
+ test("keeps extension activation explicit", async () => {
69
+ const before = CAPABILITY_KEYS.map((key) => Object.hasOwn(globalThis, key));
70
+ const extension = await import("../src/extension.ts");
71
+ expect(typeof extension.default).toBe("function");
72
+ const after = CAPABILITY_KEYS.map((key) => Object.hasOwn(globalThis, key));
73
+ expect(after).toEqual(before);
74
+ });
75
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { RenderedLinesCache } from "../src/render-cache.ts";
3
+
4
+ describe("RenderedLinesCache", () => {
5
+ test("uses exact width and revision keys with bounded widths", () => {
6
+ const cache = new RenderedLinesCache({ maxWidths: 3, maxContentKeysPerWidth: 2 });
7
+ const first = cache.get(80, "r1", () => ["same"]);
8
+ expect(cache.get(80, "r1", () => ["other"])).toBe(first);
9
+ cache.get(80, "r2", () => ["second"]);
10
+ cache.get(80, "r3", () => ["third"]);
11
+ expect(cache.get(80, "r1", () => ["new first"])).not.toBe(first);
12
+ for (let width = 1; width < 1_000; width++) cache.get(width, "r", () => [String(width)]);
13
+ expect(cache.widthCount).toBe(3);
14
+ });
15
+
16
+ test("normalizes non-finite width and content limits", () => {
17
+ const cache = new RenderedLinesCache({ maxWidths: Number.NaN, maxContentKeysPerWidth: Number.POSITIVE_INFINITY });
18
+ for (let width = 0; width < 100; width++) {
19
+ for (let revision = 0; revision < 10; revision++) {
20
+ cache.get(width, String(revision), () => [`${width}:${revision}`]);
21
+ }
22
+ }
23
+ expect(cache.widthCount).toBe(4);
24
+ });
25
+ });
@@ -0,0 +1,119 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import type { Component } from "@earendil-works/pi-tui";
4
+ import { configureTuiAppearance, DEFAULT_TUI_APPEARANCE } from "../src/appearance.ts";
5
+ import { BackgroundSurface } from "../src/background-surface.ts";
6
+ import { rgb } from "../src/color/palette.ts";
7
+ import { MarkdownText } from "../src/content/text.ts";
8
+ import { parseUnifiedDiff } from "../src/diff/index.ts";
9
+ import { RenderedLinesCache } from "../src/render-cache.ts";
10
+ import { type MeasuredTerminalColors, terminalColorsRegistry } from "../src/terminal-colors.ts";
11
+ import { ToolActivity } from "../src/tool/activity.ts";
12
+
13
+ const theme = { name: "harmonious", bold: (text: string) => text, getColorMode: () => "truecolor" } as Theme;
14
+
15
+ afterEach(() => {
16
+ configureTuiAppearance(DEFAULT_TUI_APPEARANCE);
17
+ terminalColorsRegistry().publish(undefined);
18
+ });
19
+
20
+ describe("shared render epoch", () => {
21
+ test("invalidates rendered-line caches after an effective appearance change", () => {
22
+ configureTuiAppearance({ iconPack: "unicode" });
23
+ const cache = new RenderedLinesCache();
24
+ let renders = 0;
25
+ const first = cache.get(20, "stable", () => {
26
+ renders += 1;
27
+ return ["first"];
28
+ });
29
+
30
+ configureTuiAppearance({ iconPack: "emoji" });
31
+ const second = cache.get(20, "stable", () => {
32
+ renders += 1;
33
+ return ["second"];
34
+ });
35
+
36
+ expect(renders).toBe(2);
37
+ expect(second).not.toBe(first);
38
+ expect(cache.get(20, "stable", () => ["third"])).toBe(second);
39
+ });
40
+
41
+ test("repaints a background surface after terminal colors change", () => {
42
+ const registry = terminalColorsRegistry();
43
+ registry.publish(terminalProfile(10));
44
+ const surface = new BackgroundSurface({
45
+ theme,
46
+ component: staticComponent("same content"),
47
+ background: "surface.inset",
48
+ });
49
+
50
+ const first = surface.render(40);
51
+ registry.publish(terminalProfile(180));
52
+ const second = surface.render(40);
53
+
54
+ expect(second).not.toBe(first);
55
+ expect(second).not.toEqual(first);
56
+ registry.publish(terminalProfile(180));
57
+ expect(surface.render(40)).toBe(second);
58
+ });
59
+
60
+ test("refreshes ToolActivity diff themes when terminal colors change", () => {
61
+ const registry = terminalColorsRegistry();
62
+ registry.publish(terminalProfile(10));
63
+ const activity = new ToolActivity({
64
+ theme,
65
+ requestRender() {},
66
+ view: {
67
+ action: { verb: "edit", status: "succeeded" },
68
+ payload: { kind: "diff", model: parseUnifiedDiff("--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new") },
69
+ },
70
+ });
71
+
72
+ const firstLine = activity.render(60).find((line) => line.includes("new"));
73
+ registry.publish(terminalProfile(180));
74
+ const secondLine = activity.render(60).find((line) => line.includes("new"));
75
+
76
+ expect(firstLine).toBeDefined();
77
+ expect(secondLine).toBeDefined();
78
+ expect(secondLine).not.toBe(firstLine);
79
+ activity.dispose();
80
+ });
81
+
82
+ test("rerenders Markdown at the same width after the render epoch changes", () => {
83
+ let marker = "first";
84
+ const markdown = new MarkdownText({
85
+ theme: {
86
+ ...theme,
87
+ bold: (text: string) => `${marker}:${text}`,
88
+ } as Theme,
89
+ text: "**content**",
90
+ });
91
+
92
+ const first = markdown.render(40);
93
+ marker = "second";
94
+ terminalColorsRegistry().publish(terminalProfile(10));
95
+ const second = markdown.render(40);
96
+
97
+ expect(first.join("\n")).toContain("first:content");
98
+ expect(second.join("\n")).toContain("second:content");
99
+ expect(second).not.toBe(first);
100
+ });
101
+ });
102
+
103
+ function staticComponent(text: string): Component {
104
+ return {
105
+ render: () => [text],
106
+ invalidate() {},
107
+ };
108
+ }
109
+
110
+ function terminalProfile(seed: number): MeasuredTerminalColors {
111
+ const base16 = Array.from({ length: 16 }, (_, index) => rgb(seed + index, seed + index * 2, seed + index * 3));
112
+ return {
113
+ defaultBackground: base16[0],
114
+ defaultForeground: base16[15],
115
+ ansiBase16: base16,
116
+ indexedPalette: "custom",
117
+ scheme: "dark",
118
+ };
119
+ }