@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.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/native-revision +1 -0
  4. package/package.json +58 -0
  5. package/src/activity-presentation.ts +75 -0
  6. package/src/appearance.ts +632 -0
  7. package/src/background-surface.ts +125 -0
  8. package/src/color/palette.ts +186 -0
  9. package/src/color/preview.ts +157 -0
  10. package/src/color/resolver.ts +75 -0
  11. package/src/color/theme.ts +531 -0
  12. package/src/component-stack.ts +247 -0
  13. package/src/content/markdown-code.ts +60 -0
  14. package/src/content/terminal-text.ts +135 -0
  15. package/src/content/text.ts +105 -0
  16. package/src/controls/action-panel.ts +420 -0
  17. package/src/controls/dialog-button-bar.ts +287 -0
  18. package/src/controls/multi-select.ts +467 -0
  19. package/src/controls/picker-panel.ts +447 -0
  20. package/src/controls/screen-icon-actions.ts +134 -0
  21. package/src/controls/searchable-select.ts +394 -0
  22. package/src/controls/select-box.ts +224 -0
  23. package/src/controls/selectable-list.ts +433 -0
  24. package/src/controls/selection-action-bar.ts +382 -0
  25. package/src/controls/semantic-input.ts +23 -0
  26. package/src/controls/tab.ts +189 -0
  27. package/src/cursor.ts +288 -0
  28. package/src/decoration/editor-pills.ts +156 -0
  29. package/src/decoration/glyphs.ts +326 -0
  30. package/src/decoration/pointer-interaction.ts +123 -0
  31. package/src/decoration/powerline-pill.ts +104 -0
  32. package/src/decoration/status.ts +185 -0
  33. package/src/decoration/transient-pill.ts +176 -0
  34. package/src/diff/index.ts +21 -0
  35. package/src/diff/model.ts +76 -0
  36. package/src/diff/parse.ts +310 -0
  37. package/src/diff/pierre-syntax.ts +86 -0
  38. package/src/diff/render.ts +504 -0
  39. package/src/diff/structured.ts +264 -0
  40. package/src/diff/view.ts +75 -0
  41. package/src/editor/chrome.ts +341 -0
  42. package/src/editor/composition.ts +53 -0
  43. package/src/editor/layout.ts +81 -0
  44. package/src/editor/presentation.ts +35 -0
  45. package/src/editor/protocol.ts +188 -0
  46. package/src/editor.ts +41 -0
  47. package/src/extension.ts +30 -0
  48. package/src/folding.ts +192 -0
  49. package/src/host/cursor-bridge.ts +210 -0
  50. package/src/host/editor-bridge.ts +148 -0
  51. package/src/host/extension-host.ts +231 -0
  52. package/src/host/mouse-bridge.ts +212 -0
  53. package/src/host/mouse-dispatch.ts +400 -0
  54. package/src/host/mouse-input.ts +85 -0
  55. package/src/host/mouse-targets.ts +238 -0
  56. package/src/host/pi-layout-adapter.ts +298 -0
  57. package/src/host/pi-native-selection.ts +227 -0
  58. package/src/host/split-pane-bridge.ts +720 -0
  59. package/src/host/transcript-bridge.ts +236 -0
  60. package/src/index.ts +334 -0
  61. package/src/line-layout.ts +6 -0
  62. package/src/motion.ts +1409 -0
  63. package/src/mouse/events.ts +55 -0
  64. package/src/mouse/fullscreen-layout.ts +260 -0
  65. package/src/mouse/registry.ts +348 -0
  66. package/src/mouse.ts +37 -0
  67. package/src/native-binary.ts +189 -0
  68. package/src/overlay/anchored.ts +91 -0
  69. package/src/overlay/detail-card.ts +158 -0
  70. package/src/overlay/dialog.ts +266 -0
  71. package/src/overlay/floating.ts +106 -0
  72. package/src/overlay/framed-editor.ts +236 -0
  73. package/src/overlay/fullscreen.ts +170 -0
  74. package/src/overlay/hover-tooltip.ts +109 -0
  75. package/src/overlay/modal-mount.ts +118 -0
  76. package/src/panels.ts +207 -0
  77. package/src/render-cache.ts +80 -0
  78. package/src/render-epoch.ts +48 -0
  79. package/src/request-animation.ts +160 -0
  80. package/src/scrollbar.ts +27 -0
  81. package/src/selection.ts +148 -0
  82. package/src/split-pane.ts +232 -0
  83. package/src/status-presentation.ts +411 -0
  84. package/src/stream.ts +543 -0
  85. package/src/syntax.ts +316 -0
  86. package/src/terminal/bridge-client.ts +243 -0
  87. package/src/terminal/embedding.ts +6 -0
  88. package/src/terminal/output.ts +151 -0
  89. package/src/terminal/projection.ts +362 -0
  90. package/src/terminal/pty-host.ts +263 -0
  91. package/src/terminal/pty-pane.ts +308 -0
  92. package/src/terminal-colors.ts +334 -0
  93. package/src/tool/action.ts +170 -0
  94. package/src/tool/activity.ts +500 -0
  95. package/src/tool/call-preview.ts +44 -0
  96. package/src/tool/disclosure-action.ts +89 -0
  97. package/src/tool/expanded-region-viewport.ts +103 -0
  98. package/src/tool/index.ts +26 -0
  99. package/src/tool/output.ts +271 -0
  100. package/src/tool/transcript.ts +150 -0
  101. package/src/tool/view-region.ts +581 -0
  102. package/test/appearance.test.ts +183 -0
  103. package/test/bounded-stream.test.ts +150 -0
  104. package/test/bridge-client.test.ts +42 -0
  105. package/test/color-preview.test.ts +23 -0
  106. package/test/component-stack.test.ts +206 -0
  107. package/test/components.test.ts +1165 -0
  108. package/test/content-text.test.ts +21 -0
  109. package/test/cursor-bridge.test.ts +151 -0
  110. package/test/cursor.test.ts +147 -0
  111. package/test/editor-chrome.test.ts +119 -0
  112. package/test/editor-layout.test.ts +30 -0
  113. package/test/editor-registry.test.ts +203 -0
  114. package/test/editor-theme.test.ts +33 -0
  115. package/test/editor-token-pill.test.ts +72 -0
  116. package/test/floating-overlay.test.ts +57 -0
  117. package/test/folding.test.ts +147 -0
  118. package/test/harmonious-theme.test.ts +23 -0
  119. package/test/interaction.test.ts +104 -0
  120. package/test/key-icons.test.ts +62 -0
  121. package/test/markdown-code.test.ts +24 -0
  122. package/test/modal-overlay-mount.test.ts +94 -0
  123. package/test/motion.test.ts +649 -0
  124. package/test/mouse/bridge.test.ts +1302 -0
  125. package/test/mouse/extension.test.ts +205 -0
  126. package/test/mouse/registry.test.ts +140 -0
  127. package/test/mouse/screen-decoration.test.ts +167 -0
  128. package/test/native-binary.test.ts +52 -0
  129. package/test/palette.test.ts +32 -0
  130. package/test/panels.test.ts +74 -0
  131. package/test/pointer-components.test.ts +151 -0
  132. package/test/progress.test.ts +54 -0
  133. package/test/pty-host.test.ts +126 -0
  134. package/test/pty-pane.test.ts +62 -0
  135. package/test/public-surface.test.ts +75 -0
  136. package/test/render-cache.test.ts +25 -0
  137. package/test/render-epoch.test.ts +119 -0
  138. package/test/request-animation.test.ts +193 -0
  139. package/test/screen-icon-actions.test.ts +69 -0
  140. package/test/select-box.test.ts +86 -0
  141. package/test/select-pointer.test.ts +369 -0
  142. package/test/selectable-list.test.ts +258 -0
  143. package/test/selection/registry.test.ts +44 -0
  144. package/test/selection-action-bar.test.ts +204 -0
  145. package/test/split-pane-bridge.test.ts +944 -0
  146. package/test/split-pane.test.ts +134 -0
  147. package/test/status-presentation.test.ts +39 -0
  148. package/test/syntax.test.ts +59 -0
  149. package/test/terminal-colors.test.ts +115 -0
  150. package/test/terminal-embedding.test.ts +12 -0
  151. package/test/terminal-projection.test.ts +312 -0
  152. package/test/theme-colors.test.ts +175 -0
  153. package/test/tool-output.test.ts +160 -0
  154. package/test/tool-transcript.test.ts +1190 -0
  155. package/test/unified-diff.test.ts +691 -0
  156. package/themes/harmonious.json +61 -0
  157. package/tsconfig.json +16 -0
