@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.56.1",
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.84.4",
36
- "@earendil-works/pi-coding-agent": "0.84.4",
37
- "@earendil-works/pi-tui": "0.84.4",
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
  },
@@ -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. Defer only the
361
- // physical handoff so Windows can finish dispatching this Ctrl+C first.
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.restoreParent();
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
- queueMicrotask(() => {
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
+ }
@@ -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
- const key =
617
- sanitizeSingleLine(String(keybindings.getKeys("app.thinking.cycle")[0] ?? "shift+tab")) ||
618
- "Shift+Tab";
619
- return key
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(