pi-libtui 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +205 -0
  3. package/package.json +55 -0
  4. package/src/activity-presentation.ts +75 -0
  5. package/src/appearance.ts +632 -0
  6. package/src/background-surface.ts +125 -0
  7. package/src/color/palette.ts +186 -0
  8. package/src/color/preview.ts +157 -0
  9. package/src/color/resolver.ts +75 -0
  10. package/src/color/theme.ts +531 -0
  11. package/src/component-stack.ts +247 -0
  12. package/src/content/markdown-code.ts +60 -0
  13. package/src/content/terminal-text.ts +135 -0
  14. package/src/content/text.ts +105 -0
  15. package/src/controls/action-panel.ts +420 -0
  16. package/src/controls/dialog-button-bar.ts +287 -0
  17. package/src/controls/multi-select.ts +467 -0
  18. package/src/controls/picker-panel.ts +447 -0
  19. package/src/controls/screen-icon-actions.ts +134 -0
  20. package/src/controls/searchable-select.ts +394 -0
  21. package/src/controls/select-box.ts +224 -0
  22. package/src/controls/selectable-list.ts +433 -0
  23. package/src/controls/selection-action-bar.ts +382 -0
  24. package/src/controls/semantic-input.ts +23 -0
  25. package/src/controls/tab.ts +189 -0
  26. package/src/cursor.ts +288 -0
  27. package/src/decoration/editor-pills.ts +156 -0
  28. package/src/decoration/glyphs.ts +326 -0
  29. package/src/decoration/pointer-interaction.ts +123 -0
  30. package/src/decoration/powerline-pill.ts +104 -0
  31. package/src/decoration/status.ts +185 -0
  32. package/src/decoration/transient-pill.ts +176 -0
  33. package/src/diff/index.ts +21 -0
  34. package/src/diff/model.ts +76 -0
  35. package/src/diff/parse.ts +310 -0
  36. package/src/diff/pierre-syntax.ts +86 -0
  37. package/src/diff/render.ts +504 -0
  38. package/src/diff/structured.ts +264 -0
  39. package/src/diff/view.ts +75 -0
  40. package/src/editor/chrome.ts +341 -0
  41. package/src/editor/composition.ts +53 -0
  42. package/src/editor/layout.ts +81 -0
  43. package/src/editor/presentation.ts +35 -0
  44. package/src/editor/protocol.ts +188 -0
  45. package/src/editor.ts +41 -0
  46. package/src/extension.ts +30 -0
  47. package/src/folding.ts +192 -0
  48. package/src/host/cursor-bridge.ts +210 -0
  49. package/src/host/editor-bridge.ts +148 -0
  50. package/src/host/extension-host.ts +231 -0
  51. package/src/host/mouse-bridge.ts +212 -0
  52. package/src/host/mouse-dispatch.ts +400 -0
  53. package/src/host/mouse-input.ts +85 -0
  54. package/src/host/mouse-targets.ts +238 -0
  55. package/src/host/pi-layout-adapter.ts +298 -0
  56. package/src/host/pi-native-selection.ts +227 -0
  57. package/src/host/split-pane-bridge.ts +720 -0
  58. package/src/host/transcript-bridge.ts +236 -0
  59. package/src/index.ts +334 -0
  60. package/src/line-layout.ts +6 -0
  61. package/src/motion.ts +1409 -0
  62. package/src/mouse/events.ts +55 -0
  63. package/src/mouse/fullscreen-layout.ts +260 -0
  64. package/src/mouse/registry.ts +348 -0
  65. package/src/mouse.ts +37 -0
  66. package/src/native-binary.ts +189 -0
  67. package/src/overlay/anchored.ts +91 -0
  68. package/src/overlay/detail-card.ts +158 -0
  69. package/src/overlay/dialog.ts +266 -0
  70. package/src/overlay/floating.ts +106 -0
  71. package/src/overlay/framed-editor.ts +236 -0
  72. package/src/overlay/fullscreen.ts +170 -0
  73. package/src/overlay/hover-tooltip.ts +109 -0
  74. package/src/overlay/modal-mount.ts +118 -0
  75. package/src/panels.ts +207 -0
  76. package/src/render-cache.ts +80 -0
  77. package/src/render-epoch.ts +48 -0
  78. package/src/request-animation.ts +160 -0
  79. package/src/scrollbar.ts +27 -0
  80. package/src/selection.ts +148 -0
  81. package/src/split-pane.ts +232 -0
  82. package/src/status-presentation.ts +411 -0
  83. package/src/stream.ts +543 -0
  84. package/src/syntax.ts +316 -0
  85. package/src/terminal/bridge-client.ts +243 -0
  86. package/src/terminal/embedding.ts +6 -0
  87. package/src/terminal/output.ts +151 -0
  88. package/src/terminal/projection.ts +362 -0
  89. package/src/terminal/pty-host.ts +263 -0
  90. package/src/terminal/pty-pane.ts +308 -0
  91. package/src/terminal-colors.ts +334 -0
  92. package/src/tool/action.ts +170 -0
  93. package/src/tool/activity.ts +500 -0
  94. package/src/tool/call-preview.ts +44 -0
  95. package/src/tool/disclosure-action.ts +89 -0
  96. package/src/tool/expanded-region-viewport.ts +103 -0
  97. package/src/tool/index.ts +26 -0
  98. package/src/tool/output.ts +271 -0
  99. package/src/tool/transcript.ts +150 -0
  100. package/src/tool/view-region.ts +581 -0
  101. package/test/appearance.test.ts +183 -0
  102. package/test/bounded-stream.test.ts +150 -0
  103. package/test/bridge-client.test.ts +42 -0
  104. package/test/color-preview.test.ts +23 -0
  105. package/test/component-stack.test.ts +206 -0
  106. package/test/components.test.ts +1165 -0
  107. package/test/content-text.test.ts +21 -0
  108. package/test/cursor-bridge.test.ts +151 -0
  109. package/test/cursor.test.ts +147 -0
  110. package/test/editor-chrome.test.ts +119 -0
  111. package/test/editor-layout.test.ts +30 -0
  112. package/test/editor-registry.test.ts +203 -0
  113. package/test/editor-theme.test.ts +33 -0
  114. package/test/editor-token-pill.test.ts +72 -0
  115. package/test/floating-overlay.test.ts +57 -0
  116. package/test/folding.test.ts +147 -0
  117. package/test/harmonious-theme.test.ts +23 -0
  118. package/test/interaction.test.ts +104 -0
  119. package/test/key-icons.test.ts +62 -0
  120. package/test/markdown-code.test.ts +24 -0
  121. package/test/modal-overlay-mount.test.ts +94 -0
  122. package/test/motion.test.ts +649 -0
  123. package/test/mouse/bridge.test.ts +1302 -0
  124. package/test/mouse/extension.test.ts +205 -0
  125. package/test/mouse/registry.test.ts +140 -0
  126. package/test/mouse/screen-decoration.test.ts +167 -0
  127. package/test/native-binary.test.ts +52 -0
  128. package/test/palette.test.ts +32 -0
  129. package/test/panels.test.ts +74 -0
  130. package/test/pointer-components.test.ts +151 -0
  131. package/test/progress.test.ts +54 -0
  132. package/test/pty-host.test.ts +126 -0
  133. package/test/pty-pane.test.ts +62 -0
  134. package/test/public-surface.test.ts +75 -0
  135. package/test/render-cache.test.ts +25 -0
  136. package/test/render-epoch.test.ts +119 -0
  137. package/test/request-animation.test.ts +193 -0
  138. package/test/screen-icon-actions.test.ts +69 -0
  139. package/test/select-box.test.ts +86 -0
  140. package/test/select-pointer.test.ts +369 -0
  141. package/test/selectable-list.test.ts +258 -0
  142. package/test/selection/registry.test.ts +44 -0
  143. package/test/selection-action-bar.test.ts +204 -0
  144. package/test/split-pane-bridge.test.ts +944 -0
  145. package/test/split-pane.test.ts +134 -0
  146. package/test/status-presentation.test.ts +39 -0
  147. package/test/syntax.test.ts +59 -0
  148. package/test/terminal-colors.test.ts +115 -0
  149. package/test/terminal-embedding.test.ts +12 -0
  150. package/test/terminal-projection.test.ts +312 -0
  151. package/test/theme-colors.test.ts +175 -0
  152. package/test/tool-output.test.ts +160 -0
  153. package/test/tool-transcript.test.ts +1190 -0
  154. package/test/unified-diff.test.ts +691 -0
  155. package/themes/harmonious.json +61 -0
  156. package/tsconfig.json +16 -0
