@narumitw/pi-statusline 0.13.0 → 0.13.1
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 +3 -2
- package/package.json +1 -1
- package/src/extension-status.ts +300 -0
- package/src/git-status.ts +117 -0
- package/src/render.ts +257 -0
- package/src/settings.ts +66 -0
- package/src/statusline.ts +43 -721
package/src/statusline.ts
CHANGED
|
@@ -1,107 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
StatuslineConfig,
|
|
20
|
-
StatuslinePresetName,
|
|
21
|
-
TokyoNightBlockName,
|
|
22
|
-
} from "../presets/types.js";
|
|
23
|
-
|
|
24
|
-
type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
25
|
-
export type ExtensionStatusIconAliasMap = ReadonlyMap<string, readonly string[]>;
|
|
26
|
-
|
|
27
|
-
interface RuntimeState {
|
|
28
|
-
turnCount: number;
|
|
29
|
-
activeTools: Map<string, number>;
|
|
30
|
-
lastTool?: string;
|
|
31
|
-
lastCompletedTool?: string;
|
|
32
|
-
isStreaming: boolean;
|
|
33
|
-
thinkingLevel: ThinkingLevel;
|
|
34
|
-
duplicateExtensions: string[];
|
|
35
|
-
extensionStatusIconAliases: ExtensionStatusIconAliasMap;
|
|
36
|
-
gitStatus?: GitStatusSummary;
|
|
37
|
-
requestRender?: () => void;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface GitStatusSummary {
|
|
41
|
-
ahead: number;
|
|
42
|
-
behind: number;
|
|
43
|
-
staged: number;
|
|
44
|
-
modified: number;
|
|
45
|
-
untracked: number;
|
|
46
|
-
conflicts: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface TokenTotals {
|
|
50
|
-
input: number;
|
|
51
|
-
output: number;
|
|
52
|
-
cost: number;
|
|
53
|
-
}
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
buildExtensionStatusIconAliases,
|
|
4
|
+
type ExtensionStatusIconAliasMap,
|
|
5
|
+
findDuplicateExtensions,
|
|
6
|
+
readInstalledExtensionPackages,
|
|
7
|
+
} from "./extension-status.js";
|
|
8
|
+
import {
|
|
9
|
+
gitStatusSummaryEqual,
|
|
10
|
+
readGitStatus,
|
|
11
|
+
type GitStatusSummary,
|
|
12
|
+
} from "./git-status.js";
|
|
13
|
+
import {
|
|
14
|
+
renderExtensionStatusline,
|
|
15
|
+
renderStatusline,
|
|
16
|
+
type RuntimeState,
|
|
17
|
+
} from "./render.js";
|
|
18
|
+
import { createDefaultConfig } from "./settings.js";
|
|
54
19
|
|
|
55
20
|
const STATUSLINE_KEY = "statusline";
|
|
56
|
-
const GITHUB_PR_KEY = "github-pr";
|
|
57
|
-
const SETTINGS_FILE = "pi-statusline-settings.json";
|
|
58
|
-
const DEFAULT_PRESET: StatuslinePresetName = "tokyo-night";
|
|
59
21
|
const GIT_STATUS_REFRESH_INTERVAL_MS = 30_000;
|
|
60
22
|
const GIT_STATUS_EVENT_DEBOUNCE_MS = 250;
|
|
61
|
-
const GIT_STATUS_TIMEOUT_MS = 3_000;
|
|
62
|
-
|
|
63
23
|
const EMPTY_EXTENSION_STATUS_ICON_ALIASES: ExtensionStatusIconAliasMap = new Map();
|
|
64
24
|
|
|
65
|
-
const DEFAULT_EXTENSION_STATUS_ICONS: Record<string, string> = {
|
|
66
|
-
"chrome-devtools": "🌐",
|
|
67
|
-
"codex-usage": "📊",
|
|
68
|
-
caffeinate: "💊",
|
|
69
|
-
firecrawl: "🔥",
|
|
70
|
-
"github-pr": "🔎",
|
|
71
|
-
goal: "🎯",
|
|
72
|
-
lsp: "🧰",
|
|
73
|
-
"plan-mode": "📝",
|
|
74
|
-
pisync: "🔄",
|
|
75
|
-
subagents: "🧑🤝🧑",
|
|
76
|
-
"unknown-error-retry": "🔁",
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
interface StatuslineSettings {
|
|
80
|
-
extensionStatusIcons: Record<string, string>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const DEFAULT_SEGMENTS: SegmentName[] = [
|
|
84
|
-
"brand",
|
|
85
|
-
"model",
|
|
86
|
-
"thinking",
|
|
87
|
-
"cwd",
|
|
88
|
-
"branch",
|
|
89
|
-
"tools",
|
|
90
|
-
"context",
|
|
91
|
-
"tokens",
|
|
92
|
-
"cost",
|
|
93
|
-
"time",
|
|
94
|
-
];
|
|
95
|
-
|
|
96
|
-
const PALETTES: Record<PaletteName, ThemeColor[]> = {
|
|
97
|
-
ocean: ["accent", "muted", "success", "warning"],
|
|
98
|
-
sunset: ["warning", "accent", "success", "muted"],
|
|
99
|
-
forest: ["success", "accent", "muted", "warning"],
|
|
100
|
-
candy: ["accent", "warning", "success", "muted"],
|
|
101
|
-
neon: ["accent", "success", "warning", "error"],
|
|
102
|
-
mono: ["muted", "dim"],
|
|
103
|
-
};
|
|
104
|
-
|
|
105
25
|
export default function statusline(pi: ExtensionAPI) {
|
|
106
26
|
const config = createDefaultConfig();
|
|
107
27
|
const runtime: RuntimeState = {
|
|
@@ -311,626 +231,28 @@ export default function statusline(pi: ExtensionAPI) {
|
|
|
311
231
|
});
|
|
312
232
|
}
|
|
313
233
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
return {
|
|
341
|
-
extensionStatusIcons: Object.fromEntries(
|
|
342
|
-
Object.entries(icons).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
343
|
-
),
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function readStatuslinePreset(): StatuslinePresetName {
|
|
348
|
-
const preset = process.env.PI_STATUSLINE_PRESET?.trim().toLowerCase();
|
|
349
|
-
if (preset === "classic" || preset === "tokyo-night") return preset;
|
|
350
|
-
return DEFAULT_PRESET;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function renderStatusline(
|
|
354
|
-
width: number,
|
|
355
|
-
ctx: ExtensionContext,
|
|
356
|
-
footerData: ReadonlyFooterDataProvider,
|
|
357
|
-
theme: Theme,
|
|
358
|
-
config: StatuslineConfig,
|
|
359
|
-
runtime: RuntimeState,
|
|
360
|
-
): string {
|
|
361
|
-
if (width <= 0) return "";
|
|
362
|
-
|
|
363
|
-
const segments = config.segments
|
|
364
|
-
.map((segment, index) => buildSegment(segment, index, ctx, footerData, config, runtime))
|
|
365
|
-
.filter(
|
|
366
|
-
(segment): segment is RenderSegment => segment !== undefined && segment.text.length > 0,
|
|
367
|
-
);
|
|
368
|
-
|
|
369
|
-
if (segments.length === 0) return truncateToWidth(theme.fg("dim", "pi-statusline"), width);
|
|
370
|
-
|
|
371
|
-
switch (config.preset) {
|
|
372
|
-
case "classic":
|
|
373
|
-
return renderClassicStatusline(width, segments, theme, config);
|
|
374
|
-
case "tokyo-night":
|
|
375
|
-
return renderTokyoNightStatusline(width, segments);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function renderExtensionStatusline(
|
|
380
|
-
width: number,
|
|
381
|
-
footerData: ReadonlyFooterDataProvider,
|
|
382
|
-
theme: Theme,
|
|
383
|
-
config: StatuslineConfig,
|
|
384
|
-
runtime: RuntimeState,
|
|
385
|
-
): string[] {
|
|
386
|
-
const status = formatExtensionStatuses(footerData.getExtensionStatuses(), theme, config, runtime);
|
|
387
|
-
return wrapExtensionStatusline(status, width);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function buildSegment(
|
|
391
|
-
name: SegmentName,
|
|
392
|
-
index: number,
|
|
393
|
-
ctx: ExtensionContext,
|
|
394
|
-
footerData: ReadonlyFooterDataProvider,
|
|
395
|
-
config: StatuslineConfig,
|
|
396
|
-
runtime: RuntimeState,
|
|
397
|
-
): RenderSegment | undefined {
|
|
398
|
-
const color = pickColor(config, index);
|
|
399
|
-
|
|
400
|
-
switch (name) {
|
|
401
|
-
case "brand":
|
|
402
|
-
return segment(name, "π", "accent", "header", true);
|
|
403
|
-
case "model":
|
|
404
|
-
return segment(name, `🤖 ${shortenModel(ctx.model?.id ?? "no-model")}`, color, "header");
|
|
405
|
-
case "thinking":
|
|
406
|
-
return segment(
|
|
407
|
-
name,
|
|
408
|
-
`🧠 ${runtime.thinkingLevel}`,
|
|
409
|
-
thinkingColor(runtime.thinkingLevel),
|
|
410
|
-
"header",
|
|
411
|
-
);
|
|
412
|
-
case "branch": {
|
|
413
|
-
const branch = footerData.getGitBranch();
|
|
414
|
-
const pr = branch ? prLinkFromStatuses(footerData.getExtensionStatuses()) : undefined;
|
|
415
|
-
return segment(name, formatGitBranchText(branch, runtime.gitStatus, pr), color, "git");
|
|
416
|
-
}
|
|
417
|
-
case "cwd":
|
|
418
|
-
return segment(name, `📁 ${basename(ctx.cwd) || ctx.cwd}`, color, "directory");
|
|
419
|
-
case "tools":
|
|
420
|
-
return segment(name, formatToolActivity(runtime), color, "runtime");
|
|
421
|
-
case "context": {
|
|
422
|
-
const usage = ctx.getContextUsage();
|
|
423
|
-
const value =
|
|
424
|
-
usage?.percent === null || usage?.percent === undefined
|
|
425
|
-
? "🪟 ctx ?"
|
|
426
|
-
: `🪟 ctx ${usage.percent.toFixed(0)}%`;
|
|
427
|
-
return segment(name, value, contextColor(usage?.percent), "runtime");
|
|
428
|
-
}
|
|
429
|
-
case "tokens": {
|
|
430
|
-
const totals = getTokenTotals(ctx);
|
|
431
|
-
if (totals.input === 0 && totals.output === 0)
|
|
432
|
-
return segment(name, "🔢 tok 0", color, "runtime");
|
|
433
|
-
return segment(
|
|
434
|
-
name,
|
|
435
|
-
`🔢 ↑${formatCount(totals.input)} ↓${formatCount(totals.output)}`,
|
|
436
|
-
color,
|
|
437
|
-
"runtime",
|
|
438
|
-
);
|
|
439
|
-
}
|
|
440
|
-
case "cost": {
|
|
441
|
-
const totals = getTokenTotals(ctx);
|
|
442
|
-
return segment(name, `💸 $${totals.cost.toFixed(totals.cost >= 1 ? 2 : 3)}`, color, "meter");
|
|
443
|
-
}
|
|
444
|
-
case "time":
|
|
445
|
-
return segment(name, `🕒 ${formatTime()}`, color, "meter");
|
|
446
|
-
case "turn":
|
|
447
|
-
return segment(name, `🔁 #${runtime.turnCount}`, color, "meter");
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function segment(
|
|
452
|
-
name: SegmentName,
|
|
453
|
-
text: string,
|
|
454
|
-
color: ThemeColor,
|
|
455
|
-
block: TokyoNightBlockName,
|
|
456
|
-
emphasis = false,
|
|
457
|
-
): RenderSegment {
|
|
458
|
-
return { name, text, color, block, emphasis };
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function extensionStatusSeparator(presetName: StatuslinePresetName, theme: Theme): string {
|
|
462
|
-
switch (presetName) {
|
|
463
|
-
case "classic":
|
|
464
|
-
return classicExtensionSeparator(theme);
|
|
465
|
-
case "tokyo-night":
|
|
466
|
-
return tokyoNightExtensionSeparator(theme);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function pickColor(config: StatuslineConfig, index: number): ThemeColor {
|
|
471
|
-
const palette = PALETTES[config.palette];
|
|
472
|
-
return palette[index % palette.length] ?? "muted";
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function thinkingColor(level: ThinkingLevel): ThemeColor {
|
|
476
|
-
switch (level) {
|
|
477
|
-
case "off":
|
|
478
|
-
return "dim";
|
|
479
|
-
case "minimal":
|
|
480
|
-
return "thinkingMinimal";
|
|
481
|
-
case "low":
|
|
482
|
-
return "thinkingLow";
|
|
483
|
-
case "medium":
|
|
484
|
-
return "thinkingMedium";
|
|
485
|
-
case "high":
|
|
486
|
-
return "thinkingHigh";
|
|
487
|
-
case "xhigh":
|
|
488
|
-
return "thinkingXhigh";
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
export function contextColor(percent: number | null | undefined): ThemeColor {
|
|
493
|
-
if (percent === null || percent === undefined) return "dim";
|
|
494
|
-
if (percent >= 90) return "error";
|
|
495
|
-
if (percent >= 70) return "warning";
|
|
496
|
-
return "success";
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
export function formatToolActivity(runtime: RuntimeState): string {
|
|
500
|
-
const active = [...runtime.activeTools.entries()];
|
|
501
|
-
if (active.length > 0) {
|
|
502
|
-
const [name, count] = active[0] ?? ["tool", 1];
|
|
503
|
-
const suffix = count > 1 ? `×${count}` : active.length > 1 ? `+${active.length - 1}` : "";
|
|
504
|
-
return `⚙ ${name}${suffix}`;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
if (runtime.isStreaming) return "💭 thinking";
|
|
508
|
-
if (runtime.lastCompletedTool) return `✅ ${runtime.lastCompletedTool}`;
|
|
509
|
-
return "💤 idle";
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
export function prLinkFromStatuses(statuses: ReadonlyMap<string, string>): string | undefined {
|
|
513
|
-
const value = statuses.get(GITHUB_PR_KEY);
|
|
514
|
-
if (!value) return undefined;
|
|
515
|
-
// Extract the OSC 8 hyperlink span (the clickable "#123"); skip non-PR states
|
|
516
|
-
// like "PR gh missing" that carry no link. github-pr emits exactly one link, so the
|
|
517
|
-
// first OSC 8 span is the PR number.
|
|
518
|
-
const open = value.indexOf("\x1b]8;;");
|
|
519
|
-
if (open === -1) return undefined;
|
|
520
|
-
const closeMarker = "\x1b]8;;\x07";
|
|
521
|
-
const close = value.indexOf(closeMarker, open + 1);
|
|
522
|
-
return close === -1 ? undefined : value.slice(open, close + closeMarker.length);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
async function readGitStatus(
|
|
526
|
-
pi: ExtensionAPI,
|
|
527
|
-
cwd: string,
|
|
528
|
-
): Promise<GitStatusSummary | undefined> {
|
|
529
|
-
const result = await pi.exec(
|
|
530
|
-
"git",
|
|
531
|
-
["--no-optional-locks", "status", "--porcelain=v1", "--branch", "--untracked-files=normal"],
|
|
532
|
-
{
|
|
533
|
-
cwd,
|
|
534
|
-
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
535
|
-
},
|
|
536
|
-
);
|
|
537
|
-
if (result.code !== 0 || result.killed) return undefined;
|
|
538
|
-
return parseGitStatusPorcelain(result.stdout);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
export function parseGitStatusPorcelain(output: string): GitStatusSummary {
|
|
542
|
-
const summary: GitStatusSummary = {
|
|
543
|
-
ahead: 0,
|
|
544
|
-
behind: 0,
|
|
545
|
-
staged: 0,
|
|
546
|
-
modified: 0,
|
|
547
|
-
untracked: 0,
|
|
548
|
-
conflicts: 0,
|
|
549
|
-
};
|
|
550
|
-
|
|
551
|
-
for (const line of output.split(/\r?\n/)) {
|
|
552
|
-
if (!line) continue;
|
|
553
|
-
if (line.startsWith("## ")) {
|
|
554
|
-
const ahead = line.match(/\bahead (\d+)/u);
|
|
555
|
-
const behind = line.match(/\bbehind (\d+)/u);
|
|
556
|
-
summary.ahead = ahead ? Number(ahead[1]) : 0;
|
|
557
|
-
summary.behind = behind ? Number(behind[1]) : 0;
|
|
558
|
-
continue;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
const indexStatus = line[0] ?? " ";
|
|
562
|
-
const worktreeStatus = line[1] ?? " ";
|
|
563
|
-
if (indexStatus === "?" && worktreeStatus === "?") {
|
|
564
|
-
summary.untracked += 1;
|
|
565
|
-
continue;
|
|
566
|
-
}
|
|
567
|
-
if (isConflictStatus(indexStatus, worktreeStatus)) {
|
|
568
|
-
summary.conflicts += 1;
|
|
569
|
-
continue;
|
|
570
|
-
}
|
|
571
|
-
if (isChangedStatus(indexStatus)) summary.staged += 1;
|
|
572
|
-
if (isChangedStatus(worktreeStatus)) summary.modified += 1;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
return summary;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
function isConflictStatus(indexStatus: string, worktreeStatus: string): boolean {
|
|
579
|
-
return (
|
|
580
|
-
(indexStatus === "D" && worktreeStatus === "D") ||
|
|
581
|
-
(indexStatus === "A" && worktreeStatus === "A") ||
|
|
582
|
-
indexStatus === "U" ||
|
|
583
|
-
worktreeStatus === "U"
|
|
584
|
-
);
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
function isChangedStatus(status: string): boolean {
|
|
588
|
-
return status !== " " && status !== "?" && status !== "!";
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
export function formatGitStatusSummary(summary: GitStatusSummary | undefined): string {
|
|
592
|
-
if (!summary) return "";
|
|
593
|
-
const tokens = [
|
|
594
|
-
["⇡", summary.ahead],
|
|
595
|
-
["⇣", summary.behind],
|
|
596
|
-
["+", summary.staged],
|
|
597
|
-
["~", summary.modified],
|
|
598
|
-
["?", summary.untracked],
|
|
599
|
-
["!", summary.conflicts],
|
|
600
|
-
] as const;
|
|
601
|
-
return tokens
|
|
602
|
-
.filter(([, count]) => count > 0)
|
|
603
|
-
.map(([prefix, count]) => `${prefix}${formatCount(count)}`)
|
|
604
|
-
.join(" ");
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
export function formatGitBranchText(
|
|
608
|
-
branch: string | null,
|
|
609
|
-
status: GitStatusSummary | undefined,
|
|
610
|
-
pr?: string,
|
|
611
|
-
): string {
|
|
612
|
-
if (!branch) return "🌿 no-git";
|
|
613
|
-
const suffixes = [formatGitStatusSummary(status), pr ? `(${pr})` : ""].filter(Boolean);
|
|
614
|
-
return suffixes.length > 0 ? `🌿 ${branch} ${suffixes.join(" ")}` : `🌿 ${branch}`;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function gitStatusSummaryEqual(
|
|
618
|
-
left: GitStatusSummary | undefined,
|
|
619
|
-
right: GitStatusSummary | undefined,
|
|
620
|
-
): boolean {
|
|
621
|
-
if (!left || !right) return left === right;
|
|
622
|
-
return (
|
|
623
|
-
left.ahead === right.ahead &&
|
|
624
|
-
left.behind === right.behind &&
|
|
625
|
-
left.staged === right.staged &&
|
|
626
|
-
left.modified === right.modified &&
|
|
627
|
-
left.untracked === right.untracked &&
|
|
628
|
-
left.conflicts === right.conflicts
|
|
629
|
-
);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
function formatExtensionStatuses(
|
|
633
|
-
statuses: ReadonlyMap<string, string>,
|
|
634
|
-
theme: Theme,
|
|
635
|
-
config: StatuslineConfig,
|
|
636
|
-
runtime: RuntimeState,
|
|
637
|
-
): string {
|
|
638
|
-
const separator = extensionStatusSeparator(config.preset, theme);
|
|
639
|
-
const visibleStatuses = [
|
|
640
|
-
...formatDuplicateExtensionStatus(runtime, theme),
|
|
641
|
-
...[...statuses.entries()]
|
|
642
|
-
// github-pr is rendered inline in the branch segment, so skip it here to avoid duplication.
|
|
643
|
-
.filter(
|
|
644
|
-
([key, value]) =>
|
|
645
|
-
key !== STATUSLINE_KEY && key !== GITHUB_PR_KEY && value.trim().length > 0,
|
|
646
|
-
)
|
|
647
|
-
.map(([key, value]) =>
|
|
648
|
-
formatExtensionStatus(key, value, theme, config, runtime.extensionStatusIconAliases),
|
|
649
|
-
),
|
|
650
|
-
].slice(0, 5);
|
|
651
|
-
|
|
652
|
-
return visibleStatuses.join(separator);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
export function formatExtensionStatus(
|
|
656
|
-
key: string,
|
|
657
|
-
value: string,
|
|
658
|
-
theme: Theme,
|
|
659
|
-
config: Pick<StatuslineConfig, "extensionStatusIcons">,
|
|
660
|
-
extensionStatusIconAliases: ExtensionStatusIconAliasMap = EMPTY_EXTENSION_STATUS_ICON_ALIASES,
|
|
661
|
-
): string {
|
|
662
|
-
const status = splitExtensionStatusIcon(stripExtensionStatusPrefix(key, value));
|
|
663
|
-
const text = simplifyExtensionStatusText(status.text);
|
|
664
|
-
const color = extensionColor(key, value);
|
|
665
|
-
const textColor = color === "warning" ? "warning" : "muted";
|
|
666
|
-
const icon = extensionStatusIcon(key, status.icon, config.extensionStatusIcons, extensionStatusIconAliases);
|
|
667
|
-
const renderedText = theme.fg(textColor, text);
|
|
668
|
-
return icon ? `${theme.fg(color, icon)} ${renderedText}` : renderedText;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
function extensionStatusIcon(
|
|
672
|
-
key: string,
|
|
673
|
-
leadingIcon: string | undefined,
|
|
674
|
-
configuredIcons: Record<string, string>,
|
|
675
|
-
extensionStatusIconAliases: ExtensionStatusIconAliasMap,
|
|
676
|
-
) {
|
|
677
|
-
if (Object.hasOwn(configuredIcons, key)) return configuredIcons[key];
|
|
678
|
-
for (const alias of extensionStatusAliasesForKey(key, extensionStatusIconAliases)) {
|
|
679
|
-
if (Object.hasOwn(configuredIcons, alias)) return configuredIcons[alias];
|
|
680
|
-
}
|
|
681
|
-
return leadingIcon ?? DEFAULT_EXTENSION_STATUS_ICONS[key] ?? "🔌";
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
function extensionStatusAliasesForKey(
|
|
685
|
-
key: string,
|
|
686
|
-
extensionStatusIconAliases: ExtensionStatusIconAliasMap,
|
|
687
|
-
): readonly string[] {
|
|
688
|
-
for (const [statusBase, aliases] of extensionStatusIconAliases) {
|
|
689
|
-
if (statusKeyMatchesStatusBase(key, statusBase)) return aliases;
|
|
690
|
-
}
|
|
691
|
-
return [];
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
function statusKeyMatchesStatusBase(key: string, statusBase: string): boolean {
|
|
695
|
-
return key === statusBase || key.startsWith(`${statusBase}:`) || key.startsWith(`${statusBase}/`);
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
export function wrapExtensionStatusline(status: string, width: number): string[] {
|
|
699
|
-
if (!status || width <= 0) return [];
|
|
700
|
-
return wrapTextWithAnsi(status, width);
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
function formatDuplicateExtensionStatus(runtime: RuntimeState, theme: Theme): string[] {
|
|
704
|
-
if (runtime.duplicateExtensions.length === 0) return [];
|
|
705
|
-
const names = runtime.duplicateExtensions.slice(0, 2).join(", ");
|
|
706
|
-
const suffix =
|
|
707
|
-
runtime.duplicateExtensions.length > 2 ? ` +${runtime.duplicateExtensions.length - 2}` : "";
|
|
708
|
-
return [`${theme.fg("warning", "⚠️")} ${theme.fg("warning", `dup ${names}${suffix}`)}`];
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
export function splitExtensionStatusIcon(value: string): { icon?: string; text: string } {
|
|
712
|
-
const trimmed = value.trim();
|
|
713
|
-
const [firstToken, ...restTokens] = trimmed.split(/\s+/);
|
|
714
|
-
if (firstToken && isEmojiOnlyToken(firstToken)) {
|
|
715
|
-
return { icon: firstToken, text: restTokens.join(" ") };
|
|
716
|
-
}
|
|
717
|
-
return { text: trimmed };
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
function isEmojiOnlyToken(value: string): boolean {
|
|
721
|
-
return /^(?=.*(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|[0-9#*]\ufe0f?\u20e3))(?:\p{Extended_Pictographic}|\p{Emoji_Modifier}|\p{Regional_Indicator}|\u200d|\ufe0f|[0-9#*]\ufe0f?\u20e3)+$/u.test(
|
|
722
|
-
value,
|
|
723
|
-
);
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
export function extensionColor(key: string, value: string): ThemeColor {
|
|
727
|
-
const normalized = `${key} ${value}`.toLowerCase();
|
|
728
|
-
if (/missing|error|fail|conflict|duplicate|unavailable/.test(normalized)) return "warning";
|
|
729
|
-
if (normalized.includes("codex")) return "accent";
|
|
730
|
-
if (/ready|active|running|enabled|awake|ok/.test(normalized)) return "success";
|
|
731
|
-
return "muted";
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
export function stripExtensionStatusPrefix(key: string, value: string): string {
|
|
735
|
-
return value.trim().replace(new RegExp(`^${escapeRegExp(key)}\\s*:\\s*`, "iu"), "");
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
export function simplifyExtensionStatusText(value: string): string {
|
|
739
|
-
return value
|
|
740
|
-
.trim()
|
|
741
|
-
.replace(/\bready\b/giu, "✓")
|
|
742
|
-
.replace(/\bmissing\b/giu, "✗")
|
|
743
|
-
.replace(/,\s*/g, " ")
|
|
744
|
-
.replace(/\s+\([^)]*\)\s*$/, "")
|
|
745
|
-
.replace(/\s+/g, " ");
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
function escapeRegExp(value: string): string {
|
|
749
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
interface InstalledExtensionPackage {
|
|
753
|
-
packageName: string;
|
|
754
|
-
source: string;
|
|
755
|
-
identity: string;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
function readInstalledExtensionPackages(cwd: string): InstalledExtensionPackage[] {
|
|
759
|
-
const packages: InstalledExtensionPackage[] = [];
|
|
760
|
-
const settingsFiles = extensionSettingsFiles(cwd);
|
|
761
|
-
|
|
762
|
-
for (const settingsFile of settingsFiles) {
|
|
763
|
-
const baseDirectory = dirname(settingsFile);
|
|
764
|
-
for (const rawSource of readPackageSources(settingsFile)) {
|
|
765
|
-
const source = rawSource.trim();
|
|
766
|
-
if (!source) continue;
|
|
767
|
-
const packageName = packageNameForSource(source, baseDirectory);
|
|
768
|
-
if (!packageName) continue;
|
|
769
|
-
packages.push({ packageName, source, identity: sourceIdentity(source, baseDirectory) });
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
return packages;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
function extensionSettingsFiles(cwd: string): string[] {
|
|
777
|
-
return [
|
|
778
|
-
join(process.env.HOME ?? "", ".pi", "agent", "settings.json"),
|
|
779
|
-
join(cwd, ".pi", "settings.json"),
|
|
780
|
-
].filter((file) => existsSync(file));
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
function findDuplicateExtensions(installedPackages: readonly InstalledExtensionPackage[]): string[] {
|
|
784
|
-
const sourcesByPackage = new Map<string, Set<string>>();
|
|
785
|
-
|
|
786
|
-
for (const extensionPackage of installedPackages) {
|
|
787
|
-
const sources = sourcesByPackage.get(extensionPackage.packageName) ?? new Set<string>();
|
|
788
|
-
sources.add(extensionPackage.identity);
|
|
789
|
-
sourcesByPackage.set(extensionPackage.packageName, sources);
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
return [...sourcesByPackage.entries()]
|
|
793
|
-
.filter(([, sources]) => sources.size > 1)
|
|
794
|
-
.map(([packageName]) => packageName.replace(/^@[^/]+\//, "").replace(/^pi-/, ""));
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
export function buildExtensionStatusIconAliases(
|
|
798
|
-
installedPackages: readonly { packageName: string; source?: string }[],
|
|
799
|
-
): Map<string, string[]> {
|
|
800
|
-
const packageAliasesByStatusBase = new Map<string, Map<string, string[]>>();
|
|
801
|
-
|
|
802
|
-
for (const extensionPackage of installedPackages) {
|
|
803
|
-
const candidate = extensionStatusIconAliasCandidate(extensionPackage.packageName, extensionPackage.source);
|
|
804
|
-
if (!candidate) continue;
|
|
805
|
-
const aliasesByPackage = packageAliasesByStatusBase.get(candidate.statusBase) ?? new Map<string, string[]>();
|
|
806
|
-
const existingAliases = aliasesByPackage.get(extensionPackage.packageName) ?? [];
|
|
807
|
-
aliasesByPackage.set(extensionPackage.packageName, uniqueStrings([...existingAliases, ...candidate.aliases]));
|
|
808
|
-
packageAliasesByStatusBase.set(candidate.statusBase, aliasesByPackage);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
const aliases = new Map<string, string[]>();
|
|
812
|
-
for (const [statusBase, aliasesByPackage] of packageAliasesByStatusBase) {
|
|
813
|
-
if (aliasesByPackage.size === 1) aliases.set(statusBase, [...aliasesByPackage.values()][0] ?? []);
|
|
814
|
-
}
|
|
815
|
-
return aliases;
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
function extensionStatusIconAliasCandidate(
|
|
819
|
-
packageName: string,
|
|
820
|
-
source?: string,
|
|
821
|
-
): { statusBase: string; aliases: string[] } | undefined {
|
|
822
|
-
const packageBase = packageBaseName(packageName);
|
|
823
|
-
const statusBase = statusBaseFromPackageBase(packageBase);
|
|
824
|
-
if (!statusBase) return undefined;
|
|
825
|
-
|
|
826
|
-
const sourceAliases = source?.startsWith("npm:") ? [source, `npm:${npmPackageName(source)}`] : [];
|
|
827
|
-
return {
|
|
828
|
-
statusBase,
|
|
829
|
-
aliases: uniqueStrings([...sourceAliases, packageName, packageBase, statusBase]),
|
|
830
|
-
};
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
function packageBaseName(packageName: string): string {
|
|
834
|
-
const slashIndex = packageName.lastIndexOf("/");
|
|
835
|
-
return slashIndex === -1 ? packageName : packageName.slice(slashIndex + 1);
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
function statusBaseFromPackageBase(packageBase: string): string {
|
|
839
|
-
return packageBase.startsWith("pi-") && packageBase.length > "pi-".length
|
|
840
|
-
? packageBase.slice("pi-".length)
|
|
841
|
-
: packageBase;
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
function uniqueStrings(values: readonly string[]): string[] {
|
|
845
|
-
return [...new Set(values.filter((value) => value.length > 0))];
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function readPackageSources(settingsFile: string): string[] {
|
|
849
|
-
try {
|
|
850
|
-
const settings = JSON.parse(readFileSync(settingsFile, "utf8")) as { packages?: unknown[] };
|
|
851
|
-
return (settings.packages ?? [])
|
|
852
|
-
.map((entry) => {
|
|
853
|
-
if (typeof entry === "string") return entry;
|
|
854
|
-
if (
|
|
855
|
-
entry &&
|
|
856
|
-
typeof entry === "object" &&
|
|
857
|
-
typeof (entry as { source?: unknown }).source === "string"
|
|
858
|
-
) {
|
|
859
|
-
return (entry as { source: string }).source;
|
|
860
|
-
}
|
|
861
|
-
return undefined;
|
|
862
|
-
})
|
|
863
|
-
.filter((source): source is string => source !== undefined);
|
|
864
|
-
} catch {
|
|
865
|
-
return [];
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
function packageNameForSource(source: string, baseDirectory: string): string | undefined {
|
|
870
|
-
if (source.startsWith("npm:")) return npmPackageName(source);
|
|
871
|
-
const packageJson = join(resolveSourcePath(source, baseDirectory), "package.json");
|
|
872
|
-
try {
|
|
873
|
-
const packageData = JSON.parse(readFileSync(packageJson, "utf8")) as { name?: unknown };
|
|
874
|
-
return typeof packageData.name === "string" ? packageData.name : undefined;
|
|
875
|
-
} catch {
|
|
876
|
-
return undefined;
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
export function npmPackageName(source: string): string {
|
|
881
|
-
const spec = source.slice("npm:".length);
|
|
882
|
-
if (spec.startsWith("@")) return spec.split("@").slice(0, 2).join("@").replace(/^@/, "@");
|
|
883
|
-
return spec.split("@")[0] ?? spec;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
function sourceIdentity(source: string, baseDirectory: string): string {
|
|
887
|
-
if (source.startsWith("npm:")) return `npm:${npmPackageName(source)}`;
|
|
888
|
-
return resolveSourcePath(source, baseDirectory);
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
function resolveSourcePath(source: string, baseDirectory: string): string {
|
|
892
|
-
return isAbsolute(source) ? source : resolve(baseDirectory, source);
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
function getTokenTotals(ctx: ExtensionContext): TokenTotals {
|
|
896
|
-
const totals: TokenTotals = { input: 0, output: 0, cost: 0 };
|
|
897
|
-
|
|
898
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
899
|
-
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
900
|
-
|
|
901
|
-
const usage = entry.message.usage as
|
|
902
|
-
| {
|
|
903
|
-
input?: number;
|
|
904
|
-
output?: number;
|
|
905
|
-
cost?: { total?: number };
|
|
906
|
-
}
|
|
907
|
-
| undefined;
|
|
908
|
-
|
|
909
|
-
totals.input += usage?.input ?? 0;
|
|
910
|
-
totals.output += usage?.output ?? 0;
|
|
911
|
-
totals.cost += usage?.cost?.total ?? 0;
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
return totals;
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
export function formatCount(value: number): string {
|
|
918
|
-
if (value < 1000) return `${value}`;
|
|
919
|
-
if (value < 1_000_000) return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}k`;
|
|
920
|
-
return `${(value / 1_000_000).toFixed(1)}m`;
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
function formatTime(): string {
|
|
924
|
-
const now = new Date();
|
|
925
|
-
const hours = now.getHours().toString().padStart(2, "0");
|
|
926
|
-
const minutes = now.getMinutes().toString().padStart(2, "0");
|
|
927
|
-
return `${hours}:${minutes}`;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
export function shortenModel(model: string): string {
|
|
931
|
-
return model
|
|
932
|
-
.replace(/^claude-/, "")
|
|
933
|
-
.replace(/^gpt-/, "gpt ")
|
|
934
|
-
.replace(/-20\d{6}$/, "")
|
|
935
|
-
.replace(/-latest$/, "");
|
|
936
|
-
}
|
|
234
|
+
export {
|
|
235
|
+
buildExtensionStatusIconAliases,
|
|
236
|
+
type ExtensionStatusIconAliasMap,
|
|
237
|
+
extensionColor,
|
|
238
|
+
formatExtensionStatus,
|
|
239
|
+
npmPackageName,
|
|
240
|
+
simplifyExtensionStatusText,
|
|
241
|
+
splitExtensionStatusIcon,
|
|
242
|
+
stripExtensionStatusPrefix,
|
|
243
|
+
wrapExtensionStatusline,
|
|
244
|
+
} from "./extension-status.js";
|
|
245
|
+
export {
|
|
246
|
+
formatGitBranchText,
|
|
247
|
+
formatGitStatusSummary,
|
|
248
|
+
parseGitStatusPorcelain,
|
|
249
|
+
type GitStatusSummary,
|
|
250
|
+
} from "./git-status.js";
|
|
251
|
+
export {
|
|
252
|
+
contextColor,
|
|
253
|
+
formatCount,
|
|
254
|
+
formatToolActivity,
|
|
255
|
+
prLinkFromStatuses,
|
|
256
|
+
shortenModel,
|
|
257
|
+
} from "./render.js";
|
|
258
|
+
export { normalizeStatuslineSettings, readStatuslineSettings } from "./settings.js";
|