pi-fast-resume 1.4.13 → 1.4.15

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 CHANGED
@@ -264,3 +264,16 @@ All other features — tree view, regex/exact-phrase search, sort modes, scope t
264
264
  ## License
265
265
 
266
266
  [MIT](./LICENSE)
267
+
268
+ ## Pi 1.0 compatibility (1.4.15)
269
+
270
+ Tested against Pi **1.0.0** with exact SDK development pins and wildcard host peers.
271
+ The live mapped InteractiveMode opens the fast picker and restores its selector hook on shutdown.
272
+
273
+ Run `bun run test:host` for the offline real-host regression. Set `PI1_HOST_PACKAGE` to an installed Pi package directory and `PI1_HOST_ENTRY=bundle` to test its bundled CLI runtime.
274
+
275
+ ## Pi 0.99 compatibility (1.4.14)
276
+
277
+ Selector and shortcut patches now activate only in live TUI sessions, reinstall after session replacement, and compose safely with other picker wrappers. The native 0.99 selector/shortcut method contracts and actual loading were verified.
278
+
279
+ Tested with Pi 0.99.0. Host-provided Pi packages are wildcard peers, not bundled dependencies; development uses exact 0.99.0 versions.
package/fast-resume.ts CHANGED
@@ -1425,164 +1425,53 @@ async function showFastResumePicker(
1425
1425
  }
1426
1426
  }
1427
1427
 
1428
- // Stored reference to the extension runner, captured via prototype patch on
1429
- // InteractiveMode.prototype.setupExtensionShortcuts. Used by the shortcut
1430
- // handler to create an ExtensionCommandContext with switchSession(), since
1431
- // pi.registerShortcut handlers only receive ExtensionContext.
1432
- let storedExtensionRunner: any = null;
1433
-
1434
- // Reference to the original showSessionSelector, saved before patching
1435
- let origShowSessionSelector: ((this: InteractiveMode) => void) | null = null;
1436
-
1437
- // Reference to the original setupExtensionShortcuts, saved before patching
1438
- let origSetupExtensionShortcuts: Function | null = null;
1439
-
1440
- function patchSetupExtensionShortcuts(): void {
1441
- if (origSetupExtensionShortcuts !== null) return; // Already patched
1442
- const proto = InteractiveMode.prototype as any;
1443
- if (
1444
- !InteractiveMode ||
1445
- typeof InteractiveMode !== "function" ||
1446
- typeof proto.setupExtensionShortcuts !== "function"
1447
- ) {
1448
- return;
1449
- }
1450
- origSetupExtensionShortcuts = proto.setupExtensionShortcuts;
1451
- proto.setupExtensionShortcuts = function (
1452
- this: InteractiveMode,
1453
- extensionRunner: any,
1454
- ) {
1455
- storedExtensionRunner = extensionRunner;
1456
- origSetupExtensionShortcuts!.call(this, extensionRunner);
1457
- };
1458
- }
1459
-
1460
- function unpatchSetupExtensionShortcuts(): void {
1461
- if (origSetupExtensionShortcuts === null) return;
1462
- const proto = InteractiveMode.prototype as any;
1463
- if (
1464
- InteractiveMode &&
1465
- typeof InteractiveMode === "function" &&
1466
- typeof proto.setupExtensionShortcuts === "function"
1467
- ) {
1468
- proto.setupExtensionShortcuts = origSetupExtensionShortcuts;
1469
- }
1470
- origSetupExtensionShortcuts = null;
1471
- storedExtensionRunner = null;
1472
- }
1473
-
1474
- function installResumeHijack(): void {
1475
- if (origShowSessionSelector !== null) return; // Already patched
1476
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- prototype patching requires any cast for private access
1477
- const proto = InteractiveMode.prototype as any;
1478
- if (
1479
- !InteractiveMode ||
1480
- typeof InteractiveMode !== "function" ||
1481
- typeof proto.showSessionSelector !== "function"
1482
- ) {
1483
- return; // Guard: API changed or not available
1484
- }
1485
- origShowSessionSelector = proto.showSessionSelector;
1486
- proto.showSessionSelector = function (this: InteractiveMode) {
1487
- // Try to get an ExtensionCommandContext from the running session's extension runner
1488
- const session = (this as any).session;
1489
- if (!session?.extensionRunner?.createCommandContext) {
1490
- // Fallback to original if we can't get a command context
1491
- origShowSessionSelector!.call(this);
1492
- return;
1493
- }
1494
- const ctx = session.extensionRunner.createCommandContext() as ExtensionCommandContext;
1495
- // Fire-and-forget — same pattern as the original (synchronous, UI appears immediately)
1496
- void showFastResumePicker(ctx);
1497
- };
1498
- }
1499
-
1500
- function uninstallResumeHijack(): void {
1501
- if (origShowSessionSelector === null) return;
1502
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- prototype patching requires any cast for private access
1503
- const proto = InteractiveMode.prototype as any;
1504
- if (
1505
- InteractiveMode &&
1506
- typeof InteractiveMode === "function" &&
1507
- typeof proto.showSessionSelector === "function"
1508
- ) {
1509
- proto.showSessionSelector = origShowSessionSelector;
1510
- }
1511
- origShowSessionSelector = null;
1512
- }
1428
+ import { installInteractivePatch } from "./src/interactive-patch.js";
1513
1429
 
