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,53 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** The editor factory shape exposed by Pi's interactive UI. */
|
|
4
|
+
export type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
|
|
5
|
+
|
|
6
|
+
/** The small UI surface required to compose editor factories. */
|
|
7
|
+
export type EditorUi = Pick<ExtensionContext["ui"], "getEditorComponent" | "setEditorComponent">;
|
|
8
|
+
|
|
9
|
+
/** Wrap an existing editor factory while preserving other installed layers. */
|
|
10
|
+
export type EditorLayer = (previous: EditorFactory | undefined) => EditorFactory;
|
|
11
|
+
|
|
12
|
+
interface EditorLayerState {
|
|
13
|
+
base: EditorFactory | undefined;
|
|
14
|
+
layers: Map<symbol, EditorLayer>;
|
|
15
|
+
applied: EditorFactory | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const states = new WeakMap<object, EditorLayerState>();
|
|
19
|
+
|
|
20
|
+
function compose(state: EditorLayerState): EditorFactory | undefined {
|
|
21
|
+
let factory = state.base;
|
|
22
|
+
for (const layer of state.layers.values()) factory = layer(factory);
|
|
23
|
+
return factory;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Install an idempotently removable editor layer on one Pi UI instance. */
|
|
27
|
+
export function installEditorLayer(ui: EditorUi, id: symbol, layer: EditorLayer): () => void {
|
|
28
|
+
const key = ui as object;
|
|
29
|
+
let state = states.get(key);
|
|
30
|
+
if (!state) {
|
|
31
|
+
state = { base: ui.getEditorComponent(), layers: new Map(), applied: undefined };
|
|
32
|
+
states.set(key, state);
|
|
33
|
+
}
|
|
34
|
+
state.layers.set(id, layer);
|
|
35
|
+
const applied = compose(state);
|
|
36
|
+
state.applied = applied;
|
|
37
|
+
ui.setEditorComponent(applied);
|
|
38
|
+
|
|
39
|
+
let active = true;
|
|
40
|
+
return () => {
|
|
41
|
+
if (!active) return;
|
|
42
|
+
active = false;
|
|
43
|
+
const current = states.get(key);
|
|
44
|
+
if (!current || current.layers.get(id) !== layer) return;
|
|
45
|
+
current.layers.delete(id);
|
|
46
|
+
if (ui.getEditorComponent() === current.applied) {
|
|
47
|
+
const next = compose(current);
|
|
48
|
+
current.applied = next;
|
|
49
|
+
ui.setEditorComponent(next);
|
|
50
|
+
}
|
|
51
|
+
if (current.layers.size === 0) states.delete(key);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
// type-boundary: Pi 0.84.x keeps its active layout root and stack entries private; the guards below narrow them.
|
|
4
|
+
type PiLayoutValue = unknown;
|
|
5
|
+
|
|
6
|
+
interface LayoutEntry {
|
|
7
|
+
component: Component;
|
|
8
|
+
minSize?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface EditorMinimumRowsLease {
|
|
12
|
+
reconcile(editor: Component): void;
|
|
13
|
+
dispose(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isComponent(value: PiLayoutValue): value is Component {
|
|
17
|
+
return typeof value === "object" && value !== null && typeof Reflect.get(value, "render") === "function";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isLayoutEntry(value: PiLayoutValue): value is LayoutEntry {
|
|
21
|
+
return typeof value === "object" && value !== null && isComponent(Reflect.get(value, "component"));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function childComponents(component: Component): readonly Component[] {
|
|
25
|
+
const children = Reflect.get(component, "children") as PiLayoutValue;
|
|
26
|
+
return Array.isArray(children) ? children.filter(isComponent) : [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function layoutEntries(component: Component): readonly LayoutEntry[] {
|
|
30
|
+
const entries = Reflect.get(component, "entries") as PiLayoutValue;
|
|
31
|
+
return Array.isArray(entries) ? entries.filter(isLayoutEntry) : [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function contains(root: Component, target: Component, seen = new Set<object>()): boolean {
|
|
35
|
+
if (root === target) return true;
|
|
36
|
+
if (seen.has(root)) return false;
|
|
37
|
+
seen.add(root);
|
|
38
|
+
return childComponents(root).some((child) => contains(child, target, seen));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function editorEntry(root: Component, editor: Component, seen = new Set<object>()): LayoutEntry | undefined {
|
|
42
|
+
if (seen.has(root)) return undefined;
|
|
43
|
+
seen.add(root);
|
|
44
|
+
for (const entry of layoutEntries(root)) {
|
|
45
|
+
const nested = editorEntry(entry.component, editor, seen);
|
|
46
|
+
if (nested) return nested;
|
|
47
|
+
if (contains(entry.component, editor)) return entry;
|
|
48
|
+
}
|
|
49
|
+
for (const child of childComponents(root)) {
|
|
50
|
+
const nested = editorEntry(child, editor, seen);
|
|
51
|
+
if (nested) return nested;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Adapt Pi's dock allocation to a borderless editor while tolerating hosts without the expected private layout shape. */
|
|
57
|
+
export function installEditorMinimumRows(tui: TUI, minimumRows: number): EditorMinimumRowsLease {
|
|
58
|
+
const rows = Math.max(0, Math.floor(minimumRows));
|
|
59
|
+
let active = true;
|
|
60
|
+
let entry: LayoutEntry | undefined;
|
|
61
|
+
let originalMinimum: number | undefined;
|
|
62
|
+
return {
|
|
63
|
+
reconcile(editor) {
|
|
64
|
+
if (!active) return;
|
|
65
|
+
if (!entry || !contains(entry.component, editor)) {
|
|
66
|
+
const root = Reflect.get(tui as object, "layoutRoot") as PiLayoutValue;
|
|
67
|
+
if (!isComponent(root)) return;
|
|
68
|
+
entry = editorEntry(root, editor);
|
|
69
|
+
if (!entry) return;
|
|
70
|
+
originalMinimum = entry.minSize;
|
|
71
|
+
}
|
|
72
|
+
entry.minSize = rows;
|
|
73
|
+
},
|
|
74
|
+
dispose() {
|
|
75
|
+
if (!active) return;
|
|
76
|
+
active = false;
|
|
77
|
+
if (entry?.minSize === rows) entry.minSize = originalMinimum;
|
|
78
|
+
tui.requestRender();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { CustomEditor, type KeybindingsManager, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { tuiTheme } from "../color/theme.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Adapt pi-libtui semantic colors to every color role in Pi's native editor.
|
|
7
|
+
* @param theme Active Pi theme used to generate semantic pi-libtui colors.
|
|
8
|
+
* @returns A complete native `EditorTheme` for borders and autocomplete rows.
|
|
9
|
+
*/
|
|
10
|
+
export function semanticEditorTheme(theme: Theme): EditorTheme {
|
|
11
|
+
const colors = tuiTheme(theme);
|
|
12
|
+
return {
|
|
13
|
+
borderColor: (text) => colors.fg("border", text),
|
|
14
|
+
selectList: {
|
|
15
|
+
selectedPrefix: (text) => colors.fg("accent", text),
|
|
16
|
+
selectedText: (text) => colors.fg("accent", text),
|
|
17
|
+
description: (text) => colors.fg("text.muted", text),
|
|
18
|
+
scrollInfo: (text) => colors.fg("text.muted", text),
|
|
19
|
+
noMatch: (text) => colors.fg("text.muted", text),
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Pi's extensible editor with all native color roles owned by pi-libtui. */
|
|
25
|
+
export class SemanticEditor extends CustomEditor {
|
|
26
|
+
/**
|
|
27
|
+
* Create an extensible Pi editor backed by pi-libtui semantic colors.
|
|
28
|
+
* @param tui TUI instance used by the native editor.
|
|
29
|
+
* @param theme Active Pi theme converted through {@link semanticEditorTheme}.
|
|
30
|
+
* @param keybindings Active application keybindings forwarded to `CustomEditor`.
|
|
31
|
+
*/
|
|
32
|
+
constructor(tui: TUI, theme: Theme, keybindings: KeybindingsManager) {
|
|
33
|
+
super(tui, semanticEditorTheme(theme), keybindings);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
export const EDITOR_REGISTRY_KEY = Symbol.for("pi-libtui/editor/registry/v1");
|
|
2
|
+
export const EDITOR_PROTOCOL = "pi-libtui/editor/registry/v1" as const;
|
|
3
|
+
|
|
4
|
+
export interface EditorPasteHandler {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly priority?: number;
|
|
7
|
+
handle(text: string): string | undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface EditorRenderDecorator {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly priority?: number;
|
|
13
|
+
decorate(lines: readonly string[], width: number): string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EditorRegistry {
|
|
17
|
+
readonly protocol: typeof EDITOR_PROTOCOL;
|
|
18
|
+
readonly version: 1;
|
|
19
|
+
registerPasteHandler(handler: EditorPasteHandler): () => void;
|
|
20
|
+
registerRenderDecorator(decorator: EditorRenderDecorator): () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface RegistryState {
|
|
24
|
+
pasteHandlers: EditorPasteHandler[];
|
|
25
|
+
renderDecorators: EditorRenderDecorator[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// type-boundary: Symbol.for capabilities can be populated by another extension realm; these validators narrow them.
|
|
29
|
+
type UntrustedRegistryValue = unknown;
|
|
30
|
+
|
|
31
|
+
const STATE_KEY = Symbol.for("pi-libtui/editor/registry-state/v1");
|
|
32
|
+
const states = new WeakMap<EditorRegistry, RegistryState>();
|
|
33
|
+
|
|
34
|
+
function isRecord(value: UntrustedRegistryValue): value is Record<PropertyKey, UntrustedRegistryValue> {
|
|
35
|
+
return value !== null && typeof value === "object";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasPriority(value: Record<PropertyKey, UntrustedRegistryValue>): boolean {
|
|
39
|
+
return value.priority === undefined || (typeof value.priority === "number" && Number.isFinite(value.priority));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isPasteHandler(value: UntrustedRegistryValue): value is EditorPasteHandler {
|
|
43
|
+
return isRecord(value) && typeof value.id === "string" && hasPriority(value) && typeof value.handle === "function";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isRenderDecorator(value: UntrustedRegistryValue): value is EditorRenderDecorator {
|
|
47
|
+
return isRecord(value) && typeof value.id === "string" && hasPriority(value) && typeof value.decorate === "function";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isState(value: UntrustedRegistryValue): value is RegistryState {
|
|
51
|
+
if (!isRecord(value)) return false;
|
|
52
|
+
return (
|
|
53
|
+
Array.isArray(value.pasteHandlers) &&
|
|
54
|
+
value.pasteHandlers.every(isPasteHandler) &&
|
|
55
|
+
Array.isArray(value.renderDecorators) &&
|
|
56
|
+
value.renderDecorators.every(isRenderDecorator)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stateOf(value: UntrustedRegistryValue): RegistryState | undefined {
|
|
61
|
+
if (!isRecord(value)) return undefined;
|
|
62
|
+
try {
|
|
63
|
+
const state = Reflect.get(value, STATE_KEY) as UntrustedRegistryValue;
|
|
64
|
+
return isState(state) ? state : undefined;
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isRegistry(value: UntrustedRegistryValue): value is EditorRegistry {
|
|
71
|
+
if (!isRecord(value)) return false;
|
|
72
|
+
try {
|
|
73
|
+
return (
|
|
74
|
+
value.protocol === EDITOR_PROTOCOL &&
|
|
75
|
+
value.version === 1 &&
|
|
76
|
+
typeof value.registerPasteHandler === "function" &&
|
|
77
|
+
typeof value.registerRenderDecorator === "function" &&
|
|
78
|
+
stateOf(value) !== undefined
|
|
79
|
+
);
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stateFor(registry: EditorRegistry): RegistryState {
|
|
86
|
+
const local = states.get(registry);
|
|
87
|
+
if (local && isState(local)) return local;
|
|
88
|
+
const shared = stateOf(registry);
|
|
89
|
+
if (shared) {
|
|
90
|
+
states.set(registry, shared);
|
|
91
|
+
return shared;
|
|
92
|
+
}
|
|
93
|
+
const state: RegistryState = { pasteHandlers: [], renderDecorators: [] };
|
|
94
|
+
try {
|
|
95
|
+
Object.defineProperty(registry, STATE_KEY, { value: state, enumerable: false });
|
|
96
|
+
} catch {
|
|
97
|
+
// A malformed non-configurable state from another realm must not break local editor dispatch.
|
|
98
|
+
}
|
|
99
|
+
states.set(registry, state);
|
|
100
|
+
return state;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function ordered<T extends { readonly priority?: number }>(values: readonly T[]): T[] {
|
|
104
|
+
return [...values].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function removeIdentity<T>(values: T[], value: T): void {
|
|
108
|
+
const index = values.indexOf(value);
|
|
109
|
+
if (index >= 0) values.splice(index, 1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function replaceById<T extends { readonly id: string }>(values: T[], value: T): void {
|
|
113
|
+
const index = values.findIndex((existing) => existing.id === value.id);
|
|
114
|
+
if (index >= 0) {
|
|
115
|
+
values[index] = value;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
values.push(value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function removeActiveById<T extends { readonly id: string }>(values: T[], value: T): void {
|
|
122
|
+
const index = values.findIndex((existing) => existing.id === value.id && existing === value);
|
|
123
|
+
if (index >= 0) values.splice(index, 1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function dispatchEditorPaste(registry: EditorRegistry, text: string): string | undefined {
|
|
127
|
+
for (const handler of ordered(stateFor(registry).pasteHandlers)) {
|
|
128
|
+
try {
|
|
129
|
+
const replacement = handler.handle(text);
|
|
130
|
+
if (replacement !== undefined) return replacement;
|
|
131
|
+
} catch {
|
|
132
|
+
// Optional feature handlers must not break the editor's native paste path.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function dispatchEditorRender(registry: EditorRegistry, lines: string[], width: number): string[] {
|
|
139
|
+
let rendered = lines;
|
|
140
|
+
for (const decorator of ordered(stateFor(registry).renderDecorators)) {
|
|
141
|
+
try {
|
|
142
|
+
const next = decorator.decorate(rendered, width);
|
|
143
|
+
if (Array.isArray(next) && next.every((line) => typeof line === "string")) rendered = next;
|
|
144
|
+
} catch {
|
|
145
|
+
// Rendering from one optional feature must not take down the editor.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return rendered;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Resolve the process-wide editor registry without activating any Pi host behavior. */
|
|
152
|
+
export function ensureEditorRegistry(scope: typeof globalThis = globalThis): EditorRegistry {
|
|
153
|
+
const slots = scope as Record<PropertyKey, UntrustedRegistryValue>;
|
|
154
|
+
const existing = slots[EDITOR_REGISTRY_KEY];
|
|
155
|
+
if (isRegistry(existing)) {
|
|
156
|
+
const state = stateOf(existing);
|
|
157
|
+
if (state) states.set(existing, state);
|
|
158
|
+
return existing;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const registry: EditorRegistry = {
|
|
162
|
+
protocol: EDITOR_PROTOCOL,
|
|
163
|
+
version: 1,
|
|
164
|
+
registerPasteHandler(handler) {
|
|
165
|
+
const handlers = stateFor(registry).pasteHandlers;
|
|
166
|
+
handlers.push(handler);
|
|
167
|
+
let active = true;
|
|
168
|
+
return () => {
|
|
169
|
+
if (!active) return;
|
|
170
|
+
active = false;
|
|
171
|
+
removeIdentity(handlers, handler);
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
registerRenderDecorator(decorator) {
|
|
175
|
+
const decorators = stateFor(registry).renderDecorators;
|
|
176
|
+
replaceById(decorators, decorator);
|
|
177
|
+
let active = true;
|
|
178
|
+
return () => {
|
|
179
|
+
if (!active) return;
|
|
180
|
+
active = false;
|
|
181
|
+
removeActiveById(decorators, decorator);
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
stateFor(registry);
|
|
186
|
+
slots[EDITOR_REGISTRY_KEY] = registry;
|
|
187
|
+
return registry;
|
|
188
|
+
}
|
package/src/editor.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export { SemanticEditor, semanticEditorTheme } from "./editor/presentation.ts";
|
|
2
|
+
export {
|
|
3
|
+
composeEditorStatus,
|
|
4
|
+
editorCompositionCadenceMs,
|
|
5
|
+
editorCompositionContentWidth,
|
|
6
|
+
editorStatusSeparator,
|
|
7
|
+
type EditorBottomTreatment,
|
|
8
|
+
type EditorCompositionPreview,
|
|
9
|
+
type EditorCompositionRenderOptions,
|
|
10
|
+
type EditorCompositionStatus,
|
|
11
|
+
type EditorCompositionStyle,
|
|
12
|
+
type EditorPromptMarkerMotion,
|
|
13
|
+
type EditorRailStyle,
|
|
14
|
+
type EditorStatusBandStyle,
|
|
15
|
+
type EditorStatusSeparator,
|
|
16
|
+
type EditorSurfaceStyle,
|
|
17
|
+
type EditorTopTreatment,
|
|
18
|
+
renderEditorComposition,
|
|
19
|
+
renderEditorCompositionPreview,
|
|
20
|
+
renderEditorCompositionStatus,
|
|
21
|
+
} from "./editor/chrome.ts";
|
|
22
|
+
export {
|
|
23
|
+
type EditorFactory,
|
|
24
|
+
type EditorLayer,
|
|
25
|
+
type EditorUi,
|
|
26
|
+
installEditorLayer,
|
|
27
|
+
} from "./editor/composition.ts";
|
|
28
|
+
export {
|
|
29
|
+
type EditorMinimumRowsLease,
|
|
30
|
+
installEditorMinimumRows,
|
|
31
|
+
} from "./editor/layout.ts";
|
|
32
|
+
export {
|
|
33
|
+
dispatchEditorPaste,
|
|
34
|
+
dispatchEditorRender,
|
|
35
|
+
EDITOR_PROTOCOL,
|
|
36
|
+
EDITOR_REGISTRY_KEY,
|
|
37
|
+
type EditorPasteHandler,
|
|
38
|
+
type EditorRegistry,
|
|
39
|
+
type EditorRenderDecorator,
|
|
40
|
+
ensureEditorRegistry,
|
|
41
|
+
} from "./editor/protocol.ts";
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Color256Preview } from "./color/preview.ts";
|
|
3
|
+
import { claimLibtuiExtensionHost } from "./host/extension-host.ts";
|
|
4
|
+
import { FullscreenOverlay, fullscreenOverlayOptions } from "./overlay/fullscreen.ts";
|
|
5
|
+
import { registerRequestAnimation } from "./request-animation.ts";
|
|
6
|
+
|
|
7
|
+
export default function libtuiExtension(pi: ExtensionAPI): void {
|
|
8
|
+
const host = claimLibtuiExtensionHost(pi);
|
|
9
|
+
if (!host) return;
|
|
10
|
+
registerRequestAnimation(pi);
|
|
11
|
+
pi.registerCommand("libtui:colors", {
|
|
12
|
+
description: "Show the active terminal's 256-color palette",
|
|
13
|
+
handler: async (_args, ctx) => {
|
|
14
|
+
if (ctx.mode !== "tui") {
|
|
15
|
+
ctx.ui.notify("/libtui:colors requires the interactive TUI.", "warning");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
await ctx.ui.custom<void>(
|
|
19
|
+
(tui, theme, _keybindings, done) =>
|
|
20
|
+
new FullscreenOverlay(tui, theme, new Color256Preview(theme, done), "256 colors · Esc or q to close"),
|
|
21
|
+
{ overlay: true, overlayOptions: fullscreenOverlayOptions() },
|
|
22
|
+
);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
pi.on("session_start", (_event, ctx) => host.start(ctx));
|
|
26
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
27
|
+
await host.shutdown(ctx, event.reason === "reload");
|
|
28
|
+
if (event.reason === "reload" || event.reason === "quit") host.release();
|
|
29
|
+
});
|
|
30
|
+
}
|
package/src/folding.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/** Process-wide capability for Vim-style transcript folding. */
|
|
2
|
+
export const FOLDING_REGISTRY_KEY = Symbol.for("pi-libtui/folding/registry/v2");
|
|
3
|
+
export const FOLDING_PROTOCOL = "pi-libtui/folding/registry/v2" as const;
|
|
4
|
+
|
|
5
|
+
export type FoldOperation = "open" | "close" | "open-all" | "close-all";
|
|
6
|
+
export const FOLD_TARGET_AT_ROW = Symbol.for("pi-libtui/folding/target-at-row/v1");
|
|
7
|
+
|
|
8
|
+
/** A visible component that can be opened and closed by a host interaction mode. */
|
|
9
|
+
export interface FoldTarget {
|
|
10
|
+
/** Whether this target is currently closed. */
|
|
11
|
+
isFolded(): boolean;
|
|
12
|
+
/** Open this target. */
|
|
13
|
+
open(): void;
|
|
14
|
+
/** Close this target. */
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Structural component capability for resolving a fold from a rendered local row. */
|
|
19
|
+
export interface FoldTargetAtRow {
|
|
20
|
+
readonly [FOLD_TARGET_AT_ROW]: (row: number) => FoldTarget | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Shared registry used by copy mode and other keyboard hosts. */
|
|
24
|
+
export interface FoldingRegistry {
|
|
25
|
+
readonly protocol: typeof FOLDING_PROTOCOL;
|
|
26
|
+
readonly version: 2;
|
|
27
|
+
register(target: FoldTarget): () => void;
|
|
28
|
+
setCurrent(target: FoldTarget | undefined): void;
|
|
29
|
+
readonly current: FoldTarget | undefined;
|
|
30
|
+
/** Whether this exact target is still registered with the current renderer. */
|
|
31
|
+
has(target: FoldTarget): boolean;
|
|
32
|
+
/** Apply one operation to an explicitly resolved target, or to pointer focus when omitted. */
|
|
33
|
+
apply(operation: FoldOperation, target?: FoldTarget | null): boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// type-boundary: Symbol.for state may come from another installed libtui realm; validate it before use.
|
|
37
|
+
type UntrustedValue = unknown;
|
|
38
|
+
type RegistryState = { targets: FoldTarget[]; current?: FoldTarget };
|
|
39
|
+
const states = new WeakMap<FoldingRegistry, RegistryState>();
|
|
40
|
+
const STATE_KEY = Symbol.for("pi-libtui/folding/registry-state/v2");
|
|
41
|
+
|
|
42
|
+
function isRecord(value: UntrustedValue): value is Record<PropertyKey, UntrustedValue> {
|
|
43
|
+
return value !== null && typeof value === "object";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isRegistry(value: UntrustedValue): value is FoldingRegistry {
|
|
47
|
+
if (!isRecord(value)) return false;
|
|
48
|
+
try {
|
|
49
|
+
return (
|
|
50
|
+
value.protocol === FOLDING_PROTOCOL &&
|
|
51
|
+
value.version === 2 &&
|
|
52
|
+
typeof value.register === "function" &&
|
|
53
|
+
typeof value.setCurrent === "function" &&
|
|
54
|
+
typeof value.has === "function" &&
|
|
55
|
+
typeof value.apply === "function" &&
|
|
56
|
+
stateOf(value) !== undefined
|
|
57
|
+
);
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stateFor(registry: FoldingRegistry): RegistryState {
|
|
64
|
+
const existing = states.get(registry);
|
|
65
|
+
if (existing && isState(existing)) return existing;
|
|
66
|
+
const shared = stateOf(registry);
|
|
67
|
+
if (shared) {
|
|
68
|
+
states.set(registry, shared);
|
|
69
|
+
return shared;
|
|
70
|
+
}
|
|
71
|
+
const state: RegistryState = { targets: [] };
|
|
72
|
+
try {
|
|
73
|
+
Object.defineProperty(registry, STATE_KEY, { value: state, enumerable: false });
|
|
74
|
+
} catch {
|
|
75
|
+
// A malformed non-configurable state from another realm must not break local folding behavior.
|
|
76
|
+
}
|
|
77
|
+
states.set(registry, state);
|
|
78
|
+
return state;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stateOf(value: UntrustedValue): RegistryState | undefined {
|
|
82
|
+
if (!isRecord(value)) return undefined;
|
|
83
|
+
try {
|
|
84
|
+
const state = Reflect.get(value, STATE_KEY) as UntrustedValue;
|
|
85
|
+
return isState(state) ? state : undefined;
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isState(value: UntrustedValue): value is RegistryState {
|
|
92
|
+
if (!isRecord(value) || !Array.isArray(value.targets) || !value.targets.every(isFoldTarget)) return false;
|
|
93
|
+
return value.current === undefined || (isFoldTarget(value.current) && value.targets.includes(value.current));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function removeIdentity(values: FoldTarget[], value: FoldTarget): void {
|
|
97
|
+
const index = values.indexOf(value);
|
|
98
|
+
if (index >= 0) values.splice(index, 1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isFoldTarget(value: UntrustedValue): value is FoldTarget {
|
|
102
|
+
if (!isRecord(value)) return false;
|
|
103
|
+
try {
|
|
104
|
+
return (
|
|
105
|
+
typeof value.isFolded === "function" && typeof value.open === "function" && typeof value.close === "function"
|
|
106
|
+
);
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Resolve a registered fold target from a component-local rendered row. */
|
|
113
|
+
export function foldTargetAt(component: object, row: number): FoldTarget | undefined {
|
|
114
|
+
if (!Number.isFinite(row) || row < 0) return undefined;
|
|
115
|
+
const provider = Reflect.get(component, FOLD_TARGET_AT_ROW) as UntrustedValue;
|
|
116
|
+
if (typeof provider !== "function") return undefined;
|
|
117
|
+
try {
|
|
118
|
+
const target = Reflect.apply(provider, component, [Math.floor(row)]) as UntrustedValue;
|
|
119
|
+
return isFoldTarget(target) ? target : undefined;
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Resolve or create the process-wide folding registry. */
|
|
126
|
+
export function ensureFoldingRegistry(scope: typeof globalThis = globalThis): FoldingRegistry {
|
|
127
|
+
const slots = scope as Record<PropertyKey, UntrustedValue>;
|
|
128
|
+
const existing = slots[FOLDING_REGISTRY_KEY];
|
|
129
|
+
if (isRegistry(existing)) {
|
|
130
|
+
const state = stateOf(existing);
|
|
131
|
+
if (state) states.set(existing, state);
|
|
132
|
+
return existing;
|
|
133
|
+
}
|
|
134
|
+
const registry: FoldingRegistry = {
|
|
135
|
+
protocol: FOLDING_PROTOCOL,
|
|
136
|
+
version: 2,
|
|
137
|
+
register(target) {
|
|
138
|
+
const state = stateFor(registry);
|
|
139
|
+
state.targets.push(target);
|
|
140
|
+
return () => {
|
|
141
|
+
removeIdentity(state.targets, target);
|
|
142
|
+
if (state.current === target) state.current = undefined;
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
setCurrent(target) {
|
|
146
|
+
const state = stateFor(registry);
|
|
147
|
+
state.current = target && state.targets.includes(target) ? target : undefined;
|
|
148
|
+
},
|
|
149
|
+
get current() {
|
|
150
|
+
return stateFor(registry).current;
|
|
151
|
+
},
|
|
152
|
+
has(target) {
|
|
153
|
+
return stateFor(registry).targets.includes(target);
|
|
154
|
+
},
|
|
155
|
+
apply(operation, target) {
|
|
156
|
+
const state = stateFor(registry);
|
|
157
|
+
const targets = operation.endsWith("-all")
|
|
158
|
+
? [...state.targets]
|
|
159
|
+
: target && state.targets.includes(target)
|
|
160
|
+
? [target]
|
|
161
|
+
: target === undefined && state.current
|
|
162
|
+
? [state.current]
|
|
163
|
+
: [];
|
|
164
|
+
if (targets.length === 0) return false;
|
|
165
|
+
const open = operation === "open" || operation === "open-all";
|
|
166
|
+
let applied = false;
|
|
167
|
+
for (const target of targets) {
|
|
168
|
+
try {
|
|
169
|
+
if (open) target.open();
|
|
170
|
+
else target.close();
|
|
171
|
+
applied = true;
|
|
172
|
+
} catch {
|
|
173
|
+
// A feature-owned fold must not take down copy mode.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return applied;
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
stateFor(registry);
|
|
180
|
+
slots[FOLDING_REGISTRY_KEY] = registry;
|
|
181
|
+
return registry;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Clear a target only when it still owns the current pointer/focus slot.
|
|
186
|
+
* Leave/focus-out events can arrive after another target has already become
|
|
187
|
+
* current, so an old component must not clear the newer row.
|
|
188
|
+
*/
|
|
189
|
+
export function clearFoldingCurrent(target: FoldTarget, scope: typeof globalThis = globalThis): void {
|
|
190
|
+
const registry = ensureFoldingRegistry(scope);
|
|
191
|
+
if (registry.current === target) registry.setCurrent(undefined);
|
|
192
|
+
}
|