@narumitw/pi-statusline 0.1.10 → 0.1.12

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
@@ -10,7 +10,9 @@ Use it to monitor model selection, thinking level, git branch, working directory
10
10
 
11
11
  - Replaces the default Pi footer with a compact rich statusline.
12
12
  - Shows model, thinking level, git branch, project directory, active tool, context usage, tokens, cost, and clock.
13
- - Displays statuses from other extensions, such as goal mode.
13
+ - Displays compact icon statuses from other extensions, such as goal mode and LSP readiness.
14
+ - Shows active subagent count and execution mode while `pi-subagents` is running.
15
+ - Warns when the same extension package is installed from multiple sources.
14
16
  - Uses emoji-labeled segments for readability.
15
17
  - Adapts to terminal width and truncates safely.
16
18
  - Requires no configuration.
@@ -48,7 +50,15 @@ The default statusline includes:
48
50
  - 💰 estimated cost.
49
51
  - 🕒 clock.
50
52
 
51
- Statuses from other extensions, such as goal mode, appear on their own emoji-labeled line below the main statusline and are separated with ``.
53
+ Statuses from other extensions, such as goal mode, appear on their own compact icon line below the main statusline and are separated with ``.
54
+
55
+ Examples:
56
+
57
+ - `🎯 active` for goal mode.
58
+ - `🧬 ✓` for Biome LSP readiness.
59
+ - `🐍 ty ✓ ruff ✓` for Python LSP readiness.
60
+ - `🧑‍🤝‍🧑 2 parallel` while subagents are active.
61
+ - `⚠️ dup biome-lsp` when local and npm installs register the same extension.
52
62
 
53
63
  ## 🧠 Use cases
