@jaggerxtrm/pi-extensions 0.9.4 → 0.9.5

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.
@@ -10,7 +10,7 @@
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
12
12
 
13
- import { SubprocessRunner, EventAdapter } from "../../src/core";
13
+ import { EventAdapter, SubprocessRunner } from "../../src/core";
14
14
 
15
15
  export default function (pi: ExtensionAPI) {
16
16
  interface BeadClaim {
@@ -32,6 +32,8 @@ export default function (pi: ExtensionAPI) {
32
32
  lastFetch: number;
33
33
  }
34
34
 
35
+ type RefreshKind = "runtime" | "beads";
36
+
35
37
  const STATUS_ICONS: Record<string, string> = {
36
38
  open: "○",
37
39
  in_progress: "◐",
@@ -39,7 +41,6 @@ export default function (pi: ExtensionAPI) {
39
41
  closed: "✓",
40
42
  };
41
43
 
42
- // Chip background colours (raw ANSI — theme has no bg() API)
43
44
  const CHIP_BG_NEUTRAL = "\x1b[48;5;238m";
44
45
  const CHIP_BG_ACTIVE = "\x1b[48;5;39m";
45
46
  const CHIP_BG_BLOCKED = "\x1b[48;5;88m";
@@ -52,13 +53,22 @@ export default function (pi: ExtensionAPI) {
52
53
  blocked: CHIP_BG_BLOCKED,
53
54
  };
54
55
 
56
+ const CACHE_TTL = 5000;
57
+ const REFRESH_TIMEOUT_MS = 2000;
58
+ const TOOL_RESULT_REFRESH_DELAY_MS = 200;
59
+ const FOOTER_REAPPLY_DELAY_MS = 40;
60
+
55
61
  const chip = (text: string, bg = CHIP_BG_NEUTRAL): string => `${bg}${CHIP_FG} ${text} ${CHIP_RESET}`;
56
62
 
57
63
  let capturedPi: ExtensionAPI = pi;
58
64
  let capturedCtx: any = null;
59
65
  let requestRender: (() => void) | null = null;
66
+ let footerReapplyTimer: ReturnType<typeof setTimeout> | null = null;
67
+ let scheduledRefreshTimer: ReturnType<typeof setTimeout> | null = null;
68
+ let scheduledRefreshKinds = new Set<RefreshKind>();
69
+ let runningRefreshPromise: Promise<void> | null = null;
70
+ let branchChangeUnsub: (() => void) | null = null;
60
71
 
61
- const CACHE_TTL = 5000;
62
72
  let refreshingBeads = false;
63
73
  let refreshingRuntime = false;
64
74
 
@@ -76,10 +86,9 @@ export default function (pi: ExtensionAPI) {
76
86
 
77
87
  const getCwd = () => capturedCtx?.cwd || process.cwd();
78
88
  const getShortId = (id: string) => id.split("-").pop() ?? id;
89
+ const runCommand = (command: string, args: string[], cwd: string) =>
90
+ SubprocessRunner.run(command, args, { cwd, timeoutMs: REFRESH_TIMEOUT_MS });
79
91
 
80
- /**
81
- * Parse git status --porcelain output into status flags
82
- */
83
92
  const parseGitFlags = (porcelain: string): string => {
84
93
  let modified = false;
85
94
  let staged = false;
@@ -92,9 +101,6 @@ export default function (pi: ExtensionAPI) {
92
101
  return `${modified ? "*" : ""}${staged ? "+" : ""}${deleted ? "-" : ""}`;
93
102
  };
94
103
 
95
- /**
96
- * Fetch git branch and status
97
- */
98
104
  const refreshRuntimeState = async () => {
99
105
  if (refreshingRuntime || Date.now() - runtimeState.lastFetch < CACHE_TTL) return;
100
106
  refreshingRuntime = true;
@@ -103,25 +109,21 @@ export default function (pi: ExtensionAPI) {
103
109
  let branch: string | null = null;
104
110
  let gitStatus = "";
105
111
 
106
- const rootResult = await SubprocessRunner.run("git", ["rev-parse", "--show-toplevel"], { cwd });
112
+ const rootResult = await runCommand("git", ["rev-parse", "--show-toplevel"], cwd);
107
113
  const repoRoot = rootResult.code === 0 ? rootResult.stdout.trim() : null;
108
114
 
109
115
  if (repoRoot) {
110
- const branchResult = await SubprocessRunner.run("git", ["branch", "--show-current"], { cwd });
116
+ const branchResult = await runCommand("git", ["branch", "--show-current"], cwd);
111
117
  branch = branchResult.code === 0 ? branchResult.stdout.trim() || null : null;
112
118
 
113
- const porcelainResult = await SubprocessRunner.run(
114
- "git",
115
- ["--no-optional-locks", "status", "--porcelain"],
116
- { cwd },
117
- );
119
+ const porcelainResult = await runCommand("git", ["--no-optional-locks", "status", "--porcelain"], cwd);
118
120
  const baseFlags = porcelainResult.code === 0 ? parseGitFlags(porcelainResult.stdout) : "";
119
121
 
120
122
  let upstreamFlags = "";
121
- const abResult = await SubprocessRunner.run(
123
+ const abResult = await runCommand(
122
124
  "git",
123
125
  ["--no-optional-locks", "rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
124
- { cwd },
126
+ cwd,
125
127
  );
126
128
  if (abResult.code === 0) {
127
129
  const [behindRaw, aheadRaw] = abResult.stdout.trim().split(/\s+/);
@@ -148,9 +150,6 @@ export default function (pi: ExtensionAPI) {
148
150
  }
149
151
  };
150
152
 
151
- /**
152
- * Format token counts (from original footer)
153
- */
154
153
  const formatTokens = (count: number): string => {
155
154
  if (count < 1000) return count.toString();
156
155
  if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
@@ -165,14 +164,14 @@ export default function (pi: ExtensionAPI) {
165
164
  if (!EventAdapter.isBeadsProject(cwd)) return;
166
165
  refreshingBeads = true;
167
166
  try {
168
- const inProgressResult = await SubprocessRunner.run("bd", ["list", "--status=in_progress"], { cwd });
167
+ const inProgressResult = await runCommand("bd", ["list", "--status=in_progress"], cwd);
169
168
  const inProgressRaw = inProgressResult.code === 0 ? inProgressResult.stdout : "";
170
169
  const ids = [...new Set([...inProgressRaw.matchAll(/^◐\s+([a-z][\w-]+)/gm)].map((m) => m[1]).filter((id) => id.includes("-")))];
171
170
 
172
171
  let claims: BeadClaim[] = [];
173
172
  if (ids.length === 1) {
174
173
  const [id] = ids;
175
- const showResult = await SubprocessRunner.run("bd", ["show", id, "--json"], { cwd });
174
+ const showResult = await runCommand("bd", ["show", id, "--json"], cwd);
176
175
  if (showResult.code === 0) {
177
176
  try {
178
177
  const issue = JSON.parse(showResult.stdout)?.[0];
@@ -189,10 +188,10 @@ export default function (pi: ExtensionAPI) {
189
188
 
190
189
  let openCount = 0;
191
190
  if (claims.length === 0) {
192
- const listResult = await SubprocessRunner.run("bd", ["list"], { cwd });
191
+ const listResult = await runCommand("bd", ["list"], cwd);
193
192
  if (listResult.code === 0) {
194
- const m = listResult.stdout.match(/\((\d+)\s+open/);
195
- if (m) openCount = parseInt(m[1], 10);
193
+ const match = listResult.stdout.match(/\((\d+)\s+open/);
194
+ if (match) openCount = parseInt(match[1], 10);
196
195
  }
197
196
  }
198
197
 
@@ -209,9 +208,35 @@ export default function (pi: ExtensionAPI) {
209
208
  }
210
209
  };
211
210
 
212
- /**
213
- * Build beads line: ◐ 4843.5 Rework project bootstrap... or ○ 6 open
214
- */
211
+ const runRefreshKinds = async (refreshKinds: ReadonlySet<RefreshKind>) => {
212
+ await Promise.all([
213
+ refreshKinds.has("runtime") ? refreshRuntimeState() : Promise.resolve(),
214
+ refreshKinds.has("beads") ? refreshBeadState() : Promise.resolve(),
215
+ ]);
216
+ };
217
+
218
+ const flushScheduledRefreshes = async () => {
219
+ if (runningRefreshPromise) return runningRefreshPromise;
220
+ const refreshKinds = scheduledRefreshKinds;
221
+ scheduledRefreshKinds = new Set<RefreshKind>();
222
+ runningRefreshPromise = runRefreshKinds(refreshKinds).finally(async () => {
223
+ runningRefreshPromise = null;
224
+ if (scheduledRefreshKinds.size > 0) {
225
+ await flushScheduledRefreshes();
226
+ }
227
+ });
228
+ return runningRefreshPromise;
229
+ };
230
+
231
+ const scheduleRefresh = (kinds: readonly RefreshKind[], delayMs = 0) => {
232
+ for (const kind of kinds) scheduledRefreshKinds.add(kind);
233
+ if (scheduledRefreshTimer) return;
234
+ scheduledRefreshTimer = setTimeout(() => {
235
+ scheduledRefreshTimer = null;
236
+ void flushScheduledRefreshes();
237
+ }, delayMs);
238
+ };
239
+
215
240
  const buildBeadsLine = (width: number, theme: any): string => {
216
241
  const { claims, openCount } = beadState;
217
242
 
@@ -239,55 +264,50 @@ export default function (pi: ExtensionAPI) {
239
264
  return truncateToWidth(`○ no open issues`, width);
240
265
  };
241
266
 
242
- let footerReapplyTimer: ReturnType<typeof setTimeout> | null = null;
267
+ const attachBranchChangeListener = (footerData: any) => {
268
+ branchChangeUnsub?.();
269
+ const unsubscribe = footerData.onBranchChange(() => {
270
+ runtimeState.lastFetch = 0;
271
+ scheduleRefresh(["runtime"]);
272
+ });
273
+ branchChangeUnsub = unsubscribe;
274
+ return unsubscribe;
275
+ };
243
276
 
244
277
  const applyCustomFooter = (ctx: any) => {
245
278
  capturedCtx = ctx;
246
279
  ctx.ui.setFooter((tui, theme, footerData) => {
247
- requestRender = () => tui.requestRender();
248
- const unsub = footerData.onBranchChange(() => {
249
- runtimeState.lastFetch = 0;
250
- tui.requestRender();
251
- });
280
+ const instanceRequestRender = () => tui.requestRender();
281
+ requestRender = instanceRequestRender;
282
+ const instanceBranchChangeUnsub = attachBranchChangeListener(footerData);
252
283
 
253
284
  return {
254
285
  dispose() {
255
- unsub();
256
- requestRender = null;
286
+ instanceBranchChangeUnsub?.();
287
+ if (branchChangeUnsub === instanceBranchChangeUnsub) branchChangeUnsub = null;
288
+ if (requestRender === instanceRequestRender) requestRender = null;
257
289
  },
258
290
  invalidate() {},
259
291
  render(width: number): string[] {
260
- refreshRuntimeState().catch(() => {});
261
- refreshBeadState().catch(() => {});
262
-
263
- // === LINE 1: ~/path (branch *+↑) ===
264
- // Like original, no session name, but with git status flags
265
292
  let pwd = process.cwd();
266
293
  const home = process.env.HOME || process.env.USERPROFILE;
267
294
  if (home && pwd.startsWith(home)) {
268
295
  pwd = `~${pwd.slice(home.length)}`;
269
296
  }
270
297
 
271
- // Use runtimeState branch (with git status) or fallback to footerData
272
298
  const branch = runtimeState.branch || footerData.getGitBranch();
273
299
  if (branch) {
274
- const branchWithStatus = runtimeState.gitStatus
275
- ? `${branch} ${runtimeState.gitStatus}`
276
- : branch;
300
+ const branchWithStatus = runtimeState.gitStatus ? `${branch} ${runtimeState.gitStatus}` : branch;
277
301
  pwd = `${pwd} (${branchWithStatus})`;
278
302
  }
279
303
 
280
304
  const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "..."));
281
305
 
282
- // === LINE 2: XX%/window (provider) model • thinking ===
283
306
  const usage = ctx.getContextUsage();
284
307
  const model = ctx.model;
285
-
286
308
  const contextWindow = usage?.contextWindow ?? model?.contextWindow ?? 0;
287
309
  const contextPercentValue = usage?.percent ?? 0;
288
310
  const contextPercent = usage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
289
-
290
- // Build left side: context %/window (color-coded like original)
291
311
  const contextDisplay =
292
312
  contextPercent === "?"
293
313
  ? `?/${formatTokens(contextWindow)}`
@@ -299,11 +319,8 @@ export default function (pi: ExtensionAPI) {
299
319
  return text;
300
320
  };
301
321
 
302
- // Build right side: (provider) model • thinking
303
322
  const modelName = model?.id || "no-model";
304
323
  const providerCount = footerData.getAvailableProviderCount();
305
-
306
- // Thinking level if model supports reasoning
307
324
  let rightSideWithoutProvider = modelName;
308
325
  if (model?.reasoning) {
309
326
  const thinkingLevel = capturedPi.getThinkingLevel() || "off";
@@ -311,7 +328,6 @@ export default function (pi: ExtensionAPI) {
311
328
  thinkingLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`;
312
329
  }
313
330
 
314
- // Prepend provider if >1
315
331
  let rightSide = rightSideWithoutProvider;
316
332
  if (providerCount > 1 && model) {
317
333
  rightSide = `(${model.provider}) ${rightSideWithoutProvider}`;
@@ -320,7 +336,6 @@ export default function (pi: ExtensionAPI) {
320
336
  }
321
337
  }
322
338
 
323
- // Keep provider/model adjacent to usage (no right-bound alignment)
324
339
  const separator = " ";
325
340
  const leftWidth = visibleWidth(contextDisplay);
326
341
  const separatorWidth = visibleWidth(separator);
@@ -334,16 +349,14 @@ export default function (pi: ExtensionAPI) {
334
349
  line2 = `${colorizeUsage(contextDisplay)}${theme.fg("dim", separator)}${theme.fg("dim", truncatedRight)}`;
335
350
  }
336
351
 
337
- // === LINE 3: ◐ 4843.5 Rework project bootstrap... ===
338
352
  const line3 = buildBeadsLine(width, theme);
339
-
340
353
  return [pwdLine, line2, line3];
341
354
  },
342
355
  };
343
356
  });
344
357
  };
345
358
 
346
- const scheduleFooterReapply = (ctx: any, delayMs = 40) => {
359
+ const scheduleFooterReapply = (ctx: any, delayMs = FOOTER_REAPPLY_DELAY_MS) => {
347
360
  if (footerReapplyTimer) clearTimeout(footerReapplyTimer);
348
361
  footerReapplyTimer = setTimeout(() => {
349
362
  applyCustomFooter(ctx);
@@ -351,25 +364,35 @@ export default function (pi: ExtensionAPI) {
351
364
  }, delayMs);
352
365
  };
353
366
 
354
- pi.on("session_start", async (_event, ctx) => {
355
- capturedCtx = ctx;
367
+ const resetRefreshCaches = () => {
356
368
  runtimeState.lastFetch = 0;
357
369
  beadState.lastFetch = 0;
370
+ };
371
+
372
+ pi.on("session_start", async (_event, ctx) => {
373
+ capturedCtx = ctx;
374
+ resetRefreshCaches();
358
375
  applyCustomFooter(ctx);
359
376
  scheduleFooterReapply(ctx);
377
+ scheduleRefresh(["runtime", "beads"]);
360
378
  });
361
379
 
362
380
  pi.on("session_switch", async (_event, ctx) => {
363
- runtimeState.lastFetch = 0;
381
+ capturedCtx = ctx;
382
+ resetRefreshCaches();
364
383
  scheduleFooterReapply(ctx);
384
+ scheduleRefresh(["runtime", "beads"]);
365
385
  });
366
386
 
367
387
  pi.on("session_fork", async (_event, ctx) => {
368
- runtimeState.lastFetch = 0;
388
+ capturedCtx = ctx;
389
+ resetRefreshCaches();
369
390
  scheduleFooterReapply(ctx);
391
+ scheduleRefresh(["runtime", "beads"]);
370
392
  });
371
393
 
372
394
  pi.on("model_select", async (_event, ctx) => {
395
+ capturedCtx = ctx;
373
396
  scheduleFooterReapply(ctx);
374
397
  });
375
398
 
@@ -378,20 +401,31 @@ export default function (pi: ExtensionAPI) {
378
401
  clearTimeout(footerReapplyTimer);
379
402
  footerReapplyTimer = null;
380
403
  }
404
+ if (scheduledRefreshTimer) {
405
+ clearTimeout(scheduledRefreshTimer);
406
+ scheduledRefreshTimer = null;
407
+ }
408
+ scheduledRefreshKinds.clear();
409
+ branchChangeUnsub?.();
410
+ branchChangeUnsub = null;
411
+ requestRender = null;
381
412
  });
382
413
 
383
- // Bust caches immediately after relevant writes
384
414
  pi.on("tool_result", async (event: any) => {
385
415
  const cmd = event?.input?.command;
386
416
  if (!cmd) return undefined;
387
417
 
418
+ const refreshKinds: RefreshKind[] = [];
388
419
  if (/\bbd\s+(close|update|create|claim)\b/.test(cmd)) {
389
420
  beadState.lastFetch = 0;
390
- setTimeout(() => refreshBeadState().catch(() => {}), 200);
421
+ refreshKinds.push("beads");
391
422
  }
392
423
  if (/\bgit\s+/.test(cmd)) {
393
424
  runtimeState.lastFetch = 0;
394
- setTimeout(() => refreshRuntimeState().catch(() => {}), 200);
425
+ refreshKinds.push("runtime");
426
+ }
427
+ if (refreshKinds.length > 0) {
428
+ scheduleRefresh(refreshKinds, TOOL_RESULT_REFRESH_DELAY_MS);
395
429
  }
396
430
  return undefined;
397
431
  });