@narumitw/pi-statusline 0.10.0 → 0.12.0

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
@@ -9,7 +9,7 @@ Use it to monitor model selection, thinking level, git branch, working directory
9
9
  ## ✨ Features
10
10
 
11
11
  - Replaces the default Pi footer with a compact preset-based statusline.
12
- - Shows model, thinking level, git branch, project directory, active tool, context usage, tokens, cost, and clock.
12
+ - Shows model, thinking level, git branch/status, project directory, active tool, context usage, tokens, cost, and clock.
13
13
  - Displays compact statuses published through Pi's generic extension status API.
14
14
  - Owns extension status icons through optional JSON config, including per-extension icon suppression with `""`.
15
15
  - Warns when the same extension package is installed from multiple sources.
@@ -64,14 +64,19 @@ Extension statuses use built-in icons by status key. Override or suppress them i
64
64
  "pisync": "☁️",
65
65
  "unknown-error-retry": "",
66
66
  "plan-mode": "📝",
67
- "subagents": "🤖"
67
+ "subagents": "🤖",
68
+ "@vendor/pi-foo": "🧪"
68
69
  }
69
70
  }
70
71
  ```
71
72
 
73
+ - Exact status key: always wins, e.g. `"goal"` or `"foo:server"`.
74
+ - Installed extension id: for installed packages, use the package name/source such as `"@vendor/pi-foo"`, `"npm:@vendor/pi-foo@1.2.3"`, `"pi-foo"`, or the derived key `"foo"`.
75
+ - Namespaced status keys: package `@vendor/pi-foo` can match `foo`, `foo:server`, and `foo/server`, but not fuzzy matches like `foobar`.
72
76
  - Missing key: use the built-in icon, or `🔌` for an unknown status key.
73
77
  - String value: use that string as the icon.
74
78
  - Empty string: show the status text without an icon.
79
+ - If multiple installed packages derive the same key, use the exact status key to disambiguate.
75
80
  - `PI_STATUSLINE_PRESET` remains the only preset setting; this JSON file only controls extension status icons.
76
81
 
77
82
  During the `PI_CAFFEINATE_ICON` deprecation window, a leading emoji from `pi-caffeinate` is still used when JSON does not configure `caffeinate`. JSON wins when both are set.
@@ -84,13 +89,15 @@ The default `tokyo-night` statusline uses a Starship-inspired `░▒▓` / `
84
89
  - 🤖 current model.
85
90
  - 🧠 thinking level.
86
91
  - 📁 current project directory.
87
- - 🌿 git branch.
92
+ - 🌿 git branch, with compact git status tokens when dirty or ahead/behind.
88
93
  - ⚙ active or last tool.
89
94
  - 🪟 context usage percentage.
90
95
  - 🔢 token totals.
91
96
  - 💸 estimated cost.
92
97
  - 🕒 clock.
93
98
 
99
+ Git status tokens are hidden for clean repositories. When present, they mean `⇡` ahead, `⇣` behind, `+` staged, `~` modified/deleted in the worktree, `?` untracked, and `!` conflicts. Example: `🌿 main ⇡1 +2 ~1 ?3`.
100
+
94
101
  Statuses from other extensions appear below the main statusline, use each preset's separator, and wrap onto additional footer lines when they exceed the terminal width.
95
102
 
96
103
  `pi-statusline` is extension-agnostic: it consumes Pi's generic extension status API and does not import or depend on status-producing extensions.
@@ -100,6 +107,7 @@ Examples:
100
107
  - `🎯 active` for `goal: active` using the built-in `goal` icon.
101
108
  - `🔎 PR #123 checks passing` for `github-pr: PR #123 checks passing` using the built-in `github-pr` icon.
102
109
  - `☕ display` when JSON config sets `"caffeinate": "☕"`.
110
+ - `🧪 running` for third-party status `foo:server` when an installed package is named `@vendor/pi-foo` and JSON config sets `"@vendor/pi-foo": "🧪"`.
103
111
  - `receiving` when JSON config sets `"unknown-error-retry": ""`.
104
112
  - `🔌 running` for an unknown extension status key with no configured icon.
