@narumitw/pi-statusline 0.13.0 → 0.14.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
@@ -125,7 +125,8 @@ Examples:
125
125
  ```txt
126
126
  extensions/pi-statusline/
127
127
  ├── src/
128
- │ └── statusline.ts
128
+ │ ├── statusline.ts # Pi entrypoint and watcher lifecycle
129
+ │ └── *.ts # Package-local git, extension status, settings, and render modules
129
130
  ├── presets/
130
131
  │ ├── ansi.ts
131
132
  │ ├── classic.ts
@@ -137,7 +138,7 @@ extensions/pi-statusline/
137
138
  └── package.json
138
139
  ```
139
140
 
140
- The package exposes its Pi extension through `package.json`:
141
+ Only `statusline.ts` is a Pi entrypoint; the other source modules are internal. The package exposes its Pi extension through `package.json`:
141
142
 
142
143
  ```json
143
144
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-statusline",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Pi extension that replaces the footer with an information-rich statusline.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,10 +29,10 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "devDependencies": {
32
- "@biomejs/biome": "2.5.2",
32
+ "@biomejs/biome": "2.5.3",
33
33
  "@earendil-works/pi-coding-agent": "0.80.3",
34
34
  "@earendil-works/pi-tui": "0.80.3",
35
- "@types/node": "26.1.0",
35
+ "@types/node": "26.1.1",
36
36
  "typescript": "6.0.3"
37
37
  },
38
38
  "repository": {
@@ -0,0 +1,300 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import process from "node:process";
4
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
5
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
6
+ import { classicExtensionSeparator } from "../presets/classic.js";
7
+ import { tokyoNightExtensionSeparator } from "../presets/tokyo-night.js";
8
+ import type { StatuslineConfig, StatuslinePresetName } from "../presets/types.js";
9
+
10
+ export type ExtensionStatusIconAliasMap = ReadonlyMap<string, readonly string[]>;
11
+ export interface ExtensionStatusRuntime {
12
+ duplicateExtensions: string[];
13
+ extensionStatusIconAliases: ExtensionStatusIconAliasMap;
14
+ }
15
+
16
+ const STATUSLINE_KEY = "statusline";
17
+ const GITHUB_PR_KEY = "github-pr";
18
+ const EMPTY_EXTENSION_STATUS_ICON_ALIASES: ExtensionStatusIconAliasMap = new Map();
19
+ const DEFAULT_EXTENSION_STATUS_ICONS: Record<string, string> = {
20
+ "chrome-devtools": "🌐",
21
+ "codex-usage": "📊",
22
+ caffeinate: "💊",
23
+ firecrawl: "🔥",
24
+ "github-pr": "🔎",
25
+ goal: "🎯",
26
+ lsp: "🧰",
27
+ "plan-mode": "📝",
28
+ pisync: "🔄",
29
+ subagents: "🧑‍🤝‍🧑",
30
+ "unknown-error-retry": "🔁",
31
+ };
32
+
33
+ function extensionStatusSeparator(presetName: StatuslinePresetName, theme: Theme): string {
34
+ return presetName === "classic"
35
+ ? classicExtensionSeparator(theme)
36
+ : tokyoNightExtensionSeparator(theme);
37
+ }
38
+
39
+ export function formatExtensionStatuses(
40
+ statuses: ReadonlyMap<string, string>,
41
+ theme: Theme,
42
+ config: StatuslineConfig,
43
+ runtime: ExtensionStatusRuntime,
44
+ ): string {
45
+ const separator = extensionStatusSeparator(config.preset, theme);
46
+ const visibleStatuses = [
47
+ ...formatDuplicateExtensionStatus(runtime, theme),
48
+ ...[...statuses.entries()]
49
+ // github-pr is rendered inline in the branch segment, so skip it here to avoid duplication.
50
+ .filter(
51
+ ([key, value]) =>
52
+ key !== STATUSLINE_KEY && key !== GITHUB_PR_KEY && value.trim().length > 0,
53
+ )
54
+ .map(([key, value]) =>
55
+ formatExtensionStatus(key, value, theme, config, runtime.extensionStatusIconAliases),
56
+ ),
57
+ ].slice(0, 5);
58
+
59
+ return visibleStatuses.join(separator);
60
+ }
61
+
62
+ export function formatExtensionStatus(
63
+ key: string,
64
+ value: string,
65
+ theme: Theme,
66
+ config: Pick<StatuslineConfig, "extensionStatusIcons">,
67
+ extensionStatusIconAliases: ExtensionStatusIconAliasMap = EMPTY_EXTENSION_STATUS_ICON_ALIASES,
68
+ ): string {
69
+ const status = splitExtensionStatusIcon(stripExtensionStatusPrefix(key, value));
70
+ const text = simplifyExtensionStatusText(status.text);
71
+ const color = extensionColor(key, value);
72
+ const textColor = color === "warning" ? "warning" : "muted";
73
+ const icon = extensionStatusIcon(key, status.icon, config.extensionStatusIcons, extensionStatusIconAliases);
74
+ const renderedText = theme.fg(textColor, text);
75
+ return icon ? `${theme.fg(color, icon)} ${renderedText}` : renderedText;
76
+ }
77
+
78
+ function extensionStatusIcon(
79
+ key: string,
80
+ leadingIcon: string | undefined,
81
+ configuredIcons: Record<string, string>,
82
+ extensionStatusIconAliases: ExtensionStatusIconAliasMap,
83
+ ) {
84
+ if (Object.hasOwn(configuredIcons, key)) return configuredIcons[key];
85
+ for (const alias of extensionStatusAliasesForKey(key, extensionStatusIconAliases)) {
86
+ if (Object.hasOwn(configuredIcons, alias)) return configuredIcons[alias];
87
+ }
88
+ return leadingIcon ?? DEFAULT_EXTENSION_STATUS_ICONS[key] ?? "🔌";
89
+ }
90
+
91
+ function extensionStatusAliasesForKey(
92
+ key: string,
93
+ extensionStatusIconAliases: ExtensionStatusIconAliasMap,
94
+ ): readonly string[] {
95
+ for (const [statusBase, aliases] of extensionStatusIconAliases) {
96
+ if (statusKeyMatchesStatusBase(key, statusBase)) return aliases;
97
+ }
98
+ return [];
99
+ }
100
+
101
+ function statusKeyMatchesStatusBase(key: string, statusBase: string): boolean {
102
+ return key === statusBase || key.startsWith(`${statusBase}:`) || key.startsWith(`${statusBase}/`);
103
+ }
104
+
105
+ export function wrapExtensionStatusline(status: string, width: number): string[] {
106
+ if (!status || width <= 0) return [];
107
+ return wrapTextWithAnsi(status, width);
108
+ }
109
+
110
+ function formatDuplicateExtensionStatus(runtime: ExtensionStatusRuntime, theme: Theme): string[] {
111
+ if (runtime.duplicateExtensions.length === 0) return [];
112
+ const names = runtime.duplicateExtensions.slice(0, 2).join(", ");
113
+ const suffix =
114
+ runtime.duplicateExtensions.length > 2 ? ` +${runtime.duplicateExtensions.length - 2}` : "";
115
+ return [`${theme.fg("warning", "⚠️")} ${theme.fg("warning", `dup ${names}${suffix}`)}`];
116
+ }
117
+
118
+ export function splitExtensionStatusIcon(value: string): { icon?: string; text: string } {
119
+ const trimmed = value.trim();
120
+ const [firstToken, ...restTokens] = trimmed.split(/\s+/);
121
+ if (firstToken && isEmojiOnlyToken(firstToken)) {
122
+ return { icon: firstToken, text: restTokens.join(" ") };
123
+ }
124
+ return { text: trimmed };
125
+ }
126
+
127
+ function isEmojiOnlyToken(value: string): boolean {
128
+ 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(
129
+ value,
130
+ );
131
+ }
132
+
133
+ export function extensionColor(key: string, value: string): ThemeColor {
134
+ const normalized = `${key} ${value}`.toLowerCase();
135
+ if (/missing|error|fail|conflict|duplicate|unavailable/.test(normalized)) return "warning";
136
+ if (normalized.includes("codex")) return "accent";
137
+ if (/ready|active|running|enabled|awake|ok/.test(normalized)) return "success";
138
+ return "muted";
139
+ }
140
+
141
+ export function stripExtensionStatusPrefix(key: string, value: string): string {
142
+ return value.trim().replace(new RegExp(`^${escapeRegExp(key)}\\s*:\\s*`, "iu"), "");
143
+ }
144
+
145
+ export function simplifyExtensionStatusText(value: string): string {
146
+ return value
147
+ .trim()
148
+ .replace(/\bready\b/giu, "✓")
149
+ .replace(/\bmissing\b/giu, "✗")
150
+ .replace(/,\s*/g, " ")
151
+ .replace(/\s+\([^)]*\)\s*$/, "")
152
+ .replace(/\s+/g, " ");
153
+ }
154
+
155
+ function escapeRegExp(value: string): string {
156
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
157
+ }
158
+
159
+ interface InstalledExtensionPackage {
160
+ packageName: string;
161
+ source: string;
162
+ identity: string;
163
+ }
164
+
165
+ export function readInstalledExtensionPackages(cwd: string): InstalledExtensionPackage[] {
166
+ const packages: InstalledExtensionPackage[] = [];
167
+ const settingsFiles = extensionSettingsFiles(cwd);
168
+
169
+ for (const settingsFile of settingsFiles) {
170
+ const baseDirectory = dirname(settingsFile);
171
+ for (const rawSource of readPackageSources(settingsFile)) {
172
+ const source = rawSource.trim();
173
+ if (!source) continue;
174
+ const packageName = packageNameForSource(source, baseDirectory);
175
+ if (!packageName) continue;
176
+ packages.push({ packageName, source, identity: sourceIdentity(source, baseDirectory) });
177
+ }
178
+ }
179
+
180
+ return packages;
181
+ }
182
+
183
+ function extensionSettingsFiles(cwd: string): string[] {
184
+ return [
185
+ join(process.env.HOME ?? "", ".pi", "agent", "settings.json"),
186
+ join(cwd, ".pi", "settings.json"),
187
+ ].filter((file) => existsSync(file));
188
+ }
189
+
190
+ export function findDuplicateExtensions(installedPackages: readonly InstalledExtensionPackage[]): string[] {
191
+ const sourcesByPackage = new Map<string, Set<string>>();
192
+
193
+ for (const extensionPackage of installedPackages) {
194
+ const sources = sourcesByPackage.get(extensionPackage.packageName) ?? new Set<string>();
195
+ sources.add(extensionPackage.identity);
196
+ sourcesByPackage.set(extensionPackage.packageName, sources);
197
+ }
198
+
199
+ return [...sourcesByPackage.entries()]
200
+ .filter(([, sources]) => sources.size > 1)
201
+ .map(([packageName]) => packageName.replace(/^@[^/]+\//, "").replace(/^pi-/, ""));
202
+ }
203
+
204
+ export function buildExtensionStatusIconAliases(
205
+ installedPackages: readonly { packageName: string; source?: string }[],
206
+ ): Map<string, string[]> {
207
+ const packageAliasesByStatusBase = new Map<string, Map<string, string[]>>();
208
+
209
+ for (const extensionPackage of installedPackages) {
210
+ const candidate = extensionStatusIconAliasCandidate(extensionPackage.packageName, extensionPackage.source);
211
+ if (!candidate) continue;
212
+ const aliasesByPackage = packageAliasesByStatusBase.get(candidate.statusBase) ?? new Map<string, string[]>();
213
+ const existingAliases = aliasesByPackage.get(extensionPackage.packageName) ?? [];
214
+ aliasesByPackage.set(extensionPackage.packageName, uniqueStrings([...existingAliases, ...candidate.aliases]));
215
+ packageAliasesByStatusBase.set(candidate.statusBase, aliasesByPackage);
216
+ }
217
+
218
+ const aliases = new Map<string, string[]>();
219
+ for (const [statusBase, aliasesByPackage] of packageAliasesByStatusBase) {
220
+ if (aliasesByPackage.size === 1) aliases.set(statusBase, [...aliasesByPackage.values()][0] ?? []);
221
+ }
222
+ return aliases;
223
+ }
224
+
225
+ function extensionStatusIconAliasCandidate(
226
+ packageName: string,
227
+ source?: string,
228
+ ): { statusBase: string; aliases: string[] } | undefined {
229
+ const packageBase = packageBaseName(packageName);
230
+ const statusBase = statusBaseFromPackageBase(packageBase);
231
+ if (!statusBase) return undefined;
232
+
233
+ const sourceAliases = source?.startsWith("npm:") ? [source, `npm:${npmPackageName(source)}`] : [];
234
+ return {
235
+ statusBase,
236
+ aliases: uniqueStrings([...sourceAliases, packageName, packageBase, statusBase]),
237
+ };
238
+ }
239
+
240
+ function packageBaseName(packageName: string): string {
241
+ const slashIndex = packageName.lastIndexOf("/");
242
+ return slashIndex === -1 ? packageName : packageName.slice(slashIndex + 1);
243
+ }
244
+
245
+ function statusBaseFromPackageBase(packageBase: string): string {
246
+ return packageBase.startsWith("pi-") && packageBase.length > "pi-".length
247
+ ? packageBase.slice("pi-".length)
248
+ : packageBase;
249
+ }
250
+
251
+ function uniqueStrings(values: readonly string[]): string[] {
252
+ return [...new Set(values.filter((value) => value.length > 0))];
253
+ }
254
+
255
+ function readPackageSources(settingsFile: string): string[] {
256
+ try {
257
+ const settings = JSON.parse(readFileSync(settingsFile, "utf8")) as { packages?: unknown[] };
258
+ return (settings.packages ?? [])
259
+ .map((entry) => {
260
+ if (typeof entry === "string") return entry;
261
+ if (
262
+ entry &&
263
+ typeof entry === "object" &&
264
+ typeof (entry as { source?: unknown }).source === "string"
265
+ ) {
266
+ return (entry as { source: string }).source;
267
+ }
268
+ return undefined;
269
+ })
270
+ .filter((source): source is string => source !== undefined);
271
+ } catch {
272
+ return [];
273
+ }
274
+ }
275
+
276
+ function packageNameForSource(source: string, baseDirectory: string): string | undefined {
277
+ if (source.startsWith("npm:")) return npmPackageName(source);
278
+ const packageJson = join(resolveSourcePath(source, baseDirectory), "package.json");
279
+ try {
280
+ const packageData = JSON.parse(readFileSync(packageJson, "utf8")) as { name?: unknown };
281
+ return typeof packageData.name === "string" ? packageData.name : undefined;
282
+ } catch {
283
+ return undefined;
284
+ }
285
+ }
286
+
287
+ export function npmPackageName(source: string): string {
288
+ const spec = source.slice("npm:".length);
289
+ if (spec.startsWith("@")) return spec.split("@").slice(0, 2).join("@").replace(/^@/, "@");
290
+ return spec.split("@")[0] ?? spec;
291
+ }
292
+
293
+ function sourceIdentity(source: string, baseDirectory: string): string {
294
+ if (source.startsWith("npm:")) return `npm:${npmPackageName(source)}`;
295
+ return resolveSourcePath(source, baseDirectory);
296
+ }
297
+
298
+ function resolveSourcePath(source: string, baseDirectory: string): string {
299
+ return isAbsolute(source) ? source : resolve(baseDirectory, source);
300
+ }
@@ -0,0 +1,117 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ const GIT_STATUS_TIMEOUT_MS = 3_000;
4
+
5
+ export interface GitStatusSummary {
6
+ ahead: number;
7
+ behind: number;
8
+ staged: number;
9
+ modified: number;
10
+ untracked: number;
11
+ conflicts: number;
12
+ }
13
+
14
+ export async function readGitStatus(
15
+ pi: ExtensionAPI,
16
+ cwd: string,
17
+ ): Promise<GitStatusSummary | undefined> {
18
+ const result = await pi.exec(
19
+ "git",
20
+ ["--no-optional-locks", "status", "--porcelain=v1", "--branch", "--untracked-files=normal"],
21
+ { cwd, timeout: GIT_STATUS_TIMEOUT_MS },
22
+ );
23
+ if (result.code !== 0 || result.killed) return undefined;
24
+ return parseGitStatusPorcelain(result.stdout);
25
+ }
26
+
27
+ export function parseGitStatusPorcelain(output: string): GitStatusSummary {
28
+ const summary: GitStatusSummary = {
29
+ ahead: 0,
30
+ behind: 0,
31
+ staged: 0,
32
+ modified: 0,
33
+ untracked: 0,
34
+ conflicts: 0,
35
+ };
36
+ for (const line of output.split(/\r?\n/)) {
37
+ if (!line) continue;
38
+ if (line.startsWith("## ")) {
39
+ const ahead = line.match(/\bahead (\d+)/u);
40
+ const behind = line.match(/\bbehind (\d+)/u);
41
+ summary.ahead = ahead ? Number(ahead[1]) : 0;
42
+ summary.behind = behind ? Number(behind[1]) : 0;
43
+ continue;
44
+ }
45
+ const indexStatus = line[0] ?? " ";
46
+ const worktreeStatus = line[1] ?? " ";
47
+ if (indexStatus === "?" && worktreeStatus === "?") {
48
+ summary.untracked += 1;
49
+ continue;
50
+ }
51
+ if (isConflictStatus(indexStatus, worktreeStatus)) {
52
+ summary.conflicts += 1;
53
+ continue;
54
+ }
55
+ if (isChangedStatus(indexStatus)) summary.staged += 1;
56
+ if (isChangedStatus(worktreeStatus)) summary.modified += 1;
57
+ }
58
+ return summary;
59
+ }
60
+
61
+ function isConflictStatus(indexStatus: string, worktreeStatus: string): boolean {
62
+ return (
63
+ (indexStatus === "D" && worktreeStatus === "D") ||
64
+ (indexStatus === "A" && worktreeStatus === "A") ||
65
+ indexStatus === "U" ||
66
+ worktreeStatus === "U"
67
+ );
68
+ }
69
+
70
+ function isChangedStatus(status: string): boolean {
71
+ return status !== " " && status !== "?" && status !== "!";
72
+ }
73
+
74
+ export function formatGitStatusSummary(summary: GitStatusSummary | undefined): string {
75
+ if (!summary) return "";
76
+ const tokens = [
77
+ ["⇡", summary.ahead],
78
+ ["⇣", summary.behind],
79
+ ["+", summary.staged],
80
+ ["~", summary.modified],
81
+ ["?", summary.untracked],
82
+ ["!", summary.conflicts],
83
+ ] as const;
84
+ return tokens
85
+ .filter(([, count]) => count > 0)
86
+ .map(([prefix, count]) => `${prefix}${formatCount(count)}`)
87
+ .join(" ");
88
+ }
89
+
90
+ export function formatGitBranchText(
91
+ branch: string | null,
92
+ status: GitStatusSummary | undefined,
93
+ pr?: string,
94
+ ): string {
95
+ if (!branch) return "🌿 no-git";
96
+ const suffixes = [formatGitStatusSummary(status), pr ? `(${pr})` : ""].filter(Boolean);
97
+ return suffixes.length > 0 ? `🌿 ${branch} ${suffixes.join(" ")}` : `🌿 ${branch}`;
98
+ }
99
+
100
+ export function gitStatusSummaryEqual(
101
+ left: GitStatusSummary | undefined,
102
+ right: GitStatusSummary | undefined,
103
+ ): boolean {
104
+ if (!left || !right) return left === right;
105
+ return (
106
+ left.ahead === right.ahead &&
107
+ left.behind === right.behind &&
108
+ left.staged === right.staged &&
109
+ left.modified === right.modified &&
110
+ left.untracked === right.untracked &&
111
+ left.conflicts === right.conflicts
112
+ );
113
+ }
114
+
115
+ function formatCount(value: number): string {
116
+ return Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value);
117
+ }