@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,205 @@
1
+ import { expect, test } from "bun:test";
2
+ import {
3
+ CustomEditor,
4
+ type ExtensionAPI,
5
+ type ExtensionCommandContext,
6
+ type ExtensionContext,
7
+ type Theme,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { dispatchEditorRender, ensureEditorRegistry } from "../../src/editor.ts";
10
+ import mouseExtension from "../../src/extension.ts";
11
+
12
+ type CommandHandler = (args: string, ctx: ExtensionCommandContext) => void | Promise<void>;
13
+ type EventHandler = (event: object, ctx: ExtensionContext) => void | Promise<void>;
14
+
15
+ // type-boundary: The focused extension harness implements only the ExtensionAPI methods exercised by mouseExtension.
16
+ type ExtensionApiBoundary = unknown;
17
+
18
+ // type-boundary: The focused context harness is narrowed to the UI methods exercised by command and lifecycle handlers.
19
+ type ExtensionContextBoundary = unknown;
20
+
21
+ // type-boundary: setWidget content is intentionally opaque to this registration test.
22
+ type WidgetContentBoundary = unknown;
23
+
24
+ // type-boundary: the focused theme harness implements only the color methods used by the paste decorator.
25
+ type ThemeBoundary = unknown;
26
+
27
+ const testTheme = {
28
+ name: "test",
29
+ getColorMode: () => "truecolor",
30
+ getFgAnsi: () => "\x1b[39m",
31
+ getBgAnsi: () => "\x1b[49m",
32
+ } as ThemeBoundary as Theme;
33
+
34
+ function stripAnsi(value: string): string {
35
+ return value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
36
+ }
37
+
38
+ function createPiHarness(eventBus?: object): {
39
+ api: ExtensionAPI;
40
+ handlers: Map<string, EventHandler[]>;
41
+ commands: Map<string, CommandHandler>;
42
+ } {
43
+ const handlers = new Map<string, EventHandler[]>();
44
+ const commands = new Map<string, CommandHandler>();
45
+ const piHarness = {
46
+ ...(eventBus === undefined ? {} : { events: eventBus }),
47
+ registerCommand(name: string, command: { handler: CommandHandler }) {
48
+ commands.set(name, command.handler);
49
+ },
50
+ on(name: string, handler: EventHandler) {
51
+ handlers.set(name, [...(handlers.get(name) ?? []), handler]);
52
+ },
53
+ };
54
+ const boundary: ExtensionApiBoundary = piHarness;
55
+ return { api: boundary as ExtensionAPI, handlers, commands };
56
+ }
57
+
58
+ function createContext(widgets: Array<{ key: string; content: WidgetContentBoundary }>): ExtensionContext {
59
+ const contextHarness = {
60
+ mode: "tui" as const,
61
+ hasUI: true,
62
+ ui: {
63
+ theme: testTheme,
64
+ setWorkingIndicator() {},
65
+ setWorkingMessage() {},
66
+ getTheme: () => testTheme,
67
+ setTheme() {},
68
+ setWidget(key: string, content: WidgetContentBoundary) {
69
+ widgets.push({ key, content });
70
+ },
71
+ notify() {},
72
+ },
73
+ };
74
+ const boundary: ExtensionContextBoundary = contextHarness;
75
+ return boundary as ExtensionContext;
76
+ }
77
+
78
+ test("lifecycle mounts and removes the invisible bridge", async () => {
79
+ const commands = new Map<string, CommandHandler>();
80
+ const events = new Map<string, EventHandler>();
81
+ const piHarness = {
82
+ registerCommand(name: string, command: { handler: CommandHandler }) {
83
+ commands.set(name, command.handler);
84
+ },
85
+ on(name: string, handler: EventHandler) {
86
+ events.set(name, handler);
87
+ },
88
+ };
89
+ const boundary: ExtensionApiBoundary = piHarness;
90
+ mouseExtension(boundary as ExtensionAPI);
91
+ expect(commands.has("libtui:colors")).toBe(true);
92
+
93
+ const widgets: Array<{ key: string; content: WidgetContentBoundary }> = [];
94
+ const notifications: string[] = [];
95
+ const contextHarness = {
96
+ mode: "tui",
97
+ hasUI: true,
98
+ ui: {
99
+ setWidget(key: string, content: WidgetContentBoundary) {
100
+ widgets.push({ key, content });
101
+ },
102
+ notify(message: string) {
103
+ notifications.push(message);
104
+ },
105
+ },
106
+ };
107
+ const contextBoundary: ExtensionContextBoundary = contextHarness;
108
+ const eventContext = contextBoundary as ExtensionContext;
109
+
110
+ await events.get("session_start")?.({}, eventContext);
111
+ expect(widgets.at(-1)?.key).toBe("pi-libtui.mouse-bridge");
112
+ expect(typeof widgets.at(-1)?.content).toBe("function");
113
+
114
+ await events.get("session_shutdown")?.({}, eventContext);
115
+ expect(widgets.at(-1)).toEqual({ key: "pi-libtui.mouse-bridge", content: undefined });
116
+ expect(notifications).toEqual([]);
117
+ });
118
+
119
+ test("does not claim the shared TUI host for a non-UI TUI session", async () => {
120
+ const events = new Map<string, EventHandler>();
121
+ const piHarness = {
122
+ on(name: string, handler: EventHandler) {
123
+ events.set(name, handler);
124
+ },
125
+ registerCommand() {},
126
+ };
127
+ const boundary: ExtensionApiBoundary = piHarness;
128
+ mouseExtension(boundary as ExtensionAPI);
129
+
130
+ const widgets: Array<{ key: string; content: WidgetContentBoundary }> = [];
131
+ const contextBoundary: ExtensionContextBoundary = {
132
+ mode: "tui",
133
+ hasUI: false,
134
+ ui: {
135
+ setWidget(key: string, content: WidgetContentBoundary) {
136
+ widgets.push({ key, content });
137
+ },
138
+ },
139
+ };
140
+ const context = contextBoundary as ExtensionContext;
141
+
142
+ await events.get("session_start")?.({}, context);
143
+ await events.get("session_shutdown")?.({ reason: "quit" }, context);
144
+
145
+ expect(widgets).toEqual([]);
146
+ });
147
+
148
+ test("duplicate factory copies share one host lease and a later API installs independently", async () => {
149
+ const sharedEventBus = {};
150
+ const first = createPiHarness(sharedEventBus);
151
+ const factories = await Promise.all(
152
+ ["one", "two", "three"].map(async (copy) => {
153
+ const module = await import(`../../src/extension.ts?host-copy=${copy}`);
154
+ return module.default;
155
+ }),
156
+ );
157
+ for (const factory of factories) factory(first.api);
158
+
159
+ expect(first.handlers.get("session_start")).toHaveLength(1);
160
+ expect(first.handlers.get("session_shutdown")).toHaveLength(2);
161
+
162
+ const widgets: Array<{ key: string; content: WidgetContentBoundary }> = [];
163
+ const context = createContext(widgets);
164
+ const originalRender = CustomEditor.prototype.render;
165
+ await first.handlers.get("session_start")?.[0]?.({}, context);
166
+ expect(widgets).toHaveLength(1);
167
+ expect(widgets[0]?.key).toBe("pi-libtui.mouse-bridge");
168
+ expect(CustomEditor.prototype.render).not.toBe(originalRender);
169
+
170
+ const registry = ensureEditorRegistry();
171
+ const rendered = dispatchEditorRender(registry, ["[paste #1 +1 lines]"], 40).join("\n");
172
+ expect(stripAnsi(rendered)).toContain("paste #1 +1 lines");
173
+
174
+ let replacementCalls = 0;
175
+ const removeReplacement = registry.registerRenderDecorator({
176
+ id: "pi-libtui.native-paste-markers",
177
+ decorate: (lines) => {
178
+ replacementCalls += 1;
179
+ return [...lines, "replacement"];
180
+ },
181
+ });
182
+ expect(dispatchEditorRender(registry, ["line"], 40)).toEqual(["line", "replacement"]);
183
+ expect(replacementCalls).toBe(1);
184
+ removeReplacement();
185
+
186
+ for (const handler of first.handlers.get("session_shutdown") ?? []) await handler({ reason: "switch" }, context);
187
+ expect(widgets.at(-1)).toEqual({ key: "pi-libtui.mouse-bridge", content: undefined });
188
+ expect(CustomEditor.prototype.render).toBe(originalRender);
189
+ const lateCopyName = "after-switch";
190
+ const lateCopy = await import(`../../src/extension.ts?host-copy=${lateCopyName}`);
191
+ lateCopy.default(first.api);
192
+ expect(first.handlers.get("session_start")).toHaveLength(1);
193
+ for (const handler of first.handlers.get("session_shutdown") ?? []) await handler({ reason: "reload" }, context);
194
+
195
+ const second = createPiHarness(sharedEventBus);
196
+ factories[0]!(second.api);
197
+ expect(second.handlers.get("session_start")).toHaveLength(1);
198
+ expect(second.handlers.get("session_shutdown")).toHaveLength(2);
199
+ const secondWidgets: Array<{ key: string; content: WidgetContentBoundary }> = [];
200
+ const secondContext = createContext(secondWidgets);
201
+ await second.handlers.get("session_start")?.[0]?.({}, secondContext);
202
+ expect(secondWidgets).toHaveLength(1);
203
+ for (const handler of second.handlers.get("session_shutdown") ?? [])
204
+ await handler({ reason: "reload" }, secondContext);
205
+ });
@@ -0,0 +1,140 @@
1
+ import { expect, test } from "bun:test";
2
+ import { getMouseRegistryState } from "../../src/mouse/registry.ts";
3
+ import { ensureMouseRegistry, MOUSE_PROTOCOL, registerModalPointerShield } from "../../src/mouse.ts";
4
+
5
+ test("the overlay registry is load-order independent and disposes by identity", () => {
6
+ const scope = Object.create(null) as typeof globalThis;
7
+ const registry = ensureMouseRegistry(scope);
8
+ const first = { id: "same-id", getRect: () => ({ x: 0, y: 0, width: 1, height: 1 }), onMouse: () => false };
9
+ const second = { ...first };
10
+ const removeFirst = registry.registerOverlayRegion(first);
11
+ registry.registerOverlayRegion(second);
12
+ expect(ensureMouseRegistry(scope)).toBe(registry);
13
+ expect(registry.protocol).toBe(MOUSE_PROTOCOL);
14
+ removeFirst();
15
+ expect(getMouseRegistryState(registry).regions).toEqual([second]);
16
+ });
17
+
18
+ test("modal pointer shields consume selection gestures but leave wheel and hover available", () => {
19
+ const registry = ensureMouseRegistry(Object.create(null) as typeof globalThis);
20
+ const remove = registerModalPointerShield(registry, {
21
+ id: "dialog.shield",
22
+ getRect: () => ({ x: 0, y: 0, width: 80, height: 24 }),
23
+ });
24
+ const shield = getMouseRegistryState(registry).regions[0]!;
25
+ const event = {
26
+ row: 0,
27
+ col: 0,
28
+ screenRow: 0,
29
+ screenCol: 0,
30
+ button: undefined,
31
+ wheel: undefined,
32
+ shift: false,
33
+ alt: false,
34
+ ctrl: false,
35
+ };
36
+ expect(shield.onMouse({ ...event, type: "press", button: 0 })).toBe(true);
37
+ expect(shield.onMouse({ ...event, type: "drag", button: 0 })).toBe(true);
38
+ expect(shield.onMouse({ ...event, type: "release", button: 0 })).toBe(true);
39
+ expect(shield.onMouse({ ...event, type: "wheel", wheel: -1 })).toBe(false);
40
+ remove();
41
+ expect(getMouseRegistryState(registry).regions).toEqual([]);
42
+ });
43
+
44
+ test("viewport input handlers are prioritized, composable, isolated, and disposable", () => {
45
+ const registry = ensureMouseRegistry(Object.create(null) as typeof globalThis);
46
+ const calls: string[] = [];
47
+ registry.registerViewportInputHandler({
48
+ id: "low",
49
+ handle(data) {
50
+ calls.push(`low:${data}`);
51
+ return undefined;
52
+ },
53
+ });
54
+ registry.registerViewportInputHandler({
55
+ id: "broken",
56
+ priority: 5,
57
+ handle() {
58
+ throw new Error("optional");
59
+ },
60
+ });
61
+ const removeHigh = registry.registerViewportInputHandler({
62
+ id: "high",
63
+ priority: 10,
64
+ handle(data) {
65
+ calls.push(`high:${data}`);
66
+ return { data: `${data}!` };
67
+ },
68
+ });
69
+ expect(registry.dispatchViewportInput("key")).toEqual({ data: "key!", consumed: false });
70
+ expect(calls).toEqual(["high:key", "low:key!"]);
71
+ removeHigh();
72
+ calls.length = 0;
73
+ const removeConsumer = registry.registerViewportInputHandler({
74
+ id: "consume",
75
+ priority: 20,
76
+ handle: () => ({ consume: true, data: "handled" }),
77
+ });
78
+ expect(registry.dispatchViewportInput("key")).toEqual({ data: "handled", consumed: true });
79
+ expect(calls).toEqual([]);
80
+ removeConsumer();
81
+ });
82
+
83
+ test("native copy deferrers isolate failures", () => {
84
+ const registry = ensureMouseRegistry(Object.create(null) as typeof globalThis);
85
+ registry.registerNativeCopyDeferrer(() => {
86
+ throw new Error("optional");
87
+ });
88
+ registry.registerNativeCopyDeferrer(() => false);
89
+ expect(registry.shouldDeferNativeCopy()).toBe(false);
90
+ const remove = registry.registerNativeCopyDeferrer(() => true);
91
+ expect(registry.shouldDeferNativeCopy()).toBe(true);
92
+ remove();
93
+ expect(registry.shouldDeferNativeCopy()).toBe(false);
94
+ });
95
+
96
+ test("screen decorators compose by priority and isolate failures", () => {
97
+ const registry = ensureMouseRegistry(Object.create(null) as typeof globalThis);
98
+ const calls: string[] = [];
99
+ const transcriptLines = ["logical zero", "logical one"];
100
+ let observedTranscriptLines: readonly string[] | undefined;
101
+ registry.registerScreenDecorator({
102
+ id: "low",
103
+ decorate(screen, context) {
104
+ observedTranscriptLines = context.transcriptLines;
105
+ calls.push(`low:${context.width}:${screen[0]}`);
106
+ return [...screen, "low"];
107
+ },
108
+ });
109
+ registry.registerScreenDecorator({
110
+ id: "broken",
111
+ priority: 5,
112
+ decorate() {
113
+ throw new Error("optional");
114
+ },
115
+ });
116
+ const removeHigh = registry.registerScreenDecorator({
117
+ id: "high",
118
+ priority: 10,
119
+ decorate(screen) {
120
+ calls.push(`high:${screen[0]}`);
121
+ return screen.map((line) => `${line}!`);
122
+ },
123
+ });
124
+ expect(
125
+ registry.dispatchScreenDecorators(["native"], {
126
+ width: 80,
127
+ height: 24,
128
+ hasOverlay: false,
129
+ transcriptLines,
130
+ }),
131
+ ).toEqual(["native!", "low"]);
132
+ expect(observedTranscriptLines).toBe(transcriptLines);
133
+ expect(calls).toEqual(["high:native", "low:80:native!"]);
134
+ removeHigh();
135
+ calls.length = 0;
136
+ expect(registry.dispatchScreenDecorators(["native"], { width: 80, height: 24, hasOverlay: false })).toEqual([
137
+ "native",
138
+ "low",
139
+ ]);
140
+ });
@@ -0,0 +1,167 @@
1
+ import { expect, test } from "bun:test";
2
+ import { installScreenDecoration } from "../../src/host/mouse-bridge.ts";
3
+ import { ensureMouseRegistry, type ScreenDecorationContext } from "../../src/mouse.ts";
4
+
5
+ interface DecorationRenderer {
6
+ terminal: { columns: number; rows: number };
7
+ currentLayout: object;
8
+ selectionAnchor?: object;
9
+ selectionFocus?: object;
10
+ hasOverlay(): boolean;
11
+ applySelection(screen: string[], layout?: object): string[];
12
+ }
13
+
14
+ function rendererWith(prototype: object, hasOverlay = false, selectionActive = false): DecorationRenderer {
15
+ const renderer = {
16
+ terminal: { columns: 90, rows: 30 },
17
+ currentLayout: { identity: "fallback" },
18
+ selectionAnchor: selectionActive ? {} : undefined,
19
+ selectionFocus: selectionActive ? {} : undefined,
20
+ hasOverlay: () => hasOverlay,
21
+ } as DecorationRenderer;
22
+ Object.setPrototypeOf(renderer, prototype);
23
+ return renderer;
24
+ }
25
+
26
+ test("screen decoration runs after native selection with the exact current layout and ref-counts", () => {
27
+ const nativeCalls: string[][] = [];
28
+ const prototype = {
29
+ applySelection(screen: string[]): string[] {
30
+ nativeCalls.push(screen);
31
+ return [...screen, "native-selection"];
32
+ },
33
+ };
34
+ const original = prototype.applySelection;
35
+ const registry = ensureMouseRegistry(Object.create(null) as typeof globalThis);
36
+ const contexts: object[] = [];
37
+ registry.registerScreenDecorator({
38
+ id: "copy-mode",
39
+ decorate(screen, context) {
40
+ contexts.push(context);
41
+ return [...screen, "decorated"];
42
+ },
43
+ });
44
+ const firstDispose = installScreenDecoration(prototype, registry);
45
+ const patched = prototype.applySelection;
46
+ const secondDispose = installScreenDecoration(prototype, registry);
47
+ const renderer = rendererWith(prototype, true);
48
+ const replacementRenderer = rendererWith(prototype, false);
49
+ expect(renderer.applySelection(["screen"], { identity: "passed-layout" })).toEqual([
50
+ "screen",
51
+ "native-selection",
52
+ "decorated",
53
+ ]);
54
+ expect(replacementRenderer.applySelection(["replacement"], { identity: "passed-layout" })).toEqual([
55
+ "replacement",
56
+ "native-selection",
57
+ "decorated",
58
+ ]);
59
+ expect(nativeCalls).toEqual([["screen"], ["replacement"]]);
60
+ expect(contexts).toEqual([
61
+ { width: 90, height: 30, hasOverlay: true, selectionActive: false, viewport: undefined },
62
+ { width: 90, height: 30, hasOverlay: false, selectionActive: false, viewport: undefined },
63
+ ]);
64
+ firstDispose();
65
+ expect(prototype.applySelection).toBe(patched);
66
+ secondDispose();
67
+ expect(prototype.applySelection).toBe(original);
68
+ });
69
+
70
+ test("screen decoration reports native selection state", () => {
71
+ const prototype = { applySelection: (screen: string[]) => screen };
72
+ const registry = ensureMouseRegistry(Object.create(null) as typeof globalThis);
73
+ let selectionActive = false;
74
+ registry.registerScreenDecorator({
75
+ id: "selection-state",
76
+ decorate(screen, context) {
77
+ selectionActive = context.selectionActive === true;
78
+ return screen;
79
+ },
80
+ });
81
+ const dispose = installScreenDecoration(prototype, registry);
82
+ rendererWith(prototype, false, true).applySelection(["screen"]);
83
+ expect(selectionActive).toBe(true);
84
+ dispose();
85
+ });
86
+
87
+ test("screen decoration exposes current logical transcript lines with their viewport", () => {
88
+ const prototype = { applySelection: (screen: string[]) => screen };
89
+ const registry = ensureMouseRegistry(Object.create(null) as typeof globalThis);
90
+ const lines = ["zero", "one", "two", "three"];
91
+ const scrollView = { scrollTop: 1, viewportHeight: 2, scrollTo() {} };
92
+ const component = { render: () => lines, invalidate() {} };
93
+ const renderer = rendererWith(prototype);
94
+ renderer.currentLayout = {
95
+ root: {
96
+ component,
97
+ rect: { x: 3, y: 4, width: 20, height: 2 },
98
+ clip: { x: 3, y: 4, width: 20, height: 2 },
99
+ children: [],
100
+ scrollView,
101
+ scrollContentLines: lines,
102
+ },
103
+ primaryScrollView: scrollView,
104
+ };
105
+ let context: ScreenDecorationContext | undefined;
106
+ registry.registerScreenDecorator({
107
+ id: "transcript",
108
+ decorate(screen, current) {
109
+ context = current;
110
+ return screen;
111
+ },
112
+ });
113
+ const dispose = installScreenDecoration(prototype, registry);
114
+ renderer.applySelection(["screen"]);
115
+
116
+ expect(context?.viewport).toEqual({ x: 3, y: 4, width: 20, height: 2, scrollTop: 1 });
117
+ expect(context?.transcriptLines).toBe(lines);
118
+ dispose();
119
+ });
120
+
121
+ test("no decorators preserve the exact native return", () => {
122
+ const native = ["same-array"];
123
+ const prototype = { applySelection: () => native };
124
+ const registry = ensureMouseRegistry(Object.create(null) as typeof globalThis);
125
+ const dispose = installScreenDecoration(prototype, registry);
126
+ const renderer = rendererWith(prototype);
127
+
128
+ expect(renderer.applySelection(["input"])).toBe(native);
129
+ dispose();
130
+ });
131
+
132
+ test("screen decoration lease survives a later delegating wrapper without stacking", () => {
133
+ const prototype = {
134
+ applySelection(screen: string[]): string[] {
135
+ return [...screen, "native"];
136
+ },
137
+ };
138
+ const original = prototype.applySelection;
139
+ const registry = ensureMouseRegistry(Object.create(null) as typeof globalThis);
140
+ let decorations = 0;
141
+ registry.registerScreenDecorator({
142
+ id: "count",
143
+ decorate(screen) {
144
+ decorations += 1;
145
+ return [...screen, "decorated"];
146
+ },
147
+ });
148
+ const firstDispose = installScreenDecoration(prototype, registry);
149
+ const decorationBridge = prototype.applySelection;
150
+ const wrapper = function (this: DecorationRenderer, screen: string[], layout?: object): string[] {
151
+ return Reflect.apply(decorationBridge, this, [screen, layout]);
152
+ };
153
+ prototype.applySelection = wrapper;
154
+ const renderer = rendererWith(prototype);
155
+ firstDispose();
156
+ expect(renderer.applySelection(["screen"])).toEqual(["screen", "native"]);
157
+ expect(decorations).toBe(0);
158
+
159
+ const secondDispose = installScreenDecoration(prototype, registry);
160
+ expect(prototype.applySelection).toBe(wrapper);
161
+ expect(renderer.applySelection(["screen"])).toEqual(["screen", "native", "decorated"]);
162
+ expect(decorations).toBe(1);
163
+
164
+ prototype.applySelection = decorationBridge;
165
+ secondDispose();
166
+ expect(prototype.applySelection).toBe(original);
167
+ });
@@ -0,0 +1,52 @@
1
+ import { expect, test } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { locateNativeBinary, nativeBuildArguments, TERMINAL_BRIDGE } from "../src/native-binary.ts";
4
+
5
+ const ENV = TERMINAL_BRIDGE.env;
6
+ const options = (executables: readonly string[], environment: Record<string, string | undefined> = {}) => ({
7
+ environment,
8
+ platform: "linux" as const,
9
+ nativeRoot: "/agent/native",
10
+ rev: "v1.2.3",
11
+ isExecutable: (path: string) => executables.includes(path),
12
+ });
13
+ const managed = join("/agent/native", "terminal-bridge", "v1.2.3", "bin", "terminal_bridge");
14
+
15
+ test("env override wins and must be executable", () => {
16
+ expect(locateNativeBinary(TERMINAL_BRIDGE, options(["/x/bridge"], { [ENV]: "/x/bridge" }))).toEqual({
17
+ kind: "found",
18
+ path: "/x/bridge",
19
+ });
20
+ expect(() => locateNativeBinary(TERMINAL_BRIDGE, options([], { [ENV]: "/x/missing" }))).toThrow("not executable");
21
+ });
22
+
23
+ test("a managed build for the pinned revision is used when present", () => {
24
+ expect(locateNativeBinary(TERMINAL_BRIDGE, options([managed]))).toEqual({ kind: "found", path: managed });
25
+ });
26
+
27
+ test("a miss outside a checkout asks for a build into the revision root", () => {
28
+ expect(locateNativeBinary(TERMINAL_BRIDGE, options([]))).toEqual({
29
+ kind: "build",
30
+ installRoot: join("/agent/native", "terminal-bridge", "v1.2.3"),
31
+ path: managed,
32
+ });
33
+ });
34
+
35
+ test("a checkout without a built binary is reported instead of built", () => {
36
+ const location = locateNativeBinary(TERMINAL_BRIDGE, { ...options([]), developmentStart: import.meta.dirname });
37
+ expect(location.kind).toBe("unbuilt-checkout");
38
+ });
39
+
40
+ test("build arguments pin the revision and install root", () => {
41
+ expect(nativeBuildArguments(TERMINAL_BRIDGE, "v1.2.3", "/agent/native/terminal-bridge/v1.2.3")).toEqual([
42
+ "install",
43
+ "--git",
44
+ "https://github.com/luan/agents",
45
+ "--rev",
46
+ "v1.2.3",
47
+ "--root",
48
+ "/agent/native/terminal-bridge/v1.2.3",
49
+ "--locked",
50
+ "terminal-bridge",
51
+ ]);
52
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { generateColor256, rgb } from "../src/color/palette.ts";
3
+ import { parseBackgroundAnsi } from "../src/color/theme.ts";
4
+
5
+ describe("color palette", () => {
6
+ test("parses only the requested basic SGR destination", () => {
7
+ expect(parseBackgroundAnsi("\x1b[31m\x1b[44m")).toEqual(rgb(0, 0, 128));
8
+ expect(parseBackgroundAnsi("\x1b[91m\x1b[104m")).toEqual(rgb(0, 0, 255));
9
+ });
10
+
11
+ test("fills the color cube and grayscale ramp from theme anchors", () => {
12
+ const anchors = [
13
+ rgb(10, 20, 30),
14
+ rgb(200, 20, 20),
15
+ rgb(20, 200, 20),
16
+ rgb(200, 200, 20),
17
+ rgb(20, 20, 200),
18
+ rgb(200, 20, 200),
19
+ rgb(20, 200, 200),
20
+ rgb(230, 230, 230),
21
+ ];
22
+ const palette = generateColor256([...anchors, ...anchors], anchors[0]!, anchors[7]!);
23
+ expect(palette).toHaveLength(256);
24
+ expect(palette[16]).toEqual(anchors[0]);
25
+ expect(palette[21]).toEqual(anchors[4]);
26
+ expect(palette[46]).toEqual(anchors[2]);
27
+ expect(palette[196]).toEqual(anchors[1]);
28
+ expect(palette[231]).toEqual(anchors[7]);
29
+ expect(palette[232]).not.toEqual(palette[16]);
30
+ expect(palette[255]).not.toEqual(palette[231]);
31
+ });
32
+ });
@@ -0,0 +1,74 @@
1
+ import { expect, test } from "bun:test";
2
+ import { ensureSidePanelRegistry, SIDE_PANEL_PROTOCOL, type SidePanelProvider } from "../src/panels.ts";
3
+
4
+ function provider(id: string, session: object = {}): SidePanelProvider {
5
+ return { id, session, attach: () => undefined };
6
+ }
7
+
8
+ test("side-panel providers are load-order independent and identity safe", () => {
9
+ const scope = Object.create(null) as typeof globalThis;
10
+ const registry = ensureSidePanelRegistry(scope);
11
+ const first = provider("review");
12
+ const replacement = provider("review");
13
+
14
+ expect(ensureSidePanelRegistry(scope)).toBe(registry);
15
+ expect(registry.protocol).toBe(SIDE_PANEL_PROTOCOL);
16
+ expect(registry.version).toBe(1);
17
+ const removeFirst = registry.register(first);
18
+ const removeReplacement = registry.register(replacement);
19
+ removeFirst();
20
+ expect(registry.providers()).toEqual([replacement]);
21
+ removeReplacement();
22
+ expect(registry.providers()).toEqual([]);
23
+ });
24
+
25
+ test("side-panel host attaches providers in either load order and owns their lifecycle", () => {
26
+ const registry = ensureSidePanelRegistry(Object.create(null) as typeof globalThis);
27
+ const attached: string[] = [];
28
+ const detached: string[] = [];
29
+ const session = {};
30
+ const host = registry.installHost();
31
+ const detachHost = host.attach(session, {} as never, () => {});
32
+ const removeChat = registry.register({
33
+ id: "chat",
34
+ session,
35
+ attach: () => {
36
+ attached.push("chat");
37
+ return () => detached.push("chat");
38
+ },
39
+ });
40
+ const removeOther = registry.register({
41
+ id: "other",
42
+ session: {},
43
+ attach: () => {
44
+ attached.push("other");
45
+ },
46
+ });
47
+
48
+ expect(registry.hasHost()).toBe(true);
49
+ expect(attached).toEqual(["chat"]);
50
+ removeChat();
51
+ expect(detached).toEqual(["chat"]);
52
+ removeOther();
53
+ detachHost();
54
+ host.dispose();
55
+ expect(registry.hasHost()).toBe(false);
56
+ });
57
+
58
+ test("side-panel host isolates provider failures and attaches providers registered first", () => {
59
+ const registry = ensureSidePanelRegistry(Object.create(null) as typeof globalThis);
60
+ const session = {};
61
+ const errors: string[] = [];
62
+ const remove = registry.register({
63
+ id: "broken",
64
+ session,
65
+ attach: () => {
66
+ throw new Error("broken provider");
67
+ },
68
+ });
69
+ const host = registry.installHost();
70
+ expect(() => host.attach(session, {} as never, (provider) => errors.push(provider.id))).not.toThrow();
71
+ expect(errors).toEqual(["broken"]);
72
+ remove();
73
+ host.dispose();
74
+ });