105
113
  - `⚠️ dup biome-lsp` when local and npm installs register the same extension.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-statusline",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
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
@@ -22,6 +22,7 @@ import type {
22
22
  } from "../presets/types.js";
23
23
 
24
24
  type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
25
+ export type ExtensionStatusIconAliasMap = ReadonlyMap<string, readonly string[]>;
25
26
 
26
27
  interface RuntimeState {
27
28
  turnCount: number;
@@ -31,9 +32,20 @@ interface RuntimeState {
31
32
  isStreaming: boolean;
32
33
  thinkingLevel: ThinkingLevel;
33
34
  duplicateExtensions: string[];
35
+ extensionStatusIconAliases: ExtensionStatusIconAliasMap;
36
+ gitStatus?: GitStatusSummary;
34
37
  requestRender?: () => void;
35
38
  }
36
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
+
37
49
  interface TokenTotals {
38
50
  input: number;
39
51
  output: number;
@@ -44,6 +56,11 @@ const STATUSLINE_KEY = "statusline";
44
56
  const GITHUB_PR_KEY = "github-pr";
45
57
  const SETTINGS_FILE = "pi-statusline-settings.json";
46
58
  const DEFAULT_PRESET: StatuslinePresetName = "tokyo-night";
59
+ const GIT_STATUS_REFRESH_INTERVAL_MS = 30_000;
60
+ const GIT_STATUS_EVENT_DEBOUNCE_MS = 250;
61
+ const GIT_STATUS_TIMEOUT_MS = 3_000;
62
+
63
+ const EMPTY_EXTENSION_STATUS_ICON_ALIASES: ExtensionStatusIconAliasMap = new Map();
47
64
 
48
65
  const DEFAULT_EXTENSION_STATUS_ICONS: Record<string, string> = {
49
66
  "chrome-devtools": "🌐",
@@ -93,23 +110,125 @@ export default function statusline(pi: ExtensionAPI) {
93
110
  isStreaming: false,
94
111
  thinkingLevel: "off",
95
112
  duplicateExtensions: [],
113
+ extensionStatusIconAliases: EMPTY_EXTENSION_STATUS_ICON_ALIASES,
96
114
  };
97
115
 
116
+ let sessionGeneration = 0;
117
+ let gitStatusRequestId = 0;
118
+ let activeGitStatusTarget: { cwd: string; generation: number } | undefined;
119
+ let gitStatusRefreshInFlight = false;
120
+ let gitStatusDebounceTimer: ReturnType<typeof setTimeout> | undefined;
121
+ let pendingGitStatusRefresh:
122
+ | { cwd: string; generation: number; requestId: number }
123
+ | undefined;
124
+
98
125
  const refresh = () => runtime.requestRender?.();
99
126
 
127
+ const setGitStatus = (summary: GitStatusSummary | undefined) => {
128
+ if (gitStatusSummaryEqual(runtime.gitStatus, summary)) return;
129
+ runtime.gitStatus = summary;
130
+ refresh();
131
+ };
132
+
133
+ const clearGitStatusDebounce = () => {
134
+ if (!gitStatusDebounceTimer) return;
135
+ clearTimeout(gitStatusDebounceTimer);
136
+ gitStatusDebounceTimer = undefined;
137
+ };
138
+
139
+ const isActiveGitStatusTarget = (cwd: string, generation: number) =>
140
+ activeGitStatusTarget?.cwd === cwd &&
141
+ activeGitStatusTarget.generation === generation &&
142
+ generation === sessionGeneration;
143
+
144
+ const isCurrentGitStatusRequest = (cwd: string, generation: number, requestId: number) =>
145
+ isActiveGitStatusTarget(cwd, generation) && requestId === gitStatusRequestId;
146
+
147
+ const runGitStatusRefresh = (cwd: string, generation: number, requestId: number) => {
148
+ if (!isCurrentGitStatusRequest(cwd, generation, requestId)) return;
149
+ if (gitStatusRefreshInFlight) {
150
+ pendingGitStatusRefresh = { cwd, generation, requestId };
151
+ return;
152
+ }
153
+
154
+ gitStatusRefreshInFlight = true;
155
+ void (async () => {
156
+ try {
157
+ const summary = await readGitStatus(pi, cwd);
158
+ if (isCurrentGitStatusRequest(cwd, generation, requestId)) setGitStatus(summary);
159
+ } catch {
160
+ if (isCurrentGitStatusRequest(cwd, generation, requestId)) setGitStatus(undefined);
161
+ } finally {
162
+ gitStatusRefreshInFlight = false;
163
+ const pending = pendingGitStatusRefresh;
164
+ pendingGitStatusRefresh = undefined;
165
+ if (pending) runGitStatusRefresh(pending.cwd, pending.generation, pending.requestId);
166
+ }
167
+ })();
168
+ };
169
+
170
+ const refreshGitStatus = (cwd: string, generation = sessionGeneration) => {
171
+ if (!isActiveGitStatusTarget(cwd, generation)) return;
172
+ runGitStatusRefresh(cwd, generation, ++gitStatusRequestId);
173
+ };
174
+
175
+ const scheduleGitStatusRefresh = (cwd: string, generation = sessionGeneration) => {
176
+ if (!isActiveGitStatusTarget(cwd, generation)) return;
177
+ const requestId = ++gitStatusRequestId;
178
+ clearGitStatusDebounce();
179
+ gitStatusDebounceTimer = setTimeout(() => {
180
+ gitStatusDebounceTimer = undefined;
181
+ runGitStatusRefresh(cwd, generation, requestId);
182
+ }, GIT_STATUS_EVENT_DEBOUNCE_MS);
183
+ };
184
+
185
+ const scheduleGitStatusRefreshForContext = (ctx: ExtensionContext) => {
186
+ if (!activeGitStatusTarget || activeGitStatusTarget.cwd !== ctx.cwd) return;
187
+ scheduleGitStatusRefresh(activeGitStatusTarget.cwd, activeGitStatusTarget.generation);
188
+ };
189
+
100
190
  const installFooter = (ctx: ExtensionContext) => {
191
+ const generation = ++sessionGeneration;
192
+ const cwd = ctx.cwd;
193
+ clearGitStatusDebounce();
194
+ activeGitStatusTarget = ctx.mode === "tui" ? { cwd, generation } : undefined;
195
+ runtime.gitStatus = undefined;
196
+ runtime.duplicateExtensions = [];
197
+ runtime.extensionStatusIconAliases = EMPTY_EXTENSION_STATUS_ICON_ALIASES;
101
198
  ctx.ui.setStatus(STATUSLINE_KEY, undefined);
102
- runtime.duplicateExtensions = findDuplicateExtensions(ctx.cwd);
199
+ if (!activeGitStatusTarget) return;
200
+ const installedPackages = readInstalledExtensionPackages(cwd);
201
+ runtime.duplicateExtensions = findDuplicateExtensions(installedPackages);
202
+ runtime.extensionStatusIconAliases = buildExtensionStatusIconAliases(installedPackages);
103
203
  ctx.ui.setFooter((tui, theme, footerData) => {
104
204
  runtime.requestRender = () => tui.requestRender();
105
205
 
106
- const branchUnsubscribe = footerData.onBranchChange(() => tui.requestRender());
107
- const clock = setInterval(() => tui.requestRender(), 30_000);
206
+ const refreshFooterGitStatus = () => refreshGitStatus(cwd, generation);
207
+ const branchUnsubscribe = footerData.onBranchChange(() => {
208
+ runtime.gitStatus = undefined;
209
+ clearGitStatusDebounce();
210
+ refreshFooterGitStatus();
211
+ tui.requestRender();
212
+ });
213
+ const clock = setInterval(() => {
214
+ clearGitStatusDebounce();
215
+ refreshFooterGitStatus();
216
+ tui.requestRender();
217
+ }, GIT_STATUS_REFRESH_INTERVAL_MS);
108
218
 
109
219
  return {
110
220
  dispose() {
111
221
  branchUnsubscribe();
112
222
  clearInterval(clock);
223
+ if (isActiveGitStatusTarget(cwd, generation)) {
224
+ activeGitStatusTarget = undefined;
225
+ clearGitStatusDebounce();
226
+ pendingGitStatusRefresh = undefined;
227
+ runtime.gitStatus = undefined;
228
+ runtime.duplicateExtensions = [];
229
+ runtime.extensionStatusIconAliases = EMPTY_EXTENSION_STATUS_ICON_ALIASES;
230
+ runtime.requestRender = undefined;
231
+ }
113
232
  },
114
233
  invalidate() {},
115
234
  render(width: number): string[] {
@@ -119,6 +238,7 @@ export default function statusline(pi: ExtensionAPI) {
119
238
  },
120
239
  };
121
240
  });
241
+ refreshGitStatus(cwd, generation);
122
242
  };
123
243
 
124
244
  pi.on("session_start", (_event, ctx) => {
@@ -132,6 +252,13 @@ export default function statusline(pi: ExtensionAPI) {
132
252
  });
133
253
 
134
254
  pi.on("session_shutdown", (_event, ctx) => {
255
+ sessionGeneration += 1;
256
+ activeGitStatusTarget = undefined;
257
+ clearGitStatusDebounce();
258
+ pendingGitStatusRefresh = undefined;
259
+ runtime.gitStatus = undefined;
260
+ runtime.duplicateExtensions = [];
261
+ runtime.extensionStatusIconAliases = EMPTY_EXTENSION_STATUS_ICON_ALIASES;
135
262
  ctx.ui.setFooter(undefined);
136
263
  ctx.ui.setStatus(STATUSLINE_KEY, undefined);
137
264
  runtime.requestRender = undefined;
@@ -149,8 +276,9 @@ export default function statusline(pi: ExtensionAPI) {
149
276
  refresh();
150
277
  });
151
278
 
152
- pi.on("agent_end", () => {
279
+ pi.on("agent_end", (_event, ctx) => {
153
280
  runtime.isStreaming = false;
281
+ scheduleGitStatusRefreshForContext(ctx);
154
282
  refresh();
155
283
  });
156
284
 
@@ -160,7 +288,10 @@ export default function statusline(pi: ExtensionAPI) {
160
288
  refresh();
161
289
  });
162
290
 
163
- pi.on("turn_end", () => refresh());
291
+ pi.on("turn_end", (_event, ctx) => {
292
+ scheduleGitStatusRefreshForContext(ctx);
293
+ refresh();
294
+ });
164
295
 
165
296
  pi.on("tool_execution_start", (event) => {
166
297
  const currentCount = runtime.activeTools.get(event.toolName) ?? 0;
@@ -169,12 +300,13 @@ export default function statusline(pi: ExtensionAPI) {
169
300
  refresh();
170
301
  });
171
302
 
172
- pi.on("tool_execution_end", (event) => {
303
+ pi.on("tool_execution_end", (event, ctx) => {
173
304
  const currentCount = runtime.activeTools.get(event.toolName) ?? 0;
174
305
  if (currentCount <= 1) runtime.activeTools.delete(event.toolName);
175
306
  else runtime.activeTools.set(event.toolName, currentCount - 1);
176
307
 
177
308
  runtime.lastCompletedTool = event.toolName;
309
+ scheduleGitStatusRefreshForContext(ctx);
178
310
  refresh();
179
311
  });
180
312
  }
@@ -280,7 +412,7 @@ function buildSegment(
280
412
  case "branch": {
281
413
  const branch = footerData.getGitBranch();
282
414
  const pr = branch ? prLinkFromStatuses(footerData.getExtensionStatuses()) : undefined;
283
- return segment(name, pr ? `🌿 ${branch} (${pr})` : `🌿 ${branch ?? "no-git"}`, color, "git");
415
+ return segment(name, formatGitBranchText(branch, runtime.gitStatus, pr), color, "git");
284
416
  }
285
417
  case "cwd":
286
418
  return segment(name, `📁 ${basename(ctx.cwd) || ctx.cwd}`, color, "directory");
@@ -390,6 +522,113 @@ export function prLinkFromStatuses(statuses: ReadonlyMap<string, string>): strin
390
522
  return close === -1 ? undefined : value.slice(open, close + closeMarker.length);
391
523
  }
392
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
+
393
632
  function formatExtensionStatuses(
394
633
  statuses: ReadonlyMap<string, string>,
395
634
  theme: Theme,
@@ -405,7 +644,9 @@ function formatExtensionStatuses(
405
644
  ([key, value]) =>
406
645
  key !== STATUSLINE_KEY && key !== GITHUB_PR_KEY && value.trim().length > 0,
407
646
  )
408
- .map(([key, value]) => formatExtensionStatus(key, value, theme, config)),
647
+ .map(([key, value]) =>
648
+ formatExtensionStatus(key, value, theme, config, runtime.extensionStatusIconAliases),
649
+ ),
409
650
  ].slice(0, 5);
410
651
 
411
652
  return visibleStatuses.join(separator);
@@ -416,12 +657,13 @@ export function formatExtensionStatus(
416
657
  value: string,
417
658
  theme: Theme,
418
659
  config: Pick<StatuslineConfig, "extensionStatusIcons">,
660
+ extensionStatusIconAliases: ExtensionStatusIconAliasMap = EMPTY_EXTENSION_STATUS_ICON_ALIASES,
419
661
  ): string {
420
662
  const status = splitExtensionStatusIcon(stripExtensionStatusPrefix(key, value));
421
663
  const text = simplifyExtensionStatusText(status.text);
422
664
  const color = extensionColor(key, value);
423
665
  const textColor = color === "warning" ? "warning" : "muted";
424
- const icon = extensionStatusIcon(key, status.icon, config.extensionStatusIcons);
666
+ const icon = extensionStatusIcon(key, status.icon, config.extensionStatusIcons, extensionStatusIconAliases);
425
667
  const renderedText = theme.fg(textColor, text);
426
668
  return icon ? `${theme.fg(color, icon)} ${renderedText}` : renderedText;
427
669
  }
@@ -430,11 +672,29 @@ function extensionStatusIcon(
430
672
  key: string,
431
673
  leadingIcon: string | undefined,
432
674
  configuredIcons: Record<string, string>,
675
+ extensionStatusIconAliases: ExtensionStatusIconAliasMap,
433
676
  ) {
434
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
+ }
435
681
  return leadingIcon ?? DEFAULT_EXTENSION_STATUS_ICONS[key] ?? "🔌";
436
682
  }
437
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
+
438
698
  export function wrapExtensionStatusline(status: string, width: number): string[] {
439
699
  if (!status || width <= 0) return [];
440
700
  return wrapTextWithAnsi(status, width);
@@ -489,21 +749,44 @@ function escapeRegExp(value: string): string {
489
749
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
490
750
  }
491
751
 
492
- function findDuplicateExtensions(cwd: string): string[] {
493
- const settingsFiles = [
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 [
494
778
  join(process.env.HOME ?? "", ".pi", "agent", "settings.json"),
495
779
  join(cwd, ".pi", "settings.json"),
496
780
  ].filter((file) => existsSync(file));
781
+ }
782
+
783
+ function findDuplicateExtensions(installedPackages: readonly InstalledExtensionPackage[]): string[] {
497
784
  const sourcesByPackage = new Map<string, Set<string>>();
498
785
 
499
- for (const settingsFile of settingsFiles) {
500
- for (const source of readPackageSources(settingsFile)) {
501
- const packageName = packageNameForSource(source, dirname(settingsFile));
502
- if (!packageName) continue;
503
- const sources = sourcesByPackage.get(packageName) ?? new Set<string>();
504
- sources.add(sourceIdentity(source, dirname(settingsFile)));
505
- sourcesByPackage.set(packageName, sources);
506
- }
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);
507
790
  }
508
791
 
509
792
  return [...sourcesByPackage.entries()]
@@ -511,6 +794,57 @@ function findDuplicateExtensions(cwd: string): string[] {
511
794
  .map(([packageName]) => packageName.replace(/^@[^/]+\//, "").replace(/^pi-/, ""));
512
795
  }
513
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
+
514
848
  function readPackageSources(settingsFile: string): string[] {
515
849
  try {
516
850
  const settings = JSON.parse(readFileSync(settingsFile, "utf8")) as { packages?: unknown[] };