@narumitw/pi-statusline 0.1.10 → 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.
- package/README.md +12 -2
- package/package.json +1 -1
- package/src/statusline.ts +154 -16
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
|
|
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
package/src/statusline.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
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 = [
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
407
|
-
if (
|
|
408
|
-
if (
|
|
409
|
-
|
|
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
|
|