@luan.sh/pi-libtui 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/native-revision +1 -0
- package/package.json +58 -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,189 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
const REPOSITORY = "https://github.com/luan/agents";
|
|
7
|
+
const BUILDS_KEY = Symbol.for("pi-libtui/native-builds/v1");
|
|
8
|
+
|
|
9
|
+
/** A Rust binary from this repository's `crates/` that an extension shells out to. */
|
|
10
|
+
export interface NativeBinary {
|
|
11
|
+
/** Cargo package name, as passed to `cargo install`. */
|
|
12
|
+
readonly crate: string;
|
|
13
|
+
/** Executable name without platform suffix. */
|
|
14
|
+
readonly binaryName: string;
|
|
15
|
+
/** Environment variable that overrides discovery with an explicit path. */
|
|
16
|
+
readonly env: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface NativeBinaryOptions {
|
|
20
|
+
readonly environment: Readonly<Record<string, string | undefined>>;
|
|
21
|
+
readonly platform: NodeJS.Platform;
|
|
22
|
+
/** Directory that holds per-crate, per-revision builds (`<root>/<crate>/<rev>/bin/<name>`). */
|
|
23
|
+
readonly nativeRoot: string;
|
|
24
|
+
/** Git revision of this repository the binary must be built from. */
|
|
25
|
+
readonly rev: string;
|
|
26
|
+
/** Directory to walk up from when looking for a development checkout. */
|
|
27
|
+
readonly developmentStart?: string;
|
|
28
|
+
isExecutable(path: string): boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type NativeBinaryLocation =
|
|
32
|
+
| { readonly kind: "found"; readonly path: string }
|
|
33
|
+
| { readonly kind: "build"; readonly installRoot: string; readonly path: string }
|
|
34
|
+
| { readonly kind: "unbuilt-checkout"; readonly checkout: string };
|
|
35
|
+
|
|
36
|
+
export const TERMINAL_BRIDGE: NativeBinary = {
|
|
37
|
+
crate: "terminal-bridge",
|
|
38
|
+
binaryName: "terminal_bridge",
|
|
39
|
+
env: "PI_TERMINAL_BRIDGE_BINARY",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Locate a native binary without side effects. Order: the override env var, the managed build for
|
|
44
|
+
* this revision, then a development checkout's `target/`. A miss says what would fix it.
|
|
45
|
+
*/
|
|
46
|
+
export function locateNativeBinary(binary: NativeBinary, options: NativeBinaryOptions): NativeBinaryLocation {
|
|
47
|
+
const override = options.environment[binary.env];
|
|
48
|
+
if (override !== undefined) {
|
|
49
|
+
const path = override.trim();
|
|
50
|
+
if (!path) throw new Error(`${binary.env} is set but empty`);
|
|
51
|
+
if (!options.isExecutable(path)) throw new Error(`${binary.env} is not executable: ${path}`);
|
|
52
|
+
return { kind: "found", path };
|
|
53
|
+
}
|
|
54
|
+
const binaryName = options.platform === "win32" ? `${binary.binaryName}.exe` : binary.binaryName;
|
|
55
|
+
const installRoot = join(options.nativeRoot, binary.crate, options.rev);
|
|
56
|
+
const managed = join(installRoot, "bin", binaryName);
|
|
57
|
+
if (options.isExecutable(managed)) return { kind: "found", path: managed };
|
|
58
|
+
const checkout = options.developmentStart ? findCheckoutRoot(options.developmentStart, binary.crate) : undefined;
|
|
59
|
+
if (checkout) {
|
|
60
|
+
for (const profile of ["release", "debug"]) {
|
|
61
|
+
const candidate = resolve(checkout, "target", profile, binaryName);
|
|
62
|
+
if (options.isExecutable(candidate)) return { kind: "found", path: candidate };
|
|
63
|
+
}
|
|
64
|
+
return { kind: "unbuilt-checkout", checkout };
|
|
65
|
+
}
|
|
66
|
+
return { kind: "build", installRoot, path: managed };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `cargo install` arguments that build `binary` at `rev` into `installRoot/bin`. */
|
|
70
|
+
export function nativeBuildArguments(binary: NativeBinary, rev: string, installRoot: string): readonly string[] {
|
|
71
|
+
return ["install", "--git", REPOSITORY, "--rev", rev, "--root", installRoot, "--locked", binary.crate];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface EnsureNativeBinaryHooks {
|
|
75
|
+
/** Called once when a build starts, so a UI can explain the delay. */
|
|
76
|
+
onBuild?(message: string): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a native binary for this process, building it on first use. Builds are keyed by crate and
|
|
81
|
+
* revision under Pi's agent directory and shared by every installed pi-libtui copy.
|
|
82
|
+
*/
|
|
83
|
+
export async function ensureNativeBinary(binary: NativeBinary, hooks: EnsureNativeBinaryHooks = {}): Promise<string> {
|
|
84
|
+
const options = processOptions();
|
|
85
|
+
const location = locateNativeBinary(binary, options);
|
|
86
|
+
if (location.kind === "found") return location.path;
|
|
87
|
+
if (location.kind === "unbuilt-checkout") {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`${binary.binaryName} is not built in ${location.checkout}. Run cargo build --release -p ${binary.crate}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const builds = sharedBuilds();
|
|
93
|
+
const existing = builds.get(location.path);
|
|
94
|
+
if (existing) return existing;
|
|
95
|
+
hooks.onBuild?.(`Building ${binary.binaryName} (first use); this takes a minute.`);
|
|
96
|
+
const build = runCargo(nativeBuildArguments(binary, options.rev, location.installRoot), location.installRoot)
|
|
97
|
+
.then(() => {
|
|
98
|
+
if (!isExecutable(location.path)) throw new Error(`cargo install did not produce ${location.path}`);
|
|
99
|
+
return location.path;
|
|
100
|
+
})
|
|
101
|
+
.finally(() => builds.delete(location.path));
|
|
102
|
+
builds.set(location.path, build);
|
|
103
|
+
return build;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function terminalBridgeBinaryPath(): Promise<string> {
|
|
107
|
+
return ensureNativeBinary(TERMINAL_BRIDGE);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The pack step records the exact source commit independently of npm versions. */
|
|
111
|
+
export function nativeRevision(): string {
|
|
112
|
+
const revision = readFileSync(new URL("../native-revision", import.meta.url), "utf8").trim();
|
|
113
|
+
if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error("Invalid native source revision; reinstall the package.");
|
|
114
|
+
return revision;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function processOptions(): NativeBinaryOptions {
|
|
118
|
+
return {
|
|
119
|
+
environment: process.env,
|
|
120
|
+
platform: process.platform,
|
|
121
|
+
nativeRoot: join(getAgentDir(), "native"),
|
|
122
|
+
rev: nativeRevision(),
|
|
123
|
+
developmentStart: import.meta.dirname,
|
|
124
|
+
isExecutable,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sharedBuilds(): Map<string, Promise<string>> {
|
|
129
|
+
const slots = globalThis as Record<PropertyKey, unknown>;
|
|
130
|
+
const existing = slots[BUILDS_KEY];
|
|
131
|
+
if (existing instanceof Map) return existing as Map<string, Promise<string>>;
|
|
132
|
+
const created = new Map<string, Promise<string>>();
|
|
133
|
+
slots[BUILDS_KEY] = created;
|
|
134
|
+
return created;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function runCargo(args: readonly string[], installRoot: string): Promise<void> {
|
|
138
|
+
mkdirSync(installRoot, { recursive: true });
|
|
139
|
+
return new Promise((resolvePromise, reject) => {
|
|
140
|
+
const child = spawn("cargo", args, { stdio: ["ignore", "ignore", "pipe"], env: process.env });
|
|
141
|
+
let stderr = "";
|
|
142
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
143
|
+
stderr = `${stderr}${chunk.toString("utf8")}`.slice(-8_000);
|
|
144
|
+
});
|
|
145
|
+
child.on("error", (error: NodeJS.ErrnoException) => {
|
|
146
|
+
reject(
|
|
147
|
+
error.code === "ENOENT"
|
|
148
|
+
? new Error("cargo was not found. Install Rust from https://rustup.rs to build native tools.")
|
|
149
|
+
: error,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
child.on("close", (code) => {
|
|
153
|
+
if (code === 0) resolvePromise();
|
|
154
|
+
else
|
|
155
|
+
reject(new Error(`cargo ${args.join(" ")} failed (code ${code})${stderr.trim() ? `:\n${stderr.trim()}` : ""}`));
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isExecutable(path: string): boolean {
|
|
161
|
+
try {
|
|
162
|
+
accessSync(path, constants.X_OK);
|
|
163
|
+
return true;
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Walk up to the Cargo workspace that contains the crate, if the start directory lives in one. */
|
|
170
|
+
function findCheckoutRoot(start: string, crate: string): string | undefined {
|
|
171
|
+
let current = resolve(start);
|
|
172
|
+
for (;;) {
|
|
173
|
+
if (isReadable(join(current, "Cargo.toml")) && isReadable(join(current, "crates", crate, "Cargo.toml"))) {
|
|
174
|
+
return current;
|
|
175
|
+
}
|
|
176
|
+
const parent = dirname(current);
|
|
177
|
+
if (parent === current) return undefined;
|
|
178
|
+
current = parent;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isReadable(path: string): boolean {
|
|
183
|
+
try {
|
|
184
|
+
accessSync(path, constants.R_OK);
|
|
185
|
+
return true;
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { OverlayOptions } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
/** Zero-based terminal rectangle occupied by an anchored overlay. */
|
|
4
|
+
export interface AnchoredOverlayRect {
|
|
5
|
+
/** Zero-based column of the rectangle's left edge. */
|
|
6
|
+
x: number;
|
|
7
|
+
/** Zero-based row of the rectangle's top edge. */
|
|
8
|
+
y: number;
|
|
9
|
+
/** Render width in terminal columns. */
|
|
10
|
+
width: number;
|
|
11
|
+
/** Render height in terminal rows. */
|
|
12
|
+
height: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Pi overlay options paired with the exact rectangle those options occupy. */
|
|
16
|
+
export interface AnchoredOverlayPlacement {
|
|
17
|
+
/** Options to pass to Pi's overlay host. */
|
|
18
|
+
options: OverlayOptions;
|
|
19
|
+
/** Exact zero-based screen rectangle used for pointer translation. */
|
|
20
|
+
rect: AnchoredOverlayRect;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Terminal geometry and desired size used to place an anchored overlay. */
|
|
24
|
+
export interface AnchoredOverlayRequest {
|
|
25
|
+
/** Available terminal width in columns. */
|
|
26
|
+
terminalCols: number;
|
|
27
|
+
/** Available terminal height in rows. */
|
|
28
|
+
terminalRows: number;
|
|
29
|
+
/** Zero-based screen row of the cell beside which the overlay should appear. */
|
|
30
|
+
anchorRow: number;
|
|
31
|
+
/** Zero-based screen column of the cell beside which the overlay should appear. */
|
|
32
|
+
anchorCol: number;
|
|
33
|
+
/** Requested overlay width before terminal-edge clamping. */
|
|
34
|
+
desiredWidth: number;
|
|
35
|
+
/** Requested overlay height before terminal-edge clamping. */
|
|
36
|
+
height: number;
|
|
37
|
+
/** Number of cells between the anchor and overlay; defaults to one. */
|
|
38
|
+
gap?: number;
|
|
39
|
+
/** Horizontal policy; centered overlays clamp around the anchor instead of sitting beside it. */
|
|
40
|
+
horizontalPlacement?: "adjacent" | "center";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clamp(value: number, minimum: number, maximum: number): number {
|
|
44
|
+
return Math.max(minimum, Math.min(value, maximum));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Places an overlay beside a screen cell and returns the exact matching rect.
|
|
49
|
+
* It prefers below/right, flips to above/left when that fits, then clamps.
|
|
50
|
+
*
|
|
51
|
+
* @param request Terminal bounds, anchor cell, and requested overlay geometry.
|
|
52
|
+
* @returns Pi overlay options and their exact clamped zero-based screen rectangle.
|
|
53
|
+
*/
|
|
54
|
+
export function placeAnchoredOverlay(request: AnchoredOverlayRequest): AnchoredOverlayPlacement {
|
|
55
|
+
const terminalCols = Math.max(0, Math.floor(request.terminalCols));
|
|
56
|
+
const terminalRows = Math.max(0, Math.floor(request.terminalRows));
|
|
57
|
+
const gap = Math.max(0, Math.floor(request.gap ?? 1));
|
|
58
|
+
const width = Math.min(terminalCols, Math.max(0, Math.floor(request.desiredWidth)));
|
|
59
|
+
const height = Math.min(terminalRows, Math.max(0, Math.floor(request.height)));
|
|
60
|
+
const anchorCol = clamp(Math.floor(request.anchorCol), 0, Math.max(0, terminalCols - 1));
|
|
61
|
+
const anchorRow = clamp(Math.floor(request.anchorRow), 0, Math.max(0, terminalRows - 1));
|
|
62
|
+
|
|
63
|
+
const right = anchorCol + gap;
|
|
64
|
+
const left = anchorCol - width - gap + 1;
|
|
65
|
+
const x =
|
|
66
|
+
request.horizontalPlacement === "center"
|
|
67
|
+
? clamp(Math.round(anchorCol - width / 2), 0, Math.max(0, terminalCols - width))
|
|
68
|
+
: right + width <= terminalCols
|
|
69
|
+
? right
|
|
70
|
+
: left >= 0
|
|
71
|
+
? left
|
|
72
|
+
: clamp(right, 0, Math.max(0, terminalCols - width));
|
|
73
|
+
|
|
74
|
+
const below = anchorRow + gap;
|
|
75
|
+
const above = anchorRow - height - gap + 1;
|
|
76
|
+
const y =
|
|
77
|
+
below + height <= terminalRows ? below : above >= 0 ? above : clamp(below, 0, Math.max(0, terminalRows - height));
|
|
78
|
+
|
|
79
|
+
const rect = { x, y, width, height };
|
|
80
|
+
return {
|
|
81
|
+
rect,
|
|
82
|
+
options: {
|
|
83
|
+
anchor: "top-left",
|
|
84
|
+
row: y,
|
|
85
|
+
col: x,
|
|
86
|
+
width,
|
|
87
|
+
maxHeight: height,
|
|
88
|
+
margin: 0,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
compositeTuiLine,
|
|
4
|
+
sliceByColumn,
|
|
5
|
+
stripTerminalSequences,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
import { tuiTheme } from "../color/theme.ts";
|
|
10
|
+
import type { MouseRect, MouseRegistry, ScreenDecorationContext } from "../mouse.ts";
|
|
11
|
+
|
|
12
|
+
export interface DetailCardContent {
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly rows: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface HoverDetailCardTarget {
|
|
18
|
+
readonly rect: MouseRect;
|
|
19
|
+
readonly content: DetailCardContent;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HoverDetailCardOptions {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly theme: Theme;
|
|
25
|
+
readonly registry: MouseRegistry;
|
|
26
|
+
readonly priority?: number;
|
|
27
|
+
readonly pointerPriority?: number;
|
|
28
|
+
getTarget(screen: readonly string[], context: ScreenDecorationContext): HoverDetailCardTarget | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HoverDetailCardMount {
|
|
32
|
+
dispose(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Mount the same annotation-style detail card over any screen-locatable pill. */
|
|
36
|
+
export function mountHoverDetailCard(options: HoverDetailCardOptions): HoverDetailCardMount {
|
|
37
|
+
let target: HoverDetailCardTarget | undefined;
|
|
38
|
+
let hovered = false;
|
|
39
|
+
const removeDecorator = options.registry.registerScreenDecorator({
|
|
40
|
+
id: options.id,
|
|
41
|
+
priority: options.priority ?? 5_000,
|
|
42
|
+
decorate(screen, context) {
|
|
43
|
+
target = context.hasOverlay ? undefined : options.getTarget(screen, context);
|
|
44
|
+
if (!target) hovered = false;
|
|
45
|
+
if (!hovered || !target) return screen;
|
|
46
|
+
return decorateDetailCard(
|
|
47
|
+
screen,
|
|
48
|
+
renderDetailCard(options.theme, target.content, context.width),
|
|
49
|
+
{ row: target.rect.y, col: target.rect.x },
|
|
50
|
+
context.width,
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const removeRegion = options.registry.registerOverlayRegion({
|
|
55
|
+
id: options.id,
|
|
56
|
+
priority: options.pointerPriority ?? 500,
|
|
57
|
+
getRect: () => target?.rect,
|
|
58
|
+
onMouse(event) {
|
|
59
|
+
const next = event.type !== "leave";
|
|
60
|
+
const changed = hovered !== next;
|
|
61
|
+
hovered = next;
|
|
62
|
+
return changed || next;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
dispose() {
|
|
67
|
+
target = undefined;
|
|
68
|
+
hovered = false;
|
|
69
|
+
removeDecorator();
|
|
70
|
+
removeRegion();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Render the shared annotation-style hover card. */
|
|
76
|
+
export function renderDetailCard(theme: Theme, content: DetailCardContent, availableWidth: number): string[] {
|
|
77
|
+
const contentWidth = Math.max(
|
|
78
|
+
visibleWidth(content.title) + 5,
|
|
79
|
+
...content.rows.flatMap((row) => row.split("\n")).map((row) => visibleWidth(row) + 2),
|
|
80
|
+
);
|
|
81
|
+
const width = Math.max(4, Math.min(60, availableWidth, contentWidth));
|
|
82
|
+
const innerWidth = Math.max(1, width - 2);
|
|
83
|
+
const rows = wrapRows(content.rows, innerWidth, 7);
|
|
84
|
+
const colors = tuiTheme(theme);
|
|
85
|
+
const border = (text: string): string => colors.fg("border", text);
|
|
86
|
+
const fittedTitle = truncateToWidth(content.title, Math.max(0, width - 5), "");
|
|
87
|
+
const titleWidth = visibleWidth(fittedTitle);
|
|
88
|
+
const top = colors.bg(
|
|
89
|
+
"surface.raised",
|
|
90
|
+
`${border("╭─ ")}${colors.fg("text.secondary", fittedTitle)}${border(
|
|
91
|
+
` ${"─".repeat(Math.max(0, width - titleWidth - 5))}╮`,
|
|
92
|
+
)}`,
|
|
93
|
+
);
|
|
94
|
+
const body = rows.map((row) => {
|
|
95
|
+
const text = truncateToWidth(row, innerWidth, "");
|
|
96
|
+
return colors.bg(
|
|
97
|
+
"surface.raised",
|
|
98
|
+
`${border("│")}${colors.fg("text.primary", text)}${" ".repeat(Math.max(0, innerWidth - visibleWidth(text)))}${border("│")}`,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
return [top, ...body, colors.bg("surface.raised", border(`╰${"─".repeat(width - 2)}╯`))];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Composite a detail card beside an inline anchor. */
|
|
105
|
+
export function decorateDetailCard(
|
|
106
|
+
screen: readonly string[],
|
|
107
|
+
card: readonly string[],
|
|
108
|
+
anchor: { readonly row: number; readonly col: number },
|
|
109
|
+
width: number,
|
|
110
|
+
): string[] {
|
|
111
|
+
const result = [...screen];
|
|
112
|
+
const cardWidth = Math.min(width, Math.max(0, ...card.map((line) => visibleWidth(line))));
|
|
113
|
+
const x = Math.max(0, Math.min(anchor.col, Math.max(0, width - cardWidth)));
|
|
114
|
+
const below = anchor.row + 1;
|
|
115
|
+
const y = below + card.length <= result.length ? below : Math.max(0, anchor.row - card.length);
|
|
116
|
+
for (const [offset, line] of card.entries()) {
|
|
117
|
+
const row = y + offset;
|
|
118
|
+
if (row >= result.length) break;
|
|
119
|
+
const overlayWidth = Math.min(cardWidth, width - x);
|
|
120
|
+
const clipped = sliceByColumn(line, 0, overlayWidth, true);
|
|
121
|
+
const base = result[row] ?? "";
|
|
122
|
+
result[row] = compositeTuiLine(base, clipped, x, overlayWidth, overlayTotalWidth(base, x, overlayWidth, width));
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function overlayTotalWidth(base: string, start: number, overlayWidth: number, width: number): number {
|
|
128
|
+
const afterStart = start + overlayWidth;
|
|
129
|
+
const after = sliceByColumn(base, afterStart, Math.max(0, width - afterStart), true);
|
|
130
|
+
const afterVisible = visibleWidth(after);
|
|
131
|
+
const afterContent = visibleWidth(stripTerminalSequences(after).replace(/\s+$/u, ""));
|
|
132
|
+
const hasStyledPadding = /\x1b\[[0-9;]*m/u.test(after);
|
|
133
|
+
const preservedAfter = afterContent > 0 || hasStyledPadding ? afterVisible : 0;
|
|
134
|
+
return Math.min(width, Math.max(afterStart, afterStart + preservedAfter));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function wrapRows(sourceRows: readonly string[], width: number, maxRows: number): string[] {
|
|
138
|
+
const wrapped: string[] = [];
|
|
139
|
+
for (const source of sourceRows) {
|
|
140
|
+
for (const logicalLine of source.split("\n")) {
|
|
141
|
+
let remaining = logicalLine;
|
|
142
|
+
do {
|
|
143
|
+
const row = sliceByColumn(remaining, 0, width, true);
|
|
144
|
+
wrapped.push(row);
|
|
145
|
+
remaining = sliceByColumn(
|
|
146
|
+
remaining,
|
|
147
|
+
visibleWidth(row),
|
|
148
|
+
Math.max(0, visibleWidth(remaining) - visibleWidth(row)),
|
|
149
|
+
true,
|
|
150
|
+
);
|
|
151
|
+
} while (remaining.length > 0);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (wrapped.length <= maxRows) return wrapped;
|
|
155
|
+
const result = wrapped.slice(0, maxRows);
|
|
156
|
+
result[maxRows - 1] = `${sliceByColumn(result[maxRows - 1] ?? "", 0, Math.max(0, width - 1), true)}…`;
|
|
157
|
+
return result;
|
|
158
|
+
}
|