@@ -0,0 +1,210 @@
1
+ import { type TUI, TuiAltScreen, TuiMainScreen } from "@earendil-works/pi-tui";
2
+ import type { TuiCursorStyle } from "../appearance.ts";
3
+ import { cursorStyle, findCursorPresentation, isNativeCursorStyle, stripCursorRoleMarkers } from "../cursor.ts";
4
+
5
+ const INSTALLATION_KEY = Symbol.for("pi-libtui/cursor-bridge/v1");
6
+ const PROTOCOL = "pi-libtui/cursor-bridge/v1" as const;
7
+ const RESET_CURSOR_SHAPE = "\x1b[0 q";
8
+
9
+ type CursorPosition = { row: number; col: number } | null;
10
+ type ExtractCursorPosition = (lines: string[], height: number) => CursorPosition;
11
+ type SetShowHardwareCursor = (enabled: boolean) => void;
12
+
13
+ interface CursorState {
14
+ baselineVisible: boolean;
15
+ forcingVisible: boolean;
16
+ internalVisibilityWrite: boolean;
17
+ shape?: TuiCursorStyle;
18
+ }
19
+
20
+ interface CursorBridgeInstallation {
21
+ readonly protocol: typeof PROTOCOL;
22
+ refs: number;
23
+ readonly extract: ExtractCursorPosition;
24
+ readonly setVisible: SetShowHardwareCursor;
25
+ readonly states: Map<object, CursorState>;
26
+ readonly originalExtract: ExtractCursorPosition;
27
+ readonly originalSetVisible: SetShowHardwareCursor;
28
+ dispose(): void;
29
+ }
30
+
31
+ // type-boundary: Pi 0.84.2 owns renderer prototypes; these checks narrow the
32
+ // cursor extraction and visibility methods before the compatibility patch.
33
+ type PiPrivateValue = unknown;
34
+
35
+ function installation(value: PiPrivateValue): CursorBridgeInstallation | undefined {
36
+ if (!value || typeof value !== "object") return undefined;
37
+ const candidate = value as Partial<CursorBridgeInstallation>;
38
+ return candidate.protocol === PROTOCOL &&
39
+ typeof candidate.extract === "function" &&
40
+ typeof candidate.setVisible === "function" &&
41
+ candidate.states instanceof Map
42
+ ? (candidate as CursorBridgeInstallation)
43
+ : undefined;
44
+ }
45
+
46
+ function methodOwner(value: object, property: string): object | undefined {
47
+ let candidate: object | null = value;
48
+ while (candidate) {
49
+ if (Reflect.getOwnPropertyDescriptor(candidate, property)) return candidate;
50
+ candidate = Reflect.getPrototypeOf(candidate) as object | null;
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ function getHardwareCursor(renderer: object): boolean {
56
+ const read = Reflect.get(renderer, "getShowHardwareCursor") as PiPrivateValue;
57
+ return typeof read === "function" && Reflect.apply(read, renderer, []) === true;
58
+ }
59
+
60
+ function writeTerminal(renderer: object, data: string): void {
61
+ const terminal = Reflect.get(renderer, "terminal") as PiPrivateValue;
62
+ if (!terminal || typeof terminal !== "object") return;
63
+ const write = Reflect.get(terminal, "write") as PiPrivateValue;
64
+ if (typeof write === "function") Reflect.apply(write, terminal, [data]);
65
+ }
66
+
67
+ function cursorShapeSequence(style: TuiCursorStyle): string {
68
+ const parameter =
69
+ style === "terminal-default"
70
+ ? 0
71
+ : style === "blinking-block"
72
+ ? 1
73
+ : style === "steady-block"
74
+ ? 2
75
+ : style === "blinking-underline"
76
+ ? 3
77
+ : style === "steady-underline"
78
+ ? 4
79
+ : style === "blinking-bar"
80
+ ? 5
81
+ : 6;
82
+ return `\x1b[${parameter} q`;
83
+ }
84
+
85
+ function stateFor(bridge: CursorBridgeInstallation, renderer: object): CursorState {
86
+ let state = bridge.states.get(renderer);
87
+ if (state) return state;
88
+ let previous: CursorState | undefined;
89
+ for (const candidate of bridge.states.values()) {
90
+ if (candidate.forcingVisible) previous = candidate;
91
+ }
92
+ if (previous) previous.forcingVisible = false;
93
+ state = {
94
+ // Pi copies effective visibility into a replacement renderer during a mode
95
+ // switch. Carry the original preference alongside that temporary force.
96
+ baselineVisible: previous?.baselineVisible ?? getHardwareCursor(renderer),
97
+ forcingVisible: previous !== undefined,
98
+ internalVisibilityWrite: false,
99
+ };
100
+ bridge.states.set(renderer, state);
101
+ return state;
102
+ }
103
+
104
+ function setVisible(bridge: CursorBridgeInstallation, renderer: object, state: CursorState, enabled: boolean): void {
105
+ state.internalVisibilityWrite = true;
106
+ try {
107
+ Reflect.apply(bridge.originalSetVisible, renderer, [enabled]);
108
+ } finally {
109
+ state.internalVisibilityWrite = false;
110
+ }
111
+ }
112
+
113
+ function applyCursorPresentation(
114
+ bridge: CursorBridgeInstallation,
115
+ renderer: object,
116
+ presentation: ReturnType<typeof findCursorPresentation>,
117
+ ): void {
118
+ const state = stateFor(bridge, renderer);
119
+ const style = presentation
120
+ ? "style" in presentation
121
+ ? presentation.style
122
+ : cursorStyle(presentation.role)
123
+ : "virtual";
124
+ if (isNativeCursorStyle(style)) {
125
+ state.forcingVisible = true;
126
+ setVisible(bridge, renderer, state, true);
127
+ if (state.shape !== style) {
128
+ writeTerminal(renderer, cursorShapeSequence(style));
129
+ state.shape = style;
130
+ }
131
+ return;
132
+ }
133
+
134
+ if (state.forcingVisible) setVisible(bridge, renderer, state, state.baselineVisible);
135
+ state.forcingVisible = false;
136
+ if (state.shape !== undefined) {
137
+ writeTerminal(renderer, RESET_CURSOR_SHAPE);
138
+ state.shape = undefined;
139
+ }
140
+ }
141
+
142
+ function installPrototype(prototype: object): () => void {
143
+ const existing = installation(Reflect.get(prototype, INSTALLATION_KEY) as PiPrivateValue);
144
+ if (existing) {
145
+ existing.refs += 1;
146
+ return () => existing.dispose();
147
+ }
148
+
149
+ const originalExtractValue = Reflect.get(prototype, "extractCursorPosition") as PiPrivateValue;
150
+ const originalSetValue = Reflect.get(prototype, "setShowHardwareCursor") as PiPrivateValue;
151
+ if (typeof originalExtractValue !== "function" || typeof originalSetValue !== "function") return () => {};
152
+ const originalExtract = originalExtractValue as ExtractCursorPosition;
153
+ const originalSetVisible = originalSetValue as SetShowHardwareCursor;
154
+ let bridge: CursorBridgeInstallation;
155
+
156
+ const extract: ExtractCursorPosition = function (this: object, lines, height) {
157
+ const presentation = findCursorPresentation(lines, height);
158
+ for (let index = 0; index < lines.length; index += 1) lines[index] = stripCursorRoleMarkers(lines[index] ?? "");
159
+ applyCursorPresentation(bridge, this, presentation);
160
+ return Reflect.apply(originalExtract, this, [lines, height]) as CursorPosition;
161
+ };
162
+ const setHardwareCursor: SetShowHardwareCursor = function (this: object, enabled) {
163
+ const state = stateFor(bridge, this);
164
+ if (!state.internalVisibilityWrite) state.baselineVisible = enabled;
165
+ Reflect.apply(originalSetVisible, this, [state.forcingVisible ? true : enabled]);
166
+ };
167
+
168
+ bridge = {
169
+ protocol: PROTOCOL,
170
+ refs: 1,
171
+ extract,
172
+ setVisible: setHardwareCursor,
173
+ states: new Map(),
174
+ originalExtract,
175
+ originalSetVisible,
176
+ dispose() {
177
+ this.refs -= 1;
178
+ if (this.refs > 0) return;
179
+ for (const [renderer, state] of this.states) {
180
+ if (state.shape !== undefined) writeTerminal(renderer, RESET_CURSOR_SHAPE);
181
+ setVisible(this, renderer, state, state.baselineVisible);
182
+ }
183
+ this.states.clear();
184
+ if (Reflect.get(prototype, "extractCursorPosition") === this.extract) {
185
+ Reflect.set(prototype, "extractCursorPosition", this.originalExtract);
186
+ }
187
+ if (Reflect.get(prototype, "setShowHardwareCursor") === this.setVisible) {
188
+ Reflect.set(prototype, "setShowHardwareCursor", this.originalSetVisible);
189
+ }
190
+ Reflect.set(prototype, INSTALLATION_KEY, undefined);
191
+ },
192
+ };
193
+ Reflect.set(prototype, "extractCursorPosition", extract);
194
+ Reflect.set(prototype, "setShowHardwareCursor", setHardwareCursor);
195
+ Reflect.set(prototype, INSTALLATION_KEY, bridge);
196
+ return () => bridge.dispose();
197
+ }
198
+
199
+ /** Install libtui's semantic cursor bridge across Pi's renderer modes. */
200
+ export function installCursorBridge(tui: TUI): () => void {
201
+ const prototypes = new Set<object>();
202
+ for (const candidate of [tui as object, TuiMainScreen.prototype, TuiAltScreen.prototype]) {
203
+ const owner = methodOwner(candidate, "extractCursorPosition");
204
+ if (owner) prototypes.add(owner);
205
+ }
206
+ const disposers = [...prototypes].map(installPrototype);
207
+ return () => {
208
+ for (const dispose of disposers) dispose();
209
+ };
210
+ }
@@ -0,0 +1,148 @@
1
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
2
+ import { dispatchEditorPaste, dispatchEditorRender, type EditorRegistry } from "../editor.ts";
3
+
4
+ const BRIDGE_KEY = Symbol.for("pi-libtui/editor/custom-editor-bridge/v1");
5
+ const BRIDGE_PROTOCOL = "pi-libtui/editor/custom-editor-bridge/v1" as const;
6
+ const BRACKETED_PASTE = /^\x1b\[200~([\s\S]*)\x1b\[201~$/;
7
+
8
+ type EditorPrototype = CustomEditor["constructor"]["prototype"];
9
+ type HandleInput = EditorPrototype["handleInput"];
10
+ type InsertTextAtCursor = EditorPrototype["insertTextAtCursor"];
11
+ type Render = EditorPrototype["render"];
12
+
13
+ interface OriginalMethod<T> {
14
+ readonly descriptor: PropertyDescriptor | undefined;
15
+ readonly method: T;
16
+ }
17
+
18
+ interface EditorBridgeState {
19
+ readonly protocol: typeof BRIDGE_PROTOCOL;
20
+ leases: number;
21
+ readonly handleInput: OriginalMethod<HandleInput>;
22
+ readonly insertTextAtCursor: OriginalMethod<InsertTextAtCursor>;
23
+ readonly render: OriginalMethod<Render>;
24
+ readonly wrappedHandleInput: HandleInput;
25
+ readonly wrappedInsertTextAtCursor: InsertTextAtCursor;
26
+ readonly wrappedRender: Render;
27
+ }
28
+
29
+ // type-boundary: Symbol.for bridge state can come from another installed pi-libtui copy; this validator narrows its lifecycle fields.
30
+ type UntrustedBridgeState = unknown;
31
+
32
+ function bridgeState(value: UntrustedBridgeState): EditorBridgeState | undefined {
33
+ if (!value || typeof value !== "object") return undefined;
34
+ const candidate = value as Partial<EditorBridgeState>;
35
+ if (
36
+ candidate.protocol !== BRIDGE_PROTOCOL ||
37
+ typeof candidate.leases !== "number" ||
38
+ typeof candidate.wrappedHandleInput !== "function" ||
39
+ typeof candidate.wrappedInsertTextAtCursor !== "function" ||
40
+ typeof candidate.wrappedRender !== "function"
41
+ ) {
42
+ return undefined;
43
+ }
44
+ return candidate as EditorBridgeState;
45
+ }
46
+
47
+ function originalMethod<K extends "handleInput" | "insertTextAtCursor" | "render">(
48
+ prototype: EditorPrototype,
49
+ key: K,
50
+ ): OriginalMethod<EditorPrototype[K]> {
51
+ return {
52
+ descriptor: Object.getOwnPropertyDescriptor(prototype, key),
53
+ method: prototype[key],
54
+ };
55
+ }
56
+
57
+ function installMethod<K extends "handleInput" | "insertTextAtCursor" | "render">(
58
+ prototype: EditorPrototype,
59
+ key: K,
60
+ method: EditorPrototype[K],
61
+ ): void {
62
+ Object.defineProperty(prototype, key, {
63
+ configurable: true,
64
+ enumerable: false,
65
+ writable: true,
66
+ value: method,
67
+ });
68
+ }
69
+
70
+ function restoreMethod<K extends "handleInput" | "insertTextAtCursor" | "render">(
71
+ prototype: EditorPrototype,
72
+ key: K,
73
+ original: OriginalMethod<EditorPrototype[K]>,
74
+ wrapper: EditorPrototype[K],
75
+ ): void {
76
+ if (prototype[key] !== wrapper) return;
77
+ if (original.descriptor) {
78
+ Object.defineProperty(prototype, key, original.descriptor);
79
+ } else {
80
+ Reflect.deleteProperty(prototype, key);
81
+ }
82
+ }
83
+
84
+ /** Install the extension-owned CustomEditor adapter for one active Pi session. */
85
+ export function installEditorBridge(registry: EditorRegistry): () => void {
86
+ const prototype = CustomEditor.prototype;
87
+ const active = bridgeState(Reflect.get(prototype, BRIDGE_KEY));
88
+ if (active) {
89
+ active.leases += 1;
90
+ return releaseBridge(prototype, active);
91
+ }
92
+
93
+ const handleInput = originalMethod(prototype, "handleInput");
94
+ const insertTextAtCursor = originalMethod(prototype, "insertTextAtCursor");
95
+ const render = originalMethod(prototype, "render");
96
+
97
+ const wrappedHandleInput: HandleInput = function libtuiHandleInput(this: CustomEditor, data: string): void {
98
+ const pasted = data.match(BRACKETED_PASTE)?.[1];
99
+ if (pasted !== undefined) {
100
+ const replacement = dispatchEditorPaste(registry, pasted);
101
+ if (replacement !== undefined) {
102
+ insertTextAtCursor.method.call(this, replacement);
103
+ return;
104
+ }
105
+ }
106
+ handleInput.method.call(this, data);
107
+ };
108
+ const wrappedInsertTextAtCursor: InsertTextAtCursor = function libtuiInsertTextAtCursor(
109
+ this: CustomEditor,
110
+ text: string,
111
+ ): void {
112
+ insertTextAtCursor.method.call(this, dispatchEditorPaste(registry, text) ?? text);
113
+ };
114
+ const wrappedRender: Render = function libtuiRender(this: CustomEditor, width: number): string[] {
115
+ return dispatchEditorRender(registry, render.method.call(this, width), width);
116
+ };
117
+
118
+ const state: EditorBridgeState = {
119
+ protocol: BRIDGE_PROTOCOL,
120
+ leases: 1,
121
+ handleInput,
122
+ insertTextAtCursor,
123
+ render,
124
+ wrappedHandleInput,
125
+ wrappedInsertTextAtCursor,
126
+ wrappedRender,
127
+ };
128
+ Object.defineProperty(prototype, BRIDGE_KEY, { configurable: true, value: state });
129
+ installMethod(prototype, "handleInput", wrappedHandleInput);
130
+ installMethod(prototype, "insertTextAtCursor", wrappedInsertTextAtCursor);
131
+ installMethod(prototype, "render", wrappedRender);
132
+ return releaseBridge(prototype, state);
133
+ }
134
+
135
+ function releaseBridge(prototype: EditorPrototype, state: EditorBridgeState): () => void {
136
+ let active = true;
137
+ return () => {
138
+ if (!active) return;
139
+ active = false;
140
+ state.leases -= 1;
141
+ if (state.leases > 0) return;
142
+
143
+ restoreMethod(prototype, "handleInput", state.handleInput, state.wrappedHandleInput);
144
+ restoreMethod(prototype, "insertTextAtCursor", state.insertTextAtCursor, state.wrappedInsertTextAtCursor);
145
+ restoreMethod(prototype, "render", state.render, state.wrappedRender);
146
+ if (bridgeState(Reflect.get(prototype, BRIDGE_KEY)) === state) Reflect.deleteProperty(prototype, BRIDGE_KEY);
147
+ };
148
+ }
@@ -0,0 +1,231 @@
1
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component, TUI } from "@earendil-works/pi-tui";
3
+ import { subscribeTuiAppearance } from "../appearance.ts";
4
+ import { renderEditorPasteMarkerPills } from "../decoration/editor-pills.ts";
5
+ import { ensureEditorRegistry } from "../editor.ts";
6
+ import { ensureMouseRegistry } from "../mouse.ts";
7
+ import { sharedMotionScheduler } from "../motion.ts";
8
+ import { ensureSplitPaneRegistry } from "../split-pane.ts";
9
+ import { measureTerminalColors, terminalColorsRegistry } from "../terminal-colors.ts";
10
+ import { shutdownPtyHost } from "../terminal/pty-host.ts";
11
+ import { installCursorBridge } from "./cursor-bridge.ts";
12
+ import { installEditorBridge } from "./editor-bridge.ts";
13
+ import { installMouseBridge } from "./mouse-bridge.ts";
14
+ import { installSplitPaneBridge } from "./split-pane-bridge.ts";
15
+
16
+ const WIDGET_KEY = "pi-libtui.mouse-bridge";
17
+ const HOST_CAPABILITY_KEY = Symbol.for("pi-libtui/extension-host/v1");
18
+ const HOST_PROTOCOL = "pi-libtui/extension-host/v1" as const;
19
+
20
+ class LibtuiHostWidget implements Component {
21
+ private mode: TUI["mode"];
22
+ private removeMouseBridge: () => void;
23
+ private removeCursorBridge: () => void;
24
+ private removeSplitPaneBridge: () => void;
25
+ private readonly removeAppearanceSubscription: () => void;
26
+ private readonly removeColorSubscription: () => void;
27
+ private readonly removeFocusSubscription: () => void;
28
+ private terminalColorsReady = false;
29
+ private fallbackTheme: Theme | undefined;
30
+ private disposed = false;
31
+
32
+ constructor(
33
+ private readonly tui: TUI,
34
+ private readonly ui: ExtensionContext["ui"],
35
+ ) {
36
+ this.mode = tui.mode;
37
+ this.removeMouseBridge = installMouseBridge(tui, ensureMouseRegistry());
38
+ this.removeCursorBridge = installCursorBridge(tui);
39
+ this.removeSplitPaneBridge = installSplitPaneBridge(
40
+ tui,
41
+ () => this.ui.theme,
42
+ ensureSplitPaneRegistry(),
43
+ ensureMouseRegistry(),
44
+ );
45
+ this.removeAppearanceSubscription = subscribeTuiAppearance(() => tui.requestRender());
46
+ this.removeColorSubscription = terminalColorsRegistry().subscribe(() => tui.requestRender());
47
+ this.removeFocusSubscription = tui.addInputListener((data) => {
48
+ const focused = data === "\x1b[I" ? true : data === "\x1b[O" ? false : undefined;
49
+ if (focused !== undefined) sharedMotionScheduler.setPaused(!focused);
50
+ return undefined;
51
+ });
52
+ void this.loadTerminalColors();
53
+ }
54
+
55
+ render(): string[] {
56
+ if (this.tui.mode !== this.mode) {
57
+ // Acquire the new renderer leases before releasing the old ones so a mode
58
+ // switch never briefly removes shared prototype bridges.
59
+ const removeMouseBridge = installMouseBridge(this.tui, ensureMouseRegistry());
60
+ const removeCursorBridge = installCursorBridge(this.tui);
61
+ const removeSplitPaneBridge = installSplitPaneBridge(
62
+ this.tui,
63
+ () => this.ui.theme,
64
+ ensureSplitPaneRegistry(),
65
+ ensureMouseRegistry(),
66
+ );
67
+ const previousMouseBridge = this.removeMouseBridge;
68
+ const previousCursorBridge = this.removeCursorBridge;
69
+ const previousSplitPaneBridge = this.removeSplitPaneBridge;
70
+ this.mode = this.tui.mode;
71
+ this.removeMouseBridge = removeMouseBridge;
72
+ this.removeCursorBridge = removeCursorBridge;
73
+ this.removeSplitPaneBridge = removeSplitPaneBridge;
74
+ previousSplitPaneBridge();
75
+ previousCursorBridge();
76
+ previousMouseBridge();
77
+ }
78
+ this.applyHarmoniousFallback();
79
+ return [];
80
+ }
81
+
82
+ invalidate(): void {}
83
+
84
+ dispose(): void {
85
+ this.disposed = true;
86
+ this.removeMouseBridge();
87
+ this.removeCursorBridge();
88
+ this.removeSplitPaneBridge();
89
+ this.removeAppearanceSubscription();
90
+ this.removeColorSubscription();
91
+ this.removeFocusSubscription();
92
+ sharedMotionScheduler.setPaused(false);
93
+ }
94
+
95
+ private async loadTerminalColors(): Promise<void> {
96
+ let profile: Awaited<ReturnType<typeof measureTerminalColors>>;
97
+ try {
98
+ profile = await measureTerminalColors(this.tui);
99
+ } catch {
100
+ profile = { scheme: "dark", indexedPalette: "unknown" };
101
+ }
102
+ if (this.disposed) return;
103
+ terminalColorsRegistry().publish(profile);
104
+ this.terminalColorsReady = true;
105
+ this.fallbackTheme = this.ui.getTheme(profile.scheme);
106
+ this.applyHarmoniousFallback();
107
+ }
108
+
109
+ private applyHarmoniousFallback(): void {
110
+ if (!this.terminalColorsReady || this.ui.theme.name !== "harmonious") return;
111
+ const terminal = terminalColorsRegistry().current();
112
+ if (terminal?.indexedPalette === "generated" || terminal?.ansiBase16) return;
113
+ if (this.fallbackTheme) this.ui.setTheme(this.fallbackTheme);
114
+ }
115
+ }
116
+
117
+ interface HostRegistration {
118
+ readonly protocol: typeof HOST_PROTOCOL;
119
+ start(ctx: ExtensionContext): void;
120
+ shutdown(ctx: ExtensionContext, preservePtys: boolean): Promise<void>;
121
+ }
122
+
123
+ interface HostCapability {
124
+ readonly protocol: typeof HOST_PROTOCOL;
125
+ readonly version: 1;
126
+ get(key: object): HostRegistration | undefined;
127
+ set(key: object, registration: HostRegistration): void;
128
+ delete(key: object): void;
129
+ }
130
+
131
+ // type-boundary: Symbol.for capabilities can be populated by another extension realm; this validator narrows the host registry methods.
132
+ type UntrustedHostValue = unknown;
133
+
134
+ function isHostCapability(value: UntrustedHostValue): value is HostCapability {
135
+ if (!value || typeof value !== "object") return false;
136
+ const candidate = value as Partial<HostCapability>;
137
+ return (
138
+ candidate.protocol === HOST_PROTOCOL &&
139
+ candidate.version === 1 &&
140
+ typeof candidate.get === "function" &&
141
+ typeof candidate.set === "function" &&
142
+ typeof candidate.delete === "function"
143
+ );
144
+ }
145
+
146
+ function ensureHostCapability(scope: typeof globalThis = globalThis): HostCapability {
147
+ const slots = scope as Record<PropertyKey, UntrustedHostValue>;
148
+ const existing = slots[HOST_CAPABILITY_KEY];
149
+ if (isHostCapability(existing)) return existing;
150
+
151
+ const hosts = new WeakMap<object, HostRegistration>();
152
+ const capability: HostCapability = {
153
+ protocol: HOST_PROTOCOL,
154
+ version: 1,
155
+ get: (key) => hosts.get(key),
156
+ set: (key, registration) => hosts.set(key, registration),
157
+ delete: (key) => hosts.delete(key),
158
+ };
159
+ slots[HOST_CAPABILITY_KEY] = capability;
160
+ return capability;
161
+ }
162
+
163
+ function hostIdentity(pi: ExtensionAPI): object {
164
+ const events = pi.events;
165
+ return events && typeof events === "object" ? events : (pi as object);
166
+ }
167
+
168
+ function createHostRegistration(): HostRegistration {
169
+ const editorRegistry = ensureEditorRegistry();
170
+ let activeSession:
171
+ | {
172
+ readonly sessionManager: ExtensionContext["sessionManager"];
173
+ readonly ui: ExtensionContext["ui"];
174
+ readonly removeEditorBridge: () => void;
175
+ readonly removeEditorDecorator: () => void;
176
+ }
177
+ | undefined;
178
+
179
+ function clearSession(): void {
180
+ const session = activeSession;
181
+ if (!session) return;
182
+ activeSession = undefined;
183
+ session.removeEditorDecorator();
184
+ session.removeEditorBridge();
185
+ session.ui.setWidget(WIDGET_KEY, undefined);
186
+ }
187
+
188
+ return {
189
+ protocol: HOST_PROTOCOL,
190
+ start(ctx) {
191
+ if (ctx.mode !== "tui" || !ctx.hasUI) return;
192
+ clearSession();
193
+ const removeEditorBridge = installEditorBridge(editorRegistry);
194
+ const removeEditorDecorator = editorRegistry.registerRenderDecorator({
195
+ id: "pi-libtui.native-paste-markers",
196
+ decorate: (lines, width) => renderEditorPasteMarkerPills(lines, width, ctx.ui.theme).lines,
197
+ });
198
+ activeSession = { sessionManager: ctx.sessionManager, ui: ctx.ui, removeEditorBridge, removeEditorDecorator };
199
+ // A zero-height widget obtains Pi's stable TUI reference without changing the existing spacer row.
200
+ ctx.ui.setWidget(WIDGET_KEY, (tui) => new LibtuiHostWidget(tui, ctx.ui));
201
+ },
202
+ async shutdown(ctx, preservePtys) {
203
+ if (ctx.mode !== "tui" || !ctx.hasUI || activeSession?.sessionManager !== ctx.sessionManager) return;
204
+ clearSession();
205
+ if (!preservePtys) await shutdownPtyHost();
206
+ },
207
+ };
208
+ }
209
+
210
+ export interface LibtuiExtensionHost {
211
+ start(ctx: ExtensionContext): void;
212
+ shutdown(ctx: ExtensionContext, preservePtys: boolean): Promise<void>;
213
+ release(): void;
214
+ }
215
+
216
+ /** Claim the generic host once for all installed copies of pi-libtui. */
217
+ export function claimLibtuiExtensionHost(pi: ExtensionAPI): LibtuiExtensionHost | undefined {
218
+ const capability = ensureHostCapability();
219
+ const key = hostIdentity(pi);
220
+ if (capability.get(key)) return undefined;
221
+
222
+ const registration = createHostRegistration();
223
+ capability.set(key, registration);
224
+ return {
225
+ start: (ctx) => registration.start(ctx),
226
+ shutdown: (ctx, preservePtys) => registration.shutdown(ctx, preservePtys),
227
+ release() {
228
+ if (capability.get(key) === registration) capability.delete(key);
229
+ },
230
+ };
231
+ }