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,266 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ type Focusable,
5
+ isFocusable,
6
+ type OverlayHandle,
7
+ type OverlayOptions,
8
+ type SizeValue,
9
+ type TUI,
10
+ truncateToWidth,
11
+ visibleWidth,
12
+ } from "@earendil-works/pi-tui";
13
+ import { tuiTheme } from "../color/theme.ts";
14
+ import { resolveTuiTitle, type TuiTitleSource } from "../decoration/status.ts";
15
+ import { fitLine } from "../line-layout.ts";
16
+ import type { TuiMouseEvent } from "../mouse.ts";
17
+ import { placeAnchoredOverlay } from "./anchored.ts";
18
+
19
+ type PointerComponent = Component & { onMouse?: (event: TuiMouseEvent) => boolean };
20
+ type HeightAwareComponent = Component & { setMaxHeight?: (maxHeight: number) => void };
21
+
22
+ /** Zero-based cell used to anchor a dialog relative to its parent host. */
23
+ export interface DialogOverlayAnchor {
24
+ /** Row in the parent host's coordinate space. */
25
+ row: number;
26
+ /** Column in the parent host's coordinate space. */
27
+ col: number;
28
+ }
29
+
30
+ /** Native overlay options plus pi-libtui title and parent-anchor semantics. */
31
+ export interface DialogOverlayOptions extends Omit<OverlayOptions, "anchor" | "row" | "col"> {
32
+ /** Optional title rendered into the top border. */
33
+ title?: TuiTitleSource;
34
+ /** Zero-based parent cell in the host's coordinate space. */
35
+ parent?: DialogOverlayAnchor;
36
+ }
37
+
38
+ /** Owner that opens components as focus-owning dialogs. */
39
+ export interface DialogHost {
40
+ /**
41
+ * Open a component in the host's dialog layer.
42
+ * @param component Child component rendered inside the dialog frame.
43
+ * @param options Optional sizing, visibility, title, and parent-anchor policy.
44
+ * @returns An idempotent function that closes this dialog.
45
+ */
46
+ open(component: Component, options?: DialogOverlayOptions): () => void;
47
+ }
48
+
49
+ /**
50
+ * Wrap a dialog host so parent-relative anchors are translated by an outer layout offset.
51
+ * @param host Underlying host that owns the native overlay lifecycle.
52
+ * @param offset Static offset or callback evaluated each time an anchored dialog opens.
53
+ * @returns A host that preserves unanchored options and offsets only `parent` coordinates.
54
+ */
55
+ export function offsetDialogHost(
56
+ host: DialogHost,
57
+ offset: DialogOverlayAnchor | (() => DialogOverlayAnchor),
58
+ ): DialogHost {
59
+ return {
60
+ open(component, options = {}) {
61
+ if (!options.parent) return host.open(component, options);
62
+ const resolved = typeof offset === "function" ? offset() : offset;
63
+ return host.open(component, {
64
+ ...options,
65
+ parent: {
66
+ row: resolved.row + options.parent.row,
67
+ col: resolved.col + options.parent.col,
68
+ },
69
+ });
70
+ },
71
+ };
72
+ }
73
+
74
+ function border(theme: Theme, title: string, width: number): string {
75
+ const colors = tuiTheme(theme);
76
+ if (width <= 2) return colors.fg("border", "╭╮".slice(0, width));
77
+ const visibleTitle = truncateToWidth(title, Math.max(0, width - 5), "");
78
+ const label = visibleTitle ? ` ${colors.fg("accent", visibleTitle)} ` : "";
79
+ const used = 2 + visibleWidth(label) + 1;
80
+ return colors.fg("border", "╭─") + label + colors.fg("border", `${"─".repeat(Math.max(0, width - used))}╮`);
81
+ }
82
+
83
+ /** A bordered component suitable for Pi's native overlay stack. */
84
+ export class DialogOverlay implements Component, Focusable {
85
+ private childWidth = 0;
86
+ private childHeight = 0;
87
+ private _focused = false;
88
+
89
+ /**
90
+ * Create a border around an arbitrary child component.
91
+ * @param theme Active Pi theme used for semantic frame colors.
92
+ * @param child Component rendered within the one-cell frame inset.
93
+ * @param title Static or dynamic title resolved on each render.
94
+ */
95
+ constructor(
96
+ private readonly theme: Theme,
97
+ private readonly child: Component,
98
+ private readonly title: TuiTitleSource = "",
99
+ ) {}
100
+
101
+ /** Give height-aware children the rows remaining inside this dialog's frame. */
102
+ setMaxHeight(maxHeight: number): void {
103
+ (this.child as HeightAwareComponent).setMaxHeight?.(Math.max(0, maxHeight - 2));
104
+ }
105
+
106
+ /** Whether the dialog currently owns focus. */
107
+ get focused(): boolean {
108
+ return this._focused;
109
+ }
110
+
111
+ /**
112
+ * Transfer focus to the dialog and to its child when the child is focusable.
113
+ * @param value Whether the dialog should own focus.
114
+ */
115
+ set focused(value: boolean) {
116
+ this._focused = value;
117
+ if (isFocusable(this.child)) this.child.focused = value;
118
+ }
119
+
120
+ /**
121
+ * Forward raw terminal input to the child when it implements input handling.
122
+ * @param data Raw terminal input received by the focused dialog.
123
+ */
124
+ handleInput(data: string): void {
125
+ this.child.handleInput?.(data);
126
+ }
127
+
128
+ /** Invalidate the child component's cached presentation state. */
129
+ invalidate(): void {
130
+ this.child.invalidate();
131
+ }
132
+
133
+ /**
134
+ * Translate dialog-local pointer input through the one-cell frame to the child.
135
+ * @param event Pointer event whose row and column are relative to this dialog.
136
+ * @returns The child's handled result, or `false` when the child has no pointer
137
+ * handler, the event is outside its last rendered bounds, or the handler throws.
138
+ */
139
+ onMouse(event: TuiMouseEvent): boolean {
140
+ const child = this.child as PointerComponent;
141
+ if (typeof child.onMouse !== "function") return false;
142
+ const translated = { ...event, row: event.row - 1, col: event.col - 1 };
143
+ if (
144
+ event.type !== "leave" &&
145
+ (translated.row < 0 ||
146
+ translated.row >= this.childHeight ||
147
+ translated.col < 0 ||
148
+ translated.col >= this.childWidth)
149
+ )
150
+ return false;
151
+ try {
152
+ return child.onMouse(translated) === true;
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Render the dynamic title, child content, and one-cell border.
160
+ * @param width Total available columns including both border columns.
161
+ * @returns ANSI-styled dialog rows; widths below two columns produce one blank fitted row.
162
+ */
163
+ render(width: number): string[] {
164
+ const colors = tuiTheme(this.theme);
165
+ if (width < 2) {
166
+ this.childWidth = 0;
167
+ this.childHeight = 0;
168
+ return [fitLine("", width)];
169
+ }
170
+ this.childWidth = width - 2;
171
+ const content = this.child.render(this.childWidth);
172
+ this.childHeight = content.length;
173
+ return [
174
+ border(this.theme, resolveTuiTitle(this.title), width),
175
+ ...content.map(
176
+ (line) => `${colors.fg("border", "│")}${fitLine(line, this.childWidth)}${colors.fg("border", "│")}`,
177
+ ),
178
+ colors.fg("border", `╰${"─".repeat(this.childWidth)}╯`),
179
+ ];
180
+ }
181
+ }
182
+
183
+ /** Opens reusable components on Pi's native, focus-owning overlay stack. */
184
+ export class DialogOverlayHost implements DialogHost {
185
+ private readonly handles = new Set<OverlayHandle>();
186
+
187
+ /**
188
+ * Create a dialog host for one TUI and semantic theme.
189
+ * @param tui TUI whose native overlay stack owns opened dialogs.
190
+ * @param theme Active Pi theme used for every dialog frame.
191
+ */
192
+ constructor(
193
+ private readonly tui: TUI,
194
+ private readonly theme: Theme,
195
+ ) {}
196
+
197
+ /**
198
+ * Open a centered or parent-anchored native overlay around a component.
199
+ * @param component Child component to frame and show.
200
+ * @param options Dialog title and native sizing/visibility options. Supplying
201
+ * `parent` selects bounded anchored placement; omitting it uses centered defaults.
202
+ * @returns An idempotent close function that hides only this overlay and requests a render.
203
+ */
204
+ open(component: Component, { title, parent, ...options }: DialogOverlayOptions = {}): () => void {
205
+ const dialog = new DialogOverlay(this.theme, component, title);
206
+ const requestedMaxHeight = options.maxHeight ?? (parent ? undefined : "90%");
207
+ dialog.setMaxHeight(resolveSize(requestedMaxHeight, this.tui.terminal.rows, this.tui.terminal.rows));
208
+ const overlayOptions = parent
209
+ ? this.anchoredOptions(dialog, parent, options)
210
+ : {
211
+ anchor: "center" as const,
212
+ width: 56,
213
+ maxHeight: "90%" as const,
214
+ margin: 1,
215
+ ...options,
216
+ };
217
+ const handle = this.tui.showOverlay(dialog, overlayOptions);
218
+ this.handles.add(handle);
219
+ this.tui.requestRender();
220
+ let closed = false;
221
+ return () => {
222
+ if (closed) return;
223
+ closed = true;
224
+ this.handles.delete(handle);
225
+ handle.hide();
226
+ this.tui.requestRender();
227
+ };
228
+ }
229
+
230
+ /** Hide all dialogs still owned by this host, clear their handles, and request a render. */
231
+ dispose(): void {
232
+ for (const handle of this.handles) handle.hide();
233
+ this.handles.clear();
234
+ this.tui.requestRender();
235
+ }
236
+
237
+ private anchoredOptions(
238
+ dialog: DialogOverlay,
239
+ parent: DialogOverlayAnchor,
240
+ options: Omit<DialogOverlayOptions, "title" | "parent">,
241
+ ): OverlayOptions {
242
+ const width = Math.min(this.tui.terminal.columns, resolveSize(options.width, this.tui.terminal.columns, 56));
243
+ const measuredHeight = dialog.render(width).length;
244
+ const maxHeight = resolveSize(options.maxHeight, this.tui.terminal.rows, measuredHeight);
245
+ const placement = placeAnchoredOverlay({
246
+ terminalCols: this.tui.terminal.columns,
247
+ terminalRows: this.tui.terminal.rows,
248
+ anchorRow: parent.row,
249
+ anchorCol: parent.col,
250
+ desiredWidth: width,
251
+ height: Math.min(measuredHeight, maxHeight),
252
+ });
253
+ return {
254
+ ...placement.options,
255
+ minWidth: options.minWidth,
256
+ visible: options.visible,
257
+ nonCapturing: options.nonCapturing,
258
+ };
259
+ }
260
+ }
261
+
262
+ function resolveSize(value: SizeValue | undefined, total: number, fallback: number): number {
263
+ if (typeof value === "number") return Math.max(1, Math.floor(value));
264
+ if (typeof value === "string") return Math.max(1, Math.floor((total * Number.parseFloat(value)) / 100));
265
+ return Math.max(1, Math.floor(fallback));
266
+ }
@@ -0,0 +1,106 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, compositeTuiLine, type Focusable, isFocusable } from "@earendil-works/pi-tui";
3
+ import { BackgroundSurface } from "../background-surface.ts";
4
+ import type { TuiBackgroundToken } from "../color/theme.ts";
5
+ import type { TuiMouseEvent } from "../mouse.ts";
6
+
7
+ type PointerComponent = Component & { onMouse?(event: TuiMouseEvent): boolean; dispose?(): void };
8
+ type HeightAwareComponent = Component & { setMaxHeight?(height: number): void };
9
+
10
+ export interface FloatingOverlayOptions {
11
+ readonly base: Component;
12
+ readonly overlay: Component;
13
+ readonly overlayWidth: (availableWidth: number) => number;
14
+ readonly align?: "start" | "end";
15
+ /** Top row in the base component. Values are clamped to keep the overlay visible. */
16
+ readonly top?: number | (() => number);
17
+ readonly maxHeight?: () => number;
18
+ readonly surface?: { readonly theme: Theme; readonly background: TuiBackgroundToken };
19
+ /** Consume a primary press outside the overlay and notify its owner. */
20
+ readonly onOutsidePress?: () => void;
21
+ }
22
+
23
+ /** Composites a focus-owning child over ordinary content and owns its pointer geometry. */
24
+ export class FloatingOverlay implements Component, Focusable {
25
+ private readonly renderedOverlay: Component;
26
+ private overlayX = 0;
27
+ private overlayY = 0;
28
+ private overlayWidth = 0;
29
+ private overlayHeight = 0;
30
+
31
+ constructor(private readonly options: FloatingOverlayOptions) {
32
+ this.renderedOverlay = options.surface
33
+ ? new BackgroundSurface({
34
+ theme: options.surface.theme,
35
+ component: options.overlay,
36
+ background: options.surface.background,
37
+ })
38
+ : options.overlay;
39
+ }
40
+
41
+ get focused(): boolean {
42
+ return isFocusable(this.options.overlay) && this.options.overlay.focused;
43
+ }
44
+
45
+ set focused(value: boolean) {
46
+ if (isFocusable(this.options.overlay)) this.options.overlay.focused = value;
47
+ if (isFocusable(this.options.base)) this.options.base.focused = false;
48
+ }
49
+
50
+ handleInput(data: string): void {
51
+ this.options.overlay.handleInput?.(data);
52
+ }
53
+
54
+ onMouse(event: TuiMouseEvent): boolean {
55
+ const overlay = this.options.overlay as PointerComponent;
56
+ const base = this.options.base as PointerComponent;
57
+ if (event.type === "leave") {
58
+ overlay.onMouse?.(event);
59
+ return base.onMouse?.(event) === true;
60
+ }
61
+ if (
62
+ event.col >= this.overlayX &&
63
+ event.col < this.overlayX + this.overlayWidth &&
64
+ event.row >= this.overlayY &&
65
+ event.row < this.overlayY + this.overlayHeight
66
+ )
67
+ return overlay.onMouse?.({ ...event, row: event.row - this.overlayY, col: event.col - this.overlayX }) === true;
68
+ if (event.type === "press" && event.button === 0 && this.options.onOutsidePress) {
69
+ this.options.onOutsidePress();
70
+ return true;
71
+ }
72
+ return base.onMouse?.(event) === true;
73
+ }
74
+
75
+ render(width: number): string[] {
76
+ const boundedWidth = Math.max(0, Math.floor(width));
77
+ const base = this.options.base.render(boundedWidth);
78
+ if (boundedWidth === 0) return base;
79
+ this.overlayWidth = Math.max(1, Math.min(boundedWidth, Math.floor(this.options.overlayWidth(boundedWidth))));
80
+ this.overlayX = this.options.align === "start" ? 0 : boundedWidth - this.overlayWidth;
81
+ const maxHeight = this.options.maxHeight?.();
82
+ if (maxHeight !== undefined)
83
+ (this.options.overlay as HeightAwareComponent).setMaxHeight?.(Math.max(0, Math.floor(maxHeight)));
84
+ const overlay = this.renderedOverlay.render(this.overlayWidth);
85
+ this.overlayHeight = overlay.length;
86
+ const requestedTop = typeof this.options.top === "function" ? this.options.top() : (this.options.top ?? 0);
87
+ this.overlayY = Math.max(0, Math.min(Math.floor(requestedTop), Math.max(0, base.length - overlay.length)));
88
+ return Array.from({ length: Math.max(base.length, this.overlayY + overlay.length) }, (_, row) => {
89
+ const line = base[row] ?? "";
90
+ const floating = overlay[row - this.overlayY];
91
+ return floating === undefined
92
+ ? line
93
+ : compositeTuiLine(line, floating, this.overlayX, this.overlayWidth, boundedWidth);
94
+ });
95
+ }
96
+
97
+ invalidate(): void {
98
+ this.options.base.invalidate();
99
+ this.renderedOverlay.invalidate();
100
+ }
101
+
102
+ dispose(): void {
103
+ (this.options.base as PointerComponent).dispose?.();
104
+ (this.options.overlay as PointerComponent).dispose?.();
105
+ }
106
+ }
@@ -0,0 +1,236 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ CURSOR_MARKER,
5
+ Editor,
6
+ type EditorOptions,
7
+ type Focusable,
8
+ type KeybindingsManager,
9
+ stripTerminalSequences,
10
+ type TUI,
11
+ truncateToWidth,
12
+ visibleWidth,
13
+ } from "@earendil-works/pi-tui";
14
+ import { tuiTheme } from "../color/theme.ts";
15
+ import type { ActionPanelFooter, ActionPanelMouseEvent, ActionPanelRect } from "../controls/action-panel.ts";
16
+ import { markEditorCursor } from "../cursor.ts";
17
+ import { resolveTuiTitle, type TuiTitleValue } from "../decoration/status.ts";
18
+ import { semanticEditorTheme } from "../editor.ts";
19
+ import { fitLine } from "../line-layout.ts";
20
+
21
+ function isEditorBorder(line: string): boolean {
22
+ const plain = stripTerminalSequences(line);
23
+ return /^[↑↓]?─+$/.test(plain);
24
+ }
25
+
26
+ /** Remove only Pi Editor's top and bottom rules, preserving autocomplete rows. */
27
+ function withoutEditorBorders(lines: readonly string[]): string[] {
28
+ const result = [...lines];
29
+ if (result[0] !== undefined && isEditorBorder(result[0])) result.shift();
30
+ const bottom = result.findIndex((line, index) => index > 0 && isEditorBorder(line));
31
+ if (bottom >= 0) result.splice(bottom, 1);
32
+ return result;
33
+ }
34
+
35
+ /** Keep the cursor visible when a wrapper has fewer rows than Editor rendered. */
36
+ function keepCursorVisible(lines: readonly string[], maxRows: number): string[] {
37
+ if (maxRows <= 0 || lines.length <= maxRows) return maxRows <= 0 ? [] : [...lines];
38
+ const cursor = lines.findIndex((line) => line.includes(CURSOR_MARKER));
39
+ const start = cursor < 0 ? 0 : Math.min(cursor, lines.length - maxRows);
40
+ return lines.slice(start, start + maxRows);
41
+ }
42
+
43
+ /** Last rendered bounds for a {@link FramedEditorOverlay}. */
44
+ export interface FramedEditorOverlayGeometry extends ActionPanelRect {
45
+ /** Bounds of the optional footer row, relative to the overlay's top-left cell. */
46
+ footer?: ActionPanelRect;
47
+ }
48
+
49
+ /** Construction options for a bounded native Pi multiline editor. */
50
+ export interface FramedEditorOverlayOptions {
51
+ /** TUI instance used by Pi's native editor for rendering and terminal measurements. */
52
+ tui: TUI;
53
+ /** Active Pi theme; pi-libtui maps it to semantic editor and frame colors. */
54
+ theme: Theme;
55
+ /** Active keybindings used to recognize semantic cancel input. */
56
+ keybindings: KeybindingsManager;
57
+ /** Static or semantic title rendered inside the top border. */
58
+ title: TuiTitleValue;
59
+ /** Initial editor text. Omit to start with Pi's default empty value. */
60
+ prefill?: string;
61
+ /** Maximum rendered width, including the two frame columns. */
62
+ maxWidth?: number;
63
+ /** Maximum rendered height, including frame, separator, and footer rows. */
64
+ maxHeight?: number;
65
+ /** Hide Pi Editor's horizontal rules when the outer frame is sufficient. */
66
+ editorBorders?: boolean;
67
+ /** A single component row rendered below an internal separator. */
68
+ footer?: ActionPanelFooter;
69
+ /** Options forwarded unchanged to Pi's native `Editor` constructor. */
70
+ editorOptions?: EditorOptions;
71
+ /** Called with the current editor text when Pi accepts submit input. */
72
+ onSubmit(text: string): void;
73
+ /** Called when input matches the semantic `tui.select.cancel` binding. */
74
+ onCancel(): void;
75
+ }
76
+
77
+ /** A small framed multiline overlay that delegates all text editing to Pi's Editor. */
78
+ export class FramedEditorOverlay implements Component, Focusable {
79
+ private readonly editor: Editor;
80
+ private _focused = false;
81
+ private footerPointerInside = false;
82
+ private geometry: FramedEditorOverlayGeometry | undefined;
83
+
84
+ /**
85
+ * Create a framed editor and initialize its native editor state.
86
+ * @param config Theme, bounds, editor behavior, and completion callbacks.
87
+ */
88
+ constructor(private readonly config: FramedEditorOverlayOptions) {
89
+ this.editor = new Editor(config.tui, semanticEditorTheme(config.theme), config.editorOptions);
90
+ this.editor.onSubmit = config.onSubmit;
91
+ if (config.prefill !== undefined) this.editor.setText(config.prefill);
92
+ }
93
+
94
+ /** Whether this overlay and its native editor currently own focus. */
95
+ get focused(): boolean {
96
+ return this._focused;
97
+ }
98
+
99
+ /**
100
+ * Transfer focus to or from both the frame and its native editor.
101
+ * @param value Whether the editor should own focus.
102
+ */
103
+ set focused(value: boolean) {
104
+ this._focused = value;
105
+ this.editor.focused = value;
106
+ }
107
+
108
+ /** @returns The native editor's current unrendered text. */
109
+ getText(): string {
110
+ return this.editor.getText();
111
+ }
112
+
113
+ /**
114
+ * Replace all editor text and reset the native editor to that value.
115
+ * @param text Complete unrendered value to install in the native editor.
116
+ */
117
+ setText(text: string): void {
118
+ this.editor.setText(text);
119
+ }
120
+
121
+ /**
122
+ * Route terminal input through cancel, footer shortcuts, then the native editor.
123
+ * @param data Raw terminal input received by the focused component.
124
+ */
125
+ handleInput(data: string): void {
126
+ if (this.config.keybindings.matches(data, "tui.select.cancel")) {
127
+ this.config.onCancel();
128
+ return;
129
+ }
130
+ if (this.config.footer?.handleInput?.(data) === true) return;
131
+ this.editor.handleInput(data);
132
+ }
133
+
134
+ /**
135
+ * Route overlay-local pointer input to the optional footer.
136
+ * @param event Pointer coordinates relative to the framed overlay.
137
+ * @returns `true` when the event targets the footer; otherwise `false`.
138
+ */
139
+ handleMouse(event: ActionPanelMouseEvent): boolean {
140
+ if (event.type === "leave") {
141
+ this.leaveFooter(event);
142
+ return false;
143
+ }
144
+ const footer = this.geometry?.footer;
145
+ if (
146
+ !footer ||
147
+ event.col < footer.x ||
148
+ event.col >= footer.x + footer.width ||
149
+ event.row < footer.y ||
150
+ event.row >= footer.y + footer.height
151
+ ) {
152
+ this.leaveFooter(event);
153
+ return false;
154
+ }
155
+ const translated = { ...event, row: event.row - footer.y, col: event.col - footer.x };
156
+ if (!this.footerPointerInside) {
157
+ this.footerPointerInside = true;
158
+ this.config.footer?.handleMouse?.({ ...translated, type: "enter" });
159
+ }
160
+ if (event.type !== "enter") this.config.footer?.handleMouse?.(translated);
161
+ return true;
162
+ }
163
+
164
+ /**
165
+ * @returns A defensive copy of the last rendered bounds, or `undefined`
166
+ * before rendering, after invalidation, or when the available size is too small.
167
+ */
168
+ getGeometry(): FramedEditorOverlayGeometry | undefined {
169
+ if (!this.geometry) return undefined;
170
+ return {
171
+ ...this.geometry,
172
+ footer: this.geometry.footer ? { ...this.geometry.footer } : undefined,
173
+ };
174
+ }
175
+
176
+ /** Invalidate the native editor, footer, and cached render geometry. */
177
+ invalidate(): void {
178
+ this.editor.invalidate();
179
+ this.config.footer?.invalidate?.();
180
+ this.geometry = undefined;
181
+ }
182
+
183
+ /**
184
+ * Render the frame, visible editor rows, and optional one-row footer.
185
+ * @param availableWidth Maximum columns offered by the parent layout.
186
+ * @returns ANSI-styled rows bounded by `availableWidth`, `maxWidth`, and
187
+ * `maxHeight`; returns no rows when the bounds cannot fit editor chrome.
188
+ */
189
+ render(availableWidth: number): string[] {
190
+ const colors = tuiTheme(this.config.theme);
191
+ const width = Math.max(0, Math.min(availableWidth, this.config.maxWidth ?? availableWidth));
192
+ const maxHeight = Math.max(0, Math.floor(this.config.maxHeight ?? Number.POSITIVE_INFINITY));
193
+ const chromeRows = this.config.footer ? 4 : 2;
194
+ if (width < 4 || maxHeight < chromeRows + 1) {
195
+ this.geometry = undefined;
196
+ return [];
197
+ }
198
+ const innerWidth = width - 2;
199
+ const visibleTitle = truncateToWidth(resolveTuiTitle(this.config.title), Math.max(0, innerWidth - 3), "");
200
+ const usedTitleWidth = visibleWidth(`─ ${visibleTitle} `);
201
+ const top =
202
+ colors.fg("border", "╭─ ") +
203
+ colors.fg("accent", this.config.theme.bold(visibleTitle)) +
204
+ colors.fg("border", ` ${"─".repeat(Math.max(0, innerWidth - usedTitleWidth))}╮`);
205
+ const bottom = colors.fg("border", `╰${"─".repeat(innerWidth)}╯`);
206
+ // Only the focused overlay can claim the screen's semantic cursor.
207
+ const editorLines = this.editor
208
+ .render(innerWidth)
209
+ .map((line) => markEditorCursor(line, { theme: this.config.theme, role: "insertion" }));
210
+ const content = this.config.editorBorders === false ? withoutEditorBorders(editorLines) : editorLines;
211
+ const maxContent = maxHeight - chromeRows;
212
+ const lines = [
213
+ top,
214
+ ...keepCursorVisible(content, maxContent).map(
215
+ (line) => `${colors.fg("border", "│")}${fitLine(line, innerWidth)}${colors.fg("border", "│")}`,
216
+ ),
217
+ ];
218
+ let footer: ActionPanelRect | undefined;
219
+ if (this.config.footer) {
220
+ lines.push(colors.fg("border", `├${"─".repeat(innerWidth)}┤`));
221
+ const y = lines.length;
222
+ const content = this.config.footer.render(innerWidth)[0] ?? "";
223
+ lines.push(`${colors.fg("border", "│")}${fitLine(content, innerWidth)}${colors.fg("border", "│")}`);
224
+ footer = { x: 1, y, width: innerWidth, height: 1 };
225
+ }
226
+ lines.push(bottom);
227
+ this.geometry = { x: 0, y: 0, width, height: lines.length, footer };
228
+ return lines;
229
+ }
230
+
231
+ private leaveFooter(event: ActionPanelMouseEvent): void {
232
+ if (!this.footerPointerInside) return;
233
+ this.footerPointerInside = false;
234
+ this.config.footer?.handleMouse?.({ ...event, type: "leave", row: -1, col: -1 });
235
+ }
236
+ }