@@ -0,0 +1,48 @@
1
+ const RENDER_EPOCH_KEY = Symbol.for("pi-libtui/render-epoch/v1");
2
+ const RENDER_EPOCH_TAG = "pi-libtui/render-epoch/v1" as const;
3
+
4
+ // type-boundary: Symbol.for state may come from another installed libtui realm; validate it before use.
5
+ type UntrustedRenderEpochValue = unknown;
6
+
7
+ interface RenderEpochRegistry {
8
+ readonly tag: typeof RENDER_EPOCH_TAG;
9
+ readonly current: () => number;
10
+ readonly bump: () => void;
11
+ }
12
+
13
+ function isRenderEpochRegistry(value: UntrustedRenderEpochValue): value is RenderEpochRegistry {
14
+ if (!value || typeof value !== "object") return false;
15
+ const candidate = value as Partial<RenderEpochRegistry>;
16
+ return (
17
+ candidate.tag === RENDER_EPOCH_TAG &&
18
+ typeof candidate.current === "function" &&
19
+ typeof candidate.bump === "function"
20
+ );
21
+ }
22
+
23
+ function renderEpochRegistry(scope: typeof globalThis = globalThis): RenderEpochRegistry {
24
+ const slots = scope as Record<PropertyKey, UntrustedRenderEpochValue>;
25
+ const existing = slots[RENDER_EPOCH_KEY];
26
+ if (isRenderEpochRegistry(existing)) return existing;
27
+
28
+ let epoch = 0;
29
+ const registry: RenderEpochRegistry = {
30
+ tag: RENDER_EPOCH_TAG,
31
+ current: () => epoch,
32
+ bump: () => {
33
+ epoch += 1;
34
+ },
35
+ };
36
+ slots[RENDER_EPOCH_KEY] = registry;
37
+ return registry;
38
+ }
39
+
40
+ /** Return the current process-wide rendering epoch. */
41
+ export function getTuiRenderEpoch(): number {
42
+ return renderEpochRegistry().current();
43
+ }
44
+
45
+ /** Advance the rendering epoch after effective shared appearance changes. */
46
+ export function bumpTuiRenderEpoch(): void {
47
+ renderEpochRegistry().bump();
48
+ }
@@ -0,0 +1,160 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getTuiAppearance, requestPhaseAnimation, subscribeTuiAppearance, type TuiRequestPhase } from "./appearance.ts";
3
+ import { activityPresentationCadenceMs, activityPresentationFrame } from "./activity-presentation.ts";
4
+ import { tuiTheme } from "./color/theme.ts";
5
+ import { type MotionMount, type MotionScheduler, sharedMotionScheduler } from "./motion.ts";
6
+
7
+ type RequestAnimationUi = Pick<ExtensionContext["ui"], "setWorkingIndicator" | "setWorkingMessage" | "theme">;
8
+
9
+ interface RequestAnimationContext {
10
+ ui: RequestAnimationUi;
11
+ }
12
+
13
+ interface RequestAnimationControllerOptions {
14
+ now?: () => number;
15
+ scheduler?: MotionScheduler;
16
+ width?: () => number;
17
+ }
18
+
19
+ /** Owns Pi's streaming status animation across thinking, working, and tool phases. */
20
+ export class RequestAnimationController {
21
+ private readonly activeTools = new Set<string>();
22
+ private readonly now: () => number;
23
+ private readonly scheduler: MotionScheduler;
24
+ private readonly unsubscribeAppearance: () => void;
25
+ private readonly width: () => number;
26
+ private context?: RequestAnimationContext;
27
+ private mount?: MotionMount;
28
+ private phase?: TuiRequestPhase;
29
+ private phaseStartedAt = 0;
30
+ private thinking = false;
31
+
32
+ constructor(options: RequestAnimationControllerOptions = {}) {
33
+ this.now = options.now ?? (() => performance.now());
34
+ this.scheduler = options.scheduler ?? sharedMotionScheduler;
35
+ this.width = options.width ?? (() => Math.max(10, (process.stdout.columns || 80) - 4));
36
+ this.unsubscribeAppearance = subscribeTuiAppearance(() => {
37
+ if (!this.context) return;
38
+ this.remount();
39
+ this.render();
40
+ });
41
+ }
42
+
43
+ start(context: RequestAnimationContext): void {
44
+ this.stop();
45
+ this.context = context;
46
+ this.phase = "working";
47
+ this.phaseStartedAt = this.now();
48
+ context.ui.setWorkingIndicator({ frames: [] });
49
+ this.remount();
50
+ this.render();
51
+ }
52
+
53
+ finish(context?: RequestAnimationContext): void {
54
+ this.stop(context);
55
+ }
56
+
57
+ setThinking(active: boolean): void {
58
+ if (this.thinking === active) return;
59
+ this.thinking = active;
60
+ this.syncPhase();
61
+ }
62
+
63
+ startTool(toolCallId: string): void {
64
+ const before = this.activeTools.size;
65
+ this.activeTools.add(toolCallId);
66
+ if (this.activeTools.size !== before) this.syncPhase();
67
+ }
68
+
69
+ finishTool(toolCallId: string): void {
70
+ if (!this.activeTools.delete(toolCallId)) return;
71
+ this.syncPhase();
72
+ }
73
+
74
+ dispose(context?: RequestAnimationContext): void {
75
+ this.stop(context);
76
+ this.unsubscribeAppearance();
77
+ }
78
+
79
+ private stop(context?: RequestAnimationContext): void {
80
+ const ui = context?.ui ?? this.context?.ui;
81
+ this.mount?.dispose();
82
+ this.mount = undefined;
83
+ this.context = undefined;
84
+ this.phase = undefined;
85
+ this.thinking = false;
86
+ this.activeTools.clear();
87
+ if (!ui) return;
88
+ ui.setWorkingMessage();
89
+ ui.setWorkingIndicator();
90
+ }
91
+
92
+ private syncPhase(): void {
93
+ if (!this.context) return;
94
+ const next: TuiRequestPhase = this.thinking ? "thinking" : this.activeTools.size > 0 ? "tool" : "working";
95
+ if (next === this.phase) return;
96
+ this.phase = next;
97
+ this.phaseStartedAt = this.now();
98
+ this.remount();
99
+ this.render();
100
+ }
101
+
102
+ private remount(): void {
103
+ this.mount?.dispose();
104
+ this.mount = undefined;
105
+ if (!this.context || !this.phase) return;
106
+ const appearance = getTuiAppearance();
107
+ const presentation = requestPhaseAnimation(this.phase, appearance);
108
+ const cadenceMs = activityPresentationCadenceMs(
109
+ presentation,
110
+ appearance.animationSmoothness,
111
+ appearance.animationSpeed,
112
+ );
113
+ if (cadenceMs === undefined) return;
114
+ this.mount = this.scheduler.mount({ requestRender: () => this.render() }, { cadenceMs });
115
+ }
116
+
117
+ private render(): void {
118
+ if (!this.context || !this.phase) return;
119
+ const appearance = getTuiAppearance();
120
+ const presentation = requestPhaseAnimation(this.phase, appearance);
121
+ const frame = activityPresentationFrame(
122
+ tuiTheme(this.context.ui.theme),
123
+ presentation,
124
+ this.phase,
125
+ phaseLabel(this.phase),
126
+ this.now() - this.phaseStartedAt,
127
+ this.width(),
128
+ {
129
+ animationSpeed: appearance.animationSpeed,
130
+ animationSmoothness: appearance.animationSmoothness,
131
+ },
132
+ );
133
+ this.context.ui.setWorkingMessage(frame.marker ? `${frame.marker} ${frame.text}` : frame.text);
134
+ }
135
+ }
136
+
137
+ function phaseLabel(phase: TuiRequestPhase): string {
138
+ return phase === "thinking" ? "Thinking..." : phase === "tool" ? "Running..." : "Working...";
139
+ }
140
+
141
+ /** Bind the shared request animation to Pi's public lifecycle and status APIs. */
142
+ export function registerRequestAnimation(pi: ExtensionAPI): RequestAnimationController {
143
+ const controller = new RequestAnimationController();
144
+ pi.on("agent_start", (_event, context) => controller.start(context));
145
+ pi.on("agent_end", (_event, context) => controller.finish(context));
146
+ pi.on("message_update", (event) => {
147
+ const type = event.assistantMessageEvent.type;
148
+ if (type === "thinking_start" || type === "thinking_delta") controller.setThinking(true);
149
+ if (type === "thinking_end" || type === "text_start" || type === "text_delta") controller.setThinking(false);
150
+ });
151
+ pi.on("message_end", () => controller.setThinking(false));
152
+ pi.on("tool_execution_start", (event) => controller.startTool(event.toolCallId));
153
+ pi.on("tool_execution_end", (event) => controller.finishTool(event.toolCallId));
154
+ pi.on("session_before_switch", (_event, context) => controller.finish(context));
155
+ pi.on("session_shutdown", (event, context) => {
156
+ if (event.reason === "reload" || event.reason === "quit") controller.dispose(context);
157
+ else controller.finish(context);
158
+ });
159
+ return controller;
160
+ }
@@ -0,0 +1,27 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { tuiTheme } from "./color/theme.ts";
4
+
5
+ /** Paint the compact scrollbar shared by settings and expanded tool views. */
6
+ export function applyScrollbar(
7
+ lines: readonly string[],
8
+ options: { theme: Theme; width: number; height: number; offset: number; total: number },
9
+ ): string[] {
10
+ const width = Math.max(0, Math.floor(options.width));
11
+ const height = Math.max(0, Math.floor(options.height));
12
+ if (width <= 1 || height === 0 || options.total <= height) return [...lines];
13
+ const output = [...lines];
14
+ const colors = tuiTheme(options.theme);
15
+ const thumbHeight = Math.max(1, Math.floor((height * height) / options.total));
16
+ const trackHeight = height - thumbHeight;
17
+ const maxOffset = Math.max(1, options.total - height);
18
+ const thumbStart = Math.min(trackHeight, Math.floor((Math.max(0, options.offset) * trackHeight) / maxOffset));
19
+ const contentWidth = Math.max(0, width - 2);
20
+ for (let row = 0; row < height; row += 1) {
21
+ const line = truncateToWidth(output[row] ?? "", contentWidth, "");
22
+ const padded = `${line}${" ".repeat(Math.max(0, contentWidth - visibleWidth(line)))}`;
23
+ const thumb = row >= thumbStart && row < thumbStart + thumbHeight;
24
+ output[row] = `${padded} ${thumb ? colors.fg("accent", "█") : colors.fg("text.muted", "│")}`;
25
+ }
26
+ return output;
27
+ }
@@ -0,0 +1,148 @@
1
+ export const SELECTION_REGISTRY_KEY = Symbol.for("pi-libtui/selection/v1");
2
+ export const SELECTION_PROTOCOL = "pi-libtui/selection/v1" as const;
3
+
4
+ export interface SelectionPoint {
5
+ row: number;
6
+ col: number;
7
+ }
8
+
9
+ export type SelectionShape = "character" | "line" | "column";
10
+ export type MessageIdStability = "stable" | "best-effort";
11
+
12
+ export interface SelectionOffsetRange {
13
+ start: number;
14
+ end: number;
15
+ }
16
+
17
+ export interface SelectionQuoteAnchor {
18
+ exact: string;
19
+ prefix?: string;
20
+ suffix?: string;
21
+ }
22
+
23
+ export interface SelectionSourceAnchor {
24
+ messageId?: string;
25
+ messageIdStability?: MessageIdStability;
26
+ offsets?: SelectionOffsetRange;
27
+ quote?: SelectionQuoteAnchor;
28
+ }
29
+
30
+ export interface NativeSelectionGeometry {
31
+ shape: SelectionShape;
32
+ logical: { start: SelectionPoint; end: SelectionPoint };
33
+ screen: { start: SelectionPoint; end: SelectionPoint };
34
+ }
35
+
36
+ export interface NativeSelectionCompleted extends NativeSelectionGeometry {
37
+ text: string;
38
+ source?: SelectionSourceAnchor;
39
+ }
40
+
41
+ export type SelectionCompletedListener = (selection: NativeSelectionCompleted) => void;
42
+ export interface SelectionActionRequest extends NativeSelectionCompleted {
43
+ action: string;
44
+ /** Screen position used to anchor an action overlay, independent of the selected range. */
45
+ screenAnchor?: SelectionPoint;
46
+ /** Ask the selection host to display cursor-local feedback without writing into the transcript. */
47
+ showFeedback?(feedback: { message: string; kind: "success" | "warning" }): void;
48
+ }
49
+ export type SelectionActionResult = boolean;
50
+ export type SelectionActionListener = (
51
+ request: SelectionActionRequest,
52
+ ) => SelectionActionResult | undefined | Promise<SelectionActionResult | undefined>;
53
+
54
+ export interface SelectionRegistry {
55
+ readonly protocol: typeof SELECTION_PROTOCOL;
56
+ readonly version: 1;
57
+ onSelectionCompleted(listener: SelectionCompletedListener): () => void;
58
+ publishSelectionCompleted(selection: NativeSelectionCompleted): void;
59
+ onSelectionAction(listener: SelectionActionListener): () => void;
60
+ publishSelectionAction(request: SelectionActionRequest): Promise<SelectionActionResult>;
61
+ }
62
+
63
+ type RegistryState = { listeners: Set<SelectionCompletedListener>; actionListeners: Set<SelectionActionListener> };
64
+ const STATE_KEY = Symbol.for("pi-libtui/selection-state/v1");
65
+ const states = new WeakMap<SelectionRegistry, RegistryState>();
66
+
67
+ // type-boundary: Symbol.for capabilities can be populated by another extension realm; this validator narrows the public methods.
68
+ type UntrustedRegistryValue = unknown;
69
+
70
+ function isRegistry(value: UntrustedRegistryValue): value is SelectionRegistry {
71
+ if (!value || typeof value !== "object") return false;
72
+ const candidate = value as Partial<SelectionRegistry>;
73
+ return (
74
+ candidate.protocol === SELECTION_PROTOCOL &&
75
+ candidate.version === 1 &&
76
+ typeof candidate.onSelectionCompleted === "function" &&
77
+ typeof candidate.publishSelectionCompleted === "function" &&
78
+ typeof candidate.onSelectionAction === "function" &&
79
+ typeof candidate.publishSelectionAction === "function"
80
+ );
81
+ }
82
+
83
+ function stateFor(registry: SelectionRegistry): RegistryState {
84
+ const existing = states.get(registry);
85
+ if (existing) return existing;
86
+ const shared = Reflect.get(registry as object, STATE_KEY) as UntrustedRegistryValue;
87
+ if (isState(shared)) {
88
+ states.set(registry, shared);
89
+ return shared;
90
+ }
91
+ const state = {
92
+ listeners: new Set<SelectionCompletedListener>(),
93
+ actionListeners: new Set<SelectionActionListener>(),
94
+ };
95
+ Object.defineProperty(registry, STATE_KEY, { value: state, enumerable: false, configurable: false });
96
+ states.set(registry, state);
97
+ return state;
98
+ }
99
+
100
+ function isState(value: UntrustedRegistryValue): value is RegistryState {
101
+ if (!value || typeof value !== "object") return false;
102
+ const candidate = value as Partial<RegistryState>;
103
+ return isSet(candidate.listeners) && isSet(candidate.actionListeners);
104
+ }
105
+
106
+ function isSet(value: UntrustedRegistryValue): value is Set<never> {
107
+ if (!value || typeof value !== "object") return false;
108
+ const candidate = value as { add?: UntrustedRegistryValue; delete?: UntrustedRegistryValue };
109
+ return typeof candidate.add === "function" && typeof candidate.delete === "function";
110
+ }
111
+
112
+ export function ensureSelectionRegistry(scope: typeof globalThis = globalThis): SelectionRegistry {
113
+ const slots = scope as Record<PropertyKey, UntrustedRegistryValue>;
114
+ const existing = slots[SELECTION_REGISTRY_KEY];
115
+ if (isRegistry(existing)) return existing;
116
+ const registry: SelectionRegistry = {
117
+ protocol: SELECTION_PROTOCOL,
118
+ version: 1,
119
+ onSelectionCompleted(listener) {
120
+ const state = stateFor(registry);
121
+ state.listeners.add(listener);
122
+ return () => state.listeners.delete(listener);
123
+ },
124
+ publishSelectionCompleted(selection) {
125
+ for (const listener of [...stateFor(registry).listeners]) {
126
+ try {
127
+ listener(selection);
128
+ } catch {
129
+ // Optional listeners must not affect native selection or clipboard behavior.
130
+ }
131
+ }
132
+ },
133
+ onSelectionAction(listener) {
134
+ const state = stateFor(registry);
135
+ state.actionListeners.add(listener);
136
+ return () => state.actionListeners.delete(listener);
137
+ },
138
+ async publishSelectionAction(request) {
139
+ const results = await Promise.allSettled(
140
+ [...stateFor(registry).actionListeners].map((listener) => Promise.resolve().then(() => listener(request))),
141
+ );
142
+ return results.some((result) => result.status === "fulfilled" && result.value === true);
143
+ },
144
+ };
145
+ stateFor(registry);
146
+ slots[SELECTION_REGISTRY_KEY] = registry;
147
+ return registry;
148
+ }
@@ -0,0 +1,232 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component, TUI } from "@earendil-works/pi-tui";
3
+ import type { TuiMouseEvent } from "./mouse/events.ts";
4
+
5
+ export const SPLIT_PANE_REGISTRY_KEY = Symbol.for("pi-libtui/split-panes/v2");
6
+ export const SPLIT_PANE_PROTOCOL = "pi-libtui/split-panes/v2" as const;
7
+
8
+ export type SplitPanePosition = "left" | "right";
9
+ export type SplitPaneComponent = Component & {
10
+ /** The component already enforces the width passed to render. */
11
+ readonly rendersWithinWidth?: true;
12
+ /** Whether this input can only change the component after later asynchronous output. */
13
+ defersInputRender?(data: string): boolean;
14
+ onMouse?(event: TuiMouseEvent): boolean;
15
+ /** Return false while this pane should remain pointer- and action-interactive without taking keyboard focus. */
16
+ acceptsFocus?(): boolean;
17
+ dispose?(): void;
18
+ };
19
+
20
+ /** Renderer access available to a split-pane component. */
21
+ export interface SplitPaneHost {
22
+ /** The active fullscreen renderer. */
23
+ readonly tui: TUI;
24
+ /** Current pane viewport size. Falls back to the renderer size until layout geometry exists. */
25
+ getTerminalSize(): { columns: number; rows: number };
26
+ requestRender(): void;
27
+ /** Focus this pane, remembering the component that should regain focus. */
28
+ focus(): void;
29
+ /** Restore the remembered focus target when this pane still owns focus. */
30
+ blur(): void;
31
+ /** Whether this pane currently receives terminal input. */
32
+ isFocused(): boolean;
33
+ }
34
+
35
+ export type SplitPaneComponentFactory = (host: SplitPaneHost, theme: Theme) => SplitPaneComponent;
36
+
37
+ /** One optional pane composed beside Pi's complete fullscreen layout. */
38
+ export interface SplitPaneDefinition {
39
+ /** Stable diagnostic identity for the contribution. */
40
+ readonly id: string;
41
+ /** Side of Pi's main pane. */
42
+ readonly position: SplitPanePosition;
43
+ /** Component created separately for each fullscreen renderer. */
44
+ readonly component: SplitPaneComponentFactory;
45
+ /** Initial pane width in terminal cells. */
46
+ readonly size: number;
47
+ /** Initial share of the terminal width when no committed size has been restored. */
48
+ readonly initialRatio?: number;
49
+ /** Called once when a pointer resize is committed. */
50
+ readonly onResize?: (size: number) => void;
51
+ /** Minimum width reserved for Pi's main pane. */
52
+ readonly minMainSize: number;
53
+ /** Empty cells beside the draggable border. Defaults to one. */
54
+ readonly gap?: number;
55
+ /** Visibility priority. Highest wins; the latest mount wins ties. Defaults to zero. */
56
+ readonly priority?: number;
57
+ }
58
+
59
+ /** Validated split-pane definition stored by the shared registry. */
60
+ export interface MountedSplitPane extends Omit<SplitPaneDefinition, "gap" | "priority"> {
61
+ readonly gap: number;
62
+ readonly priority: number;
63
+ }
64
+
65
+ export interface SplitPaneRegistry {
66
+ readonly protocol: typeof SPLIT_PANE_PROTOCOL;
67
+ readonly version: 2;
68
+ mount(definition: SplitPaneDefinition): () => void;
69
+ current(): MountedSplitPane | undefined;
70
+ subscribe(listener: () => void): () => void;
71
+ }
72
+
73
+ interface SplitPaneRegistryState {
74
+ readonly panes: MountedSplitPane[];
75
+ readonly listeners: Array<() => void>;
76
+ }
77
+
78
+ const STATE_KEY = Symbol.for("pi-libtui/split-panes/state/v2");
79
+ const states = new WeakMap<SplitPaneRegistry, SplitPaneRegistryState>();
80
+
81
+ // type-boundary: Symbol.for capabilities can be populated by another extension realm; this validator narrows the public methods.
82
+ type UntrustedRegistryValue = unknown;
83
+
84
+ function isRegistry(value: UntrustedRegistryValue): value is SplitPaneRegistry {
85
+ if (!value || typeof value !== "object") return false;
86
+ const candidate = value as Partial<SplitPaneRegistry>;
87
+ return (
88
+ candidate.protocol === SPLIT_PANE_PROTOCOL &&
89
+ candidate.version === 2 &&
90
+ typeof candidate.mount === "function" &&
91
+ typeof candidate.current === "function" &&
92
+ typeof candidate.subscribe === "function"
93
+ );
94
+ }
95
+
96
+ function isState(value: UntrustedRegistryValue): value is SplitPaneRegistryState {
97
+ if (!value || typeof value !== "object") return false;
98
+ const candidate = value as Partial<SplitPaneRegistryState>;
99
+ return Array.isArray(candidate.panes) && Array.isArray(candidate.listeners);
100
+ }
101
+
102
+ function stateFor(registry: SplitPaneRegistry): SplitPaneRegistryState {
103
+ const cached = states.get(registry);
104
+ if (cached) return cached;
105
+ const shared = Reflect.get(registry as object, STATE_KEY) as UntrustedRegistryValue;
106
+ if (isState(shared)) {
107
+ states.set(registry, shared);
108
+ return shared;
109
+ }
110
+ const state: SplitPaneRegistryState = { panes: [], listeners: [] };
111
+ Object.defineProperty(registry, STATE_KEY, { configurable: false, enumerable: false, value: state });
112
+ states.set(registry, state);
113
+ return state;
114
+ }
115
+
116
+ function positiveInteger(value: number, name: string): number {
117
+ if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive integer`);
118
+ return value;
119
+ }
120
+
121
+ function nonNegativeInteger(value: number, name: string): number {
122
+ if (!Number.isSafeInteger(value) || value < 0) throw new RangeError(`${name} must be a non-negative integer`);
123
+ return value;
124
+ }
125
+
126
+ function integer(value: number, name: string): number {
127
+ if (!Number.isSafeInteger(value)) throw new RangeError(`${name} must be a safe integer`);
128
+ return value;
129
+ }
130
+
131
+ function normalized(definition: SplitPaneDefinition): MountedSplitPane {
132
+ if (definition.id.trim().length === 0) throw new TypeError("split pane id must not be empty");
133
+ if (definition.position !== "left" && definition.position !== "right") {
134
+ throw new TypeError('split pane position must be "left" or "right"');
135
+ }
136
+ if (typeof definition.component !== "function") throw new TypeError("split pane component must be a factory");
137
+ const size = positiveInteger(definition.size, "split pane size");
138
+ if (
139
+ definition.initialRatio !== undefined &&
140
+ (!Number.isFinite(definition.initialRatio) || definition.initialRatio <= 0 || definition.initialRatio >= 1)
141
+ ) {
142
+ throw new RangeError("split pane initial ratio must be between zero and one");
143
+ }
144
+ if (definition.onResize !== undefined && typeof definition.onResize !== "function") {
145
+ throw new TypeError("split pane resize callback must be a function");
146
+ }
147
+ return {
148
+ id: definition.id,
149
+ position: definition.position,
150
+ component: definition.component,
151
+ size,
152
+ ...(definition.initialRatio === undefined ? {} : { initialRatio: definition.initialRatio }),
153
+ ...(definition.onResize === undefined ? {} : { onResize: definition.onResize }),
154
+ minMainSize: positiveInteger(definition.minMainSize, "split pane minimum main size"),
155
+ gap: nonNegativeInteger(definition.gap ?? 1, "split pane gap"),
156
+ priority: integer(definition.priority ?? 0, "split pane priority"),
157
+ };
158
+ }
159
+
160
+ function currentPane(state: SplitPaneRegistryState): MountedSplitPane | undefined {
161
+ let selected: MountedSplitPane | undefined;
162
+ for (const pane of state.panes) {
163
+ if (!selected || pane.priority >= selected.priority) selected = pane;
164
+ }
165
+ return selected;
166
+ }
167
+
168
+ function removeIdentity<T>(values: T[], value: T): boolean {
169
+ const index = values.indexOf(value);
170
+ if (index < 0) return false;
171
+ values.splice(index, 1);
172
+ return true;
173
+ }
174
+
175
+ function notify(state: SplitPaneRegistryState): void {
176
+ for (const listener of [...state.listeners]) {
177
+ try {
178
+ listener();
179
+ } catch {
180
+ // Optional consumers must not break other split-pane contributions.
181
+ }
182
+ }
183
+ }
184
+
185
+ /** Resolve or create the process-wide split-pane registry. */
186
+ export function ensureSplitPaneRegistry(scope: typeof globalThis = globalThis): SplitPaneRegistry {
187
+ const slots = scope as Record<PropertyKey, UntrustedRegistryValue>;
188
+ const existing = slots[SPLIT_PANE_REGISTRY_KEY];
189
+ if (isRegistry(existing)) {
190
+ stateFor(existing);
191
+ return existing;
192
+ }
193
+ const registry: SplitPaneRegistry = {
194
+ protocol: SPLIT_PANE_PROTOCOL,
195
+ version: 2,
196
+ mount(definition) {
197
+ const pane = normalized(definition);
198
+ const state = stateFor(registry);
199
+ const previous = currentPane(state);
200
+ state.panes.push(pane);
201
+ if (currentPane(state) !== previous) notify(state);
202
+ let disposed = false;
203
+ return () => {
204
+ if (disposed) return;
205
+ disposed = true;
206
+ const previous = currentPane(state);
207
+ if (removeIdentity(state.panes, pane) && currentPane(state) !== previous) notify(state);
208
+ };
209
+ },
210
+ current() {
211
+ return currentPane(stateFor(registry));
212
+ },
213
+ subscribe(listener) {
214
+ const state = stateFor(registry);
215
+ state.listeners.push(listener);
216
+ let disposed = false;
217
+ return () => {
218
+ if (disposed) return;
219
+ disposed = true;
220
+ removeIdentity(state.listeners, listener);
221
+ };
222
+ },
223
+ };
224
+ stateFor(registry);
225
+ slots[SPLIT_PANE_REGISTRY_KEY] = registry;
226
+ return registry;
227
+ }
228
+
229
+ /** Mount one pane beside Pi's fullscreen layout until the returned lease is disposed. */
230
+ export function mountSplitPane(definition: SplitPaneDefinition, scope: typeof globalThis): () => void {
231
+ return ensureSplitPaneRegistry(scope).mount(definition);
232
+ }