@narumitw/pi-btw 0.58.1 → 0.59.0

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.
@@ -1,213 +1,203 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import {
3
- copyToClipboard as copyToHostClipboard,
4
- type ExtensionCommandContext,
5
- type KeybindingsManager,
6
- type Theme,
3
+ copyToClipboard as copyToHostClipboard,
4
+ type ExtensionCommandContext,
5
+ type KeybindingsManager,
6
+ type Theme,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import {
9
- type Component,
10
- isKeyRelease,
11
- isKittyProtocolActive,
12
- Key,
13
- type OverlayHandle,
14
- parseKey,
15
- type TUI,
16
- TuiAltScreen,
17
- type TuiInputListener,
18
- type TuiInputListenerResult,
19
- truncateToWidth,
9
+ type Component,
10
+ isKeyRelease,
11
+ isKittyProtocolActive,
12
+ Key,
13
+ type OverlayHandle,
14
+ parseKey,
15
+ type TUI,
16
+ TuiAltScreen,
17
+ type TuiInputListener,
18
+ type TuiInputListenerResult,
19
+ truncateToWidth,
20
20
  } from "@earendil-works/pi-tui";
21
- import {
22
- type BtwKeybindingOverrides,
23
- BtwPasteGuard,
24
- resolveBtwShortcuts,
25
- setBtwShortcuts,
26
- } from "./keybindings.js";
21
+ import { type BtwKeybindingOverrides, BtwPasteGuard, resolveBtwShortcuts, setBtwShortcuts } from "./keybindings.js";
27
22
  import { formatKeyLabel, sanitizeSingleLine } from "./text.js";
28
23
 
29
24
  type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
30
25
  type BtwCustomFactory<T> = (
31
- tui: TUI,
32
- theme: Theme,
33
- keybindings: KeybindingsManager,
34
- done: (result: T) => void,
26
+ tui: TUI,
27
+ theme: Theme,
28
+ keybindings: KeybindingsManager,
29
+ done: (result: T) => void,
35
30
  ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>;
36
31
 
37
32
  type BtwFullscreenTui = TUI & {
38
- flash?: (message: string, durationMs?: number) => void;
39
- setLayoutRoot(component: Component | undefined): void;
40
- addInputListenerBeforeAll?(listener: TuiInputListener): () => void;
41
- addInputListenerBeforeViewport?(listener: TuiInputListener): () => void;
33
+ flash?: (message: string, durationMs?: number) => void;
34
+ setLayoutRoot(component: Component | undefined): void;
35
+ addInputListenerBeforeAll?(listener: TuiInputListener): () => void;
36
+ addInputListenerBeforeViewport?(listener: TuiInputListener): () => void;
42
37
  };
43
38
 
44
39
  export interface BtwFullscreenLayoutComponent extends Component {
45
- getFullscreenLayout(): Component;
40
+ getFullscreenLayout(): Component;
46
41
  }
47
42
 
48
43
  export interface BtwFullscreenOptions {
49
- keybindings?: BtwKeybindingOverrides;
50
- copyOnSelect?: boolean;
44
+ keybindings?: BtwKeybindingOverrides;
45
+ copyOnSelect?: boolean;
51
46
  }
52
47
 
53
48
  export type BtwFullscreenTuiFactory = (
54
- parent: TUI,
55
- theme: Theme,
56
- keybindings: KeybindingsManager,
57
- options: BtwFullscreenOptions,
49
+ parent: TUI,
50
+ theme: Theme,
51
+ keybindings: KeybindingsManager,
52
+ options: BtwFullscreenOptions,
58
53
  ) => BtwFullscreenTui;
59
54
 
60
55
  export interface BtwFullscreenDependencies {
61
- createTui?: BtwFullscreenTuiFactory;
62
- openUrl?: (url: string) => void;
63
- copyToClipboard?: (text: string) => Promise<void>;
64
- manualSelectionCopySupported?: boolean;
56
+ createTui?: BtwFullscreenTuiFactory;
57
+ openUrl?: (url: string) => void;
58
+ copyToClipboard?: (text: string) => Promise<void>;
59
+ manualSelectionCopySupported?: boolean;
65
60
  }
66
61
 
67
62
  export type RunBtwFullscreen = <T>(
68
- ctx: ExtensionCommandContext,
69
- run: (ctx: ExtensionCommandContext) => Promise<T>,
70
- options?: BtwFullscreenOptions,
63
+ ctx: ExtensionCommandContext,
64
+ run: (ctx: ExtensionCommandContext) => Promise<T>,
65
+ options?: BtwFullscreenOptions,
71
66
  ) => Promise<T>;
72
67
 
73
68
  type FullscreenOutcome<T> = { kind: "completed"; value: T } | { kind: "failed"; error: unknown };
74
69
 
75
70
  class FullscreenUiDisposedError extends Error {
76
- constructor() {
77
- super("The dedicated pi-btw UI was disposed.");
78
- this.name = "FullscreenUiDisposedError";
79
- }
71
+ constructor() {
72
+ super("The dedicated pi-btw UI was disposed.");
73
+ this.name = "FullscreenUiDisposedError";
74
+ }
80
75
  }
81
76
 
82
77
  export async function runBtwFullscreen<T>(
83
- ctx: ExtensionCommandContext,
84
- run: (ctx: ExtensionCommandContext) => Promise<T>,
85
- options: BtwFullscreenOptions = {},
86
- dependencies: BtwFullscreenDependencies = {},
78
+ ctx: ExtensionCommandContext,
79
+ run: (ctx: ExtensionCommandContext) => Promise<T>,
80
+ options: BtwFullscreenOptions = {},
81
+ dependencies: BtwFullscreenDependencies = {},
87
82
  ): Promise<T> {
88
- const createTui =
89
- dependencies.createTui ??
90
- ((
91
- parent: TUI,
92
- theme: Theme,
93
- keybindings: KeybindingsManager,
94
- fullscreenOptions: BtwFullscreenOptions,
95
- ) =>
96
- createBtwFullscreenTui(
97
- parent,
98
- theme,
99
- keybindings,
100
- fullscreenOptions.copyOnSelect ?? true,
101
- dependencies.manualSelectionCopySupported ?? hasManualSelectionCopyApi(),
102
- dependencies.openUrl ?? openUrlInBrowser,
103
- dependencies.copyToClipboard ?? copyToHostClipboard,
104
- ));
105
- let liveEditorText = ctx.ui.getEditorText();
106
- let restoreEditor = false;
107
- let host: BtwFullscreenHost<T> | undefined;
108
- const outcome = await ctx.ui.custom<FullscreenOutcome<T>>(
109
- (parent, theme, keybindings, done) => {
110
- host = new BtwFullscreenHost(
111
- parent,
112
- theme,
113
- keybindings,
114
- ctx,
115
- run,
116
- (value) => {
117
- try {
118
- liveEditorText = ctx.ui.getEditorText();
119
- restoreEditor = true;
120
- } catch {
121
- // A replaced session owns a different editor and must not receive stale text.
122
- }
123
- done(value);
124
- },
125
- createTui,
126
- options,
127
- );
128
- return host;
129
- },
130
- {
131
- overlay: true,
132
- onHandle: (handle) => host?.setParentOverlay(handle),
133
- },
134
- );
135
- if (restoreEditor) {
136
- try {
137
- if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
138
- } catch {
139
- // A replaced session owns a different editor and must not receive stale restoration.
140
- }
141
- }
142
- if (outcome.kind === "failed") throw outcome.error;
143
- return outcome.value;
83
+ const createTui =
84
+ dependencies.createTui ??
85
+ ((parent: TUI, theme: Theme, keybindings: KeybindingsManager, fullscreenOptions: BtwFullscreenOptions) =>
86
+ createBtwFullscreenTui(
87
+ parent,
88
+ theme,
89
+ keybindings,
90
+ fullscreenOptions.copyOnSelect ?? true,
91
+ dependencies.manualSelectionCopySupported ?? hasManualSelectionCopyApi(),
92
+ dependencies.openUrl ?? openUrlInBrowser,
93
+ dependencies.copyToClipboard ?? copyToHostClipboard,
94
+ ));
95
+ let liveEditorText = ctx.ui.getEditorText();
96
+ let restoreEditor = false;
97
+ let host: BtwFullscreenHost<T> | undefined;
98
+ const outcome = await ctx.ui.custom<FullscreenOutcome<T>>(
99
+ (parent, theme, keybindings, done) => {
100
+ host = new BtwFullscreenHost(
101
+ parent,
102
+ theme,
103
+ keybindings,
104
+ ctx,
105
+ run,
106
+ (value) => {
107
+ try {
108
+ liveEditorText = ctx.ui.getEditorText();
109
+ restoreEditor = true;
110
+ } catch {
111
+ // A replaced session owns a different editor and must not receive stale text.
112
+ }
113
+ done(value);
114
+ },
115
+ createTui,
116
+ options,
117
+ );
118
+ return host;
119
+ },
120
+ {
121
+ overlay: true,
122
+ onHandle: (handle) => host?.setParentOverlay(handle),
123
+ },
124
+ );
125
+ if (restoreEditor) {
126
+ try {
127
+ if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
128
+ } catch {
129
+ // A replaced session owns a different editor and must not receive stale restoration.
130
+ }
131
+ }
132
+ if (outcome.kind === "failed") throw outcome.error;
133
+ return outcome.value;
144
134
  }
145
135
 
146
136
  type BtwInputListeners = {
147
- beforeAll: Set<TuiInputListener>;
148
- beforeViewport: Set<TuiInputListener>;
149
- regular: Set<TuiInputListener>;
137
+ beforeAll: Set<TuiInputListener>;
138
+ beforeViewport: Set<TuiInputListener>;
139
+ regular: Set<TuiInputListener>;
150
140
  };
151
141
 
152
142
  const btwInputListeners = new WeakMap<BtwTuiAltScreen, BtwInputListeners>();
153
143
 
154
144
  function dispatchBtwInput(listeners: BtwInputListeners, data: string): TuiInputListenerResult {
155
- let current = data;
156
- for (const group of [listeners.beforeAll, listeners.beforeViewport, listeners.regular]) {
157
- for (const listener of group) {
158
- const result = listener(current);
159
- if (result?.consume) return result;
160
- if (result?.data !== undefined) current = result.data;
161
- }
162
- }
163
- return current === data ? undefined : { data: current };
145
+ let current = data;
146
+ for (const group of [listeners.beforeAll, listeners.beforeViewport, listeners.regular]) {
147
+ for (const listener of group) {
148
+ const result = listener(current);
149
+ if (result?.consume) return result;
150
+ if (result?.data !== undefined) current = result.data;
151
+ }
152
+ }
153
+ return current === data ? undefined : { data: current };
164
154
  }
165
155
 
166
156
  class BtwTuiAltScreen extends TuiAltScreen {
167
- hasFocusedOverlay(): boolean {
168
- return this.isOverlayFocused();
169
- }
170
-
171
- override addInputListener(listener: TuiInputListener): () => void {
172
- let listeners = btwInputListeners.get(this);
173
- if (!listeners) {
174
- const registeredListeners: BtwInputListeners = {
175
- beforeAll: new Set(),
176
- beforeViewport: new Set(),
177
- regular: new Set(),
178
- };
179
- btwInputListeners.set(this, registeredListeners);
180
- super.addInputListener((data) => dispatchBtwInput(registeredListeners, data));
181
- listeners = registeredListeners;
182
- }
183
- listeners.regular.add(listener);
184
- return () => listeners.regular.delete(listener);
185
- }
186
-
187
- addInputListenerBeforeAll(listener: TuiInputListener): () => void {
188
- const listeners = btwInputListeners.get(this);
189
- if (!listeners) return super.addInputListener(listener);
190
- listeners.beforeAll.add(listener);
191
- return () => listeners.beforeAll.delete(listener);
192
- }
193
-
194
- addInputListenerBeforeViewport(listener: TuiInputListener): () => void {
195
- const listeners = btwInputListeners.get(this);
196
- if (!listeners) return super.addInputListener(listener);
197
- listeners.beforeViewport.add(listener);
198
- return () => listeners.beforeViewport.delete(listener);
199
- }
200
-
201
- override removeInputListener(listener: TuiInputListener): void {
202
- const listeners = btwInputListeners.get(this);
203
- if (!listeners) {
204
- super.removeInputListener(listener);
205
- return;
206
- }
207
- listeners.beforeAll.delete(listener);
208
- listeners.beforeViewport.delete(listener);
209
- listeners.regular.delete(listener);
210
- }
157
+ hasFocusedOverlay(): boolean {
158
+ return this.isOverlayFocused();
159
+ }
160
+
161
+ override addInputListener(listener: TuiInputListener): () => void {
162
+ let listeners = btwInputListeners.get(this);
163
+ if (!listeners) {
164
+ const registeredListeners: BtwInputListeners = {
165
+ beforeAll: new Set(),
166
+ beforeViewport: new Set(),
167
+ regular: new Set(),
168
+ };
169
+ btwInputListeners.set(this, registeredListeners);
170
+ super.addInputListener((data) => dispatchBtwInput(registeredListeners, data));
171
+ listeners = registeredListeners;
172
+ }
173
+ listeners.regular.add(listener);
174
+ return () => listeners.regular.delete(listener);
175
+ }
176
+
177
+ addInputListenerBeforeAll(listener: TuiInputListener): () => void {
178
+ const listeners = btwInputListeners.get(this);
179
+ if (!listeners) return super.addInputListener(listener);
180
+ listeners.beforeAll.add(listener);
181
+ return () => listeners.beforeAll.delete(listener);
182
+ }
183
+
184
+ addInputListenerBeforeViewport(listener: TuiInputListener): () => void {
185
+ const listeners = btwInputListeners.get(this);
186
+ if (!listeners) return super.addInputListener(listener);
187
+ listeners.beforeViewport.add(listener);
188
+ return () => listeners.beforeViewport.delete(listener);
189
+ }
190
+
191
+ override removeInputListener(listener: TuiInputListener): void {
192
+ const listeners = btwInputListeners.get(this);
193
+ if (!listeners) {
194
+ super.removeInputListener(listener);
195
+ return;
196
+ }
197
+ listeners.beforeAll.delete(listener);
198
+ listeners.beforeViewport.delete(listener);
199
+ listeners.regular.delete(listener);
200
+ }
211
201
  }
212
202
 
213
203
  const BRACKETED_PASTE_START = "\u001b[200~";
@@ -215,554 +205,563 @@ const BRACKETED_PASTE_END = "\u001b[201~";
215
205
 
216
206
  // TuiAltScreen evaluates these actions before bottom, so shared keys cannot jump to latest.
217
207
  const ALT_SCREEN_ACTIONS_BEFORE_BOTTOM = [
218
- "tui.altScreen.search",
219
- "tui.altScreen.searchNext",
220
- "tui.altScreen.searchPrevious",
221
- "tui.altScreen.searchClose",
222
- "tui.altScreen.pageUp",
223
- "tui.altScreen.pageDown",
224
- "tui.altScreen.halfPageUp",
225
- "tui.altScreen.halfPageDown",
226
- "tui.altScreen.lineUp",
227
- "tui.altScreen.lineDown",
228
- "tui.altScreen.previousPrompt",
229
- "tui.altScreen.nextPrompt",
230
- "tui.altScreen.top",
208
+ "tui.altScreen.search",
209
+ "tui.altScreen.searchNext",
210
+ "tui.altScreen.searchPrevious",
211
+ "tui.altScreen.searchClose",
212
+ "tui.altScreen.pageUp",
213
+ "tui.altScreen.pageDown",
214
+ "tui.altScreen.halfPageUp",
215
+ "tui.altScreen.halfPageDown",
216
+ "tui.altScreen.lineUp",
217
+ "tui.altScreen.lineDown",
218
+ "tui.altScreen.previousPrompt",
219
+ "tui.altScreen.nextPrompt",
220
+ "tui.altScreen.top",
231
221
  ] as const;
232
222
  const KEY_MODIFIER_ORDER = ["shift", "ctrl", "alt", "super"] as const;
233
223
  const MATCHABLE_SPECIAL_KEYS = new Set([
234
- "space",
235
- "tab",
236
- "enter",
237
- "backspace",
238
- "delete",
239
- "insert",
240
- "home",
241
- "end",
242
- "pageup",
243
- "pagedown",
244
- "up",
245
- "down",
246
- "left",
247
- "right",
224
+ "space",
225
+ "tab",
226
+ "enter",
227
+ "backspace",
228
+ "delete",
229
+ "insert",
230
+ "home",
231
+ "end",
232
+ "pageup",
233
+ "pagedown",
234
+ "up",
235
+ "down",
236
+ "left",
237
+ "right",
248
238
  ]);
249
239
  const MATCHABLE_SYMBOL_KEYS = new Set("`-=[]\\;',./!@#$%^&*()_+|~{}:<>?");
250
240
 
251
241
  function normalizedKeyId(key: string): string {
252
- const parts = key.toLowerCase().split("+");
253
- const base = parts.at(-1);
254
- if (!base) return "";
255
- const normalizedBase = base === "esc" ? "escape" : base === "return" ? "enter" : base;
256
- const modifiers = KEY_MODIFIER_ORDER.filter((modifier) => parts.includes(modifier));
257
- return [...modifiers, normalizedBase].join("+");
242
+ const parts = key.toLowerCase().split("+");
243
+ const base = parts.at(-1);
244
+ if (!base) return "";
245
+ const normalizedBase = base === "esc" ? "escape" : base === "return" ? "enter" : base;
246
+ const modifiers = KEY_MODIFIER_ORDER.filter((modifier) => parts.includes(modifier));
247
+ return [...modifiers, normalizedBase].join("+");
258
248
  }
259
249
 
260
250
  function formatEffectiveKeyLabel(key: string): string {
261
- const parts = key.split("+");
262
- const base = parts.at(-1);
263
- if (base === "pageup") parts[parts.length - 1] = "pageUp";
264
- if (base === "pagedown") parts[parts.length - 1] = "pageDown";
265
- return formatKeyLabel(parts.join("+"));
251
+ const parts = key.split("+");
252
+ const base = parts.at(-1);
253
+ if (base === "pageup") parts[parts.length - 1] = "pageUp";
254
+ if (base === "pagedown") parts[parts.length - 1] = "pageDown";
255
+ return formatKeyLabel(parts.join("+"));
266
256
  }
267
257
 
268
258
  function canMatchKeyInput(key: string): boolean {
269
- const parts = key.split("+");
270
- const base = parts.at(-1) ?? "";
271
- const modifiers = parts.slice(0, -1);
272
- if (base === "escape") return modifiers.length === 0;
273
- if (base === "clear") {
274
- return (
275
- modifiers.length === 0 ||
276
- (modifiers.length === 1 && (modifiers[0] === "shift" || modifiers[0] === "ctrl"))
277
- );
278
- }
279
- if (/^f(?:[1-9]|1[0-2])$/u.test(base)) return modifiers.length === 0;
280
- return (
281
- MATCHABLE_SPECIAL_KEYS.has(base) ||
282
- (base.length === 1 && (/^[a-z0-9]$/u.test(base) || MATCHABLE_SYMBOL_KEYS.has(base)))
283
- );
259
+ const parts = key.split("+");
260
+ const base = parts.at(-1) ?? "";
261
+ const modifiers = parts.slice(0, -1);
262
+ if (base === "escape") return modifiers.length === 0;
263
+ if (base === "clear") {
264
+ return modifiers.length === 0 || (modifiers.length === 1 && (modifiers[0] === "shift" || modifiers[0] === "ctrl"));
265
+ }
266
+ if (/^f(?:[1-9]|1[0-2])$/u.test(base)) return modifiers.length === 0;
267
+ return (
268
+ MATCHABLE_SPECIAL_KEYS.has(base) ||
269
+ (base.length === 1 && (/^[a-z0-9]$/u.test(base) || MATCHABLE_SYMBOL_KEYS.has(base)))
270
+ );
284
271
  }
285
272
 
286
273
  function rawCtrlInput(base: string): string | undefined {
287
- if (base.length !== 1) return undefined;
288
- const rawBase = base === "-" ? "_" : base;
289
- if (!"abcdefghijklmnopqrstuvwxyz[\\]_".includes(rawBase)) return undefined;
290
- return String.fromCharCode(rawBase.charCodeAt(0) & 0x1f);
274
+ if (base.length !== 1) return undefined;
275
+ const rawBase = base === "-" ? "_" : base;
276
+ if (!"abcdefghijklmnopqrstuvwxyz[\\]_".includes(rawBase)) return undefined;
277
+ return String.fromCharCode(rawBase.charCodeAt(0) & 0x1f);
291
278
  }
292
279
 
293
280
  function legacyRawInput(key: string): string | undefined {
294
- const parts = key.split("+");
295
- const base = parts.at(-1) ?? "";
296
- if (parts.length === 2 && parts[0] === "ctrl") return rawCtrlInput(base);
297
- if (isKittyProtocolActive()) return undefined;
298
- if (parts.length === 2 && parts[0] === "alt" && base.length === 1) return `\u001b${base}`;
299
- if (parts.length === 3 && parts[0] === "ctrl" && parts[1] === "alt") {
300
- const input = rawCtrlInput(base);
301
- return input ? `\u001b${input}` : undefined;
302
- }
303
- return undefined;
281
+ const parts = key.split("+");
282
+ const base = parts.at(-1) ?? "";
283
+ if (parts.length === 2 && parts[0] === "ctrl") return rawCtrlInput(base);
284
+ if (isKittyProtocolActive()) return undefined;
285
+ if (parts.length === 2 && parts[0] === "alt" && base.length === 1) return `\u001b${base}`;
286
+ if (parts.length === 3 && parts[0] === "ctrl" && parts[1] === "alt") {
287
+ const input = rawCtrlInput(base);
288
+ return input ? `\u001b${input}` : undefined;
289
+ }
290
+ return undefined;
304
291
  }
305
292
 
306
293
  // Mirror matchesKey(), using parseKey() to canonicalize IDs that share legacy raw input.
307
294
  function keyInputIdentity(key: string): string {
308
- const identity = normalizedKeyId(key);
309
- const input = legacyRawInput(identity);
310
- return input ? normalizedKeyId(parseKey(input) ?? identity) : identity;
295
+ const identity = normalizedKeyId(key);
296
+ const input = legacyRawInput(identity);
297
+ return input ? normalizedKeyId(parseKey(input) ?? identity) : identity;
311
298
  }
312
299
 
313
300
  function hasManualSelectionCopyApi(): boolean {
314
- return (
315
- typeof TuiAltScreen.prototype.hasActiveSelection === "function" &&
316
- typeof TuiAltScreen.prototype.copyActiveSelectionToClipboard === "function"
317
- );
301
+ return (
302
+ typeof TuiAltScreen.prototype.hasActiveSelection === "function" &&
303
+ typeof TuiAltScreen.prototype.copyActiveSelectionToClipboard === "function"
304
+ );
318
305
  }
319
306
 
320
307
  function createBtwFullscreenTui(
321
- parent: TUI,
322
- theme: Theme,
323
- keybindings: KeybindingsManager,
324
- copyOnSelect: boolean,
325
- manualSelectionCopySupported: boolean,
326
- openUrl: (url: string) => void,
327
- copyToClipboard: (text: string) => Promise<void>,
308
+ parent: TUI,
309
+ theme: Theme,
310
+ keybindings: KeybindingsManager,
311
+ copyOnSelect: boolean,
312
+ manualSelectionCopySupported: boolean,
313
+ openUrl: (url: string) => void,
314
+ copyToClipboard: (text: string) => Promise<void>,
328
315
  ): BtwFullscreenTui {
329
- if (!copyOnSelect && !manualSelectionCopySupported) {
330
- throw new Error(
331
- "Manual fullscreen selection copying is unavailable in this Pi version; update Pi or enable automatic selection copying.",
332
- );
333
- }
334
- const styleSearchMatch = (text: string) =>
335
- theme.bg("searchMatchBg", theme.fg("searchMatchText", text));
336
- const fullscreen = new BtwTuiAltScreen(
337
- parent.terminal,
338
- parent.getShowHardwareCursor(),
339
- undefined,
340
- {
341
- mouse: true,
342
- copyOnSelect,
343
- searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),
344
- scrollToEndIndicator: () => {
345
- const unavailableKeyIdentities = new Set<string>([keyInputIdentity(Key.ctrl("c"))]);
346
- for (const action of ALT_SCREEN_ACTIONS_BEFORE_BOTTOM) {
347
- for (const actionKey of keybindings.getKeys(action)) {
348
- unavailableKeyIdentities.add(keyInputIdentity(String(actionKey)));
349
- }
350
- }
351
- if (!copyOnSelect) {
352
- for (const copyKey of keybindings.getKeys("app.message.copy")) {
353
- unavailableKeyIdentities.add(keyInputIdentity(String(copyKey)));
354
- }
355
- }
356
- const key = keybindings
357
- .getKeys("tui.altScreen.bottom")
358
- .map((candidate) => keyInputIdentity(String(candidate)))
359
- .find(
360
- (identity) =>
361
- identity &&
362
- canMatchKeyInput(identity) &&
363
- !unavailableKeyIdentities.has(identity) &&
364
- formatEffectiveKeyLabel(identity),
365
- );
366
- const label = theme.fg("text", " ↓ Jump to latest message");
367
- const shortcut = key ? theme.fg("muted", ` · ${formatEffectiveKeyLabel(key)}`) : "";
368
- return theme.bg("selectedBg", `${label}${shortcut} `);
369
- },
370
- searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
371
- openUrl,
372
- copySelection: async (text) => {
373
- try {
374
- await copyToClipboard(text);
375
- return true;
376
- } catch {
377
- return false;
378
- }
379
- },
380
- },
381
- );
382
- if (!copyOnSelect) {
383
- let isInBracketedPaste = false;
384
- fullscreen.addInputListenerBeforeViewport((data) => {
385
- const wasInBracketedPaste = isInBracketedPaste;
386
- const startsBracketedPaste = data.includes(BRACKETED_PASTE_START);
387
- if (startsBracketedPaste) isInBracketedPaste = true;
388
- if (isInBracketedPaste && data.includes(BRACKETED_PASTE_END)) {
389
- isInBracketedPaste = false;
390
- }
391
- if (
392
- wasInBracketedPaste ||
393
- startsBracketedPaste ||
394
- fullscreen.hasFocusedOverlay() ||
395
- isKeyRelease(data) ||
396
- !keybindings.matches(data, "app.message.copy")
397
- ) {
398
- return undefined;
399
- }
400
- if (!fullscreen.hasActiveSelection()) {
401
- fullscreen.flash("No selection to copy");
402
- return { consume: true };
403
- }
404
- void fullscreen.copyActiveSelectionToClipboard().catch(() => fullscreen.flash("Copy failed"));
405
- return { consume: true };
406
- });
407
- }
408
- return fullscreen;
316
+ if (!copyOnSelect && !manualSelectionCopySupported) {
317
+ throw new Error(
318
+ "Manual fullscreen selection copying is unavailable in this Pi version; update Pi or enable automatic selection copying.",
319
+ );
320
+ }
321
+ const styleSearchMatch = (text: string) => theme.bg("searchMatchBg", theme.fg("searchMatchText", text));
322
+ const fullscreen = new BtwTuiAltScreen(parent.terminal, parent.getShowHardwareCursor(), undefined, {
323
+ mouse: true,
324
+ copyOnSelect,
325
+ searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),
326
+ scrollToEndIndicator: () => {
327
+ const unavailableKeyIdentities = new Set<string>([keyInputIdentity(Key.ctrl("c"))]);
328
+ for (const action of ALT_SCREEN_ACTIONS_BEFORE_BOTTOM) {
329
+ for (const actionKey of keybindings.getKeys(action)) {
330
+ unavailableKeyIdentities.add(keyInputIdentity(String(actionKey)));
331
+ }
332
+ }
333
+ if (!copyOnSelect) {
334
+ for (const copyKey of keybindings.getKeys("app.message.copy")) {
335
+ unavailableKeyIdentities.add(keyInputIdentity(String(copyKey)));
336
+ }
337
+ }
338
+ const key = keybindings
339
+ .getKeys("tui.altScreen.bottom")
340
+ .map((candidate) => keyInputIdentity(String(candidate)))
341
+ .find(
342
+ (identity) =>
343
+ identity &&
344
+ canMatchKeyInput(identity) &&
345
+ !unavailableKeyIdentities.has(identity) &&
346
+ formatEffectiveKeyLabel(identity),
347
+ );
348
+ const label = theme.fg("text", " ↓ Jump to latest message");
349
+ const shortcut = key ? theme.fg("muted", ` · ${formatEffectiveKeyLabel(key)}`) : "";
350
+ return theme.bg("selectedBg", `${label}${shortcut} `);
351
+ },
352
+ searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
353
+ openUrl,
354
+ copySelection: async (text) => {
355
+ try {
356
+ await copyToClipboard(text);
357
+ return true;
358
+ } catch {
359
+ return false;
360
+ }
361
+ },
362
+ });
363
+ if (!copyOnSelect) {
364
+ let isInBracketedPaste = false;
365
+ fullscreen.addInputListenerBeforeViewport((data) => {
366
+ const wasInBracketedPaste = isInBracketedPaste;
367
+ const startsBracketedPaste = data.includes(BRACKETED_PASTE_START);
368
+ if (startsBracketedPaste) isInBracketedPaste = true;
369
+ if (isInBracketedPaste && data.includes(BRACKETED_PASTE_END)) {
370
+ isInBracketedPaste = false;
371
+ }
372
+ if (
373
+ wasInBracketedPaste ||
374
+ startsBracketedPaste ||
375
+ fullscreen.hasFocusedOverlay() ||
376
+ isKeyRelease(data) ||
377
+ !keybindings.matches(data, "app.message.copy")
378
+ ) {
379
+ return undefined;
380
+ }
381
+ if (!fullscreen.hasActiveSelection()) {
382
+ fullscreen.flash("No selection to copy");
383
+ return { consume: true };
384
+ }
385
+ void fullscreen.copyActiveSelectionToClipboard().catch(() => fullscreen.flash("Copy failed"));
386
+ return { consume: true };
387
+ });
388
+ }
389
+ return fullscreen;
409
390
  }
