@narumitw/pi-btw 0.58.0 → 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.
- package/README.md +2 -0
- package/dist/index.ts +296 -367
- package/dist/index.ts.map +3 -3
- package/package.json +52 -53
- package/src/bring-to-main.ts +494 -547
- package/src/btw.ts +813 -844
- package/src/fullscreen-ui.ts +673 -674
- package/src/keybindings.ts +228 -270
- package/src/main-tree-picker.ts +309 -318
- package/src/menu.ts +416 -454
- package/src/settings.ts +203 -219
- package/src/side-thread.ts +173 -201
- package/src/text.ts +21 -23
- package/src/transcript-markdown.ts +65 -0
- package/src/transcript-pager.ts +611 -662
package/src/fullscreen-ui.ts
CHANGED
|
@@ -1,213 +1,203 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
40
|
+
getFullscreenLayout(): Component;
|
|
46
41
|
}
|
|
47
42
|
|
|
48
43
|
export interface BtwFullscreenOptions {
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
keybindings?: BtwKeybindingOverrides;
|
|
45
|
+
copyOnSelect?: boolean;
|
|
51
46
|
}
|
|
52
47
|
|
|
53
48
|
export type BtwFullscreenTuiFactory = (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
49
|
+
parent: TUI,
|
|
50
|
+
theme: Theme,
|
|
51
|
+
keybindings: KeybindingsManager,
|
|
52
|
+
options: BtwFullscreenOptions,
|
|
58
53
|
) => BtwFullscreenTui;
|
|
59
54
|
|
|
60
55
|
export interface BtwFullscreenDependencies {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
78
|
+
ctx: ExtensionCommandContext,
|
|
79
|
+
run: (ctx: ExtensionCommandContext) => Promise<T>,
|
|
80
|
+
options: BtwFullscreenOptions = {},
|
|
81
|
+
dependencies: BtwFullscreenDependencies = {},
|
|
87
82
|
): Promise<T> {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
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
|
}
|