pi-libtui 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +205 -0
  3. package/package.json +55 -0
  4. package/src/activity-presentation.ts +75 -0
  5. package/src/appearance.ts +632 -0
  6. package/src/background-surface.ts +125 -0
  7. package/src/color/palette.ts +186 -0
  8. package/src/color/preview.ts +157 -0
  9. package/src/color/resolver.ts +75 -0
  10. package/src/color/theme.ts +531 -0
  11. package/src/component-stack.ts +247 -0
  12. package/src/content/markdown-code.ts +60 -0
  13. package/src/content/terminal-text.ts +135 -0
  14. package/src/content/text.ts +105 -0
  15. package/src/controls/action-panel.ts +420 -0
  16. package/src/controls/dialog-button-bar.ts +287 -0
  17. package/src/controls/multi-select.ts +467 -0
  18. package/src/controls/picker-panel.ts +447 -0
  19. package/src/controls/screen-icon-actions.ts +134 -0
  20. package/src/controls/searchable-select.ts +394 -0
  21. package/src/controls/select-box.ts +224 -0
  22. package/src/controls/selectable-list.ts +433 -0
  23. package/src/controls/selection-action-bar.ts +382 -0
  24. package/src/controls/semantic-input.ts +23 -0
  25. package/src/controls/tab.ts +189 -0
  26. package/src/cursor.ts +288 -0
  27. package/src/decoration/editor-pills.ts +156 -0
  28. package/src/decoration/glyphs.ts +326 -0
  29. package/src/decoration/pointer-interaction.ts +123 -0
  30. package/src/decoration/powerline-pill.ts +104 -0
  31. package/src/decoration/status.ts +185 -0
  32. package/src/decoration/transient-pill.ts +176 -0
  33. package/src/diff/index.ts +21 -0
  34. package/src/diff/model.ts +76 -0
  35. package/src/diff/parse.ts +310 -0
  36. package/src/diff/pierre-syntax.ts +86 -0
  37. package/src/diff/render.ts +504 -0
  38. package/src/diff/structured.ts +264 -0
  39. package/src/diff/view.ts +75 -0
  40. package/src/editor/chrome.ts +341 -0
  41. package/src/editor/composition.ts +53 -0
  42. package/src/editor/layout.ts +81 -0
  43. package/src/editor/presentation.ts +35 -0
  44. package/src/editor/protocol.ts +188 -0
  45. package/src/editor.ts +41 -0
  46. package/src/extension.ts +30 -0
  47. package/src/folding.ts +192 -0
  48. package/src/host/cursor-bridge.ts +210 -0
  49. package/src/host/editor-bridge.ts +148 -0
  50. package/src/host/extension-host.ts +231 -0
  51. package/src/host/mouse-bridge.ts +212 -0
  52. package/src/host/mouse-dispatch.ts +400 -0
  53. package/src/host/mouse-input.ts +85 -0
  54. package/src/host/mouse-targets.ts +238 -0
  55. package/src/host/pi-layout-adapter.ts +298 -0
  56. package/src/host/pi-native-selection.ts +227 -0
  57. package/src/host/split-pane-bridge.ts +720 -0
  58. package/src/host/transcript-bridge.ts +236 -0
  59. package/src/index.ts +334 -0
  60. package/src/line-layout.ts +6 -0
  61. package/src/motion.ts +1409 -0
  62. package/src/mouse/events.ts +55 -0
  63. package/src/mouse/fullscreen-layout.ts +260 -0
  64. package/src/mouse/registry.ts +348 -0
  65. package/src/mouse.ts +37 -0
  66. package/src/native-binary.ts +189 -0
  67. package/src/overlay/anchored.ts +91 -0
  68. package/src/overlay/detail-card.ts +158 -0
  69. package/src/overlay/dialog.ts +266 -0
  70. package/src/overlay/floating.ts +106 -0
  71. package/src/overlay/framed-editor.ts +236 -0
  72. package/src/overlay/fullscreen.ts +170 -0
  73. package/src/overlay/hover-tooltip.ts +109 -0
  74. package/src/overlay/modal-mount.ts +118 -0
  75. package/src/panels.ts +207 -0
  76. package/src/render-cache.ts +80 -0
  77. package/src/render-epoch.ts +48 -0
  78. package/src/request-animation.ts +160 -0
  79. package/src/scrollbar.ts +27 -0
  80. package/src/selection.ts +148 -0
  81. package/src/split-pane.ts +232 -0
  82. package/src/status-presentation.ts +411 -0
  83. package/src/stream.ts +543 -0
  84. package/src/syntax.ts +316 -0
  85. package/src/terminal/bridge-client.ts +243 -0
  86. package/src/terminal/embedding.ts +6 -0
  87. package/src/terminal/output.ts +151 -0
  88. package/src/terminal/projection.ts +362 -0
  89. package/src/terminal/pty-host.ts +263 -0
  90. package/src/terminal/pty-pane.ts +308 -0
  91. package/src/terminal-colors.ts +334 -0
  92. package/src/tool/action.ts +170 -0
  93. package/src/tool/activity.ts +500 -0
  94. package/src/tool/call-preview.ts +44 -0
  95. package/src/tool/disclosure-action.ts +89 -0
  96. package/src/tool/expanded-region-viewport.ts +103 -0
  97. package/src/tool/index.ts +26 -0
  98. package/src/tool/output.ts +271 -0
  99. package/src/tool/transcript.ts +150 -0
  100. package/src/tool/view-region.ts +581 -0
  101. package/test/appearance.test.ts +183 -0
  102. package/test/bounded-stream.test.ts +150 -0
  103. package/test/bridge-client.test.ts +42 -0
  104. package/test/color-preview.test.ts +23 -0
  105. package/test/component-stack.test.ts +206 -0
  106. package/test/components.test.ts +1165 -0
  107. package/test/content-text.test.ts +21 -0
  108. package/test/cursor-bridge.test.ts +151 -0
  109. package/test/cursor.test.ts +147 -0
  110. package/test/editor-chrome.test.ts +119 -0
  111. package/test/editor-layout.test.ts +30 -0
  112. package/test/editor-registry.test.ts +203 -0
  113. package/test/editor-theme.test.ts +33 -0
  114. package/test/editor-token-pill.test.ts +72 -0
  115. package/test/floating-overlay.test.ts +57 -0
  116. package/test/folding.test.ts +147 -0
  117. package/test/harmonious-theme.test.ts +23 -0
  118. package/test/interaction.test.ts +104 -0
  119. package/test/key-icons.test.ts +62 -0
  120. package/test/markdown-code.test.ts +24 -0
  121. package/test/modal-overlay-mount.test.ts +94 -0
  122. package/test/motion.test.ts +649 -0
  123. package/test/mouse/bridge.test.ts +1302 -0
  124. package/test/mouse/extension.test.ts +205 -0
  125. package/test/mouse/registry.test.ts +140 -0
  126. package/test/mouse/screen-decoration.test.ts +167 -0
  127. package/test/native-binary.test.ts +52 -0
  128. package/test/palette.test.ts +32 -0
  129. package/test/panels.test.ts +74 -0
  130. package/test/pointer-components.test.ts +151 -0
  131. package/test/progress.test.ts +54 -0
  132. package/test/pty-host.test.ts +126 -0
  133. package/test/pty-pane.test.ts +62 -0
  134. package/test/public-surface.test.ts +75 -0
  135. package/test/render-cache.test.ts +25 -0
  136. package/test/render-epoch.test.ts +119 -0
  137. package/test/request-animation.test.ts +193 -0
  138. package/test/screen-icon-actions.test.ts +69 -0
  139. package/test/select-box.test.ts +86 -0
  140. package/test/select-pointer.test.ts +369 -0
  141. package/test/selectable-list.test.ts +258 -0
  142. package/test/selection/registry.test.ts +44 -0
  143. package/test/selection-action-bar.test.ts +204 -0
  144. package/test/split-pane-bridge.test.ts +944 -0
  145. package/test/split-pane.test.ts +134 -0
  146. package/test/status-presentation.test.ts +39 -0
  147. package/test/syntax.test.ts +59 -0
  148. package/test/terminal-colors.test.ts +115 -0
  149. package/test/terminal-embedding.test.ts +12 -0
  150. package/test/terminal-projection.test.ts +312 -0
  151. package/test/theme-colors.test.ts +175 -0
  152. package/test/tool-output.test.ts +160 -0
  153. package/test/tool-transcript.test.ts +1190 -0
  154. package/test/unified-diff.test.ts +691 -0
  155. package/themes/harmonious.json +61 -0
  156. package/tsconfig.json +16 -0