1514
1430
  export default function (pi: ExtensionAPI) {
1515
1431
  const config = readConfig();
1516
- const hijackResume = config.hijackResume !== false;
1517
-
1518
- if (hijackResume) {
1519
- // Hijack /resume — replace the built-in session selector with our fast picker
1520
- installResumeHijack();
1521
- // Don't register /fast-resume — /resume already opens the fast picker
1522
- } else {
1523
- // Normal mode — register /fast-resume as a standalone command
1524
- pi.registerCommand("fast-resume", {
1525
- description: "Fast session resume — instant picker with incremental loading",
1526
- getArgumentCompletions: (prefix: string) => {
1527
- if (!prefix) return null;
1528
- return [{ value: prefix, label: `Search: ${prefix}` }];
1529
- },
1530
- handler: async (args, ctx) => {
1531
- const query = args?.trim() || undefined;
1532
- await showFastResumePicker(ctx, query);
1533
- },
1534
- });
1535
- }
1536
-
1537
- // Register a standalone keyboard shortcut for the fast resume picker.
1538
- // pi.registerShortcut handlers receive ExtensionContext (no switchSession),
1539
- // so we capture the extension runner via a prototype patch on
1540
- // InteractiveMode.prototype.setupExtensionShortcuts and use it to create
1541
- // an ExtensionCommandContext inside the handler.
1542
- //
1543
- // Users can rebind the key via pi-fast-resume.json:
1544
- // { "shortcut": "alt+u" }
1545
- //
1546
- // In hijack mode, app.session.resume also opens the fast picker (rebindable
1547
- // in ~/.pi/agent/keybindings.json). The shortcut config is an additional
1548
- // independent binding that does not override the built-in /resume.
1549
- const shortcut = config.shortcut;
1550
- if (shortcut) {
1551
- // Patch setupExtensionShortcuts so we can capture the extension runner.
1552
- // This runs before setupExtensionShortcuts is called (during extension
1553
- // load, which precedes the shortcut setup phase).
1554
- patchSetupExtensionShortcuts();
1555
-
1556
- pi.registerShortcut(shortcut as KeyId, {
1557
- description: "Fast session resume",
1558
- handler: async (ctx) => {
1559
- // Use the stored extension runner to get a full command context
1560
- // with switchSession(), since the shortcut handler ctx (ExtensionContext)
1561
- // does not include session-switching methods.
1562
- if (
1563
- !storedExtensionRunner ||
1564
- typeof storedExtensionRunner.createCommandContext !== "function"
1565
- ) {
1566
- ctx.ui.notify(
1567
- "Fast resume shortcut: extension runner not available. Try reloading with /reload.",
1568
- "error",
1569
- );
1570
- return;
1571
- }
1572
- const cmdCtx =
1573
- storedExtensionRunner.createCommandContext() as ExtensionCommandContext;
1574
- await showFastResumePicker(cmdCtx);
1575
- },
1576
- });
1577
- }
1432
+ const hijack = config.hijackResume !== false;
1433
+ let runner: { createCommandContext(): ExtensionCommandContext } | undefined;
1434
+ let restores: Array<() => void> = [];
1435
+
1436
+ // Pi 0.99 binds session_start before setupExtensionShortcuts, including
1437
+ // session replacement. Factories loaded for discovery must not patch the UI.
1438
+ pi.on("session_start", (_event, ctx) => {
1439
+ if (ctx.mode !== "tui" || restores.length) return;
1440
+ try {
1441
+ if (hijack) restores.push(installInteractivePatch(InteractiveMode.prototype, "showSessionSelector", function (original, ...args) {
1442
+ const currentRunner = this.session?.extensionRunner;
1443
+ if (!currentRunner?.createCommandContext) return original.apply(this, args);
1444
+ return showFastResumePicker(currentRunner.createCommandContext()).catch((error: unknown) => {
1445
+ ctx.ui.notify(`Fast resume: ${String(error)}`, "error");
1446
+ });
1447
+ }));
1448
+ if (config.shortcut) restores.push(installInteractivePatch(InteractiveMode.prototype, "setupExtensionShortcuts", function (original, extensionRunner) {
1449
+ runner = extensionRunner;
1450
+ return original.call(this, extensionRunner);
1451
+ }));
1452
+ } catch (error) {
1453
+ for (const restore of restores.reverse()) restore();
1454
+ restores = [];
1455
+ ctx.ui.notify(String(error), "warning");
1456
+ }
1457
+ });
1578
1458
 
1579
- // Clean up prototype patches on session shutdown (reload, quit, session switch)
1459
+ if (!hijack) pi.registerCommand("fast-resume", {
1460
+ description: "Fast session resume",
1461
+ getArgumentCompletions: (prefix: string) => prefix ? [{ value: prefix, label: `Search: ${prefix}` }] : null,
1462
+ handler: async (args, ctx) => { await showFastResumePicker(ctx, args?.trim() || undefined); },
1463
+ });
1464
+ if (config.shortcut) pi.registerShortcut(config.shortcut as KeyId, {
1465
+ description: "Fast session resume",
1466
+ handler: async (ctx) => {
1467
+ if (ctx.mode !== "tui") return;
1468
+ if (!runner) { ctx.ui.notify("Fast resume: session shortcuts are not bound", "error"); return; }
1469
+ await showFastResumePicker(runner.createCommandContext());
1470
+ },
1471
+ });
1580
1472
  pi.on("session_shutdown", () => {
1581
- if (hijackResume) {
1582
- uninstallResumeHijack();
1583
- }
1584
- if (shortcut) {
1585
- unpatchSetupExtensionShortcuts();
1586
- }
1473
+ for (const restore of restores.reverse()) restore();
1474
+ restores = [];
1475
+ runner = undefined;
1587
1476
  });
1588
1477
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fast-resume",
3
- "version": "1.4.13",
3
+ "version": "1.4.15",
4
4
  "description": "Fast session picker for pi — reads headers + first messages from 16KB partial reads instead of full-file parsing",
5
5
  "type": "module",
6
6
  "author": "Tom X Nguyen",
@@ -30,6 +30,7 @@
30
30
  "README.md"
31
31
  ],
