@narumitw/pi-statusline 0.1.4 → 0.1.11

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.
Files changed (3) hide show
  1. package/README.md +57 -19
  2. package/package.json +6 -1
  3. package/src/statusline.ts +154 -16
package/README.md CHANGED
@@ -1,14 +1,29 @@
1
- # pi-statusline
1
+ # pi-statusline — Rich Statusline for the Pi Coding Agent
2
2
 
3
- A public [pi](https://pi.dev) extension package that replaces Pi's footer with a beautiful, information-rich statusline.
3
+ [![npm](https://img.shields.io/npm/v/@narumitw/pi-statusline)](https://www.npmjs.com/package/@narumitw/pi-statusline) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
4
 
5
- ## Install
5
+ `@narumitw/pi-statusline` is a native [Pi coding agent](https://pi.dev) extension that replaces Pi's footer with a beautiful, information-rich terminal statusline.
6
+
7
+ Use it to monitor model selection, thinking level, git branch, working directory, active tools, context usage, token totals, estimated cost, time, and statuses from other Pi extensions.
8
+
9
+ ## ✨ Features
10
+
11
+ - Replaces the default Pi footer with a compact rich statusline.
12
+ - Shows model, thinking level, git branch, project directory, active tool, context usage, tokens, cost, and clock.
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.
16
+ - Uses emoji-labeled segments for readability.
17
+ - Adapts to terminal width and truncates safely.
18
+ - Requires no configuration.
19
+
20
+ ## 📦 Install
6
21
 
7
22
  ```bash
8
23
  pi install npm:@narumitw/pi-statusline
9
24
  ```
10
25
 
11
- Try without installing:
26
+ Try without installing permanently:
12
27
 
13
28
  ```bash
14
29
  pi -e npm:@narumitw/pi-statusline
@@ -20,25 +35,40 @@ Try this package locally from the repository root:
20
35
  pi -e ./extensions/pi-statusline
21
36
  ```
22
37
 
23
- ## What it shows
38
+ ## 👀 What it shows
24
39
 
25
40
  The default statusline includes:
26
41
 
27
- - `π` brand marker
28
- - emoji-labeled current model
29
- - emoji-labeled thinking level
30
- - emoji-labeled git branch
31
- - emoji-labeled current project directory
32
- - emoji-labeled active or last tool
33
- - emoji-labeled context usage percentage
34
- - emoji-labeled token totals
35
- - emoji-labeled estimated cost
36
- - emoji-labeled clock
42
+ - `π` brand marker.
43
+ - 🤖 current model.
44
+ - 🧠 thinking level.
45
+ - 🌿 git branch.
46
+ - 📁 current project directory.
47
+ - 🔧 active or last tool.
48
+ - 📊 context usage percentage.
49
+ - 🔢 token totals.
50
+ - 💰 estimated cost.
51
+ - 🕒 clock.
52
+
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:
37
56
 
38
- Statuses from other extensions, such as goal mode, appear on their own emoji-labeled line below the main statusline and are separated with ``.
39
- The layout adapts to terminal width and truncates safely.
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.
40
62
 
41
- ## Package layout
63
+ ## 🧠 Use cases
64
+
65
+ - Track agent context usage during long coding sessions.
66
+ - See which model and thinking level are active.
67
+ - Monitor token totals and estimated cost.
68
+ - Keep git branch and project directory visible.
69
+ - Make Pi terminal sessions easier to scan at a glance.
70
+
71
+ ## 🗂️ Package layout
42
72
 
43
73
  ```txt
44
74
  extensions/pi-statusline/
@@ -50,7 +80,7 @@ extensions/pi-statusline/
50
80
  └── package.json
51
81
  ```
52
82
 
53
- The package exposes its extension through `package.json`:
83
+ The package exposes its Pi extension through `package.json`:
54
84
 
55
85
  ```json
56
86
  {
@@ -59,3 +89,11 @@ The package exposes its extension through `package.json`:
59
89
  }
60
90
  }
61
91
  ```
92
+
93
+ ## 🔎 Keywords
94
+
95
+ Pi extension, Pi coding agent, statusline, terminal UI, AI coding agent status, token usage, context window, model status, TypeScript Pi package.
96
+
97
+ ## 📄 License
98
+
99
+ MIT. See [`LICENSE`](./LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-statusline",
3
- "version": "0.1.4",
3
+ "version": "0.1.11",
4
4
  "description": "Pi extension that replaces the footer with an information-rich statusline.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,5 +33,10 @@
33
33
  "@mariozechner/pi-tui": "0.73.0",
34
34
  "@types/node": "25.6.0",
35
35
  "typescript": "6.0.3"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/narumiruna/pi-extensions",
40
+ "directory": "extensions/pi-statusline"
36
41
  }
37
42
  }
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