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,21 @@
1
+ import { expect, test } from "bun:test";
2
+ import { sanitizeTuiAnsiChunk, sanitizeTuiFieldPreview, sanitizeTuiTextPreview } from "../src/content/terminal-text.ts";
3
+
4
+ test("sanitizes split SGR and OSC sequences only after their boundary arrives", () => {
5
+ const sgrStart = sanitizeTuiAnsiChunk("before\x1b[3");
6
+ const sgrEnd = sanitizeTuiAnsiChunk(`${sgrStart.pending}1mred`);
7
+ const oscStart = sanitizeTuiAnsiChunk("before\x1b]52;c;secret");
8
+ const oscEnd = sanitizeTuiAnsiChunk(`${oscStart.pending}\x1b\\after`);
9
+
10
+ expect(sgrStart.text).toBe("before");
11
+ expect(sgrEnd.text).toBe("\x1b[31mred");
12
+ expect(oscStart.text).toBe("before");
13
+ expect(oscEnd.text).toBe("after");
14
+ });
15
+
16
+ test("bounds display previews without splitting Unicode or terminal controls", () => {
17
+ expect(sanitizeTuiTextPreview("a🙂b", 2)).toBe("a…");
18
+ expect(sanitizeTuiTextPreview("a🙂b", 3)).toBe("a🙂…");
19
+ expect(sanitizeTuiTextPreview("before\x1b]52;c;secret", 12)).toBe("before…");
20
+ expect(sanitizeTuiFieldPreview("one\ntwo\tthree", 7)).toBe("one two…");
21
+ });
@@ -0,0 +1,151 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { CURSOR_MARKER, type TUI, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { configureTuiAppearance, DEFAULT_TUI_APPEARANCE } from "../src/appearance.ts";
5
+ import { markNativeCursorPosition, renderSemanticCursor } from "../src/cursor.ts";
6
+ import { installCursorBridge } from "../src/host/cursor-bridge.ts";
7
+
8
+ // type-boundary: The cursor bridge fixture implements only the TUI surface used by the compatibility patch.
9
+ type TuiBoundary = unknown;
10
+ // type-boundary: The cursor renderer fixture implements only the theme methods used by semantic colors.
11
+ type ThemeBoundary = unknown;
12
+
13
+ const theme = {
14
+ name: "dark",
15
+ fg: (_color: string, text: string) => text,
16
+ bg: (_color: string, text: string) => text,
17
+ getFgAnsi: () => "",
18
+ getBgAnsi: () => "",
19
+ } as ThemeBoundary as Theme;
20
+
21
+ class CursorTuiFixture {
22
+ readonly mode = "fullscreen";
23
+ readonly writes: string[] = [];
24
+ readonly terminal = { write: (data: string) => this.writes.push(data) };
25
+ visible: boolean;
26
+
27
+ constructor(visible = false) {
28
+ this.visible = visible;
29
+ }
30
+
31
+ getShowHardwareCursor(): boolean {
32
+ return this.visible;
33
+ }
34
+ setShowHardwareCursor(enabled: boolean): void {
35
+ this.visible = enabled;
36
+ }
37
+
38
+ extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null {
39
+ const top = Math.max(0, lines.length - height);
40
+ for (let row = lines.length - 1; row >= top; row -= 1) {
41
+ const marker = lines[row]?.indexOf(CURSOR_MARKER) ?? -1;
42
+ if (marker < 0) continue;
43
+ const line = lines[row] ?? "";
44
+ const col = visibleWidth(line.slice(0, marker));
45
+ lines[row] = line.slice(0, marker) + line.slice(marker + CURSOR_MARKER.length);
46
+ return { row, col };
47
+ }
48
+ return null;
49
+ }
50
+ }
51
+
52
+ describe("semantic hardware cursor bridge", () => {
53
+ afterEach(() => configureTuiAppearance(DEFAULT_TUI_APPEARANCE));
54
+
55
+ test("positions a native cursor, applies its DEC shape, and removes private markers", () => {
56
+ configureTuiAppearance({ insertionCursor: "blinking-bar" });
57
+ const fixture = new CursorTuiFixture();
58
+ const dispose = installCursorBridge(fixture as TuiBoundary as TUI);
59
+ const lines = [`before ${renderSemanticCursor(theme, "\x1b[31mx\x1b[0m", { role: "insertion" })} after`];
60
+
61
+ expect(fixture.extractCursorPosition(lines, 20)).toEqual({ row: 0, col: 7 });
62
+ expect(fixture.visible).toBe(true);
63
+ expect(fixture.writes).toEqual(["\x1b[5 q"]);
64
+ expect(lines[0]).toBe("before \x1b[31mx\x1b[0m after");
65
+ dispose();
66
+ expect(fixture.visible).toBe(false);
67
+ expect(fixture.writes.at(-1)).toBe("\x1b[0 q");
68
+ });
69
+
70
+ test("applies an embedded terminal's projected native cursor shape", () => {
71
+ const fixture = new CursorTuiFixture();
72
+ const dispose = installCursorBridge(fixture as TuiBoundary as TUI);
73
+ const lines = [markNativeCursorPosition(`copy ${CURSOR_MARKER}x`, "blinking-block")];
74
+
75
+ expect(fixture.extractCursorPosition(lines, 1)).toEqual({ row: 0, col: 5 });
76
+ expect(fixture.visible).toBe(true);
77
+ expect(fixture.writes).toEqual(["\x1b[1 q"]);
78
+ expect(lines[0]).toBe("copy x");
79
+ dispose();
80
+ });
81
+
82
+ test("restores a live hardware-cursor preference after a native role", () => {
83
+ configureTuiAppearance({ navigationCursor: "steady-block" });
84
+ const fixture = new CursorTuiFixture(true);
85
+ const dispose = installCursorBridge(fixture as TuiBoundary as TUI);
86
+ const native = [renderSemanticCursor(theme, "x", { role: "navigation" })];
87
+ fixture.extractCursorPosition(native, 1);
88
+ expect(fixture.visible).toBe(true);
89
+
90
+ fixture.setShowHardwareCursor(false);
91
+ expect(fixture.visible).toBe(true);
92
+ configureTuiAppearance({ navigationCursor: "virtual" });
93
+ const virtual = [renderSemanticCursor(theme, "x", { role: "navigation" })];
94
+ expect(fixture.extractCursorPosition(virtual, 1)).toBeNull();
95
+ expect(fixture.visible).toBe(false);
96
+ expect(fixture.writes).toEqual(["\x1b[2 q", "\x1b[0 q"]);
97
+ dispose();
98
+ });
99
+
100
+ test("uses the DEC parameter for every configured native cursor name", () => {
101
+ const cases = [
102
+ ["terminal-default", 0],
103
+ ["blinking-block", 1],
104
+ ["steady-block", 2],
105
+ ["blinking-underline", 3],
106
+ ["steady-underline", 4],
107
+ ["blinking-bar", 5],
108
+ ["steady-bar", 6],
109
+ ] as const;
110
+ for (const [style, parameter] of cases) {
111
+ configureTuiAppearance({ selectionCursor: style });
112
+ const fixture = new CursorTuiFixture();
113
+ const dispose = installCursorBridge(fixture as TuiBoundary as TUI);
114
+ const lines = [renderSemanticCursor(theme, "x", { role: "selection" })];
115
+ fixture.extractCursorPosition(lines, 1);
116
+ expect(fixture.writes[0]).toBe(`\x1b[${parameter} q`);
117
+ dispose();
118
+ }
119
+ });
120
+
121
+ test("keeps one renderer active until its final bridge lease is released", () => {
122
+ configureTuiAppearance({ insertionCursor: "steady-bar" });
123
+ const fixture = new CursorTuiFixture();
124
+ const tui = fixture as TuiBoundary as TUI;
125
+ const disposeFirst = installCursorBridge(tui);
126
+ const disposeSecond = installCursorBridge(tui);
127
+ fixture.extractCursorPosition([renderSemanticCursor(theme, "x", { role: "insertion" })], 1);
128
+
129
+ disposeFirst();
130
+ expect(fixture.visible).toBe(true);
131
+ expect(fixture.writes).toEqual(["\x1b[6 q"]);
132
+ disposeSecond();
133
+ expect(fixture.visible).toBe(false);
134
+ expect(fixture.writes).toEqual(["\x1b[6 q", "\x1b[0 q"]);
135
+ });
136
+
137
+ test("carries the baseline preference across a renderer replacement", () => {
138
+ configureTuiAppearance({ insertionCursor: "blinking-bar" });
139
+ const previous = new CursorTuiFixture(false);
140
+ const dispose = installCursorBridge(previous as TuiBoundary as TUI);
141
+ previous.extractCursorPosition([renderSemanticCursor(theme, "x", { role: "insertion" })], 1);
142
+ expect(previous.getShowHardwareCursor()).toBe(true);
143
+
144
+ // Pi seeds the replacement with the previous renderer's effective value.
145
+ const next = new CursorTuiFixture(previous.getShowHardwareCursor());
146
+ next.extractCursorPosition(["plain"], 1);
147
+ expect(next.visible).toBe(false);
148
+ dispose();
149
+ expect(previous.visible).toBe(false);
150
+ });
151
+ });
@@ -0,0 +1,147 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { CURSOR_MARKER } from "@earendil-works/pi-tui";
4
+ import { configureTuiAppearance, DEFAULT_TUI_APPEARANCE } from "../src/appearance.ts";
5
+ import { tuiTheme } from "../src/color/theme.ts";
6
+ import { SemanticInput } from "../src/controls/semantic-input.ts";
7
+ import {
8
+ findCursorPresentation,
9
+ findCursorRole,
10
+ markNativeCursorPosition,
11
+ markEditorCursor,
12
+ removeUnmarkedEditorCursor,
13
+ renderSemanticCursor,
14
+ renderVirtualCursor,
15
+ stripCursorRoleMarkers,
16
+ } from "../src/cursor.ts";
17
+
18
+ describe("TUI cursor cleanup", () => {
19
+ afterEach(() => configureTuiAppearance(DEFAULT_TUI_APPEARANCE));
20
+
21
+ test("removes the unfocused editor cursor without removing native selection", () => {
22
+ expect(removeUnmarkedEditorCursor("prompt \x1b[7m界\x1b[0m rest")).toBe("prompt 界 rest");
23
+ expect(removeUnmarkedEditorCursor("prompt \x1b[7m界\x1b[27m rest")).toBe("prompt \x1b[7m界\x1b[27m rest");
24
+ expect(removeUnmarkedEditorCursor("prompt \x1b_pi:c\x07\x1b[7m界\x1b[0m rest")).toBe(
25
+ "prompt \x1b_pi:c\x07\x1b[7m界\x1b[0m rest",
26
+ );
27
+ });
28
+
29
+ test("leaves multi-grapheme inverse styling alone", () => {
30
+ const line = "prompt \x1b[7mtext\x1b[0m rest";
31
+ expect(removeUnmarkedEditorCursor(line)).toBe(line);
32
+ });
33
+
34
+ test("marks a focused wrapped editor cursor when Pi omitted its marker", () => {
35
+ expect(markEditorCursor("prompt \x1b[7m界\x1b[0m rest")).toBe("prompt \x1b_pi:c\x07\x1b[7m界\x1b[0m rest");
36
+ expect(markEditorCursor("prompt \x1b_pi:c\x07\x1b[7m界\x1b[0m rest")).toContain("\x1b_pi:c\x07");
37
+ });
38
+
39
+ test("preserves Pi's native cursor styling when Pi already emitted its marker", () => {
40
+ const line = markEditorCursor("before \x1b_pi:c\x07\x1b[7m界\x1b[0m after");
41
+ expect(line).toBe("before \x1b_pi:c\x07\x1b[7m界\x1b[0m after");
42
+ });
43
+
44
+ test("renders a virtual cursor through libtui's semantic surface and text colors", () => {
45
+ const theme = {
46
+ name: "harmonious",
47
+ fg: (_color: string, text: string) => text,
48
+ bg: (_color: string, text: string) => text,
49
+ getFgAnsi: () => "",
50
+ getBgAnsi: () => "",
51
+ } as unknown as Theme;
52
+ const rendered = renderVirtualCursor(theme, " ");
53
+ expect(rendered).toContain("\x1b[7m \x1b[27m");
54
+ });
55
+
56
+ test("can render a softer virtual cursor from a semantic surface", () => {
57
+ const theme = {
58
+ name: "harmonious",
59
+ fg: (_color: string, text: string) => text,
60
+ bg: (_color: string, text: string) => text,
61
+ getFgAnsi: () => "",
62
+ getBgAnsi: () => "",
63
+ } as unknown as Theme;
64
+ configureTuiAppearance({ softCursor: true });
65
+ const rendered = renderVirtualCursor(theme, " ");
66
+ const colors = tuiTheme(theme);
67
+ expect(rendered).toBe(colors.bg("surface.cursor", colors.fg("text.primary", " ")));
68
+ expect(rendered).not.toContain("\x1b[7m");
69
+ });
70
+
71
+ test("renders an active copy cursor with the high-contrast amber cursor", () => {
72
+ const theme = {
73
+ name: "harmonious",
74
+ fg: (_color: string, text: string) => text,
75
+ bg: (_color: string, text: string) => text,
76
+ getFgAnsi: () => "",
77
+ getBgAnsi: () => "",
78
+ } as unknown as Theme;
79
+ const colors = tuiTheme(theme);
80
+ expect(renderVirtualCursor(theme, "x", { selected: true })).toBe(
81
+ `\x1b[1m${colors.fgAnsi("cursor.selectedText")}${colors.bgAnsi("cursor.selected")}x\x1b[0m`,
82
+ );
83
+ });
84
+
85
+ test("lets copy mode opt into the softer cursor surface", () => {
86
+ const theme = {
87
+ name: "harmonious",
88
+ fg: (_color: string, text: string) => text,
89
+ bg: (_color: string, text: string) => text,
90
+ getFgAnsi: () => "",
91
+ getBgAnsi: () => "",
92
+ } as unknown as Theme;
93
+ configureTuiAppearance({ softCursor: true });
94
+ const colors = tuiTheme(theme);
95
+ expect(renderVirtualCursor(theme, "x", { mode: "copy" })).toBe(
96
+ `\x1b[1m${colors.fgAnsi("text.primary")}${colors.bgAnsi("surface.cursor")}x\x1b[0m`,
97
+ );
98
+ });
99
+
100
+ test("keeps an IME marker for virtual insertion but not virtual navigation", () => {
101
+ const theme = {
102
+ name: "harmonious",
103
+ fg: (_color: string, text: string) => text,
104
+ bg: (_color: string, text: string) => text,
105
+ getFgAnsi: () => "",
106
+ getBgAnsi: () => "",
107
+ } as unknown as Theme;
108
+ expect(renderSemanticCursor(theme, "x", { role: "insertion" })).toContain("\x1b_pi:c\x07");
109
+ expect(renderSemanticCursor(theme, "x", { role: "navigation" })).not.toContain("\x1b_pi:c\x07");
110
+ });
111
+
112
+ test("pairs a role only with Pi's selected cursor marker", () => {
113
+ configureTuiAppearance({ insertionCursor: "blinking-bar", navigationCursor: "steady-bar" });
114
+ const insertion = renderSemanticCursor({} as Theme, "x", { role: "insertion" });
115
+ const navigation = renderSemanticCursor({} as Theme, "y", { role: "navigation" });
116
+ expect(findCursorRole([`${insertion}${navigation}`], 1)).toBe("insertion");
117
+ expect(findCursorRole([`\x1b_pi:c\x07x${navigation}`], 1)).toBeUndefined();
118
+ expect(findCursorRole([navigation], 1)).toBe("navigation");
119
+ expect(findCursorRole([navigation, "stale \x1b_pi-libtui:cursor:insertion\x07"], 2)).toBe("navigation");
120
+ });
121
+
122
+ test("preserves a projected terminal's concrete native cursor style", () => {
123
+ const rendered = markNativeCursorPosition(`x${CURSOR_MARKER}`, "blinking-block");
124
+ expect(findCursorPresentation([rendered], 1)).toEqual({ style: "blinking-block" });
125
+ expect(stripCursorRoleMarkers(rendered)).toBe(`x${CURSOR_MARKER}`);
126
+ });
127
+
128
+ test("does not invent a semantic cursor for an unfocused editor", () => {
129
+ const rendered = markEditorCursor("prompt \x1b[7m界\x1b[0m rest", { theme: {} as Theme, role: "insertion" });
130
+ expect(rendered).toBe("prompt 界 rest");
131
+ expect(rendered).not.toContain("\x1b_pi:c\x07");
132
+ });
133
+
134
+ test("restyles Pi Input's SGR 27 cursor and removes it when unfocused", () => {
135
+ configureTuiAppearance({ insertionCursor: "blinking-bar" });
136
+ const input = new SemanticInput({} as Theme);
137
+ input.setValue("abc");
138
+ input.focused = true;
139
+ const focused = input.render(20).join("\n");
140
+ expect(focused).toContain("\x1b_pi:c\x07\x1b_pi-libtui:cursor:insertion\x07");
141
+ expect(focused).not.toContain("\x1b[7m");
142
+ input.focused = false;
143
+ const unfocused = input.render(20).join("\n");
144
+ expect(unfocused).not.toContain("\x1b_pi:c\x07");
145
+ expect(unfocused).not.toContain("\x1b[7m");
146
+ });
147
+ });
@@ -0,0 +1,119 @@
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 "pi-libtui";
5
+ import {
6
+ type EditorCompositionStyle,
7
+ renderEditorComposition,
8
+ renderEditorCompositionPreview,
9
+ renderEditorCompositionStatus,
10
+ } from "pi-libtui/editor";
11
+
12
+ const theme = {
13
+ name: "editor-chrome-test",
14
+ getColorMode: () => "truecolor",
15
+ getFgAnsi: () => "\x1b[38;2;100;140;200m",
16
+ getBgAnsi: () => "\x1b[48;2;24;28;36m",
17
+ } as never as Theme;
18
+
19
+ const style: EditorCompositionStyle = {
20
+ surface: "editor",
21
+ top: "half-block",
22
+ bottom: "none",
23
+ leftRail: "animated",
24
+ rightRail: "static",
25
+ promptMarker: ["▸", "▹"],
26
+ promptMarkerMotion: "always",
27
+ bottomStatus: true,
28
+ statusSeparator: "dot",
29
+ statusBand: "transparent",
30
+ inactiveRailTone: "accent",
31
+ };
32
+
33
+ describe("editor composition chrome", () => {
34
+ test("top rule renders the top quadrants", () => {
35
+ const topRuleStyle = { ...style, top: "rule" } as const;
36
+ const plain = renderEditorComposition(theme, topRuleStyle, {
37
+ width: 48,
38
+ content: ["draft"],
39
+ topStatus: { left: "Working", right: "ctx 4%" },
40
+ }).map(stripTerminalSequences);
41
+
42
+ expect(plain[0]).toContain("Working");
43
+ expect(plain[0]).toContain("ctx 4%");
44
+ });
45
+ test("keeps every composed row within the exact terminal width", () => {
46
+ const lines = renderEditorComposition(theme, style, {
47
+ width: 80,
48
+ content: ["draft"],
49
+ topStatus: { right: "~/src/agents · next · GPT-5.6 Sol · xhigh · fast" },
50
+ active: true,
51
+ elapsedMs: 720,
52
+ });
53
+
54
+ expect(lines.every((line) => visibleWidth(line) === 80)).toBe(true);
55
+ expect(stripTerminalSequences(lines.join("\n"))).toContain("draft");
56
+ });
57
+
58
+ test("uses the production renderer for complete candidate previews", () => {
59
+ const lines = renderEditorCompositionPreview(theme, { style }, 72);
60
+
61
+ expect(lines.every((line) => visibleWidth(line) === 72)).toBe(true);
62
+ expect(stripTerminalSequences(lines.join("\n"))).toContain("Ask anything, edit files, run tools");
63
+ });
64
+
65
+ test("continues the editor surface and rails through its standalone status row", () => {
66
+ const belowStyle = { ...style, bottomStatus: true } as const;
67
+ const lines = renderEditorCompositionStatus(theme, belowStyle, { left: "Working", right: "ctx 4%" }, 48, {
68
+ active: true,
69
+ elapsedMs: 400,
70
+ });
71
+
72
+ expect(lines).toHaveLength(1);
73
+ expect(stripTerminalSequences(lines[0] ?? "")).toContain("Working");
74
+ expect(stripTerminalSequences(lines[0] ?? "").trimEnd()).toEndWith("┃");
75
+ expect(visibleWidth(lines[0] ?? "")).toBe(48);
76
+ });
77
+
78
+ test("keeps the cap half-height and metadata inline when the prompt row has room", () => {
79
+ const lines = renderEditorComposition(theme, style, {
80
+ width: 48,
81
+ content: ["draft"],
82
+ topStatus: { right: "metadata" },
83
+ });
84
+
85
+ expect(visibleWidth(lines[0] ?? "")).toBe(48);
86
+ expect(stripTerminalSequences(lines[0] ?? "")).toStartWith("╻▄");
87
+ expect(stripTerminalSequences(lines[0] ?? "").trimEnd()).toEndWith("▄╻");
88
+ expect(lines[0]).not.toContain(tuiTheme(theme).bgAnsi("surface.editor"));
89
+ expect(stripTerminalSequences(lines[0] ?? "")).not.toContain("metadata");
90
+ expect(stripTerminalSequences(lines[1] ?? "")).toContain("draft");
91
+ expect(stripTerminalSequences(lines[1] ?? "")).toContain("metadata");
92
+ });
93
+
94
+ test("lifts metadata and expands the right rail when the prompt row needs the space", () => {
95
+ const lines = renderEditorComposition(theme, style, {
96
+ width: 48,
97
+ content: ["a draft long enough to consume the prompt row"],
98
+ topStatus: { right: "metadata" },
99
+ });
100
+
101
+ expect(stripTerminalSequences(lines[0] ?? "")).toContain("metadata");
102
+ expect(stripTerminalSequences(lines[0] ?? "")).toStartWith("╻▄");
103
+ expect(stripTerminalSequences(lines[0] ?? "").trimEnd()).toEndWith("┃");
104
+ expect(lines[0]).toContain(`${tuiTheme(theme).bgAnsi("surface.editor")} `);
105
+ expect(stripTerminalSequences(lines[1] ?? "")).not.toContain("metadata");
106
+ });
107
+
108
+ test("advances multi-frame prompt markers on the supplied UI timeline", () => {
109
+ const first = stripTerminalSequences(
110
+ renderEditorComposition(theme, style, { width: 40, content: ["draft"], elapsedMs: 0 })[1] ?? "",
111
+ );
112
+ const second = stripTerminalSequences(
113
+ renderEditorComposition(theme, style, { width: 40, content: ["draft"], elapsedMs: 200 })[1] ?? "",
114
+ );
115
+
116
+ expect(first).toStartWith("┃ ▸ ");
117
+ expect(second).toStartWith("┃ ▹ ");
118
+ });
119
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Container, type Component, type TUI, VStack } from "@earendil-works/pi-tui";
3
+ import { installEditorMinimumRows } from "../src/editor/layout.ts";
4
+
5
+ function rows(...lines: string[]): Component {
6
+ return { render: () => lines, invalidate() {} };
7
+ }
8
+
9
+ describe("editor layout", () => {
10
+ test("removes Pi's reserved border row for a borderless editor and restores it on dispose", () => {
11
+ const editor = rows("transition", "prompt");
12
+ const editorContainer = new Container();
13
+ editorContainer.addChild(editor);
14
+ const dock = new VStack([
15
+ { component: editorContainer, minSize: 3 },
16
+ { component: rows("footer"), minSize: 1 },
17
+ ]);
18
+ let renders = 0;
19
+ const tui = { layoutRoot: dock, requestRender: () => renders++ } as never as TUI;
20
+ const lease = installEditorMinimumRows(tui, 2);
21
+
22
+ expect(dock.render(80)).toEqual(["transition", "prompt", "", "footer"]);
23
+ lease.reconcile(editor);
24
+ expect(dock.render(80)).toEqual(["transition", "prompt", "footer"]);
25
+
26
+ lease.dispose();
27
+ expect(dock.render(80)).toEqual(["transition", "prompt", "", "footer"]);
28
+ expect(renders).toBe(1);
29
+ });
30
+ });
@@ -0,0 +1,203 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { runInNewContext } from "node:vm";
3
+ import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ dispatchEditorPaste,
6
+ dispatchEditorRender,
7
+ EDITOR_REGISTRY_KEY,
8
+ type EditorRegistry,
9
+ ensureEditorRegistry,
10
+ } from "../src/editor.ts";
11
+ import { installEditorBridge } from "../src/host/editor-bridge.ts";
12
+
13
+ // type-boundary: these tests provide only the host methods exercised by Pi's CustomEditor.
14
+ type EditorHostBoundary = unknown;
15
+
16
+ const tui = { terminal: { rows: 30, columns: 80 }, requestRender() {} } as EditorHostBoundary as never;
17
+ const keys = { matches: () => false } as EditorHostBoundary as KeybindingsManager;
18
+ const editorTheme = {
19
+ borderColor: (text: string) => text,
20
+ selectList: {
21
+ selectedPrefix: (text: string) => text,
22
+ selectedText: (text: string) => text,
23
+ description: (text: string) => text,
24
+ scrollInfo: (text: string) => text,
25
+ noMatch: (text: string) => text,
26
+ },
27
+ } as EditorHostBoundary as never;
28
+
29
+ const prototype = CustomEditor.prototype;
30
+
31
+ function isolatedRegistry() {
32
+ return ensureEditorRegistry(Object.create(null) as typeof globalThis);
33
+ }
34
+
35
+ describe("editor registry ownership", () => {
36
+ test("reuses structurally valid hidden state from another JavaScript realm", () => {
37
+ // type-boundary: node:vm returns values from an isolated realm; the editor registry validator narrows it.
38
+ type ForeignRealmValue = unknown;
39
+ const foreignRegistry = runInNewContext(`
40
+ const state = { pasteHandlers: [], renderDecorators: [] };
41
+ const registry = {
42
+ protocol: "pi-libtui/editor/registry/v1",
43
+ version: 1,
44
+ registerPasteHandler(handler) {
45
+ state.pasteHandlers.push(handler);
46
+ return () => state.pasteHandlers.splice(state.pasteHandlers.indexOf(handler), 1);
47
+ },
48
+ registerRenderDecorator(decorator) {
49
+ state.renderDecorators.push(decorator);
50
+ return () => state.renderDecorators.splice(state.renderDecorators.indexOf(decorator), 1);
51
+ },
52
+ };
53
+ Object.defineProperty(registry, Symbol.for("pi-libtui/editor/registry-state/v1"), { value: state });
54
+ registry;
55
+ `) as ForeignRealmValue as EditorRegistry;
56
+ const scope = Object.create(null) as Record<PropertyKey, ForeignRealmValue>;
57
+ scope[EDITOR_REGISTRY_KEY] = foreignRegistry;
58
+
59
+ const registry = ensureEditorRegistry(scope as typeof globalThis);
60
+ registry.registerPasteHandler({ id: "foreign.paste", handle: (text) => text.toUpperCase() });
61
+ registry.registerRenderDecorator({ id: "foreign.render", decorate: (lines) => [...lines, "foreign"] });
62
+
63
+ expect(registry).toBe(foreignRegistry);
64
+ expect(dispatchEditorPaste(registry, "paste")).toBe("PASTE");
65
+ expect(dispatchEditorRender(registry, ["native"], 40)).toEqual(["native", "foreign"]);
66
+ });
67
+
68
+ test("replaces a compatible-looking registry with malformed hidden state", () => {
69
+ const scope = Object.create(null) as Record<PropertyKey, unknown>;
70
+ const malformed = {
71
+ protocol: "pi-libtui/editor/registry/v1",
72
+ version: 1,
73
+ registerPasteHandler: () => () => {},
74
+ registerRenderDecorator: () => () => {},
75
+ };
76
+ Object.defineProperty(malformed, Symbol.for("pi-libtui/editor/registry-state/v1"), {
77
+ value: { pasteHandlers: [null], renderDecorators: [] },
78
+ });
79
+ scope[EDITOR_REGISTRY_KEY] = malformed;
80
+
81
+ const registry = ensureEditorRegistry(scope as typeof globalThis);
82
+ expect(registry).not.toBe(malformed);
83
+ expect(dispatchEditorRender(registry, ["native"], 40)).toEqual(["native"]);
84
+ });
85
+
86
+ test("resolving the registry does not patch CustomEditor", () => {
87
+ const handleInput = prototype.handleInput;
88
+ const insertTextAtCursor = prototype.insertTextAtCursor;
89
+ const render = prototype.render;
90
+
91
+ ensureEditorRegistry(Object.create(null) as typeof globalThis);
92
+
93
+ expect(prototype.handleInput).toBe(handleInput);
94
+ expect(prototype.insertTextAtCursor).toBe(insertTextAtCursor);
95
+ expect(prototype.render).toBe(render);
96
+ });
97
+
98
+ test("the installed host bridge dispatches paste and render handlers", () => {
99
+ const registry = isolatedRegistry();
100
+ const removePaste = registry.registerPasteHandler({
101
+ id: "test.paste",
102
+ handle: (text) => (text === "image.png" ? "attachment-token" : undefined),
103
+ });
104
+ const removeDecorator = registry.registerRenderDecorator({
105
+ id: "test.render",
106
+ decorate: (lines) => [...lines, "decorated"],
107
+ });
108
+ const removeBridge = installEditorBridge(registry);
109
+ try {
110
+ const nativeEditor = new CustomEditor(tui, editorTheme, keys);
111
+ nativeEditor.insertTextAtCursor("image.png");
112
+ expect(nativeEditor.getText()).toBe("attachment-token");
113
+ expect(nativeEditor.render(40).at(-1)).toBe("decorated");
114
+
115
+ const bracketedEditor = new CustomEditor(tui, editorTheme, keys);
116
+ bracketedEditor.handleInput("\x1b[200~image.png\x1b[201~");
117
+ expect(bracketedEditor.getText()).toBe("attachment-token");
118
+ } finally {
119
+ removeBridge();
120
+ removeDecorator();
121
+ removePaste();
122
+ }
123
+ });
124
+
125
+ test("multiple bridge leases share one patch and restore after the last release", () => {
126
+ const handleInput = prototype.handleInput;
127
+ const insertTextAtCursor = prototype.insertTextAtCursor;
128
+ const render = prototype.render;
129
+ const registry = isolatedRegistry();
130
+ const releaseFirst = installEditorBridge(registry);
131
+ const wrappedHandleInput = prototype.handleInput;
132
+ const wrappedInsertTextAtCursor = prototype.insertTextAtCursor;
133
+ const wrappedRender = prototype.render;
134
+ const releaseSecond = installEditorBridge(registry);
135
+ try {
136
+ expect(prototype.handleInput).toBe(wrappedHandleInput);
137
+ expect(prototype.insertTextAtCursor).toBe(wrappedInsertTextAtCursor);
138
+ expect(prototype.render).toBe(wrappedRender);
139
+
140
+ releaseFirst();
141
+ releaseFirst();
142
+ expect(prototype.handleInput).toBe(wrappedHandleInput);
143
+ expect(prototype.insertTextAtCursor).toBe(wrappedInsertTextAtCursor);
144
+ expect(prototype.render).toBe(wrappedRender);
145
+
146
+ releaseSecond();
147
+ expect(prototype.handleInput).toBe(handleInput);
148
+ expect(prototype.insertTextAtCursor).toBe(insertTextAtCursor);
149
+ expect(prototype.render).toBe(render);
150
+ } finally {
151
+ releaseFirst();
152
+ releaseSecond();
153
+ }
154
+ });
155
+
156
+ test("render decorators replace by id without stale cleanup removing the active copy", () => {
157
+ const registry = isolatedRegistry();
158
+ const first = {
159
+ id: "shared.decorator",
160
+ decorate: (lines: readonly string[]) => [...lines, "first"],
161
+ };
162
+ const second = {
163
+ id: "shared.decorator",
164
+ decorate: (lines: readonly string[]) => [...lines, "second"],
165
+ };
166
+ const removeFirst = registry.registerRenderDecorator(first);
167
+ const removeSecond = registry.registerRenderDecorator(second);
168
+
169
+ try {
170
+ expect(dispatchEditorRender(registry, ["line"], 40)).toEqual(["line", "second"]);
171
+ removeFirst();
172
+ expect(dispatchEditorRender(registry, ["line"], 40)).toEqual(["line", "second"]);
173
+ removeSecond();
174
+ expect(dispatchEditorRender(registry, ["line"], 40)).toEqual(["line"]);
175
+ } finally {
176
+ removeFirst();
177
+ removeSecond();
178
+ }
179
+ });
180
+
181
+ test("release preserves a method replaced by another owner", () => {
182
+ const originalRenderDescriptor = Object.getOwnPropertyDescriptor(prototype, "render");
183
+ const originalRender = prototype.render;
184
+ const originalHandleInput = prototype.handleInput;
185
+ const originalInsertTextAtCursor = prototype.insertTextAtCursor;
186
+ const release = installEditorBridge(isolatedRegistry());
187
+ const externalRender = function externalRender(): string[] {
188
+ return ["external"];
189
+ };
190
+ prototype.render = externalRender;
191
+ try {
192
+ release();
193
+ expect(prototype.render).toBe(externalRender);
194
+ expect(prototype.handleInput).toBe(originalHandleInput);
195
+ expect(prototype.insertTextAtCursor).toBe(originalInsertTextAtCursor);
196
+ } finally {
197
+ release();
198
+ if (originalRenderDescriptor) Object.defineProperty(prototype, "render", originalRenderDescriptor);
199
+ else Reflect.deleteProperty(prototype, "render");
200
+ expect(prototype.render).toBe(originalRender);
201
+ }
202
+ });
203
+ });