32
32
  "scripts": {
33
+ "test:host": "node scripts/pi1-host.mjs",
33
34
  "test": "vitest run",
34
35
  "test:watch": "vitest",
35
36
  "test:coverage": "vitest run --coverage",
@@ -38,8 +39,8 @@
38
39
  "lint:dead": "knip --no-gitignore"
39
40
  },
40
41
  "devDependencies": {
41
- "@earendil-works/pi-coding-agent": "0.87.0",
42
- "@earendil-works/pi-tui": "0.87.0",
42
+ "@earendil-works/pi-coding-agent": "1.0.0",
43
+ "@earendil-works/pi-tui": "1.0.0",
43
44
  "@types/node": "25.9.1",
44
45
  "@vitest/coverage-v8": "4.1.7",
45
46
  "knip": "6.14.1",
@@ -53,5 +54,9 @@
53
54
  },
54
55
  "overrides": {
55
56
  "brace-expansion": "5.0.6"
57
+ },
58
+ "peerDependencies": {
59
+ "@earendil-works/pi-coding-agent": "*",
60
+ "@earendil-works/pi-tui": "*"
56
61
  }
57
62
  }
@@ -0,0 +1,26 @@
1
+ // Shared metadata lets independent picker packages unwind wrappers in either order.
2
+ const PATCH = Symbol.for("pi.fast-picker.patch.v1");
3
+ type Method = (this: any, ...args: any[]) => any;
4
+ type Wrapped = Method & { [PATCH]?: { active: boolean; original: Method } };
5
+ function unwrap(method: Wrapped): Method {
6
+ while (method[PATCH] && !method[PATCH]!.active) method = method[PATCH]!.original;
7
+ return method;
8
+ }
9
+ export function installInteractivePatch(
10
+ prototype: any,
11
+ name: string,
12
+ handler: (this: any, original: Method, ...args: any[]) => any,
13
+ ): () => void {
14
+ const original = prototype[name] as Method;
15
+ if (typeof original !== "function") throw new Error(`Pi interactive contract missing: ${name}`);
16
+ const state = { active: true, original };
17
+ const wrapped: Wrapped = function (...args) {
18
+ return state.active ? handler.call(this, original, ...args) : original.apply(this, args);
19
+ };
20
+ wrapped[PATCH] = state;
21
+ prototype[name] = wrapped;
22
+ return () => {
23
+ state.active = false;
24
+ if (prototype[name] === wrapped) prototype[name] = unwrap(original);
25
+ };
26
+ }