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.
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +55 -0
- package/src/activity-presentation.ts +75 -0
- package/src/appearance.ts +632 -0
- package/src/background-surface.ts +125 -0
- package/src/color/palette.ts +186 -0
- package/src/color/preview.ts +157 -0
- package/src/color/resolver.ts +75 -0
- package/src/color/theme.ts +531 -0
- package/src/component-stack.ts +247 -0
- package/src/content/markdown-code.ts +60 -0
- package/src/content/terminal-text.ts +135 -0
- package/src/content/text.ts +105 -0
- package/src/controls/action-panel.ts +420 -0
- package/src/controls/dialog-button-bar.ts +287 -0
- package/src/controls/multi-select.ts +467 -0
- package/src/controls/picker-panel.ts +447 -0
- package/src/controls/screen-icon-actions.ts +134 -0
- package/src/controls/searchable-select.ts +394 -0
- package/src/controls/select-box.ts +224 -0
- package/src/controls/selectable-list.ts +433 -0
- package/src/controls/selection-action-bar.ts +382 -0
- package/src/controls/semantic-input.ts +23 -0
- package/src/controls/tab.ts +189 -0
- package/src/cursor.ts +288 -0
- package/src/decoration/editor-pills.ts +156 -0
- package/src/decoration/glyphs.ts +326 -0
- package/src/decoration/pointer-interaction.ts +123 -0
- package/src/decoration/powerline-pill.ts +104 -0
- package/src/decoration/status.ts +185 -0
- package/src/decoration/transient-pill.ts +176 -0
- package/src/diff/index.ts +21 -0
- package/src/diff/model.ts +76 -0
- package/src/diff/parse.ts +310 -0
- package/src/diff/pierre-syntax.ts +86 -0
- package/src/diff/render.ts +504 -0
- package/src/diff/structured.ts +264 -0
- package/src/diff/view.ts +75 -0
- package/src/editor/chrome.ts +341 -0
- package/src/editor/composition.ts +53 -0
- package/src/editor/layout.ts +81 -0
- package/src/editor/presentation.ts +35 -0
- package/src/editor/protocol.ts +188 -0
- package/src/editor.ts +41 -0
- package/src/extension.ts +30 -0
- package/src/folding.ts +192 -0
- package/src/host/cursor-bridge.ts +210 -0
- package/src/host/editor-bridge.ts +148 -0
- package/src/host/extension-host.ts +231 -0
- package/src/host/mouse-bridge.ts +212 -0
- package/src/host/mouse-dispatch.ts +400 -0
- package/src/host/mouse-input.ts +85 -0
- package/src/host/mouse-targets.ts +238 -0
- package/src/host/pi-layout-adapter.ts +298 -0
- package/src/host/pi-native-selection.ts +227 -0
- package/src/host/split-pane-bridge.ts +720 -0
- package/src/host/transcript-bridge.ts +236 -0
- package/src/index.ts +334 -0
- package/src/line-layout.ts +6 -0
- package/src/motion.ts +1409 -0
- package/src/mouse/events.ts +55 -0
- package/src/mouse/fullscreen-layout.ts +260 -0
- package/src/mouse/registry.ts +348 -0
- package/src/mouse.ts +37 -0
- package/src/native-binary.ts +189 -0
- package/src/overlay/anchored.ts +91 -0
- package/src/overlay/detail-card.ts +158 -0
- package/src/overlay/dialog.ts +266 -0
- package/src/overlay/floating.ts +106 -0
- package/src/overlay/framed-editor.ts +236 -0
- package/src/overlay/fullscreen.ts +170 -0
- package/src/overlay/hover-tooltip.ts +109 -0
- package/src/overlay/modal-mount.ts +118 -0
- package/src/panels.ts +207 -0
- package/src/render-cache.ts +80 -0
- package/src/render-epoch.ts +48 -0
- package/src/request-animation.ts +160 -0
- package/src/scrollbar.ts +27 -0
- package/src/selection.ts +148 -0
- package/src/split-pane.ts +232 -0
- package/src/status-presentation.ts +411 -0
- package/src/stream.ts +543 -0
- package/src/syntax.ts +316 -0
- package/src/terminal/bridge-client.ts +243 -0
- package/src/terminal/embedding.ts +6 -0
- package/src/terminal/output.ts +151 -0
- package/src/terminal/projection.ts +362 -0
- package/src/terminal/pty-host.ts +263 -0
- package/src/terminal/pty-pane.ts +308 -0
- package/src/terminal-colors.ts +334 -0
- package/src/tool/action.ts +170 -0
- package/src/tool/activity.ts +500 -0
- package/src/tool/call-preview.ts +44 -0
- package/src/tool/disclosure-action.ts +89 -0
- package/src/tool/expanded-region-viewport.ts +103 -0
- package/src/tool/index.ts +26 -0
- package/src/tool/output.ts +271 -0
- package/src/tool/transcript.ts +150 -0
- package/src/tool/view-region.ts +581 -0
- package/test/appearance.test.ts +183 -0
- package/test/bounded-stream.test.ts +150 -0
- package/test/bridge-client.test.ts +42 -0
- package/test/color-preview.test.ts +23 -0
- package/test/component-stack.test.ts +206 -0
- package/test/components.test.ts +1165 -0
- package/test/content-text.test.ts +21 -0
- package/test/cursor-bridge.test.ts +151 -0
- package/test/cursor.test.ts +147 -0
- package/test/editor-chrome.test.ts +119 -0
- package/test/editor-layout.test.ts +30 -0
- package/test/editor-registry.test.ts +203 -0
- package/test/editor-theme.test.ts +33 -0
- package/test/editor-token-pill.test.ts +72 -0
- package/test/floating-overlay.test.ts +57 -0
- package/test/folding.test.ts +147 -0
- package/test/harmonious-theme.test.ts +23 -0
- package/test/interaction.test.ts +104 -0
- package/test/key-icons.test.ts +62 -0
- package/test/markdown-code.test.ts +24 -0
- package/test/modal-overlay-mount.test.ts +94 -0
- package/test/motion.test.ts +649 -0
- package/test/mouse/bridge.test.ts +1302 -0
- package/test/mouse/extension.test.ts +205 -0
- package/test/mouse/registry.test.ts +140 -0
- package/test/mouse/screen-decoration.test.ts +167 -0
- package/test/native-binary.test.ts +52 -0
- package/test/palette.test.ts +32 -0
- package/test/panels.test.ts +74 -0
- package/test/pointer-components.test.ts +151 -0
- package/test/progress.test.ts +54 -0
- package/test/pty-host.test.ts +126 -0
- package/test/pty-pane.test.ts +62 -0
- package/test/public-surface.test.ts +75 -0
- package/test/render-cache.test.ts +25 -0
- package/test/render-epoch.test.ts +119 -0
- package/test/request-animation.test.ts +193 -0
- package/test/screen-icon-actions.test.ts +69 -0
- package/test/select-box.test.ts +86 -0
- package/test/select-pointer.test.ts +369 -0
- package/test/selectable-list.test.ts +258 -0
- package/test/selection/registry.test.ts +44 -0
- package/test/selection-action-bar.test.ts +204 -0
- package/test/split-pane-bridge.test.ts +944 -0
- package/test/split-pane.test.ts +134 -0
- package/test/status-presentation.test.ts +39 -0
- package/test/syntax.test.ts +59 -0
- package/test/terminal-colors.test.ts +115 -0
- package/test/terminal-embedding.test.ts +12 -0
- package/test/terminal-projection.test.ts +312 -0
- package/test/theme-colors.test.ts +175 -0
- package/test/tool-output.test.ts +160 -0
- package/test/tool-transcript.test.ts +1190 -0
- package/test/unified-diff.test.ts +691 -0
- package/themes/harmonious.json +61 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
type Focusable,
|
|
5
|
+
isFocusable,
|
|
6
|
+
type OverlayOptions,
|
|
7
|
+
type TUI,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import { subscribeTuiAppearance } from "../appearance.ts";
|
|
12
|
+
import { tuiTheme } from "../color/theme.ts";
|
|
13
|
+
import { resolveTuiTitle, type TuiTitleSource } from "../decoration/status.ts";
|
|
14
|
+
import { fitLine } from "../line-layout.ts";
|
|
15
|
+
import type { TuiMouseEvent } from "../mouse.ts";
|
|
16
|
+
|
|
17
|
+
type PointerComponent = Component & {
|
|
18
|
+
onMouse?: (event: TuiMouseEvent) => boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type DisposableComponent = Component & {
|
|
22
|
+
dispose?(): void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns Pi overlay options that cover the terminal from its top-left corner.
|
|
27
|
+
*
|
|
28
|
+
* @returns Full-width, full-height, zero-margin overlay options.
|
|
29
|
+
*/
|
|
30
|
+
export function fullscreenOverlayOptions(): OverlayOptions {
|
|
31
|
+
return {
|
|
32
|
+
anchor: "top-left",
|
|
33
|
+
width: "100%",
|
|
34
|
+
maxHeight: "100%",
|
|
35
|
+
margin: 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function topBorder(theme: Theme, title: string, width: number): string {
|
|
40
|
+
const colors = tuiTheme(theme);
|
|
41
|
+
const visibleTitle = truncateToWidth(title, Math.max(0, width - 5), "");
|
|
42
|
+
const label = visibleTitle ? ` ${colors.fg("accent", visibleTitle)} ` : "";
|
|
43
|
+
const used = 2 + visibleWidth(label) + 1;
|
|
44
|
+
return colors.fg("border", "╭─") + label + colors.fg("border", `${"─".repeat(Math.max(0, width - used))}╮`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Covers the terminal with a bordered component while leaving the host TUI alive. */
|
|
48
|
+
export class FullscreenOverlay implements Component, Focusable {
|
|
49
|
+
private readonly removeAppearanceSubscription: () => void;
|
|
50
|
+
private childWidth = 0;
|
|
51
|
+
private childHeight = 0;
|
|
52
|
+
private _focused = false;
|
|
53
|
+
private disposed = false;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates a terminal-sized bordered host around a child component.
|
|
57
|
+
*
|
|
58
|
+
* @param tui Active Pi TUI whose terminal row count defines the overlay height.
|
|
59
|
+
* @param theme Pi theme used to derive semantic border and title colors.
|
|
60
|
+
* @param child Component rendered inside the one-cell frame.
|
|
61
|
+
* @param title Static or dynamic title source rendered in the top border.
|
|
62
|
+
*/
|
|
63
|
+
constructor(
|
|
64
|
+
private readonly tui: TUI,
|
|
65
|
+
private readonly theme: Theme,
|
|
66
|
+
private readonly child: DisposableComponent,
|
|
67
|
+
private readonly title: TuiTitleSource = "",
|
|
68
|
+
) {
|
|
69
|
+
this.removeAppearanceSubscription = subscribeTuiAppearance(() => this.tui.requestRender());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether this overlay and its focusable child own focus. */
|
|
73
|
+
get focused(): boolean {
|
|
74
|
+
return this._focused;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Transfers focus to or from the focusable child.
|
|
78
|
+
*
|
|
79
|
+
* @param value Whether the overlay should own focus.
|
|
80
|
+
*/
|
|
81
|
+
set focused(value: boolean) {
|
|
82
|
+
this._focused = value;
|
|
83
|
+
if (isFocusable(this.child)) this.child.focused = value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Invalidates the child component when it supports invalidation.
|
|
88
|
+
*
|
|
89
|
+
* @returns Nothing.
|
|
90
|
+
*/
|
|
91
|
+
invalidate(): void {
|
|
92
|
+
this.child.invalidate?.();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Releases the appearance subscription owned by this overlay.
|
|
97
|
+
*
|
|
98
|
+
* @returns Nothing.
|
|
99
|
+
*/
|
|
100
|
+
dispose(): void {
|
|
101
|
+
if (this.disposed) return;
|
|
102
|
+
this.disposed = true;
|
|
103
|
+
this.removeAppearanceSubscription();
|
|
104
|
+
this.child.dispose?.();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Forwards raw terminal input to the child when it handles input.
|
|
109
|
+
*
|
|
110
|
+
* @param data Raw terminal input sequence from Pi.
|
|
111
|
+
* @returns Nothing.
|
|
112
|
+
*/
|
|
113
|
+
handleInput(data: string): void {
|
|
114
|
+
this.child.handleInput?.(data);
|
|
115
|
+
this.tui.requestRender();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Translates a frame-local pointer event into child coordinates and dispatches it.
|
|
120
|
+
*
|
|
121
|
+
* @param event Pointer event expressed in overlay-local coordinates.
|
|
122
|
+
* @returns Whether the child handled the event; modal selection gestures are consumed anywhere inside the overlay.
|
|
123
|
+
*/
|
|
124
|
+
onMouse(event: TuiMouseEvent): boolean {
|
|
125
|
+
const blocksSelection = event.type === "press" || event.type === "drag" || event.type === "release";
|
|
126
|
+
const child = this.child as PointerComponent;
|
|
127
|
+
if (typeof child.onMouse !== "function") return blocksSelection;
|
|
128
|
+
const translated = { ...event, row: event.row - 1, col: event.col - 1 };
|
|
129
|
+
if (
|
|
130
|
+
event.type !== "leave" &&
|
|
131
|
+
(translated.row < 0 ||
|
|
132
|
+
translated.row >= this.childHeight ||
|
|
133
|
+
translated.col < 0 ||
|
|
134
|
+
translated.col >= this.childWidth)
|
|
135
|
+
)
|
|
136
|
+
return blocksSelection;
|
|
137
|
+
try {
|
|
138
|
+
return Reflect.apply(child.onMouse, child, [translated]) === true || blocksSelection;
|
|
139
|
+
} catch {
|
|
140
|
+
return blocksSelection;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Renders a border and pads or clips the child to the current terminal height.
|
|
146
|
+
*
|
|
147
|
+
* @param width Available terminal width in columns.
|
|
148
|
+
* @returns Exactly `tui.terminal.rows` rows when both dimensions can contain a frame, otherwise one blank fitted row.
|
|
149
|
+
*/
|
|
150
|
+
render(width: number): string[] {
|
|
151
|
+
const colors = tuiTheme(this.theme);
|
|
152
|
+
const height = Math.max(1, this.tui.terminal.rows);
|
|
153
|
+
if (width < 2 || height < 2) {
|
|
154
|
+
this.childWidth = 0;
|
|
155
|
+
this.childHeight = 0;
|
|
156
|
+
return [fitLine("", width)];
|
|
157
|
+
}
|
|
158
|
+
const innerWidth = width - 2;
|
|
159
|
+
const innerHeight = height - 2;
|
|
160
|
+
this.childWidth = innerWidth;
|
|
161
|
+
this.childHeight = innerHeight;
|
|
162
|
+
const content = this.child.render(innerWidth).slice(0, innerHeight);
|
|
163
|
+
while (content.length < innerHeight) content.push("");
|
|
164
|
+
return [
|
|
165
|
+
topBorder(this.theme, resolveTuiTitle(this.title), width),
|
|
166
|
+
...content.map((line) => `${colors.fg("border", "│")}${fitLine(line, innerWidth)}${colors.fg("border", "│")}`),
|
|
167
|
+
colors.fg("border", `╰${"─".repeat(innerWidth)}╯`),
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
3
|
+
import { compositeTuiLine, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { sanitizeTuiText } from "../content/terminal-text.ts";
|
|
5
|
+
import { renderKeyHint } from "../decoration/glyphs.ts";
|
|
6
|
+
import { renderPill } from "../decoration/powerline-pill.ts";
|
|
7
|
+
import type { MouseRect, MouseRegistry, ScreenDecorationContext } from "../mouse.ts";
|
|
8
|
+
|
|
9
|
+
export interface HoverTooltipTarget {
|
|
10
|
+
readonly rect: MouseRect;
|
|
11
|
+
readonly label: string;
|
|
12
|
+
readonly shortcut?: KeyId;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface HoverTooltipMountOptions {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly theme: Theme;
|
|
18
|
+
readonly registry: MouseRegistry;
|
|
19
|
+
readonly priority?: number;
|
|
20
|
+
readonly pointerPriority?: number;
|
|
21
|
+
getTarget(screen: readonly string[], context: ScreenDecorationContext): HoverTooltipTarget | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface HoverTooltipMount {
|
|
25
|
+
dispose(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Find one visible plain-text span in an ANSI-styled screen. */
|
|
29
|
+
export function findScreenTextRect(screen: readonly string[], text: string): MouseRect | undefined {
|
|
30
|
+
for (const [row, line] of screen.entries()) {
|
|
31
|
+
const plain = stripTerminalSequences(line);
|
|
32
|
+
const index = plain.indexOf(text);
|
|
33
|
+
if (index < 0) continue;
|
|
34
|
+
return {
|
|
35
|
+
x: visibleWidth(plain.slice(0, index)),
|
|
36
|
+
y: row,
|
|
37
|
+
width: visibleWidth(text),
|
|
38
|
+
height: 1,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Mount a pointer-hover tooltip over any screen-locatable target. */
|
|
45
|
+
export function mountHoverTooltip(options: HoverTooltipMountOptions): HoverTooltipMount {
|
|
46
|
+
let target: HoverTooltipTarget | undefined;
|
|
47
|
+
let hovered = false;
|
|
48
|
+
let disposed = false;
|
|
49
|
+
const removeDecorator = options.registry.registerScreenDecorator({
|
|
50
|
+
id: options.id,
|
|
51
|
+
priority: options.priority ?? 5_000,
|
|
52
|
+
decorate(screen, context) {
|
|
53
|
+
target = context.hasOverlay ? undefined : options.getTarget(screen, context);
|
|
54
|
+
if (!target) hovered = false;
|
|
55
|
+
return hovered && target ? compositeHoverTooltip(screen, context, options.theme, target) : screen;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const removeRegion = options.registry.registerOverlayRegion({
|
|
59
|
+
id: options.id,
|
|
60
|
+
priority: options.pointerPriority ?? 500,
|
|
61
|
+
getRect: () => target?.rect,
|
|
62
|
+
onMouse(event) {
|
|
63
|
+
const next = event.type !== "leave";
|
|
64
|
+
const changed = hovered !== next;
|
|
65
|
+
hovered = next;
|
|
66
|
+
return changed || next;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
dispose() {
|
|
71
|
+
if (disposed) return;
|
|
72
|
+
disposed = true;
|
|
73
|
+
target = undefined;
|
|
74
|
+
hovered = false;
|
|
75
|
+
removeDecorator();
|
|
76
|
+
removeRegion();
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Composite a dim, borderless tooltip pill adjacent to a hovered target. */
|
|
82
|
+
export function compositeHoverTooltip(
|
|
83
|
+
screen: readonly string[],
|
|
84
|
+
context: Pick<ScreenDecorationContext, "width" | "height">,
|
|
85
|
+
theme: Theme,
|
|
86
|
+
target: HoverTooltipTarget,
|
|
87
|
+
): string[] {
|
|
88
|
+
const label = sanitizeTuiText(target.label).replaceAll("\n", " ").trim();
|
|
89
|
+
if (!label || context.width < 3 || context.height < 2) return [...screen];
|
|
90
|
+
const hint = target.shortcut ? ` ${renderKeyHint(theme, target.shortcut)}` : "";
|
|
91
|
+
const pill = renderPill(
|
|
92
|
+
theme,
|
|
93
|
+
{ icon: false, label: truncateToWidth(`${label}${hint}`, Math.max(1, context.width - 2), "") },
|
|
94
|
+
"surface.inset",
|
|
95
|
+
"text.muted",
|
|
96
|
+
undefined,
|
|
97
|
+
"\x1b[49m",
|
|
98
|
+
true,
|
|
99
|
+
);
|
|
100
|
+
const width = Math.min(context.width, visibleWidth(pill));
|
|
101
|
+
if (width === 0) return [...screen];
|
|
102
|
+
const below = target.rect.y + target.rect.height;
|
|
103
|
+
const y = below < context.height ? below : Math.max(0, target.rect.y - 1);
|
|
104
|
+
const center = target.rect.x + target.rect.width / 2;
|
|
105
|
+
const x = Math.max(0, Math.min(context.width - width, Math.round(center - width / 2)));
|
|
106
|
+
const result = [...screen];
|
|
107
|
+
result[y] = compositeTuiLine(result[y] ?? "", pill, x, width, context.width);
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Component, Focusable } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { MouseRect, TuiMouseEvent } from "../mouse/events.ts";
|
|
3
|
+
import { type MouseRegistry, registerModalPointerShield } from "../mouse/registry.ts";
|
|
4
|
+
|
|
5
|
+
/** Pointer lifecycle translated into modal-component-local coordinates. */
|
|
6
|
+
export interface ModalOverlayMouseEvent {
|
|
7
|
+
type: "enter" | "move" | "leave" | "press" | "release";
|
|
8
|
+
row: number;
|
|
9
|
+
col: number;
|
|
10
|
+
button?: 0 | 1 | 2;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A modal component that accepts overlay-local keyboard and pointer input. */
|
|
14
|
+
export interface ModalOverlayComponent extends Component, Focusable {
|
|
15
|
+
handleInput(data: string): void;
|
|
16
|
+
handleMouse(event: ModalOverlayMouseEvent): boolean;
|
|
17
|
+
dispose?(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Component wrapper returned to Pi for a mounted modal overlay. */
|
|
21
|
+
export interface MountedModalOverlayComponent extends Component, Focusable {
|
|
22
|
+
handleInput(data: string): void;
|
|
23
|
+
dispose(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Shared host mechanics for a screen-positioned modal component. */
|
|
27
|
+
export interface ModalOverlayMountOptions {
|
|
28
|
+
/** Shared registry used by the active Pi pointer bridge. */
|
|
29
|
+
registry: MouseRegistry;
|
|
30
|
+
/** Stable diagnostic identity for the component's pointer region. */
|
|
31
|
+
id: string;
|
|
32
|
+
/** Current screen rectangle occupied by the component. */
|
|
33
|
+
getRect(): MouseRect | undefined;
|
|
34
|
+
/** Current screen rectangle whose selection gestures the modal must block. */
|
|
35
|
+
getShieldRect(): MouseRect | undefined;
|
|
36
|
+
/** Component hit priority; defaults to 10,000. */
|
|
37
|
+
priority?: number;
|
|
38
|
+
/** Shield hit priority; defaults immediately below the component region. */
|
|
39
|
+
shieldPriority?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function componentMouseEvent(event: TuiMouseEvent): ModalOverlayMouseEvent | undefined {
|
|
43
|
+
if (
|
|
44
|
+
event.type !== "enter" &&
|
|
45
|
+
event.type !== "move" &&
|
|
46
|
+
event.type !== "leave" &&
|
|
47
|
+
event.type !== "press" &&
|
|
48
|
+
event.type !== "release"
|
|
49
|
+
)
|
|
50
|
+
return undefined;
|
|
51
|
+
return {
|
|
52
|
+
type: event.type,
|
|
53
|
+
row: event.row,
|
|
54
|
+
col: event.col,
|
|
55
|
+
...(event.button === undefined ? {} : { button: event.button }),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class MountedModalOverlay implements MountedModalOverlayComponent {
|
|
60
|
+
private readonly removeRegion: () => void;
|
|
61
|
+
private readonly removeShield: () => void;
|
|
62
|
+
private disposed = false;
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
private readonly component: ModalOverlayComponent,
|
|
66
|
+
options: ModalOverlayMountOptions,
|
|
67
|
+
) {
|
|
68
|
+
const priority = options.priority ?? 10_000;
|
|
69
|
+
this.removeRegion = options.registry.registerOverlayRegion({
|
|
70
|
+
id: options.id,
|
|
71
|
+
priority,
|
|
72
|
+
getRect: options.getRect,
|
|
73
|
+
onMouse: (event) => {
|
|
74
|
+
const translated = componentMouseEvent(event);
|
|
75
|
+
return translated ? component.handleMouse(translated) : false;
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
this.removeShield = registerModalPointerShield(options.registry, {
|
|
79
|
+
id: `${options.id}.shield`,
|
|
80
|
+
priority: options.shieldPriority ?? priority - 1,
|
|
81
|
+
getRect: options.getShieldRect,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get focused(): boolean {
|
|
86
|
+
return this.component.focused;
|
|
87
|
+
}
|
|
88
|
+
set focused(value: boolean) {
|
|
89
|
+
this.component.focused = value;
|
|
90
|
+
}
|
|
91
|
+
render(width: number): string[] {
|
|
92
|
+
return this.component.render(width);
|
|
93
|
+
}
|
|
94
|
+
invalidate(): void {
|
|
95
|
+
this.component.invalidate();
|
|
96
|
+
}
|
|
97
|
+
handleInput(data: string): void {
|
|
98
|
+
this.component.handleInput(data);
|
|
99
|
+
}
|
|
100
|
+
dispose(): void {
|
|
101
|
+
if (this.disposed) return;
|
|
102
|
+
this.disposed = true;
|
|
103
|
+
this.removeRegion();
|
|
104
|
+
this.removeShield();
|
|
105
|
+
this.component.dispose?.();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Mounts a screen-positioned modal component with local pointer input, a
|
|
111
|
+
* selection shield, and one disposal owner for both registrations.
|
|
112
|
+
*/
|
|
113
|
+
export function mountModalOverlay(
|
|
114
|
+
component: ModalOverlayComponent,
|
|
115
|
+
options: ModalOverlayMountOptions,
|
|
116
|
+
): MountedModalOverlayComponent {
|
|
117
|
+
return new MountedModalOverlay(component, options);
|
|
118
|
+
}
|
package/src/panels.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component, Focusable } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { SplitPaneHost, TuiIconName } from "pi-libtui";
|
|
4
|
+
import type { TuiMouseEvent } from "pi-libtui/mouse";
|
|
5
|
+
|
|
6
|
+
export const SIDE_PANEL_REGISTRY_KEY = Symbol.for("pi-panels/registry/v1");
|
|
7
|
+
export const SIDE_PANEL_PROTOCOL = "pi-panels/registry/v1" as const;
|
|
8
|
+
|
|
9
|
+
export type SidePanelContent = Component &
|
|
10
|
+
Partial<Focusable> & {
|
|
11
|
+
defersInputRender?(data: string): boolean;
|
|
12
|
+
dispose?(): void;
|
|
13
|
+
onMouse?(event: TuiMouseEvent): boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface SidePanelHeaderAction {
|
|
17
|
+
readonly label: string;
|
|
18
|
+
readonly actionId: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SidePanelTab {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly label: string;
|
|
24
|
+
readonly icon?: TuiIconName | { readonly glyph: string };
|
|
25
|
+
readonly headerAction?: SidePanelHeaderAction;
|
|
26
|
+
readonly inputActions?: readonly string[];
|
|
27
|
+
create(host: SplitPaneHost, theme: Theme): SidePanelContent;
|
|
28
|
+
onClose?(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SidePanelEmptyAction {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly label: string;
|
|
34
|
+
readonly actionId: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SidePanelSession {
|
|
38
|
+
readonly protocol: typeof SIDE_PANEL_PROTOCOL;
|
|
39
|
+
readonly version: 1;
|
|
40
|
+
addTab(tab: SidePanelTab, options?: { activate?: boolean; focus?: boolean }): void;
|
|
41
|
+
restoreTab(tab: SidePanelTab): void;
|
|
42
|
+
updateTab(tab: SidePanelTab): void;
|
|
43
|
+
removeTab(id: string): void;
|
|
44
|
+
activate(id: string): void;
|
|
45
|
+
activeTabId(): string | undefined;
|
|
46
|
+
registerEmptyAction(action: SidePanelEmptyAction): () => void;
|
|
47
|
+
show(options?: { focus?: boolean }): void;
|
|
48
|
+
toggle(): void;
|
|
49
|
+
toggleZoom(): void;
|
|
50
|
+
focus(): void;
|
|
51
|
+
focusMain(): void;
|
|
52
|
+
focusNext(): void;
|
|
53
|
+
activatePrevious(): void;
|
|
54
|
+
activateNext(): void;
|
|
55
|
+
isVisible(): boolean;
|
|
56
|
+
isZoomed(): boolean;
|
|
57
|
+
requestRender(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SidePanelProvider {
|
|
61
|
+
readonly id: string;
|
|
62
|
+
/** Identity of the Pi session that owns this contribution. */
|
|
63
|
+
readonly session: object;
|
|
64
|
+
attach(panel: SidePanelSession): undefined | (() => void);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SidePanelRegistry {
|
|
68
|
+
readonly protocol: typeof SIDE_PANEL_PROTOCOL;
|
|
69
|
+
readonly version: 1;
|
|
70
|
+
register(provider: SidePanelProvider): () => void;
|
|
71
|
+
providers(): readonly SidePanelProvider[];
|
|
72
|
+
installHost(): SidePanelHost;
|
|
73
|
+
hasHost(): boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SidePanelHost {
|
|
77
|
+
attach(
|
|
78
|
+
session: object,
|
|
79
|
+
panel: SidePanelSession,
|
|
80
|
+
onError: (provider: SidePanelProvider, error: unknown) => void,
|
|
81
|
+
): () => void;
|
|
82
|
+
dispose(): void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface RegistryState {
|
|
86
|
+
readonly providers: Map<string, SidePanelProvider>;
|
|
87
|
+
host?: HostState;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface HostState {
|
|
91
|
+
readonly identity: object;
|
|
92
|
+
session?: object;
|
|
93
|
+
panel?: SidePanelSession;
|
|
94
|
+
onError?: (provider: SidePanelProvider, error: unknown) => void;
|
|
95
|
+
readonly attachments: Map<string, { readonly provider: SidePanelProvider; readonly dispose?: () => void }>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const state = new WeakMap<SidePanelRegistry, RegistryState>();
|
|
99
|
+
type UntrustedRegistry = unknown;
|
|
100
|
+
|
|
101
|
+
function isRegistry(value: UntrustedRegistry): value is SidePanelRegistry {
|
|
102
|
+
if (!value || typeof value !== "object") return false;
|
|
103
|
+
const candidate = value as Partial<SidePanelRegistry>;
|
|
104
|
+
return (
|
|
105
|
+
candidate.protocol === SIDE_PANEL_PROTOCOL &&
|
|
106
|
+
candidate.version === 1 &&
|
|
107
|
+
typeof candidate.register === "function" &&
|
|
108
|
+
typeof candidate.providers === "function" &&
|
|
109
|
+
typeof candidate.installHost === "function" &&
|
|
110
|
+
typeof candidate.hasHost === "function"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function registryState(registry: SidePanelRegistry): RegistryState {
|
|
115
|
+
const existing = state.get(registry);
|
|
116
|
+
if (existing) return existing;
|
|
117
|
+
const created = {
|
|
118
|
+
providers: new Map<string, SidePanelProvider>(),
|
|
119
|
+
};
|
|
120
|
+
state.set(registry, created);
|
|
121
|
+
return created;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function releaseAttachment(host: HostState, id: string): void {
|
|
125
|
+
const attachment = host.attachments.get(id);
|
|
126
|
+
if (!attachment) return;
|
|
127
|
+
host.attachments.delete(id);
|
|
128
|
+
try {
|
|
129
|
+
attachment.dispose?.();
|
|
130
|
+
} catch {
|
|
131
|
+
// Optional providers cannot block the host lifecycle.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function attachProvider(host: HostState, provider: SidePanelProvider): void {
|
|
136
|
+
if (!host.panel || provider.session !== host.session) return;
|
|
137
|
+
if (host.attachments.get(provider.id)?.provider === provider) return;
|
|
138
|
+
releaseAttachment(host, provider.id);
|
|
139
|
+
try {
|
|
140
|
+
host.attachments.set(provider.id, { provider, dispose: provider.attach(host.panel) });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
host.onError?.(provider, error);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function detachSession(host: HostState): void {
|
|
147
|
+
for (const id of [...host.attachments.keys()]) releaseAttachment(host, id);
|
|
148
|
+
host.session = undefined;
|
|
149
|
+
host.panel = undefined;
|
|
150
|
+
host.onError = undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function ensureSidePanelRegistry(scope: typeof globalThis = globalThis): SidePanelRegistry {
|
|
154
|
+
const slots = scope as Record<PropertyKey, UntrustedRegistry>;
|
|
155
|
+
if (isRegistry(slots[SIDE_PANEL_REGISTRY_KEY])) return slots[SIDE_PANEL_REGISTRY_KEY];
|
|
156
|
+
const registry: SidePanelRegistry = {
|
|
157
|
+
protocol: SIDE_PANEL_PROTOCOL,
|
|
158
|
+
version: 1,
|
|
159
|
+
register(provider) {
|
|
160
|
+
const current = registryState(registry);
|
|
161
|
+
const replaced = current.providers.get(provider.id);
|
|
162
|
+
if (replaced && current.host) releaseAttachment(current.host, provider.id);
|
|
163
|
+
current.providers.set(provider.id, provider);
|
|
164
|
+
if (current.host) attachProvider(current.host, provider);
|
|
165
|
+
return () => {
|
|
166
|
+
if (current.providers.get(provider.id) !== provider) return;
|
|
167
|
+
current.providers.delete(provider.id);
|
|
168
|
+
if (current.host?.attachments.get(provider.id)?.provider === provider) {
|
|
169
|
+
releaseAttachment(current.host, provider.id);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
providers: () => [...registryState(registry).providers.values()],
|
|
174
|
+
installHost() {
|
|
175
|
+
const current = registryState(registry);
|
|
176
|
+
if (current.host) detachSession(current.host);
|
|
177
|
+
const host: HostState = { identity: {}, attachments: new Map() };
|
|
178
|
+
current.host = host;
|
|
179
|
+
return {
|
|
180
|
+
attach(session, panel, onError) {
|
|
181
|
+
if (current.host !== host) return () => {};
|
|
182
|
+
detachSession(host);
|
|
183
|
+
host.session = session;
|
|
184
|
+
host.panel = panel;
|
|
185
|
+
host.onError = onError;
|
|
186
|
+
for (const provider of current.providers.values()) attachProvider(host, provider);
|
|
187
|
+
return () => {
|
|
188
|
+
if (current.host === host && host.session === session && host.panel === panel) detachSession(host);
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
dispose() {
|
|
192
|
+
if (current.host !== host) return;
|
|
193
|
+
detachSession(host);
|
|
194
|
+
current.host = undefined;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
hasHost: () => registryState(registry).host !== undefined,
|
|
199
|
+
};
|
|
200
|
+
registryState(registry);
|
|
201
|
+
slots[SIDE_PANEL_REGISTRY_KEY] = registry;
|
|
202
|
+
return registry;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function registerSidePanelProvider(provider: SidePanelProvider, scope: typeof globalThis): () => void {
|
|
206
|
+
return ensureSidePanelRegistry(scope).register(provider);
|
|
207
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { getTuiRenderEpoch } from "./render-epoch.ts";
|
|
2
|
+
|
|
3
|
+
/** Options for a bounded exact-key cache. */
|
|
4
|
+
interface ExactRenderCacheOptions {
|
|
5
|
+
/** Maximum retained entries. The least recently used entry is evicted first. */
|
|
6
|
+
maxEntries?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function positiveInteger(value: number | undefined, fallback: number): number {
|
|
10
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(1, Math.floor(value)) : fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A small exact-key LRU cache for rendered values. */
|
|
14
|
+
class ExactRenderCache<Key, Value> {
|
|
15
|
+
private readonly entries = new Map<Key, Value>();
|
|
16
|
+
private readonly maxEntries: number;
|
|
17
|
+
|
|
18
|
+
constructor(options: ExactRenderCacheOptions = {}) {
|
|
19
|
+
this.maxEntries = positiveInteger(options.maxEntries, 8);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Return an exact cached value, producing it once on a miss. */
|
|
23
|
+
get(key: Key, produce: () => Value): Value {
|
|
24
|
+
const cached = this.entries.get(key);
|
|
25
|
+
if (cached !== undefined || this.entries.has(key)) {
|
|
26
|
+
this.entries.delete(key);
|
|
27
|
+
this.entries.set(key, cached as Value);
|
|
28
|
+
return cached as Value;
|
|
29
|
+
}
|
|
30
|
+
const value = produce();
|
|
31
|
+
if (this.entries.size >= this.maxEntries) {
|
|
32
|
+
const oldest = this.entries.keys().next();
|
|
33
|
+
if (!oldest.done) this.entries.delete(oldest.value);
|
|
34
|
+
}
|
|
35
|
+
this.entries.set(key, value);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Remove all retained values. */
|
|
40
|
+
clear(): void {
|
|
41
|
+
this.entries.clear();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Current retained entry count. */
|
|
45
|
+
get size(): number {
|
|
46
|
+
return this.entries.size;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Bounded cache keyed exactly by terminal width and a caller-owned content key. */
|
|
50
|
+
export class RenderedLinesCache {
|
|
51
|
+
private readonly widths: ExactRenderCache<number, ExactRenderCache<string, string[]>>;
|
|
52
|
+
private readonly maxContentKeys: number;
|
|
53
|
+
private epoch = getTuiRenderEpoch();
|
|
54
|
+
|
|
55
|
+
constructor(options: { maxWidths?: number; maxContentKeysPerWidth?: number } = {}) {
|
|
56
|
+
this.widths = new ExactRenderCache({ maxEntries: positiveInteger(options.maxWidths, 4) });
|
|
57
|
+
this.maxContentKeys = positiveInteger(options.maxContentKeysPerWidth, 2);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Return the original rendered line array for an exact width and content key. */
|
|
61
|
+
get(width: number, contentKey: string, produce: () => string[]): string[] {
|
|
62
|
+
const epoch = getTuiRenderEpoch();
|
|
63
|
+
if (epoch !== this.epoch) {
|
|
64
|
+
this.epoch = epoch;
|
|
65
|
+
this.clear();
|
|
66
|
+
}
|
|
67
|
+
const byContent = this.widths.get(width, () => new ExactRenderCache({ maxEntries: this.maxContentKeys }));
|
|
68
|
+
return byContent.get(contentKey, produce);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Drop every cached width and content key. */
|
|
72
|
+
clear(): void {
|
|
73
|
+
this.widths.clear();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Number of retained terminal widths. */
|
|
77
|
+
get widthCount(): number {
|
|
78
|
+
return this.widths.size;
|
|
79
|
+
}
|
|
80
|
+
}
|