@narumitw/pi-btw 0.56.1 → 0.57.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 +5 -1
- package/dist/index.ts +125 -10
- package/dist/index.ts.map +3 -3
- package/package.json +4 -4
- package/src/fullscreen-ui.ts +137 -5
- package/src/text.ts +18 -0
- package/src/transcript-pager.ts +5 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-btw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"description": "Pi extension that adds a /btw side-question command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@biomejs/biome": "2.5.11",
|
|
35
|
-
"@earendil-works/pi-ai": "0.
|
|
36
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
37
|
-
"@earendil-works/pi-tui": "0.
|
|
35
|
+
"@earendil-works/pi-ai": "0.85.0",
|
|
36
|
+
"@earendil-works/pi-coding-agent": "0.85.0",
|
|
37
|
+
"@earendil-works/pi-tui": "0.85.0",
|
|
38
38
|
"esbuild": "0.28.2",
|
|
39
39
|
"typescript": "7.0.2"
|
|
40
40
|
},
|
package/src/fullscreen-ui.ts
CHANGED
|
@@ -8,16 +8,18 @@ import {
|
|
|
8
8
|
import {
|
|
9
9
|
type Component,
|
|
10
10
|
isKeyRelease,
|
|
11
|
+
isKittyProtocolActive,
|
|
11
12
|
Key,
|
|
12
13
|
matchesKey,
|
|
13
14
|
type OverlayHandle,
|
|
15
|
+
parseKey,
|
|
14
16
|
type TUI,
|
|
15
17
|
TuiAltScreen,
|
|
16
18
|
type TuiInputListener,
|
|
17
19
|
type TuiInputListenerResult,
|
|
18
20
|
truncateToWidth,
|
|
19
21
|
} from "@earendil-works/pi-tui";
|
|
20
|
-
import { sanitizeSingleLine } from "./text.js";
|
|
22
|
+
import { formatKeyLabel, sanitizeSingleLine } from "./text.js";
|
|
21
23
|
|
|
22
24
|
type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
|
|
23
25
|
type BtwCustomFactory<T> = (
|
|
@@ -205,6 +207,103 @@ class BtwTuiAltScreen extends TuiAltScreen {
|
|
|
205
207
|
const BRACKETED_PASTE_START = "\u001b[200~";
|
|
206
208
|
const BRACKETED_PASTE_END = "\u001b[201~";
|
|
207
209
|
|
|
210
|
+
// TuiAltScreen evaluates these actions before bottom, so shared keys cannot jump to latest.
|
|
211
|
+
const ALT_SCREEN_ACTIONS_BEFORE_BOTTOM = [
|
|
212
|
+
"tui.altScreen.search",
|
|
213
|
+
"tui.altScreen.searchNext",
|
|
214
|
+
"tui.altScreen.searchPrevious",
|
|
215
|
+
"tui.altScreen.searchClose",
|
|
216
|
+
"tui.altScreen.pageUp",
|
|
217
|
+
"tui.altScreen.pageDown",
|
|
218
|
+
"tui.altScreen.halfPageUp",
|
|
219
|
+
"tui.altScreen.halfPageDown",
|
|
220
|
+
"tui.altScreen.lineUp",
|
|
221
|
+
"tui.altScreen.lineDown",
|
|
222
|
+
"tui.altScreen.previousPrompt",
|
|
223
|
+
"tui.altScreen.nextPrompt",
|
|
224
|
+
"tui.altScreen.top",
|
|
225
|
+
] as const;
|
|
226
|
+
const KEY_MODIFIER_ORDER = ["shift", "ctrl", "alt", "super"] as const;
|
|
227
|
+
const MATCHABLE_SPECIAL_KEYS = new Set([
|
|
228
|
+
"space",
|
|
229
|
+
"tab",
|
|
230
|
+
"enter",
|
|
231
|
+
"backspace",
|
|
232
|
+
"delete",
|
|
233
|
+
"insert",
|
|
234
|
+
"home",
|
|
235
|
+
"end",
|
|
236
|
+
"pageup",
|
|
237
|
+
"pagedown",
|
|
238
|
+
"up",
|
|
239
|
+
"down",
|
|
240
|
+
"left",
|
|
241
|
+
"right",
|
|
242
|
+
]);
|
|
243
|
+
const MATCHABLE_SYMBOL_KEYS = new Set("`-=[]\\;',./!@#$%^&*()_+|~{}:<>?");
|
|
244
|
+
|
|
245
|
+
function normalizedKeyId(key: string): string {
|
|
246
|
+
const parts = key.toLowerCase().split("+");
|
|
247
|
+
const base = parts.at(-1);
|
|
248
|
+
if (!base) return "";
|
|
249
|
+
const normalizedBase = base === "esc" ? "escape" : base === "return" ? "enter" : base;
|
|
250
|
+
const modifiers = KEY_MODIFIER_ORDER.filter((modifier) => parts.includes(modifier));
|
|
251
|
+
return [...modifiers, normalizedBase].join("+");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function formatEffectiveKeyLabel(key: string): string {
|
|
255
|
+
const parts = key.split("+");
|
|
256
|
+
const base = parts.at(-1);
|
|
257
|
+
if (base === "pageup") parts[parts.length - 1] = "pageUp";
|
|
258
|
+
if (base === "pagedown") parts[parts.length - 1] = "pageDown";
|
|
259
|
+
return formatKeyLabel(parts.join("+"));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function canMatchKeyInput(key: string): boolean {
|
|
263
|
+
const parts = key.split("+");
|
|
264
|
+
const base = parts.at(-1) ?? "";
|
|
265
|
+
const modifiers = parts.slice(0, -1);
|
|
266
|
+
if (base === "escape") return modifiers.length === 0;
|
|
267
|
+
if (base === "clear") {
|
|
268
|
+
return (
|
|
269
|
+
modifiers.length === 0 ||
|
|
270
|
+
(modifiers.length === 1 && (modifiers[0] === "shift" || modifiers[0] === "ctrl"))
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (/^f(?:[1-9]|1[0-2])$/u.test(base)) return modifiers.length === 0;
|
|
274
|
+
return (
|
|
275
|
+
MATCHABLE_SPECIAL_KEYS.has(base) ||
|
|
276
|
+
(base.length === 1 && (/^[a-z0-9]$/u.test(base) || MATCHABLE_SYMBOL_KEYS.has(base)))
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function rawCtrlInput(base: string): string | undefined {
|
|
281
|
+
if (base.length !== 1) return undefined;
|
|
282
|
+
const rawBase = base === "-" ? "_" : base;
|
|
283
|
+
if (!"abcdefghijklmnopqrstuvwxyz[\\]_".includes(rawBase)) return undefined;
|
|
284
|
+
return String.fromCharCode(rawBase.charCodeAt(0) & 0x1f);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function legacyRawInput(key: string): string | undefined {
|
|
288
|
+
const parts = key.split("+");
|
|
289
|
+
const base = parts.at(-1) ?? "";
|
|
290
|
+
if (parts.length === 2 && parts[0] === "ctrl") return rawCtrlInput(base);
|
|
291
|
+
if (isKittyProtocolActive()) return undefined;
|
|
292
|
+
if (parts.length === 2 && parts[0] === "alt" && base.length === 1) return `\u001b${base}`;
|
|
293
|
+
if (parts.length === 3 && parts[0] === "ctrl" && parts[1] === "alt") {
|
|
294
|
+
const input = rawCtrlInput(base);
|
|
295
|
+
return input ? `\u001b${input}` : undefined;
|
|
296
|
+
}
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Mirror matchesKey(), using parseKey() to canonicalize IDs that share legacy raw input.
|
|
301
|
+
function keyInputIdentity(key: string): string {
|
|
302
|
+
const identity = normalizedKeyId(key);
|
|
303
|
+
const input = legacyRawInput(identity);
|
|
304
|
+
return input ? normalizedKeyId(parseKey(input) ?? identity) : identity;
|
|
305
|
+
}
|
|
306
|
+
|
|
208
307
|
function hasManualSelectionCopyApi(): boolean {
|
|
209
308
|
return (
|
|
210
309
|
typeof TuiAltScreen.prototype.hasActiveSelection === "function" &&
|
|
@@ -236,6 +335,32 @@ function createBtwFullscreenTui(
|
|
|
236
335
|
mouse: true,
|
|
237
336
|
copyOnSelect,
|
|
238
337
|
searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),
|
|
338
|
+
scrollToEndIndicator: () => {
|
|
339
|
+
const unavailableKeyIdentities = new Set<string>([keyInputIdentity(Key.ctrl("c"))]);
|
|
340
|
+
for (const action of ALT_SCREEN_ACTIONS_BEFORE_BOTTOM) {
|
|
341
|
+
for (const actionKey of keybindings.getKeys(action)) {
|
|
342
|
+
unavailableKeyIdentities.add(keyInputIdentity(String(actionKey)));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (!copyOnSelect) {
|
|
346
|
+
for (const copyKey of keybindings.getKeys("app.message.copy")) {
|
|
347
|
+
unavailableKeyIdentities.add(keyInputIdentity(String(copyKey)));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const key = keybindings
|
|
351
|
+
.getKeys("tui.altScreen.bottom")
|
|
352
|
+
.map((candidate) => keyInputIdentity(String(candidate)))
|
|
353
|
+
.find(
|
|
354
|
+
(identity) =>
|
|
355
|
+
identity &&
|
|
356
|
+
canMatchKeyInput(identity) &&
|
|
357
|
+
!unavailableKeyIdentities.has(identity) &&
|
|
358
|
+
formatEffectiveKeyLabel(identity),
|
|
359
|
+
);
|
|
360
|
+
const label = theme.fg("text", " ↓ Jump to latest message");
|
|
361
|
+
const shortcut = key ? theme.fg("muted", ` · ${formatEffectiveKeyLabel(key)}`) : "";
|
|
362
|
+
return theme.bg("selectedBg", `${label}${shortcut} `);
|
|
363
|
+
},
|
|
239
364
|
searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
|
|
240
365
|
openUrl,
|
|
241
366
|
copySelection: async (text) => {
|
|
@@ -304,6 +429,7 @@ class BtwFullscreenHost<T> implements Component {
|
|
|
304
429
|
private fullscreenCreated = false;
|
|
305
430
|
private fullscreenStopped = false;
|
|
306
431
|
private parentRestoreQueued = false;
|
|
432
|
+
private parentRestorePromise: Promise<void> | undefined;
|
|
307
433
|
private cleanupError: unknown;
|
|
308
434
|
|
|
309
435
|
constructor(
|
|
@@ -357,8 +483,8 @@ class BtwFullscreenHost<T> implements Component {
|
|
|
357
483
|
try {
|
|
358
484
|
this.hardCancelActiveCustom?.();
|
|
359
485
|
} finally {
|
|
360
|
-
// ProcessTerminal.stop() destroys its active input buffer.
|
|
361
|
-
//
|
|
486
|
+
// ProcessTerminal.stop() destroys its active input buffer. Keep cancellation
|
|
487
|
+
// synchronous, then drain input before the physical Windows terminal handoff.
|
|
362
488
|
// Pi has no public input injection, so do not replay bytes already coalesced
|
|
363
489
|
// behind the hard-cancel key.
|
|
364
490
|
this.queueParentRestore();
|
|
@@ -375,7 +501,8 @@ class BtwFullscreenHost<T> implements Component {
|
|
|
375
501
|
} catch (error) {
|
|
376
502
|
this.cleanupError ??= error;
|
|
377
503
|
}
|
|
378
|
-
this.
|
|
504
|
+
if (this.parentRestorePromise) await this.parentRestorePromise;
|
|
505
|
+
else this.restoreParent();
|
|
379
506
|
if (this.cleanupError !== undefined) outcome = { kind: "failed", error: this.cleanupError };
|
|
380
507
|
this.finished = true;
|
|
381
508
|
this.done(outcome);
|
|
@@ -384,7 +511,12 @@ class BtwFullscreenHost<T> implements Component {
|
|
|
384
511
|
private queueParentRestore(): void {
|
|
385
512
|
if (this.parentRestoreQueued || this.parentRestoreAttempted) return;
|
|
386
513
|
this.parentRestoreQueued = true;
|
|
387
|
-
|
|
514
|
+
this.parentRestorePromise = Promise.resolve().then(async () => {
|
|
515
|
+
try {
|
|
516
|
+
await this.fullscreen?.terminal.drainInput?.();
|
|
517
|
+
} catch (error) {
|
|
518
|
+
this.cleanupError ??= error;
|
|
519
|
+
}
|
|
388
520
|
this.parentRestoreQueued = false;
|
|
389
521
|
this.restoreParent();
|
|
390
522
|
});
|
package/src/text.ts
CHANGED
|
@@ -8,3 +8,21 @@ export function sanitizeSingleLine(text: string): string {
|
|
|
8
8
|
.replace(/ +/gu, " ")
|
|
9
9
|
.trim();
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
export function formatKeyLabel(key: string): string {
|
|
13
|
+
const sanitized = sanitizeSingleLine(key);
|
|
14
|
+
if (!sanitized) return "";
|
|
15
|
+
return sanitized
|
|
16
|
+
.split("+")
|
|
17
|
+
.map((part) => {
|
|
18
|
+
const lower = part.toLowerCase();
|
|
19
|
+
if (lower === "shift") return "Shift";
|
|
20
|
+
if (lower === "ctrl") return "Ctrl";
|
|
21
|
+
if (lower === "alt") return "Alt";
|
|
22
|
+
if (lower === "super") return "Super";
|
|
23
|
+
return part.length === 1
|
|
24
|
+
? part.toUpperCase()
|
|
25
|
+
: `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`;
|
|
26
|
+
})
|
|
27
|
+
.join("+");
|
|
28
|
+
}
|
package/src/transcript-pager.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from "@earendil-works/pi-tui";
|
|
25
25
|
import type { BtwFullscreenLayoutComponent } from "./fullscreen-ui.js";
|
|
26
26
|
import type { BtwThinkingLevel, SideThreadTurn } from "./side-thread.js";
|
|
27
|
-
import { sanitizeSingleLine } from "./text.js";
|
|
27
|
+
import { formatKeyLabel, sanitizeSingleLine } from "./text.js";
|
|
28
28
|
|
|
29
29
|
const TRANSCRIPT_CHROME_LINES = 2;
|
|
30
30
|
const MAX_STEERING_DISPLAY_LINES = 3;
|
|
@@ -613,21 +613,10 @@ function renderSideThreadHeader(
|
|
|
613
613
|
}
|
|
614
614
|
|
|
615
615
|
function thinkingKeyLabel(keybindings: KeybindingsManager): string {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
"Shift+Tab"
|
|
619
|
-
|
|
620
|
-
.split("+")
|
|
621
|
-
.map((part) => {
|
|
622
|
-
const lower = part.toLowerCase();
|
|
623
|
-
if (lower === "shift") return "Shift";
|
|
624
|
-
if (lower === "ctrl") return "Ctrl";
|
|
625
|
-
if (lower === "alt") return "Alt";
|
|
626
|
-
return part.length === 1
|
|
627
|
-
? part.toUpperCase()
|
|
628
|
-
: `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`;
|
|
629
|
-
})
|
|
630
|
-
.join("+");
|
|
616
|
+
return (
|
|
617
|
+
formatKeyLabel(String(keybindings.getKeys("app.thinking.cycle")[0] ?? "shift+tab")) ||
|
|
618
|
+
"Shift+Tab"
|
|
619
|
+
);
|
|
631
620
|
}
|
|
632
621
|
|
|
633
622
|
function fitComposerLayout(
|