@@ -0,0 +1,1165 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { initTheme, type Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ CURSOR_MARKER,
5
+ KeybindingsManager,
6
+ setKeybindings,
7
+ stripTerminalSequences,
8
+ TUI_KEYBINDINGS,
9
+ visibleWidth,
10
+ } from "@earendil-works/pi-tui";
11
+ import {
12
+ ActionPanel,
13
+ backgroundAnsiAtColumn,
14
+ configureTuiAppearance,
15
+ contrastingPillBackground,
16
+ DEFAULT_TUI_APPEARANCE,
17
+ DialogButtonBar,
18
+ DialogOverlay,
19
+ DialogOverlayHost,
20
+ FramedEditorOverlay,
21
+ FullscreenOverlay,
22
+ MultiSelect,
23
+ offsetDialogHost,
24
+ PickerPanel,
25
+ placeAnchoredOverlay,
26
+ placeTransientPill,
27
+ renderPill,
28
+ SearchableSelect,
29
+ TabBar,
30
+ TransientPill,
31
+ tuiTheme,
32
+ } from "../src/index.ts";
33
+
34
+ describe("shared TUI components", () => {
35
+ afterEach(() => configureTuiAppearance(DEFAULT_TUI_APPEARANCE));
36
+
37
+ const theme = {
38
+ bold: (text: string) => text,
39
+ fg: (_color: string, text: string) => text,
40
+ bg: (color: string, text: string) => `<bg:${color}>${text}</bg>`,
41
+ underline: (text: string) => text,
42
+ } as Theme;
43
+ const ansiTheme = {
44
+ bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
45
+ fg: (color: string, text: string) => {
46
+ const code = { accent: 32, border: 34, dim: 90, text: 37, error: 31, muted: 90, success: 32 }[color] ?? 37;
47
+ return `\x1b[${code}m${text}\x1b[39m`;
48
+ },
49
+ bg: (color: string, text: string) => {
50
+ const code = { selectedBg: 45, toolErrorBg: 41, toolPendingBg: 100, toolSuccessBg: 42 }[color] ?? 40;
51
+ return `\x1b[${code}m${text}\x1b[49m`;
52
+ },
53
+ getFgAnsi: (color: string) => {
54
+ const code = { error: 31, muted: 90, success: 32 }[color] ?? 37;
55
+ return `\x1b[${code}m`;
56
+ },
57
+ getBgAnsi: (color: string) => {
58
+ const code = { selectedBg: 45, toolErrorBg: 41, toolPendingBg: 100, toolSuccessBg: 42 }[color] ?? 40;
59
+ return `\x1b[${code}m`;
60
+ },
61
+ underline: (text: string) => text,
62
+ } as Theme;
63
+
64
+ function initializeTui(): void {
65
+ initTheme("dark", false);
66
+ setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
67
+ }
68
+
69
+ test("tabs use h/l and arrows and reset inactive backgrounds on both sides", () => {
70
+ const selected: string[] = [];
71
+ const tabs = new TabBar(
72
+ [
73
+ { id: "a", label: "A" },
74
+ { id: "b", label: "B" },
75
+ { id: "c", label: "C" },
76
+ ],
77
+ theme,
78
+ );
79
+ tabs.onChange = (tab) => selected.push(tab.id);
80
+
81
+ tabs.handleInput("l");
82
+ expect(selected).toEqual(["b"]);
83
+ const rendered = tabs.render(80)[0]!;
84
+ expect(rendered.match(/\x1b\[49m/g)?.length).toBeGreaterThanOrEqual(3);
85
+ tabs.handleInput("h");
86
+ tabs.handleInput("\x1b[D");
87
+ tabs.handleInput("\x1b[C");
88
+ expect(selected).toEqual(["b", "a", "c", "a"]);
89
+ });
90
+
91
+ test("tabs resolve semantic icons from the active icon pack on every render", () => {
92
+ const tabs = new TabBar([{ id: "appearance", label: "Appearance", icon: "appearance" }], theme);
93
+ configureTuiAppearance({ iconPack: "unicode" });
94
+ expect(stripTerminalSequences(tabs.render(80)[0]!)).toContain("✦ Appearance");
95
+ configureTuiAppearance({ iconPack: "emoji" });
96
+ expect(stripTerminalSequences(tabs.render(80)[0]!)).toContain("🎨 Appearance");
97
+ });
98
+
99
+ test("fullscreen overlay draws a border without applying a panel background", () => {
100
+ const child = { focused: false, render: () => ["content"], invalidate() {} };
101
+ const overlay = new FullscreenOverlay({ terminal: { rows: 4 } } as never, theme, child, "Settings");
102
+ overlay.focused = true;
103
+ const rendered = overlay.render(30).join("\n");
104
+
105
+ const plain = stripTerminalSequences(rendered);
106
+ expect(plain).toContain("╭─ Settings");
107
+ expect(plain).toContain("│content");
108
+ expect(plain).toContain("╰");
109
+ expect(rendered).not.toContain("<bg:");
110
+ expect(child.focused).toBe(true);
111
+ });
112
+
113
+ test("fullscreen overlay renders in the same turn as forwarded input", () => {
114
+ const inputs: string[] = [];
115
+ let renders = 0;
116
+ const overlay = new FullscreenOverlay(
117
+ {
118
+ terminal: { rows: 4 },
119
+ requestRender: () => renders++,
120
+ } as never,
121
+ theme,
122
+ {
123
+ render: () => ["content"],
124
+ invalidate() {},
125
+ handleInput: (data) => {
126
+ inputs.push(data);
127
+ },
128
+ },
129
+ "Settings",
130
+ );
131
+
132
+ overlay.handleInput("j");
133
+
134
+ expect(inputs).toEqual(["j"]);
135
+ expect(renders).toBe(1);
136
+ });
137
+
138
+ test("fullscreen overlay refreshes and resolves dynamic semantic titles", () => {
139
+ let renders = 0;
140
+ let disposals = 0;
141
+ let title = "Before";
142
+ const tui = {
143
+ terminal: { rows: 4 },
144
+ requestRender: () => {
145
+ renders += 1;
146
+ },
147
+ };
148
+ const overlay = new FullscreenOverlay(
149
+ tui as never,
150
+ theme,
151
+ { render: () => ["content"], invalidate() {}, dispose: () => disposals++ },
152
+ () => ({
153
+ label: title,
154
+ icon: "settings",
155
+ }),
156
+ );
157
+ expect(stripTerminalSequences(overlay.render(30)[0]!)).toContain("⚙ Before");
158
+ configureTuiAppearance({ iconPack: "emoji" });
159
+ expect(renders).toBe(1);
160
+ title = "After";
161
+ expect(stripTerminalSequences(overlay.render(30)[0]!)).toContain("⚙️ After");
162
+ overlay.dispose();
163
+ overlay.dispose();
164
+ expect(disposals).toBe(1);
165
+ configureTuiAppearance(DEFAULT_TUI_APPEARANCE);
166
+ expect(renders).toBe(1);
167
+ });
168
+
169
+ test("dialog host uses Pi's native overlay stack and closes idempotently", () => {
170
+ let shown:
171
+ | {
172
+ component: import("@earendil-works/pi-tui").Component;
173
+ options?: import("@earendil-works/pi-tui").OverlayOptions;
174
+ }
175
+ | undefined;
176
+ let hidden = 0;
177
+ let renders = 0;
178
+ const tui = {
179
+ terminal: { columns: 100, rows: 30 },
180
+ showOverlay(
181
+ component: import("@earendil-works/pi-tui").Component,
182
+ options?: import("@earendil-works/pi-tui").OverlayOptions,
183
+ ) {
184
+ shown = { component, options };
185
+ return {
186
+ hide: () => {
187
+ hidden += 1;
188
+ },
189
+ };
190
+ },
191
+ requestRender: () => {
192
+ renders += 1;
193
+ },
194
+ };
195
+ const host = new DialogOverlayHost(tui as never, theme);
196
+ let childHeight = 0;
197
+ const heightAwareChild = {
198
+ render: () => ["choice"],
199
+ invalidate() {},
200
+ setMaxHeight: (height: number) => {
201
+ childHeight = height;
202
+ },
203
+ };
204
+ const closeHeight = host.open(heightAwareChild, { title: "Height", width: 30, maxHeight: 18 });
205
+ expect(childHeight).toBe(16);
206
+ closeHeight();
207
+ const close = host.open({ render: () => ["choice"], invalidate() {} }, { title: "Mode", width: "60%" });
208
+
209
+ expect(shown?.component).toBeInstanceOf(DialogOverlay);
210
+ expect(shown?.options).toMatchObject({ anchor: "center", width: "60%", maxHeight: "90%", margin: 1 });
211
+ expect(stripTerminalSequences(shown!.component.render(30).join("\n"))).toContain("│choice");
212
+ expect(stripTerminalSequences(shown!.component.render(30)[0]!)).toContain("Mode");
213
+ close();
214
+ close();
215
+ expect(hidden).toBe(2);
216
+ expect(renders).toBe(4);
217
+
218
+ const scoped = offsetDialogHost(host, { row: 2, col: 3 });
219
+ const closeAnchored = scoped.open(
220
+ { render: () => ["anchored"], invalidate() {} },
221
+ {
222
+ width: 30,
223
+ parent: { row: 8, col: 20 },
224
+ },
225
+ );
226
+ expect(shown?.options).toMatchObject({ anchor: "top-left", row: 11, col: 24, width: 30, maxHeight: 3, margin: 0 });
227
+ closeAnchored();
228
+ });
229
+
230
+ test("searchable select navigates immediately and filters only after slash", () => {
231
+ initializeTui();
232
+ const selected: string[] = [];
233
+ const select = new SearchableSelect({
234
+ title: "Mode",
235
+ options: [
236
+ { value: "default", label: "Default" },
237
+ { value: "minimal", label: "Minimal" },
238
+ ],
239
+ selected: "default",
240
+ theme,
241
+ onSelect: (value) => selected.push(value),
242
+ onCancel() {},
243
+ });
244
+
245
+ select.handleInput("j");
246
+ select.handleInput("\r");
247
+ expect(selected).toEqual(["minimal"]);
248
+ const filtered = new SearchableSelect({
249
+ title: "Mode",
250
+ options: [
251
+ { value: "default", label: "Default" },
252
+ { value: "minimal", label: "Minimal" },
253
+ ],
254
+ theme,
255
+ onSelect() {},
256
+ onCancel() {},
257
+ });
258
+ filtered.handleInput("/");
259
+ for (const character of "min") filtered.handleInput(character);
260
+ expect(stripTerminalSequences(filtered.render(60).join("\n"))).toContain("Minimal");
261
+ expect(stripTerminalSequences(filtered.render(60).join("\n"))).not.toContain("Default");
262
+ });
263
+
264
+ test("searchable select preserves its dialog border and shows a scrollbar when height-bound", () => {
265
+ initializeTui();
266
+ const select = new SearchableSelect({
267
+ title: "Activity marker",
268
+ showTitle: false,
269
+ description: "Compact marker shown beside active work.",
270
+ options: Array.from({ length: 15 }, (_, index) => ({
271
+ value: String(index),
272
+ label: `Option ${index}`,
273
+ })),
274
+ theme,
275
+ onSelect() {},
276
+ onCancel() {},
277
+ });
278
+ const dialog = new DialogOverlay(theme, select, "Activity marker");
279
+ dialog.setMaxHeight(10);
280
+
281
+ const rendered = dialog.render(32);
282
+ const plain = rendered.map(stripTerminalSequences);
283
+
284
+ expect(rendered).toHaveLength(10);
285
+ expect(plain.at(-1)).toStartWith("╰");
286
+ expect(plain.some((line) => line.includes(" █"))).toBe(true);
287
+ });
288
+
289
+ test("searchable select shrink-wraps when its content fits below the height cap", () => {
290
+ initializeTui();
291
+ const select = new SearchableSelect({
292
+ title: "Mode",
293
+ showTitle: false,
294
+ options: [{ value: "default", label: "Default" }],
295
+ theme,
296
+ onSelect() {},
297
+ onCancel() {},
298
+ });
299
+ const dialog = new DialogOverlay(theme, select, "Mode");
300
+ dialog.setMaxHeight(12);
301
+
302
+ expect(dialog.render(48)).toHaveLength(6);
303
+ });
304
+
305
+ test("searchable select uses available height before introducing a scrollbar", () => {
306
+ initializeTui();
307
+ const select = new SearchableSelect({
308
+ title: "Mode",
309
+ showTitle: false,
310
+ options: Array.from({ length: 15 }, (_, index) => ({
311
+ value: String(index),
312
+ label: `Option ${index}`,
313
+ })),
314
+ theme,
315
+ onSelect() {},
316
+ onCancel() {},
317
+ });
318
+ const dialog = new DialogOverlay(theme, select, "Mode");
319
+ dialog.setMaxHeight(30);
320
+
321
+ const plain = dialog.render(48).map(stripTerminalSequences);
322
+
323
+ expect(plain).toHaveLength(20);
324
+ expect(plain.some((line) => line.includes("Option 14"))).toBe(true);
325
+ expect(plain.some((line) => line.includes("█"))).toBe(false);
326
+ });
327
+
328
+ test("searchable select wraps option descriptions below narrow rows", () => {
329
+ initializeTui();
330
+ const select = new SearchableSelect({
331
+ title: "Speed",
332
+ showTitle: false,
333
+ descriptionLayout: "below",
334
+ options: [
335
+ {
336
+ value: "slow",
337
+ label: "Slow",
338
+ description: "Run animations at sixty percent of their normal pace.",
339
+ },
340
+ ],
341
+ theme,
342
+ onSelect() {},
343
+ onCancel() {},
344
+ });
345
+
346
+ const lines = select.render(24).map(stripTerminalSequences);
347
+ expect(lines.join(" ").replace(/\s+/gu, " ")).toContain("Run animations at sixty percent of their normal pace.");
348
+ expect(lines.every((line) => visibleWidth(line) <= 24)).toBe(true);
349
+ });
350
+
351
+ test("picker panel renders rich rows and keeps filtering distinct from navigation", () => {
352
+ initializeTui();
353
+ const selected: string[] = [];
354
+ let cancelled = 0;
355
+ const keybindings = new KeybindingsManager(TUI_KEYBINDINGS);
356
+ const pickerTheme = {
357
+ ...theme,
358
+ bg: (_color: string, text: string) => `\x1b[44m${text}\x1b[49m`,
359
+ } as Theme;
360
+ const picker = new PickerPanel({
361
+ tui: { terminal: { rows: 20 }, requestRender() {} },
362
+ theme: pickerTheme,
363
+ keybindings,
364
+ title: "Choose",
365
+ options: [
366
+ { value: "a", label: "Alpha", searchText: "alpha first" },
367
+ { value: "b", label: "Beta", searchText: "beta second" },
368
+ { value: "c", label: "Gamma", searchText: "gamma third" },
369
+ ],
370
+ selected: "b",
371
+ maxVisible: 2,
372
+ renderOption: (option, context) => `${option.label} at ${context.width}`,
373
+ onSelect: (value) => selected.push(value),
374
+ onCancel: () => {
375
+ cancelled += 1;
376
+ },
377
+ });
378
+
379
+ const initial = picker.render(48);
380
+ expect(stripTerminalSequences(initial.join("\n"))).toContain("Choose 2/3");
381
+ expect(
382
+ initial.some((line) => line.includes(tuiTheme(pickerTheme).bgAnsi("surface.selected")) && line.includes("Beta")),
383
+ ).toBe(true);
384
+ expect(initial.every((line) => stripTerminalSequences(line).length <= 48)).toBe(true);
385
+ picker.handleInput("j");
386
+ picker.handleInput("\r");
387
+ expect(selected).toEqual(["c"]);
388
+
389
+ picker.handleInput("/");
390
+ picker.focused = true;
391
+ for (const character of "alpha") picker.handleInput(character);
392
+ const filtered = picker.render(48).join("\n");
393
+ expect(filtered).toContain(CURSOR_MARKER);
394
+ expect(stripTerminalSequences(filtered)).toContain("Alpha");
395
+ expect(stripTerminalSequences(filtered)).not.toContain("Gamma");
396
+ picker.handleInput("\x1b");
397
+ expect(picker.render(48).join("\n")).not.toContain(CURSOR_MARKER);
398
+ expect(cancelled).toBe(0);
399
+ picker.handleInput("\x1b");
400
+ expect(cancelled).toBe(1);
401
+ });
402
+
403
+ test("action panel supports keyboard, numbered activation, bounded geometry, and cancel", () => {
404
+ initializeTui();
405
+ const selected: string[] = [];
406
+ let renders = 0;
407
+ let cancelled = 0;
408
+ const boundedTheme = { ...theme, bg: (_color: string, text: string) => text } as Theme;
409
+ const panel = new ActionPanel({
410
+ theme: boundedTheme,
411
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
412
+ title: "React",
413
+ options: [
414
+ { value: "good", label: "👍 Looks good" },
415
+ { value: "no", label: "🚫 Rejected" },
416
+ { value: "yes", label: "✅ Approved" },
417
+ ],
418
+ numberShortcuts: true,
419
+ maxWidth: 32,
420
+ maxHeight: 5,
421
+ requestRender: () => {
422
+ renders += 1;
423
+ },
424
+ onSelect: (value) => selected.push(value),
425
+ onCancel: () => {
426
+ cancelled += 1;
427
+ },
428
+ });
429
+
430
+ const lines = panel.render(80);
431
+ expect(lines).toHaveLength(5);
432
+ expect(lines.every((line) => visibleWidth(line) <= 32)).toBe(true);
433
+ expect(panel.getGeometry()).toEqual({
434
+ x: 0,
435
+ y: 0,
436
+ width: 32,
437
+ height: 5,
438
+ rows: [
439
+ { x: 1, y: 1, width: 30, height: 1, index: 0, value: "good" },
440
+ { x: 1, y: 2, width: 30, height: 1, index: 1, value: "no" },
441
+ ],
442
+ });
443
+
444
+ panel.handleInput("j");
445
+ panel.handleInput("\r");
446
+ panel.handleInput("3");
447
+ panel.handleInput("\x1b");
448
+ expect(selected).toEqual(["no", "yes"]);
449
+ expect(cancelled).toBe(1);
450
+ expect(renders).toBeGreaterThan(0);
451
+ expect(
452
+ new ActionPanel({
453
+ theme: boundedTheme,
454
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
455
+ title: "Too short",
456
+ options: [{ value: "a", label: "A" }],
457
+ maxHeight: 3,
458
+ requestRender() {},
459
+ onSelect() {},
460
+ onCancel() {},
461
+ }).render(30),
462
+ ).toEqual([]);
463
+ });
464
+
465
+ test("action panel hover highlights rows and primary click selects", () => {
466
+ initializeTui();
467
+ const selected: string[] = [];
468
+ let renders = 0;
469
+ const panelTheme = {
470
+ ...theme,
471
+ bg: (_color: string, text: string) => `<selected>${text}</selected>`,
472
+ } as Theme;
473
+ const panel = new ActionPanel({
474
+ theme: panelTheme,
475
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
476
+ title: "Actions",
477
+ options: [
478
+ { value: "a", label: "Alpha" },
479
+ { value: "b", label: "Beta" },
480
+ ],
481
+ requestRender: () => {
482
+ renders += 1;
483
+ },
484
+ onSelect: (value) => selected.push(value),
485
+ onCancel() {},
486
+ });
487
+
488
+ panel.render(30);
489
+ expect(panel.handleMouse({ type: "enter", row: 2, col: 5 })).toBe(true);
490
+ const hovered = panel.render(30)[2]!;
491
+ expect(hovered).toContain(tuiTheme(panelTheme).bgAnsi("surface.selected"));
492
+ expect(hovered).toContain("Beta");
493
+ panel.handleMouse({ type: "press", row: 2, col: 5, button: 0 });
494
+ panel.handleMouse({ type: "release", row: 2, col: 5, button: 0 });
495
+ expect(selected).toEqual(["b"]);
496
+
497
+ panel.handleMouse({ type: "move", row: 0, col: 5 });
498
+ panel.handleMouse({ type: "leave", row: 0, col: 0 });
499
+ expect(renders).toBeGreaterThanOrEqual(2);
500
+ });
501
+
502
+ test("action panel can separate click selection from explicit activation", () => {
503
+ initializeTui();
504
+ const selected: string[] = [];
505
+ const activated: string[] = [];
506
+ const panel = new ActionPanel({
507
+ theme: { ...theme, bg: (_color: string, text: string) => text } as Theme,
508
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
509
+ title: "React",
510
+ options: [
511
+ { value: "good", label: "Looks good" },
512
+ { value: "no", label: "Rejected" },
513
+ ],
514
+ numberShortcuts: true,
515
+ activateOnClick: false,
516
+ requestRender() {},
517
+ onSelectionChange: (value) => selected.push(value),
518
+ onSelect: (value) => activated.push(value),
519
+ onCancel() {},
520
+ });
521
+
522
+ panel.render(30);
523
+ panel.handleMouse({ type: "press", row: 2, col: 5, button: 0 });
524
+ panel.handleMouse({ type: "release", row: 2, col: 5, button: 0 });
525
+ expect(panel.getSelectedValue()).toBe("no");
526
+ expect(selected).toEqual(["no"]);
527
+ expect(activated).toEqual([]);
528
+
529
+ panel.handleInput("1");
530
+ expect(activated).toEqual(["good"]);
531
+ });
532
+
533
+ test("action panel hosts and translates input to a structural footer", () => {
534
+ initializeTui();
535
+ const activated: string[] = [];
536
+ const footer = new DialogButtonBar({
537
+ theme: ansiTheme,
538
+ buttons: [
539
+ { value: "cancel", label: "Cancel", foreground: "text.primary", background: "badge.neutral" },
540
+ { value: "add", label: "Add", foreground: "positive", background: "badge.positive", shortcuts: ["ctrl+d"] },
541
+ ],
542
+ requestRender() {},
543
+ onActivate: (value) => activated.push(value),
544
+ });
545
+ const panel = new ActionPanel({
546
+ theme: ansiTheme,
547
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
548
+ title: "React",
549
+ options: [
550
+ { value: "a", label: "Alpha" },
551
+ { value: "b", label: "Beta" },
552
+ ],
553
+ activateOnClick: false,
554
+ footer,
555
+ maxHeight: 6,
556
+ requestRender() {},
557
+ onSelect() {},
558
+ onCancel() {},
559
+ });
560
+
561
+ const rendered = panel.render(30);
562
+ expect(rendered).toHaveLength(6);
563
+ expect(stripTerminalSequences(rendered[3]!)).toBe("├────────────────────────────┤");
564
+ expect(panel.getGeometry()?.footer).toEqual({ x: 1, y: 4, width: 28, height: 1 });
565
+ expect(footer.getGeometry()?.buttons).toEqual([
566
+ { x: 10, y: 0, width: 8, height: 1, index: 0, value: "cancel" },
567
+ { x: 19, y: 0, width: 9, height: 1, index: 1, value: "add" },
568
+ ]);
569
+
570
+ panel.handleMouse({ type: "press", row: 4, col: 25, button: 0 });
571
+ panel.handleMouse({ type: "release", row: 4, col: 25, button: 0 });
572
+ panel.handleInput("\x04");
573
+ expect(activated).toEqual(["add", "add"]);
574
+
575
+ panel.handleMouse({ type: "move", row: 1, col: 3 });
576
+ expect(panel.handleMouse({ type: "leave", row: 0, col: 0 })).toBe(false);
577
+ });
578
+
579
+ test("styled panel and editor titles restore border ANSI around every border segment", () => {
580
+ initializeTui();
581
+ const overlay = new FullscreenOverlay(
582
+ { terminal: { rows: 4 }, requestRender() {} } as never,
583
+ ansiTheme,
584
+ { render: () => ["content"], invalidate() {} },
585
+ "Settings",
586
+ );
587
+ const overlayHeader = overlay.render(32)[0]!;
588
+ expect(stripTerminalSequences(overlayHeader)).toBe("╭─ Settings ───────────────────╮");
589
+ const colors = tuiTheme(ansiTheme);
590
+ expect(
591
+ overlayHeader.startsWith(`${colors.fgAnsi("border")}╭─\x1b[39m ${colors.fgAnsi("accent")}Settings\x1b[39m `),
592
+ ).toBe(true);
593
+
594
+ const panel = new ActionPanel({
595
+ theme: ansiTheme,
596
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
597
+ title: "React",
598
+ options: Array.from({ length: 7 }, (_, index) => ({ value: `${index}`, label: `Option ${index}` })),
599
+ requestRender() {},
600
+ onSelect() {},
601
+ onCancel() {},
602
+ });
603
+ const header = panel.render(32)[0]!;
604
+ expect(stripTerminalSequences(header)).toBe("╭─ React 1/7 ──────────────────╮");
605
+ expect(header.startsWith(`${colors.fgAnsi("border")}╭─ `)).toBe(true);
606
+ expect(header).toContain(`\x1b[39m${colors.fgAnsi("border")} ─`);
607
+
608
+ const compactPanel = new ActionPanel({
609
+ theme: ansiTheme,
610
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
611
+ title: "Compact",
612
+ options: [
613
+ { value: "a", label: "Alpha" },
614
+ { value: "b", label: "Beta" },
615
+ ],
616
+ showHint: false,
617
+ maxHeight: 4,
618
+ requestRender() {},
619
+ onSelect() {},
620
+ onCancel() {},
621
+ });
622
+ const compact = compactPanel.render(32);
623
+ expect(compact).toHaveLength(4);
624
+ expect(stripTerminalSequences(compact.join("\n"))).not.toContain("navigate");
625
+ expect(compactPanel.getGeometry()?.rows).toHaveLength(2);
626
+
627
+ const editor = new FramedEditorOverlay({
628
+ tui: { terminal: { rows: 20, columns: 80 }, requestRender() {} } as never,
629
+ theme: ansiTheme,
630
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
631
+ title: "Annotate",
632
+ onSubmit() {},
633
+ onCancel() {},
634
+ });
635
+ const editorHeader = editor.render(32)[0]!;
636
+ expect(editorHeader.startsWith(`${colors.fgAnsi("border")}╭─ `)).toBe(true);
637
+ expect(editorHeader).toContain(`\x1b[39m${colors.fgAnsi("border")} ─`);
638
+ const editorLines = editor.render(32);
639
+ expect(editorLines.some((line) => line.includes(CURSOR_MARKER))).toBe(false);
640
+ expect(editorLines.some((line) => line.includes("\x1b[7m") && line.includes("\x1b[27m"))).toBe(false);
641
+ editor.focused = true;
642
+ expect(
643
+ editor
644
+ .render(32)
645
+ .some((line) => line.includes(CURSOR_MARKER) && line.includes("\x1b[7m") && line.includes("\x1b[27m")),
646
+ ).toBe(true);
647
+
648
+ const picker = new PickerPanel({
649
+ tui: { terminal: { rows: 20, columns: 80 }, requestRender() {} } as never,
650
+ theme: ansiTheme,
651
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
652
+ title: "Models",
653
+ options: [{ value: "one", label: "One" }],
654
+ onSelect() {},
655
+ onCancel() {},
656
+ });
657
+ const pickerHeader = picker.render(32)[0]!;
658
+ expect(pickerHeader.startsWith(`${colors.fgAnsi("border")}╭\x1b[39m${colors.fgAnsi("border")}─ \x1b[39m`)).toBe(
659
+ true,
660
+ );
661
+ });
662
+
663
+ test("dialog buttons expose exact geometry, semantic colors, hover, click, and shortcuts", () => {
664
+ const activated: string[] = [];
665
+ let renders = 0;
666
+ const buttons = new DialogButtonBar({
667
+ theme: ansiTheme,
668
+ leading: () => "Hint that truncates",
669
+ buttons: [
670
+ { value: "delete", label: "Delete", foreground: "negative", background: "badge.negative", align: "start" },
671
+ {
672
+ value: "cancel",
673
+ label: "Cancel",
674
+ foreground: "text.muted",
675
+ background: "badge.neutral",
676
+ shortcuts: ["escape"],
677
+ },
678
+ {
679
+ value: "add",
680
+ label: () => "Add",
681
+ foreground: "positive",
682
+ background: "badge.positive",
683
+ shortcuts: ["ctrl+d"],
684
+ },
685
+ ],
686
+ requestRender: () => {
687
+ renders += 1;
688
+ },
689
+ onActivate: (value) => activated.push(value),
690
+ });
691
+
692
+ const normal = buttons.render(40)[0]!;
693
+ expect(visibleWidth(normal)).toBe(40);
694
+ expect(normal).toContain("\x1b[48;2;");
695
+ expect(stripTerminalSequences(normal)).toContain(" Delete ");
696
+ expect(stripTerminalSequences(normal)).toContain(" Cancel ");
697
+ expect(stripTerminalSequences(normal)).toContain(" Add ");
698
+ expect(stripTerminalSequences(normal)).toContain("Hint that");
699
+ expect(stripTerminalSequences(normal)).toContain("Cancel ⎋");
700
+ expect(stripTerminalSequences(normal)).toContain("Add ⌃ 🅳");
701
+ expect(buttons.getGeometry()).toEqual({
702
+ x: 0,
703
+ y: 0,
704
+ width: 40,
705
+ height: 1,
706
+ buttons: [
707
+ { x: 0, y: 0, width: 8, height: 1, index: 0, value: "delete" },
708
+ { x: 20, y: 0, width: 10, height: 1, index: 1, value: "cancel" },
709
+ { x: 31, y: 0, width: 9, height: 1, index: 2, value: "add" },
710
+ ],
711
+ });
712
+
713
+ expect(buttons.handleMouse({ type: "enter", row: 0, col: 22 })).toBe(true);
714
+ const hovered = buttons.render(40)[0]!;
715
+ expect(stripTerminalSequences(hovered)).toBe(stripTerminalSequences(normal));
716
+ expect(hovered).not.toBe(normal);
717
+ expect(hovered).toContain(
718
+ tuiTheme(ansiTheme).bgAnsi(tuiTheme(ansiTheme).contrastBackground(tuiTheme(ansiTheme).color("badge.neutral"))),
719
+ );
720
+ buttons.handleMouse({ type: "press", row: 0, col: 22, button: 0 });
721
+ buttons.handleMouse({ type: "release", row: 0, col: 22, button: 0 });
722
+ buttons.handleInput("\x04");
723
+ expect(activated).toEqual(["cancel", "add"]);
724
+ expect(renders).toBeGreaterThan(0);
725
+ });
726
+
727
+ test("powerline buttons use rounded caps in their rendered and hit geometry", () => {
728
+ configureTuiAppearance({ powerlineButtons: true });
729
+ const buttons = new DialogButtonBar({
730
+ theme: ansiTheme,
731
+ buttons: [
732
+ {
733
+ value: "save",
734
+ label: "Save",
735
+ foreground: "positive",
736
+ background: "badge.positive",
737
+ shortcuts: ["enter"],
738
+ },
739
+ ],
740
+ requestRender() {},
741
+ onActivate() {},
742
+ });
743
+
744
+ const rendered = buttons.render(20)[0]!;
745
+ const colors = tuiTheme(ansiTheme);
746
+ expect(stripTerminalSequences(rendered)).toContain(" Save ⏎ ");
747
+ expect(rendered).toContain(colors.bgAnsi("badge.positive"));
748
+ expect(rendered).not.toContain(colors.bgAnsi("badge.neutral"));
749
+ expect(buttons.getGeometry()?.buttons).toEqual([{ x: 10, y: 0, width: 10, height: 1, index: 0, value: "save" }]);
750
+ expect(buttons.handleMouse({ type: "press", row: 0, col: 10, button: 0 })).toBe(true);
751
+ expect(buttons.handleMouse({ type: "release", row: 0, col: 19, button: 0 })).toBe(true);
752
+ });
753
+
754
+ test("dialog buttons can form a centered action group", () => {
755
+ const buttons = new DialogButtonBar({
756
+ theme: ansiTheme,
757
+ buttons: [
758
+ {
759
+ value: "new",
760
+ label: "New",
761
+ foreground: "text.primary",
762
+ background: "action.neutral",
763
+ align: "center",
764
+ },
765
+ ],
766
+ requestRender() {},
767
+ onActivate() {},
768
+ });
769
+
770
+ expect(stripTerminalSequences(buttons.render(20)[0]!).trim()).toBe("New");
771
+ expect(buttons.getGeometry()?.buttons).toEqual([{ x: 7, y: 0, width: 5, height: 1, index: 0, value: "new" }]);
772
+ });
773
+
774
+ test("powerline separators derive exact truecolor and 256-color cap foregrounds", () => {
775
+ configureTuiAppearance({ iconPack: "nerd-fonts", powerline: true });
776
+ const pillTheme = (backgroundAnsi: string): Theme =>
777
+ ({
778
+ getBgAnsi: () => backgroundAnsi,
779
+ bg: (_color: string, text: string) => `${backgroundAnsi}${text}\x1b[49m`,
780
+ fg: (_color: string, text: string) => `\x1b[38;5;15m${text}\x1b[39m`,
781
+ }) as never;
782
+
783
+ const truecolorTheme = pillTheme("\x1b[48;2;10;20;30m");
784
+ const truecolorColors = tuiTheme(truecolorTheme);
785
+ const truecolorColor = truecolorColors.color({ hue: "blue", shade: 2 });
786
+ const truecolor = renderPill(truecolorTheme, { icon: false, label: " Add " }, truecolorColor, "text.primary");
787
+ const truecolorBackground = truecolorColors.bgAnsi(truecolorColor);
788
+ expect(truecolor).toContain(truecolorBackground.replace("[48;", "[38;"));
789
+ expect(visibleWidth(truecolor)).toBe(7);
790
+
791
+ const indexedTheme = pillTheme("\x1b[48;5;240m");
792
+ const indexedColors = tuiTheme(indexedTheme);
793
+ const indexedColor = indexedColors.color({ hue: "gray", shade: 1 });
794
+ const indexed = renderPill(indexedTheme, { icon: false, label: "OK" }, indexedColor, "text.primary");
795
+ const indexedBackground = indexedColors.bgAnsi(indexedColor);
796
+ expect(indexed.startsWith(`${indexedBackground.replace("[48;", "[38;")}`)).toBe(true);
797
+ expect(indexed.endsWith(`${indexedBackground.replace("[48;", "[38;")}\x1b[39m\x1b[49m`)).toBe(true);
798
+ expect(visibleWidth(indexed)).toBe(4);
799
+
800
+ const nestedTheme = {
801
+ getBgAnsi: (color: string) => (color === "userMessageBg" ? "\x1b[48;5;238m" : "\x1b[48;5;240m"),
802
+ bg: (_color: string, text: string) => `\x1b[48;5;240m${text}\x1b[49m`,
803
+ fg: (_color: string, text: string) => `\x1b[38;5;15m${text}\x1b[39m`,
804
+ } as never;
805
+ const nestedColors = tuiTheme(nestedTheme);
806
+ const nestedColor = nestedColors.color({ hue: "gray", shade: 1 });
807
+ const nested = renderPill(
808
+ nestedTheme,
809
+ { icon: false, label: "OK" },
810
+ nestedColor,
811
+ "text.primary",
812
+ undefined,
813
+ "\x1b[48;5;238m",
814
+ );
815
+ const nestedBackground = nestedColors.bgAnsi(nestedColor);
816
+ expect(nested).toContain(`\x1b[48;5;238m${nestedBackground.replace("[48;", "[38;")}`);
817
+ });
818
+
819
+ test("tracks the destination background and restores it after a pill", () => {
820
+ expect(backgroundAnsiAtColumn("\x1b[48;5;8mabc\x1b[49mdef", 0)).toBe("\x1b[48;5;8m");
821
+ expect(backgroundAnsiAtColumn("\x1b[48;5;8mabc\x1b[49mdef", 1)).toBe("\x1b[48;5;8m");
822
+ expect(backgroundAnsiAtColumn("\x1b[48;5;8mabc\x1b[49mdef", 4)).toBe("\x1b[49m");
823
+ expect(backgroundAnsiAtColumn("\x1b[38;2;45;45;45mabc", 1)).toBe("\x1b[49m");
824
+ expect(backgroundAnsiAtColumn("\x1b[48;5;8m\x1b[38;2;45;45;45mabc", 1)).toBe("\x1b[48;5;8m");
825
+ expect(backgroundAnsiAtColumn("\x1b[48;2;40;46;68m\x1b[38;2;95;210;255mabc", 1)).toBe("\x1b[48;2;40;46;68m");
826
+ const theme = {
827
+ getBgAnsi: (color: string) => (color === "selectedBg" ? "\x1b[48;5;8m" : "\x1b[48;5;4m"),
828
+ bg: (_color: string, text: string) => text,
829
+ fg: (_color: string, text: string) => text,
830
+ } as never;
831
+ const colors = tuiTheme(theme);
832
+ const selected = contrastingPillBackground(theme, colors.bgAnsi("surface.selected"));
833
+ const neutral = contrastingPillBackground(theme, colors.bgAnsi("badge.neutral"));
834
+ const defaultBackground = contrastingPillBackground(theme, "\x1b[49m");
835
+ expect(typeof selected).toBe("object");
836
+ expect(typeof neutral).toBe("object");
837
+ expect(typeof defaultBackground).toBe("object");
838
+ expect(colors.bgAnsi(selected)).not.toBe(colors.bgAnsi(neutral));
839
+ expect(renderPill(theme, { icon: false, label: "OK" }, selected, "text.primary")).toContain(
840
+ colors.bgAnsi(selected),
841
+ );
842
+ const pill = renderPill(
843
+ theme,
844
+ { icon: false, label: "OK" },
845
+ "badge.neutral",
846
+ "text.primary",
847
+ undefined,
848
+ "\x1b[48;5;8m",
849
+ );
850
+ expect(pill).toStartWith("\x1b[48;5;8m");
851
+ expect(pill).toEndWith("\x1b[48;5;8m");
852
+ });
853
+
854
+ test("powerline separators use flat Unicode blocks when disabled", () => {
855
+ configureTuiAppearance({ powerline: false });
856
+ const pillTheme = {
857
+ getBgAnsi: () => "\x1b[48;5;240m",
858
+ bg: (_color: string, text: string) => text,
859
+ fg: (_color: string, text: string) => text,
860
+ } as never;
861
+
862
+ const rendered = renderPill(pillTheme, { icon: false, label: "OK" }, "badge.neutral", "text.primary");
863
+ expect(rendered).toContain("▐");
864
+ expect(rendered).toContain("▌");
865
+ expect(rendered).not.toContain("");
866
+ expect(rendered).not.toContain("");
867
+ });
868
+
869
+ test("anchored overlays prefer below/right, flip, and clamp to terminal bounds", () => {
870
+ expect(
871
+ placeAnchoredOverlay({
872
+ terminalCols: 80,
873
+ terminalRows: 24,
874
+ anchorRow: 4,
875
+ anchorCol: 10,
876
+ desiredWidth: 30,
877
+ height: 6,
878
+ gap: 1,
879
+ }),
880
+ ).toEqual({
881
+ rect: { x: 11, y: 5, width: 30, height: 6 },
882
+ options: { anchor: "top-left", row: 5, col: 11, width: 30, maxHeight: 6, margin: 0 },
883
+ });
884
+
885
+ expect(
886
+ placeAnchoredOverlay({
887
+ terminalCols: 80,
888
+ terminalRows: 24,
889
+ anchorRow: 22,
890
+ anchorCol: 78,
891
+ desiredWidth: 30,
892
+ height: 6,
893
+ }),
894
+ ).toEqual({
895
+ rect: { x: 48, y: 16, width: 30, height: 6 },
896
+ options: { anchor: "top-left", row: 16, col: 48, width: 30, maxHeight: 6, margin: 0 },
897
+ });
898
+
899
+ expect(
900
+ placeAnchoredOverlay({
901
+ terminalCols: 8,
902
+ terminalRows: 4,
903
+ anchorRow: 100,
904
+ anchorCol: 100,
905
+ desiredWidth: 20,
906
+ height: 10,
907
+ }),
908
+ ).toEqual({
909
+ rect: { x: 0, y: 0, width: 8, height: 4 },
910
+ options: { anchor: "top-left", row: 0, col: 0, width: 8, maxHeight: 4, margin: 0 },
911
+ });
912
+
913
+ expect(
914
+ placeAnchoredOverlay({
915
+ terminalCols: 80,
916
+ terminalRows: 24,
917
+ anchorRow: 22,
918
+ anchorCol: 40,
919
+ desiredWidth: 30,
920
+ height: 6,
921
+ horizontalPlacement: "center",
922
+ }),
923
+ ).toEqual({
924
+ rect: { x: 25, y: 16, width: 30, height: 6 },
925
+ options: { anchor: "top-left", row: 16, col: 25, width: 30, maxHeight: 6, margin: 0 },
926
+ });
927
+ });
928
+
929
+ test("transient pills center above their anchor, flip, clamp, and composite semantic feedback", () => {
930
+ expect(placeTransientPill({ anchor: { row: 3, col: 10 }, width: 8, screenWidth: 20, screenHeight: 5 })).toEqual({
931
+ x: 7,
932
+ y: 2,
933
+ width: 8,
934
+ height: 1,
935
+ });
936
+ expect(placeTransientPill({ anchor: { row: 0, col: 1 }, width: 8, screenWidth: 20, screenHeight: 5 })).toEqual({
937
+ x: 0,
938
+ y: 1,
939
+ width: 8,
940
+ height: 1,
941
+ });
942
+
943
+ const pill = new TransientPill({ theme: ansiTheme, requestRender() {}, durationMs: 60_000 });
944
+ pill.show({ label: "No text selected to annotate.", icon: "warning", tone: "warning" }, { row: 2, col: 9 });
945
+ const rendered = pill.composite(
946
+ Array.from({ length: 4 }, () => " ".repeat(50)),
947
+ {
948
+ width: 50,
949
+ height: 4,
950
+ },
951
+ );
952
+ pill.dispose();
953
+ expect(stripTerminalSequences(rendered.join("\n"))).toContain("⚠ No text selected to annotate.");
954
+ expect(rendered.join("\n")).toContain(tuiTheme(ansiTheme).bgAnsi("badge.warning"));
955
+ });
956
+
957
+ test("framed editor delegates multiline editing, submit, and cancel to Pi Editor", () => {
958
+ initializeTui();
959
+ const submitted: string[] = [];
960
+ let cancelled = 0;
961
+ const tui = {
962
+ terminal: { rows: 20, columns: 80 },
963
+ requestRender() {},
964
+ } as never;
965
+ const editor = new FramedEditorOverlay({
966
+ tui,
967
+ theme,
968
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
969
+ title: "Comment",
970
+ prefill: "first",
971
+ maxWidth: 30,
972
+ maxHeight: 8,
973
+ onSubmit: (text) => submitted.push(text),
974
+ onCancel: () => {
975
+ cancelled += 1;
976
+ },
977
+ });
978
+
979
+ editor.handleInput("\n");
980
+ for (const character of "second") editor.handleInput(character);
981
+ expect(editor.getText()).toBe("first\nsecond");
982
+ expect(editor.render(80).every((line) => visibleWidth(line) <= 30)).toBe(true);
983
+ editor.handleInput("\r");
984
+ expect(submitted).toEqual(["first\nsecond"]);
985
+ editor.handleInput("\x1b");
986
+ expect(cancelled).toBe(1);
987
+ });
988
+
989
+ test("framed editor hides native borders and keeps its cursor row visible", () => {
990
+ initializeTui();
991
+ const editor = new FramedEditorOverlay({
992
+ tui: { terminal: { rows: 20, columns: 80 }, requestRender() {} } as never,
993
+ theme,
994
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
995
+ title: "Comment",
996
+ prefill: ["one", "two", "three", "four"].join("\n"),
997
+ editorBorders: false,
998
+ maxWidth: 20,
999
+ maxHeight: 5,
1000
+ onSubmit() {},
1001
+ onCancel() {},
1002
+ });
1003
+
1004
+ const visibleContent = (lines: string[]) =>
1005
+ lines.slice(1, -1).map((line) => stripTerminalSequences(line).slice(1, -1).trim());
1006
+ expect(visibleContent(editor.render(80))).toEqual(["one", "two", "three"]);
1007
+
1008
+ editor.focused = true;
1009
+ expect(visibleContent(editor.render(80))).toEqual(["two", "three", "four"]);
1010
+ });
1011
+
1012
+ test("framed editor owns structural footer layout, shortcuts, and translated mouse lifecycle", () => {
1013
+ initializeTui();
1014
+ const activated: string[] = [];
1015
+ const footer = new DialogButtonBar({
1016
+ theme: ansiTheme,
1017
+ buttons: [
1018
+ { value: "add", label: "Add", foreground: "positive", background: "badge.positive", shortcuts: ["ctrl+d"] },
1019
+ ],
1020
+ requestRender() {},
1021
+ onActivate: (value) => activated.push(value),
1022
+ });
1023
+ const editor = new FramedEditorOverlay({
1024
+ tui: { terminal: { rows: 20, columns: 80 }, requestRender() {} } as never,
1025
+ theme: ansiTheme,
1026
+ keybindings: new KeybindingsManager(TUI_KEYBINDINGS),
1027
+ title: "Annotate",
1028
+ prefill: "comment",
1029
+ footer,
1030
+ editorBorders: false,
1031
+ maxWidth: 30,
1032
+ maxHeight: 8,
1033
+ onSubmit() {},
1034
+ onCancel() {},
1035
+ });
1036
+
1037
+ const lines = editor.render(80);
1038
+ const geometry = editor.getGeometry();
1039
+ expect(lines).toHaveLength(5);
1040
+ expect(stripTerminalSequences(lines[1]!)).toContain("comment");
1041
+ expect(stripTerminalSequences(lines[2]!)).toBe("├────────────────────────────┤");
1042
+ expect(geometry?.footer).toEqual({ x: 1, y: 3, width: 28, height: 1 });
1043
+ expect(editor.getText()).toBe("comment");
1044
+
1045
+ editor.handleMouse({ type: "press", row: 3, col: 25, button: 0 });
1046
+ editor.handleMouse({ type: "release", row: 3, col: 25, button: 0 });
1047
+ editor.handleInput("\x04");
1048
+ expect(activated).toEqual(["add", "add"]);
1049
+ expect(editor.handleMouse({ type: "move", row: 2, col: 2 })).toBe(false);
1050
+ });
1051
+
1052
+ test("multi-select saves order and warns before discarding changes", () => {
1053
+ initializeTui();
1054
+ const saved: string[][] = [];
1055
+ let cancelled = false;
1056
+ const select = new MultiSelect({
1057
+ title: "Models",
1058
+ options: [
1059
+ { value: "a", label: "A" },
1060
+ { value: "b", label: "B" },
1061
+ ],
1062
+ value: ["b", "a"],
1063
+ ordered: true,
1064
+ theme,
1065
+ onSave: (value) => saved.push(value),
1066
+ onCancel: () => {
1067
+ cancelled = true;
1068
+ },
1069
+ });
1070
+
1071
+ select.handleInput("l");
1072
+ expect(stripTerminalSequences(select.render(60).join("\n")).indexOf("A")).toBeLessThan(
1073
+ stripTerminalSequences(select.render(60).join("\n")).indexOf("B"),
1074
+ );
1075
+ select.handleInput("\x1b");
1076
+ expect(select.render(60).join("\n")).toContain("Unsaved changes");
1077
+ expect(cancelled).toBe(false);
1078
+ select.handleInput("\r");
1079
+ expect(saved).toEqual([["a", "b"]]);
1080
+ });
1081
+
1082
+ test("multi-select wraps descriptions below option labels", () => {
1083
+ initializeTui();
1084
+ const select = new MultiSelect({
1085
+ title: "Tools",
1086
+ options: [
1087
+ {
1088
+ value: "exec",
1089
+ label: "exec_command",
1090
+ description: "Run a shell command with bounded output and persistent process support.",
1091
+ },
1092
+ ],
1093
+ value: ["exec"],
1094
+ descriptionLayout: "below",
1095
+ theme,
1096
+ onSave() {},
1097
+ onCancel() {},
1098
+ });
1099
+
1100
+ const lines = select.render(32).map(stripTerminalSequences);
1101
+ const labelRow = lines.findIndex((line) => line.includes("exec_command"));
1102
+ expect(labelRow).toBeGreaterThanOrEqual(0);
1103
+ expect(lines[labelRow]).not.toContain("Run a shell");
1104
+ expect(lines.slice(labelRow + 1, -2).join(" ")).toContain("Run a shell command with");
1105
+ expect(lines.slice(labelRow + 1, -2).join(" ")).toContain("bounded output");
1106
+ expect(lines.every((line) => line.length <= 32)).toBe(true);
1107
+ });
1108
+
1109
+ test("multi-select keeps wrapped options and actions inside its height budget", () => {
1110
+ initializeTui();
1111
+ const select = new MultiSelect({
1112
+ title: "Tools",
1113
+ description: "Choose tools.",
1114
+ options: Array.from({ length: 8 }, (_, index) => ({
1115
+ value: `tool-${index}`,
1116
+ label: `tool-${index}`,
1117
+ description: `Description for tool ${index} that wraps onto another line at this width.`,
1118
+ })),
1119
+ value: [],
1120
+ descriptionLayout: "below",
1121
+ maxHeight: 10,
1122
+ theme,
1123
+ onSave: () => {},
1124
+ onCancel: () => {},
1125
+ });
1126
+
1127
+ const initial = select.render(36);
1128
+ expect(initial.length).toBeLessThanOrEqual(10);
1129
+ expect(stripTerminalSequences(initial.join("\n"))).toContain("█");
1130
+ for (let index = 0; index < 7; index += 1) select.handleInput("j");
1131
+ const final = select.render(36);
1132
+ const rendered = stripTerminalSequences(final.join("\n"));
1133
+ expect(final).toHaveLength(initial.length);
1134
+ expect(rendered).toContain("tool-7");
1135
+ expect(rendered).not.toContain("tool-0");
1136
+ expect(rendered).toContain("Save");
1137
+ });
1138
+
1139
+ test("multi-select filters labels and descriptions without changing its height", () => {
1140
+ initializeTui();
1141
+ const select = new MultiSelect({
1142
+ title: "Tools",
1143
+ options: [
1144
+ { value: "shell", label: "exec_command", description: "Run a shell command." },
1145
+ { value: "patch", label: "apply_patch", description: "Edit workspace files." },
1146
+ { value: "search", label: "tool_search", description: "Find deferred tools." },
1147
+ ],
1148
+ value: [],
1149
+ descriptionLayout: "below",
1150
+ maxHeight: 12,
1151
+ theme,
1152
+ onSave() {},
1153
+ onCancel() {},
1154
+ });
1155
+
1156
+ const initialHeight = select.render(40).length;
1157
+ select.handleInput("/");
1158
+ for (const character of "workspace") select.handleInput(character);
1159
+ const filtered = stripTerminalSequences(select.render(40).join("\n"));
1160
+ expect(filtered).toContain("apply_patch");
1161
+ expect(filtered).not.toContain("exec_command");
1162
+ expect(filtered).not.toContain("tool_search");
1163
+ expect(select.render(40)).toHaveLength(initialHeight);
1164
+ });
1165
+ });