@narumitw/pi-btw 0.56.2 → 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.2",
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) => {
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(