410
391
 
411
392
  // Pi does not export its browser opener, so mirror its shell-free launcher for this isolated TUI.
412
393
  function openUrlInBrowser(target: string): void {
413
- const [command, args] =
414
- process.platform === "darwin"
415
- ? ["open", [target]]
416
- : process.platform === "win32"
417
- ? ["rundll32", ["url.dll,FileProtocolHandler", target]]
418
- : ["xdg-open", [target]];
419
- spawn(command, args, { stdio: "ignore", detached: true })
420
- .on("error", () => {})
421
- .unref();
394
+ const [command, args] =
395
+ process.platform === "darwin"
396
+ ? ["open", [target]]
397
+ : process.platform === "win32"
398
+ ? ["rundll32", ["url.dll,FileProtocolHandler", target]]
399
+ : ["xdg-open", [target]];
400
+ spawn(command, args, { stdio: "ignore", detached: true })
401
+ .on("error", () => {})
402
+ .unref();
422
403
  }
423
404
 
424
405
  class BtwFullscreenHost<T> implements Component {
425
- private fullscreen: BtwFullscreenTui | undefined;
426
- private parentOverlay: OverlayHandle | undefined;
427
- private cancelActiveCustom: (() => void) | undefined;
428
- private hardCancelActiveCustom: (() => void) | undefined;
429
- private removeHardCancelListener: (() => void) | undefined;
430
- private started = false;
431
- private disposed = false;
432
- private finished = false;
433
- private parentStopped = false;
434
- private parentRestoreAttempted = false;
435
- private fullscreenCreated = false;
436
- private fullscreenStopped = false;
437
- private parentRestoreQueued = false;
438
- private parentRestorePromise: Promise<void> | undefined;
439
- private cleanupError: unknown;
440
-
441
- constructor(
442
- private readonly parent: TUI,
443
- private readonly theme: Theme,
444
- private readonly keybindings: KeybindingsManager,
445
- private readonly ctx: ExtensionCommandContext,
446
- private readonly run: (ctx: ExtensionCommandContext) => Promise<T>,
447
- private readonly done: (outcome: FullscreenOutcome<T>) => void,
448
- private readonly createTui: BtwFullscreenTuiFactory,
449
- private readonly options: BtwFullscreenOptions,
450
- ) {
451
- queueMicrotask(() => void this.start());
452
- }
453
-
454
- setParentOverlay(overlay: OverlayHandle): void {
455
- this.parentOverlay = overlay;
456
- }
457
-
458
- render(width: number): string[] {
459
- return [truncateToWidth(this.theme.fg("muted", "Opening btw side thread…"), width)];
460
- }
461
-
462
- invalidate(): void {}
463
-
464
- dispose(): void {
465
- if (this.disposed || this.finished) return;
466
- this.disposed = true;
467
- this.cancelActiveCustom?.();
468
- }
469
-
470
- private async start(): Promise<void> {
471
- if (this.started || this.finished) return;
472
- this.started = true;
473
- let outcome: FullscreenOutcome<T>;
474
- try {
475
- if (this.disposed) throw new FullscreenUiDisposedError();
476
- this.parent.stop({ preserveScreen: true });
477
- this.parentStopped = true;
478
- if (this.disposed) throw new FullscreenUiDisposedError();
479
- this.fullscreen = this.createTui(this.parent, this.theme, this.keybindings, this.options);
480
- this.fullscreenCreated = true;
481
- this.fullscreen.start();
482
- const shortcuts = resolveBtwShortcuts(
483
- this.options.keybindings,
484
- this.keybindings,
485
- this.options.copyOnSelect ?? true,
486
- );
487
- setBtwShortcuts(this.fullscreen, shortcuts);
488
- // Negotiate before warning when possible: the first dispatched user input uses
489
- // the current mode. Recheck each input, including later mode transitions.
490
- let previousWarnings: readonly string[] = [];
491
- const reportWarnings = () => {
492
- const warnings = shortcuts.warnings;
493
- for (const warning of warnings) {
494
- if (previousWarnings.includes(warning)) continue;
495
- try {
496
- this.ctx.ui.notify(`Pi BTW: ${warning}`, "warning");
497
- } catch {
498
- /* A replaced context must not prevent terminal cleanup. */
499
- }
500
- }
501
- previousWarnings = warnings;
502
- };
503
- const pasteGuard = new BtwPasteGuard();
504
- // Waiting for the custom promise would leave follow-up keys bound to the side TUI.
505
- const addHardCancelListener =
506
- this.fullscreen.addInputListenerBeforeAll?.bind(this.fullscreen) ??
507
- this.fullscreen.addInputListenerBeforeViewport?.bind(this.fullscreen) ??
508
- this.fullscreen.addInputListener.bind(this.fullscreen);
509
- this.removeHardCancelListener = addHardCancelListener((data) => {
510
- reportWarnings();
511
- if (pasteGuard.consume(data) || !shortcuts.matches(data, "exit")) return undefined;
512
- this.disposed = true;
513
- try {
514
- this.hardCancelActiveCustom?.();
515
- } finally {
516
- // ProcessTerminal.stop() destroys its active input buffer. Keep cancellation
517
- // synchronous, then drain input before the physical Windows terminal handoff.
518
- // Pi has no public input injection, so do not replay bytes already coalesced
519
- // behind the hard-cancel key.
520
- this.queueParentRestore();
521
- }
522
- return { consume: true };
523
- });
524
- outcome = { kind: "completed", value: await this.run(this.createContext()) };
525
- } catch (error) {
526
- outcome = { kind: "failed", error };
527
- }
528
-
529
- try {
530
- this.cancelActiveCustom?.();
531
- } catch (error) {
532
- this.cleanupError ??= error;
533
- }
534
- if (this.parentRestorePromise) await this.parentRestorePromise;
535
- else this.restoreParent();
536
- if (this.cleanupError !== undefined) outcome = { kind: "failed", error: this.cleanupError };
537
- this.finished = true;
538
- this.done(outcome);
539
- }
540
-
541
- private queueParentRestore(): void {
542
- if (this.parentRestoreQueued || this.parentRestoreAttempted) return;
543
- this.parentRestoreQueued = true;
544
- this.parentRestorePromise = Promise.resolve().then(async () => {
545
- try {
546
- await this.fullscreen?.terminal.drainInput?.();
547
- } catch (error) {
548
- this.cleanupError ??= error;
549
- }
550
- this.parentRestoreQueued = false;
551
- this.restoreParent();
552
- });
553
- }
554
-
555
- private restoreParent(): void {
556
- const removeHardCancelListener = this.removeHardCancelListener;
557
- this.removeHardCancelListener = undefined;
558
- try {
559
- removeHardCancelListener?.();
560
- } catch (error) {
561
- this.cleanupError ??= error;
562
- }
563
- if (this.fullscreenCreated && !this.fullscreenStopped) {
564
- this.fullscreenStopped = true;
565
- try {
566
- this.fullscreen?.stop({ preserveScreen: true });
567
- } catch (error) {
568
- this.cleanupError ??= error;
569
- }
570
- }
571
- if (!this.parentStopped || this.parentRestoreAttempted) return;
572
- const parentOverlay = this.parentOverlay;
573
- this.parentOverlay = undefined;
574
- try {
575
- parentOverlay?.setHidden(true);
576
- } catch (error) {
577
- this.cleanupError ??= error;
578
- }
579
- try {
580
- this.parentRestoreAttempted = true;
581
- this.parent.start();
582
- this.parent.renderNow(false);
583
- } catch (error) {
584
- this.cleanupError ??= error;
585
- }
586
- }
587
-
588
- private createContext(): ExtensionCommandContext {
589
- const ui = new Proxy(this.ctx.ui, {
590
- get: (target, property) => {
591
- if (property === "custom") {
592
- return <Value>(factory: BtwCustomFactory<Value>, options?: BtwCustomOptions) =>
593
- this.showCustom(factory, options);
594
- }
595
- if (property === "notify") {
596
- return (
597
- message: string,
598
- level?: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
599
- ) => {
600
- target.notify(message, level);
601
- const display = sanitizeSingleLine(message);
602
- if (display) this.fullscreen?.flash?.(display);
603
- };
604
- }
605
- const value = Reflect.get(target, property, target) as unknown;
606
- return typeof value === "function" ? value.bind(target) : value;
607
- },
608
- });
609
- return new Proxy(this.ctx, {
610
- get: (target, property) => (property === "ui" ? ui : Reflect.get(target, property, target)),
611
- });
612
- }
613
-
614
- private showCustom<Value>(
615
- factory: BtwCustomFactory<Value>,
616
- options?: BtwCustomOptions,
617
- ): Promise<Value> {
618
- const fullscreen = this.fullscreen;
619
- if (!fullscreen || this.disposed || this.finished) {
620
- return Promise.reject(new FullscreenUiDisposedError());
621
- }
622
- if (this.cancelActiveCustom) {
623
- return Promise.reject(new Error("pi-btw attempted to open overlapping custom UI."));
624
- }
625
-
626
- return new Promise<Value>((resolve, reject) => {
627
- let component: (Component & { dispose?(): void }) | undefined;
628
- let overlay: OverlayHandle | undefined;
629
- let mounted = false;
630
- let layoutMounted = false;
631
- let factorySettled = false;
632
- let closed = false;
633
- let promiseSettled = false;
634
- let componentDisposed = false;
635
- let pendingValue: Value | undefined;
636
- let hasPendingValue = false;
637
-
638
- const disposeComponent = () => {
639
- if (!component || componentDisposed) return;
640
- componentDisposed = true;
641
- try {
642
- component.dispose?.();
643
- } catch {
644
- // Cleanup must continue so terminal ownership is restored.
645
- }
646
- };
647
- const unmount = () => {
648
- let cleanupError: unknown;
649
- try {
650
- if (overlay) overlay.hide();
651
- else if (mounted && layoutMounted) fullscreen.setLayoutRoot(undefined);
652
- else if (mounted && component) fullscreen.removeChild(component);
653
- } catch (error) {
654
- cleanupError = error;
655
- }
656
- if (overlay || mounted) {
657
- try {
658
- fullscreen.setFocus(null);
659
- fullscreen.requestRender();
660
- } catch (error) {
661
- cleanupError ??= error;
662
- }
663
- }
664
- disposeComponent();
665
- if (cleanupError !== undefined) throw cleanupError;
666
- };
667
- const complete = () => {
668
- if (promiseSettled || !hasPendingValue) return;
669
- promiseSettled = true;
670
- this.cancelActiveCustom = undefined;
671
- this.hardCancelActiveCustom = undefined;
672
- if (!factorySettled) {
673
- resolve(pendingValue as Value);
674
- return;
675
- }
676
- try {
677
- unmount();
678
- resolve(pendingValue as Value);
679
- } catch (error) {
680
- reject(error);
681
- }
682
- };
683
- const close = (value: Value) => {
684
- if (closed || promiseSettled) return;
685
- closed = true;
686
- pendingValue = value;
687
- hasPendingValue = true;
688
- complete();
689
- };
690
- const fail = (error: unknown) => {
691
- if (promiseSettled) return;
692
- closed = true;
693
- promiseSettled = true;
694
- this.cancelActiveCustom = undefined;
695
- this.hardCancelActiveCustom = undefined;
696
- try {
697
- unmount();
698
- reject(error);
699
- } catch (cleanupError) {
700
- reject(cleanupError);
701
- }
702
- };
703
- this.cancelActiveCustom = () => {
704
- if (promiseSettled) return;
705
- disposeComponent();
706
- if (!promiseSettled) fail(new FullscreenUiDisposedError());
707
- };
708
- this.hardCancelActiveCustom = () => {
709
- if (promiseSettled) return;
710
- try {
711
- component?.handleInput?.("\u0003");
712
- } catch (error) {
713
- fail(error);
714
- return;
715
- }
716
- this.cancelActiveCustom?.();
717
- };
718
-
719
- let created: ReturnType<BtwCustomFactory<Value>>;
720
- try {
721
- created = factory(fullscreen, this.theme, this.keybindings, close);
722
- } catch (error) {
723
- factorySettled = true;
724
- fail(error);
725
- return;
726
- }
727
- Promise.resolve(created)
728
- .then((value) => {
729
- component = value;
730
- factorySettled = true;
731
- if (promiseSettled) {
732
- disposeComponent();
733
- return;
734
- }
735
- if (closed) {
736
- complete();
737
- return;
738
- }
739
- if (options?.overlay) {
740
- const overlayOptions =
741
- typeof options.overlayOptions === "function"
742
- ? options.overlayOptions()
743
- : options.overlayOptions;
744
- overlay = fullscreen.showOverlay(component, overlayOptions);
745
- options.onHandle?.(overlay);
746
- } else {
747
- fullscreen.clear();
748
- mounted = true;
749
- if (isFullscreenLayoutComponent(component)) {
750
- layoutMounted = true;
751
- fullscreen.setLayoutRoot(component.getFullscreenLayout());
752
- } else {
753
- fullscreen.addChild(component);
754
- }
755
- fullscreen.setFocus(component);
756
- fullscreen.requestRender();
757
- }
758
- })
759
- .catch(fail);
760
- });
761
- }
406
+ private fullscreen: BtwFullscreenTui | undefined;
407
+ private parentOverlay: OverlayHandle | undefined;
408
+ private cancelActiveCustom: (() => void) | undefined;
409
+ private hardCancelActiveCustom: (() => void) | undefined;
410
+ private removeHardCancelListener: (() => void) | undefined;
411
+ private removeUpstreamAbortListener: (() => void) | undefined;
412
+ private started = false;
413
+ private disposed = false;
414
+ private finished = false;
415
+ private parentStopped = false;
416
+ private parentRestoreAttempted = false;
417
+ private fullscreenCreated = false;
418
+ private fullscreenStopped = false;
419
+ private parentRestoreQueued = false;
420
+ private parentRestorePromise: Promise<void> | undefined;
421
+ private cleanupError: unknown;
422
+ private readonly lifetimeController = new AbortController();
423
+
424
+ constructor(
425
+ private readonly parent: TUI,
426
+ private readonly theme: Theme,
427
+ private readonly keybindings: KeybindingsManager,
428
+ private readonly ctx: ExtensionCommandContext,
429
+ private readonly run: (ctx: ExtensionCommandContext) => Promise<T>,
430
+ private readonly done: (outcome: FullscreenOutcome<T>) => void,
431
+ private readonly createTui: BtwFullscreenTuiFactory,
432
+ private readonly options: BtwFullscreenOptions,
433
+ ) {
434
+ queueMicrotask(() => void this.start());
435
+ }
436
+
437
+ setParentOverlay(overlay: OverlayHandle): void {
438
+ this.parentOverlay = overlay;
439
+ }
440
+
441
+ render(width: number): string[] {
442
+ return [truncateToWidth(this.theme.fg("muted", "Opening btw side thread…"), width)];
443
+ }
444
+
445
+ invalidate(): void {}
446
+
447
+ dispose(): void {
448
+ if (this.disposed || this.finished) return;
449
+ this.disposed = true;
450
+ this.lifetimeController.abort();
451
+ this.cancelActiveCustom?.();
452
+ }
453
+
454
+ private async start(): Promise<void> {
455
+ if (this.started || this.finished) return;
456
+ this.started = true;
457
+ this.watchUpstreamCancellation();
458
+ let outcome: FullscreenOutcome<T>;
459
+ try {
460
+ if (this.disposed) throw new FullscreenUiDisposedError();
461
+ this.parent.stop({ preserveScreen: true });
462
+ this.parentStopped = true;
463
+ if (this.disposed) throw new FullscreenUiDisposedError();
464
+ this.fullscreen = this.createTui(this.parent, this.theme, this.keybindings, this.options);
465
+ this.fullscreenCreated = true;
466
+ this.fullscreen.start();
467
+ const shortcuts = resolveBtwShortcuts(
468
+ this.options.keybindings,
469
+ this.keybindings,
470
+ this.options.copyOnSelect ?? true,
471
+ );
472
+ setBtwShortcuts(this.fullscreen, shortcuts);
473
+ // Negotiate before warning when possible: the first dispatched user input uses
474
+ // the current mode. Recheck each input, including later mode transitions.
475
+ let previousWarnings: readonly string[] = [];
476
+ const reportWarnings = () => {
477
+ const warnings = shortcuts.warnings;
478
+ for (const warning of warnings) {
479
+ if (previousWarnings.includes(warning)) continue;
480
+ try {
481
+ this.ctx.ui.notify(`Pi BTW: ${warning}`, "warning");
482
+ } catch {
483
+ /* A replaced context must not prevent terminal cleanup. */
484
+ }
485
+ }
486
+ previousWarnings = warnings;
487
+ };
488
+ const pasteGuard = new BtwPasteGuard();
489
+ // Waiting for the custom promise would leave follow-up keys bound to the side TUI.
490
+ const addHardCancelListener =
491
+ this.fullscreen.addInputListenerBeforeAll?.bind(this.fullscreen) ??
492
+ this.fullscreen.addInputListenerBeforeViewport?.bind(this.fullscreen) ??
493
+ this.fullscreen.addInputListener.bind(this.fullscreen);
494
+ this.removeHardCancelListener = addHardCancelListener((data) => {
495
+ reportWarnings();
496
+ if (pasteGuard.consume(data) || !shortcuts.matches(data, "exit")) return undefined;
497
+ this.disposed = true;
498
+ this.lifetimeController.abort();
499
+ try {
500
+ this.hardCancelActiveCustom?.();
501
+ } finally {
502
+ // ProcessTerminal.stop() destroys its active input buffer. Keep cancellation
503
+ // synchronous, then drain input before the physical Windows terminal handoff.
504
+ // Pi has no public input injection, so do not replay bytes already coalesced
505
+ // behind the hard-cancel key.
506
+ this.queueParentRestore();
507
+ }
508
+ return { consume: true };
509
+ });
510
+ outcome = { kind: "completed", value: await this.run(this.createContext()) };
511
+ } catch (error) {
512
+ outcome = { kind: "failed", error };
513
+ }
514
+
515
+ try {
516
+ this.cancelActiveCustom?.();
517
+ } catch (error) {
518
+ this.cleanupError ??= error;
519
+ }
520
+ if (this.parentRestorePromise) await this.parentRestorePromise;
521
+ else this.restoreParent();
522
+ if (this.cleanupError !== undefined) outcome = { kind: "failed", error: this.cleanupError };
523
+ this.finished = true;
524
+ this.done(outcome);
525
+ }
526
+
527
+ private watchUpstreamCancellation(): void {
528
+ const signal = this.ctx.signal;
529
+ if (!signal) return;
530
+ const onAbort = () => this.dispose();
531
+ signal.addEventListener("abort", onAbort, { once: true });
532
+ this.removeUpstreamAbortListener = () => signal.removeEventListener("abort", onAbort);
533
+ if (signal.aborted) onAbort();
534
+ }
535
+
536
+ private queueParentRestore(): void {
537
+ if (this.parentRestoreQueued || this.parentRestoreAttempted) return;
538
+ this.parentRestoreQueued = true;
539
+ this.parentRestorePromise = Promise.resolve().then(async () => {
540
+ try {
541
+ await this.fullscreen?.terminal.drainInput?.();
542
+ } catch (error) {
543
+ this.cleanupError ??= error;
544
+ }
545
+ this.parentRestoreQueued = false;
546
+ this.restoreParent();
547
+ });
548
+ }
549
+
550
+ private restoreParent(): void {
551
+ const removeUpstreamAbortListener = this.removeUpstreamAbortListener;
552
+ this.removeUpstreamAbortListener = undefined;
553
+ try {
554
+ removeUpstreamAbortListener?.();
555
+ } catch (error) {
556
+ this.cleanupError ??= error;
557
+ }
558
+ const removeHardCancelListener = this.removeHardCancelListener;
559
+ this.removeHardCancelListener = undefined;
560
+ try {
561
+ removeHardCancelListener?.();
562
+ } catch (error) {
563
+ this.cleanupError ??= error;
564
+ }
565
+ if (this.fullscreenCreated && !this.fullscreenStopped) {
566
+ this.fullscreenStopped = true;
567
+ try {
568
+ this.fullscreen?.stop({ preserveScreen: true });
569
+ } catch (error) {
570
+ this.cleanupError ??= error;
571
+ }
572
+ }
573
+ if (!this.parentStopped || this.parentRestoreAttempted) return;
574
+ const parentOverlay = this.parentOverlay;
575
+ this.parentOverlay = undefined;
576
+ try {
577
+ parentOverlay?.setHidden(true);
578
+ } catch (error) {
579
+ this.cleanupError ??= error;
580
+ }
581
+ try {
582
+ this.parentRestoreAttempted = true;
583
+ this.parent.start();
584
+ this.parent.renderNow(false);
585
+ } catch (error) {
586
+ this.cleanupError ??= error;
587
+ }
588
+ }
589
+
590
+ private createContext(): ExtensionCommandContext {
591
+ const ui = new Proxy(this.ctx.ui, {
592
+ get: (target, property) => {
593
+ if (property === "custom") {
594
+ return <Value>(factory: BtwCustomFactory<Value>, options?: BtwCustomOptions) =>
595
+ this.showCustom(factory, options);
596
+ }
597
+ if (property === "notify") {
598
+ return (message: string, level?: Parameters<ExtensionCommandContext["ui"]["notify"]>[1]) => {
599
+ target.notify(message, level);
600
+ const display = sanitizeSingleLine(message);
601
+ if (display) this.fullscreen?.flash?.(display);
602
+ };
603
+ }
604
+ const value = Reflect.get(target, property, target) as unknown;
605
+ return typeof value === "function" ? value.bind(target) : value;
606
+ },
607
+ });
608
+ const signal = this.ctx.signal
609
+ ? AbortSignal.any([this.ctx.signal, this.lifetimeController.signal])
610
+ : this.lifetimeController.signal;
611
+ return new Proxy(this.ctx, {
612
+ get: (target, property) => {
613
+ if (property === "ui") return ui;
614
+ if (property === "signal") return signal;
615
+ return Reflect.get(target, property, target);
616
+ },
617
+ });
618
+ }
619
+
620
+ private showCustom<Value>(factory: BtwCustomFactory<Value>, options?: BtwCustomOptions): Promise<Value> {
621
+ const fullscreen = this.fullscreen;
622
+ if (!fullscreen || this.disposed || this.finished) {
623
+ return Promise.reject(new FullscreenUiDisposedError());
624
+ }
625
+ if (this.cancelActiveCustom) {
626
+ return Promise.reject(new Error("pi-btw attempted to open overlapping custom UI."));
627
+ }
628
+
629
+ return new Promise<Value>((resolve, reject) => {
630
+ let component: (Component & { dispose?(): void }) | undefined;
631
+ let overlay: OverlayHandle | undefined;
632
+ let mounted = false;
633
+ let layoutMounted = false;
634
+ let factorySettled = false;
635
+ let closed = false;
636
+ let promiseSettled = false;
637
+ let componentDisposed = false;
638
+ let pendingValue: Value | undefined;
639
+ let hasPendingValue = false;
640
+
641
+ const disposeComponent = () => {
642
+ if (!component || componentDisposed) return;
643
+ componentDisposed = true;
644
+ try {
645
+ component.dispose?.();
646
+ } catch {
647
+ // Cleanup must continue so terminal ownership is restored.
648
+ }
649
+ };
650
+ const unmount = () => {
651
+ let cleanupError: unknown;
652
+ try {
653
+ if (overlay) overlay.hide();
654
+ else if (mounted && layoutMounted) fullscreen.setLayoutRoot(undefined);
655
+ else if (mounted && component) fullscreen.removeChild(component);
656
+ } catch (error) {
657
+ cleanupError = error;
658
+ }
659
+ if (overlay || mounted) {
660
+ try {
661
+ fullscreen.setFocus(null);
662
+ fullscreen.requestRender();
663
+ } catch (error) {
664
+ cleanupError ??= error;
665
+ }
666
+ }
667
+ disposeComponent();
668
+ if (cleanupError !== undefined) throw cleanupError;
669
+ };
670
+ const complete = () => {
671
+ if (promiseSettled || !hasPendingValue) return;
672
+ promiseSettled = true;
673
+ this.cancelActiveCustom = undefined;
674
+ this.hardCancelActiveCustom = undefined;
675
+ if (!factorySettled) {
676
+ resolve(pendingValue as Value);
677
+ return;
678
+ }
679
+ try {
680
+ unmount();
681
+ resolve(pendingValue as Value);
682
+ } catch (error) {
683
+ reject(error);
684
+ }
685
+ };
686
+ const close = (value: Value) => {
687
+ if (closed || promiseSettled) return;
688
+ closed = true;
689
+ pendingValue = value;
690
+ hasPendingValue = true;
691
+ complete();
692
+ };
693
+ const fail = (error: unknown) => {
694
+ if (promiseSettled) return;
695
+ closed = true;
696
+ promiseSettled = true;
697
+ this.cancelActiveCustom = undefined;
698
+ this.hardCancelActiveCustom = undefined;
699
+ try {
700
+ unmount();
701
+ reject(error);
702
+ } catch (cleanupError) {
703
+ reject(cleanupError);
704
+ }
705
+ };
706
+ this.cancelActiveCustom = () => {
707
+ if (promiseSettled) return;
708
+ disposeComponent();
709
+ if (!promiseSettled) fail(new FullscreenUiDisposedError());
710
+ };
711
+ this.hardCancelActiveCustom = () => {
712
+ if (promiseSettled) return;
713
+ try {
714
+ component?.handleInput?.("\u0003");
715
+ } catch (error) {
716
+ fail(error);
717
+ return;
718
+ }
719
+ this.cancelActiveCustom?.();
720
+ };
721
+
722
+ let created: ReturnType<BtwCustomFactory<Value>>;
723
+ try {
724
+ created = factory(fullscreen, this.theme, this.keybindings, close);
725
+ } catch (error) {
726
+ factorySettled = true;
727
+ fail(error);
728
+ return;
729
+ }
730
+ Promise.resolve(created)
731
+ .then((value) => {
732
+ component = value;
733
+ factorySettled = true;
734
+ if (promiseSettled) {
735
+ disposeComponent();
736
+ return;
737
+ }
738
+ if (closed) {
739
+ complete();
740
+ return;
741
+ }
742
+ if (options?.overlay) {
743
+ const overlayOptions =
744
+ typeof options.overlayOptions === "function" ? options.overlayOptions() : options.overlayOptions;
745
+ overlay = fullscreen.showOverlay(component, overlayOptions);
746
+ options.onHandle?.(overlay);
747
+ } else {
748
+ fullscreen.clear();
749
+ mounted = true;
750
+ if (isFullscreenLayoutComponent(component)) {
751
+ layoutMounted = true;
752
+ fullscreen.setLayoutRoot(component.getFullscreenLayout());
753
+ } else {
754
+ fullscreen.addChild(component);
755
+ }
756
+ fullscreen.setFocus(component);
757
+ fullscreen.requestRender();
758
+ }
759
+ })
760
+ .catch(fail);
761
+ });
762
+ }
762
763
  }
763
764
 
764
- function isFullscreenLayoutComponent(
765
- component: Component,
766
- ): component is BtwFullscreenLayoutComponent {
767
- return "getFullscreenLayout" in component && typeof component.getFullscreenLayout === "function";
765
+ function isFullscreenLayoutComponent(component: Component): component is BtwFullscreenLayoutComponent {
766
+ return "getFullscreenLayout" in component && typeof component.getFullscreenLayout === "function";
768
767
  }