@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 +11 -3
- package/package.json +1 -1
- package/src/statusline.ts +353 -19
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
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
|
-
|
|
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
|
|
107
|
-
const
|
|
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", () =>
|
|
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,
|
|
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]) =>
|
|
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
|
-
|
|
493
|
-
|
|
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
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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[] };
|