54
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-statusline",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Pi extension that replaces the footer with an information-rich statusline.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/statusline.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { basename } from "node:path";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
+ import process from "node:process";
2
4
  import type {
3
5
  ExtensionAPI,
4
6
  ExtensionContext,
@@ -38,13 +40,20 @@ interface StatuslineConfig {
38
40
  interface RuntimeState {
39
41
  turnCount: number;
40
42
  activeTools: Map<string, number>;
43
+ activeSubagents: Map<string, SubagentActivity>;
41
44
  lastTool?: string;
42
45
  lastCompletedTool?: string;
43
46
  isStreaming: boolean;
44
47
  thinkingLevel: ThinkingLevel;
48
+ duplicateExtensions: string[];
45
49
  requestRender?: () => void;
46
50
  }
47
51
 
52
+ interface SubagentActivity {
53
+ mode: string;
54
+ count: number;
55
+ }
56
+
48
57
  interface TokenTotals {
49
58
  input: number;
50
59
  output: number;
@@ -89,14 +98,17 @@ export default function statusline(pi: ExtensionAPI) {
89
98
  const runtime: RuntimeState = {
90
99
  turnCount: 0,
91
100
  activeTools: new Map(),
101
+ activeSubagents: new Map(),
92
102
  isStreaming: false,
93
103
  thinkingLevel: "off",
104
+ duplicateExtensions: [],
94
105
  };
95
106
 
96
107
  const refresh = () => runtime.requestRender?.();
97
108
 
98
109
  const installFooter = (ctx: ExtensionContext) => {
99
110
  ctx.ui.setStatus(STATUSLINE_KEY, undefined);
111
+ runtime.duplicateExtensions = findDuplicateExtensions(ctx.cwd);
100
112
  ctx.ui.setFooter((tui, theme, footerData) => {
101
113
  runtime.requestRender = () => tui.requestRender();
102
114
 
@@ -111,7 +123,7 @@ export default function statusline(pi: ExtensionAPI) {
111
123
  invalidate() {},
112
124
  render(width: number): string[] {
113
125
  const lines = [renderStatusline(width, ctx, footerData, theme, config, runtime)];
114
- const extensionStatusLine = renderExtensionStatusline(width, footerData, theme);
126
+ const extensionStatusLine = renderExtensionStatusline(width, footerData, theme, runtime);
115
127
  if (extensionStatusLine) lines.push(extensionStatusLine);
116
128
  return lines;
117
129
  },
@@ -163,6 +175,9 @@ export default function statusline(pi: ExtensionAPI) {
163
175
  pi.on("tool_execution_start", (event) => {
164
176
  const currentCount = runtime.activeTools.get(event.toolName) ?? 0;
165
177
  runtime.activeTools.set(event.toolName, currentCount + 1);
178
+ if (event.toolName === "subagent") {
179
+ runtime.activeSubagents.set(event.toolCallId, parseSubagentActivity(event.args));
180
+ }
166
181
  runtime.lastTool = event.toolName;
167
182
  refresh();
168
183
  });
@@ -171,6 +186,7 @@ export default function statusline(pi: ExtensionAPI) {
171
186
  const currentCount = runtime.activeTools.get(event.toolName) ?? 0;
172
187
  if (currentCount <= 1) runtime.activeTools.delete(event.toolName);
173
188
  else runtime.activeTools.set(event.toolName, currentCount - 1);
189
+ if (event.toolName === "subagent") runtime.activeSubagents.delete(event.toolCallId);
174
190
 
175
191
  runtime.lastCompletedTool = event.toolName;
176
192
  refresh();
@@ -232,8 +248,9 @@ function renderExtensionStatusline(
232
248
  width: number,
233
249
  footerData: ReadonlyFooterDataProvider,
234
250
  theme: Theme,
251
+ runtime: RuntimeState,
235
252
  ): string | undefined {
236
- const status = formatExtensionStatuses(footerData.getExtensionStatuses(), theme);
253
+ const status = formatExtensionStatuses(footerData.getExtensionStatuses(), theme, runtime);
237
254
  if (!status) return undefined;
238
255
 
239
256
  return truncateToWidth(status, width, "");
@@ -388,32 +405,76 @@ function formatToolActivity(runtime: RuntimeState): string {
388
405
  return "💤 idle";
389
406
  }
390
407
 
391
- function formatExtensionStatuses(statuses: ReadonlyMap<string, string>, theme: Theme): string {
408
+ function formatExtensionStatuses(
409
+ statuses: ReadonlyMap<string, string>,
410
+ theme: Theme,
411
+ runtime: RuntimeState,
412
+ ): string {
392
413
  const separator = theme.fg("dim", "  ");
393
- const visibleStatuses = [...statuses.entries()]
394
- .filter(([key, value]) => key !== STATUSLINE_KEY && value.trim().length > 0)
395
- .slice(0, 3)
396
- .map(([key, value]) => {
397
- const label = theme.fg("dim", `${extensionIcon(key)} ${key}: `);
398
- const text = truncateToWidth(simplifyExtensionStatus(key, value), 24, "…");
399
- return `${label}${text}`;
400
- });
414
+ const visibleStatuses = [
415
+ ...formatSubagentStatus(runtime, theme),
416
+ ...formatDuplicateExtensionStatus(runtime, theme),
417
+ ...[...statuses.entries()]
418
+ .filter(([key, value]) => key !== STATUSLINE_KEY && value.trim().length > 0)
419
+ .map(([key, value]) => formatExtensionStatus(key, value, theme)),
420
+ ].slice(0, 5);
401
421
 
402
422
  return visibleStatuses.join(separator);
403
423
  }
404
424
 
425
+ function formatExtensionStatus(key: string, value: string, theme: Theme): string {
426
+ const text = truncateToWidth(simplifyExtensionStatus(key, value), 22, "…");
427
+ return `${theme.fg(extensionColor(key, value), extensionIcon(key))} ${theme.fg("muted", text)}`;
428
+ }
429
+
430
+ function formatSubagentStatus(runtime: RuntimeState, theme: Theme): string[] {
431
+ const activities = [...runtime.activeSubagents.values()];
432
+ if (activities.length === 0) return [];
433
+
434
+ const total = activities.reduce((sum, activity) => sum + activity.count, 0);
435
+ const mode = activities[0]?.mode ?? "active";
436
+ const suffix = activities.length > 1 ? ` +${activities.length - 1}` : "";
437
+ return [`${theme.fg("accent", "🧑‍🤝‍🧑")} ${theme.fg("muted", `${total} ${mode}${suffix}`)}`];
438
+ }
439
+
440
+ function formatDuplicateExtensionStatus(runtime: RuntimeState, theme: Theme): string[] {
441
+ if (runtime.duplicateExtensions.length === 0) return [];
442
+ const names = runtime.duplicateExtensions.slice(0, 2).join(", ");
443
+ const suffix = runtime.duplicateExtensions.length > 2 ? ` +${runtime.duplicateExtensions.length - 2}` : "";
444
+ return [`${theme.fg("warning", "⚠️")} ${theme.fg("warning", `dup ${names}${suffix}`)}`];
445
+ }
446
+
405
447
  function extensionIcon(key: string): string {
406
- if (key.includes("caffeinate")) return "☕";
407
- if (key.includes("chrome") || key.includes("devtools") || key === "cdp") return "🌐";
408
- if (key.includes("firecrawl")) return "🔥";
409
- if (key.includes("goal")) return "🎯";
448
+ const normalizedKey = key.toLowerCase();
449
+ if (normalizedKey.includes("biome")) return "🧬";
450
+ if (normalizedKey.includes("python") || normalizedKey.includes("ruff") || normalizedKey.includes("ty"))
451
+ return "🐍";
452
+ if (normalizedKey.includes("subagent")) return "🧑‍🤝‍🧑";
453
+ if (normalizedKey.includes("caffeinate")) return "☕";
454
+ if (normalizedKey.includes("chrome") || normalizedKey.includes("devtools") || normalizedKey === "cdp")
455
+ return "🌐";
456
+ if (normalizedKey.includes("firecrawl")) return "🔥";
457
+ if (normalizedKey.includes("goal")) return "🎯";
458
+ if (normalizedKey.includes("retry")) return "🔁";
410
459
  return "🔌";
411
460
  }
412
461
 
462
+ function extensionColor(key: string, value: string): ThemeColor {
463
+ const normalized = `${key} ${value}`.toLowerCase();
464
+ if (/missing|error|fail|conflict|duplicate/.test(normalized)) return "warning";
465
+ if (/ready|active|running|enabled|ok/.test(normalized)) return "success";
466
+ return "muted";
467
+ }
468
+
413
469
  function simplifyExtensionStatus(key: string, value: string): string {
414
470
  return value
415
471
  .trim()
416
472
  .replace(new RegExp(`^${escapeRegExp(key)}\\s*:\\s*`, "iu"), "")
473
+ .replace(/^python-lsp\s*:\s*/iu, "")
474
+ .replace(/^biome-lsp\s*:\s*/iu, "")
475
+ .replace(/\bready\b/giu, "✓")
476
+ .replace(/\bmissing\b/giu, "✗")
477
+ .replace(/,\s*/g, " ")
417
478
  .replace(/\s+\([^)]*\)\s*$/, "")
418
479
  .replace(/\s+/g, " ");
419
480
  }
@@ -422,6 +483,83 @@ function escapeRegExp(value: string): string {
422
483
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
423
484
  }
424
485
 
486
+ function parseSubagentActivity(args: unknown): SubagentActivity {
487
+ const input = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
488
+ const tasks = Array.isArray(input.tasks) ? input.tasks.length : 0;
489
+ const chain = Array.isArray(input.chain) ? input.chain.length : 0;
490
+ const hasAggregator = input.aggregator !== undefined;
491
+
492
+ if (chain > 0) return { mode: "chain", count: chain };
493
+ if (tasks > 0 && hasAggregator) return { mode: "fan-in", count: tasks };
494
+ if (tasks > 0) return { mode: "parallel", count: tasks };
495
+ return { mode: "single", count: 1 };
496
+ }
497
+
498
+ function findDuplicateExtensions(cwd: string): string[] {
499
+ const settingsFiles = [
500
+ join(process.env.HOME ?? "", ".pi", "agent", "settings.json"),
501
+ join(cwd, ".pi", "settings.json"),
502
+ ].filter((file) => existsSync(file));
503
+ const sourcesByPackage = new Map<string, Set<string>>();
504
+
505
+ for (const settingsFile of settingsFiles) {
506
+ for (const source of readPackageSources(settingsFile)) {
507
+ const packageName = packageNameForSource(source, dirname(settingsFile));
508
+ if (!packageName) continue;
509
+ const sources = sourcesByPackage.get(packageName) ?? new Set<string>();
510
+ sources.add(sourceIdentity(source, dirname(settingsFile)));
511
+ sourcesByPackage.set(packageName, sources);
512
+ }
513
+ }
514
+
515
+ return [...sourcesByPackage.entries()]
516
+ .filter(([, sources]) => sources.size > 1)
517
+ .map(([packageName]) => packageName.replace(/^@[^/]+\//, "").replace(/^pi-/, ""));
518
+ }
519
+
520
+ function readPackageSources(settingsFile: string): string[] {
521
+ try {
522
+ const settings = JSON.parse(readFileSync(settingsFile, "utf8")) as { packages?: unknown[] };
523
+ return (settings.packages ?? [])
524
+ .map((entry) => {
525
+ if (typeof entry === "string") return entry;
526
+ if (entry && typeof entry === "object" && typeof (entry as { source?: unknown }).source === "string") {
527
+ return (entry as { source: string }).source;
528
+ }
529
+ return undefined;
530
+ })
531
+ .filter((source): source is string => source !== undefined);
532
+ } catch {
533
+ return [];
534
+ }
535
+ }
536
+
537
+ function packageNameForSource(source: string, baseDirectory: string): string | undefined {
538
+ if (source.startsWith("npm:")) return npmPackageName(source);
539
+ const packageJson = join(resolveSourcePath(source, baseDirectory), "package.json");
540
+ try {
541
+ const packageData = JSON.parse(readFileSync(packageJson, "utf8")) as { name?: unknown };
542
+ return typeof packageData.name === "string" ? packageData.name : undefined;
543
+ } catch {
544
+ return undefined;
545
+ }
546
+ }
547
+
548
+ function npmPackageName(source: string): string {
549
+ const spec = source.slice("npm:".length);
550
+ if (spec.startsWith("@")) return spec.split("@").slice(0, 2).join("@").replace(/^@/, "@");
551
+ return spec.split("@")[0] ?? spec;
552
+ }
553
+
554
+ function sourceIdentity(source: string, baseDirectory: string): string {
555
+ if (source.startsWith("npm:")) return `npm:${npmPackageName(source)}`;
556
+ return resolveSourcePath(source, baseDirectory);
557
+ }
558
+
559
+ function resolveSourcePath(source: string, baseDirectory: string): string {
560
+ return isAbsolute(source) ? source : resolve(baseDirectory, source);
561
+ }
562
+
425
563
  function getTokenTotals(ctx: ExtensionContext): TokenTotals {
426
564
  const totals: TokenTotals = { input: 0, output: 0, cost: 0 };
427
565