@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 +3 -2
- package/package.json +3 -3
- 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/README.md
CHANGED
|
@@ -125,7 +125,8 @@ Examples:
|
|
|
125
125
|
```txt
|
|
126
126
|
extensions/pi-statusline/
|
|
127
127
|
├── src/
|
|
128
|
-
│
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|