@narumitw/pi-statusline 0.50.3 → 0.51.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/CHANGELOG.md +126 -0
- package/README.md +12 -1
- package/dist/chunks/{chunk-LARLXEUL.ts → chunk-HJJWE7KU.ts} +43 -2
- package/dist/chunks/{chunk-LARLXEUL.ts.map → chunk-HJJWE7KU.ts.map} +3 -3
- package/dist/chunks/{commands-KDWSOSZZ.ts → commands-WV3JHCQ6.ts} +9 -4
- package/dist/chunks/commands-WV3JHCQ6.ts.map +7 -0
- package/dist/index.ts +314 -96
- package/dist/index.ts.map +4 -4
- package/docs/configuration.md +6 -0
- package/docs/extension-status-protocol.md +92 -0
- package/package.json +6 -5
- package/src/commands.ts +6 -1
- package/src/extension-status-metadata.ts +208 -0
- package/src/extension-status.ts +51 -5
- package/src/git-status.ts +7 -2
- package/src/render.ts +15 -3
- package/src/statusline.ts +102 -41
- package/src/usage.ts +17 -4
- package/dist/chunks/commands-KDWSOSZZ.ts.map +0 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-statusline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.0",
|
|
4
4
|
"description": "Pi extension that replaces the footer with an information-rich statusline.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"dist",
|
|
18
18
|
"docs",
|
|
19
19
|
"README.md",
|
|
20
|
+
"CHANGELOG.md",
|
|
20
21
|
"LICENSE"
|
|
21
22
|
],
|
|
22
23
|
"pi": {
|
|
@@ -36,9 +37,9 @@
|
|
|
36
37
|
"@earendil-works/pi-tui": "*"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
40
|
-
"@earendil-works/pi-tui": "1.
|
|
41
|
-
"@types/node": "26.6.
|
|
40
|
+
"@earendil-works/pi-coding-agent": "1.5.0",
|
|
41
|
+
"@earendil-works/pi-tui": "1.5.0",
|
|
42
|
+
"@types/node": "26.6.6",
|
|
42
43
|
"esbuild": "0.28.2",
|
|
43
44
|
"typescript": "7.0.2"
|
|
44
45
|
},
|
|
@@ -48,6 +49,6 @@
|
|
|
48
49
|
"directory": "packages/pi-statusline"
|
|
49
50
|
},
|
|
50
51
|
"dependencies": {
|
|
51
|
-
"@narumitw/pi-tui-kit": "^0.
|
|
52
|
+
"@narumitw/pi-tui-kit": "^0.69.0"
|
|
52
53
|
}
|
|
53
54
|
}
|
package/src/commands.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
SEGMENT_NAMES,
|
|
31
31
|
type SegmentName,
|
|
32
32
|
} from "./types.js";
|
|
33
|
+
import { summarizeFooterUsage } from "./usage.js";
|
|
33
34
|
|
|
34
35
|
const EDIT_SETTINGS_LABEL = "Edit settings JSON";
|
|
35
36
|
const SEGMENT_VIEWPORT_SIZE = 6;
|
|
@@ -477,6 +478,7 @@ async function chooseSegments(ctx: ExtensionCommandContext, options: StatuslineC
|
|
|
477
478
|
updateThemedText();
|
|
478
479
|
|
|
479
480
|
return {
|
|
481
|
+
capturesViewportInput: true,
|
|
480
482
|
render: (width: number) => container.render(width),
|
|
481
483
|
invalidate() {
|
|
482
484
|
container.invalidate();
|
|
@@ -816,6 +818,8 @@ function editableSettings(
|
|
|
816
818
|
function showStatus(ctx: ExtensionCommandContext, options: StatuslineCommandOptions) {
|
|
817
819
|
if (!canNotify(ctx)) return;
|
|
818
820
|
const loaded = options.getLoaded();
|
|
821
|
+
const usage = summarizeFooterUsage(ctx.sessionManager.getEntries());
|
|
822
|
+
const costSource = usage.costTotalFromProvider ? "provider-reported" : "unconfirmed";
|
|
819
823
|
const diagnostics = loaded.diagnostics
|
|
820
824
|
.slice(0, 5)
|
|
821
825
|
.map((item) => `${item.path || "root"}: ${item.message}`)
|
|
@@ -830,6 +834,7 @@ function showStatus(ctx: ExtensionCommandContext, options: StatuslineCommandOpti
|
|
|
830
834
|
`separator: ${loaded.config.separator}`,
|
|
831
835
|
`information: ${inferInformationProfile(loaded.config.segments)}`,
|
|
832
836
|
`segments: ${loaded.config.segments.join(", ") || "none"}`,
|
|
837
|
+
`session cost: $${usage.cost.toFixed(usage.cost >= 1 ? 2 : 3)} (${costSource})`,
|
|
833
838
|
diagnostics ? `warnings: ${diagnostics}` : "warnings: none",
|
|
834
839
|
].join("\n"),
|
|
835
840
|
loaded.diagnostics.length > 0 ? "warning" : "info",
|
|
@@ -842,7 +847,7 @@ function showHelp(ctx: ExtensionCommandContext, settingsPath: string) {
|
|
|
842
847
|
[
|
|
843
848
|
"/statusline — open Appearance, Information, Advanced, Status, and Help",
|
|
844
849
|
"/statusline settings — edit and apply JSON",
|
|
845
|
-
"/statusline status — show
|
|
850
|
+
"/statusline status — show settings, session cost source, and warnings",
|
|
846
851
|
"/statusline help — show this help",
|
|
847
852
|
"Menu actions: Appearance, Information, Advanced, Status, Help.",
|
|
848
853
|
"Information levels: minimal, balanced, detailed; any other segment array is custom.",
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const UPDATE_CHANNEL = "extension-status:v1:update";
|
|
4
|
+
const REQUEST_CHANNEL = "extension-status:v1:request";
|
|
5
|
+
const ACTIVITIES = new Set([
|
|
6
|
+
"idle",
|
|
7
|
+
"checking",
|
|
8
|
+
"working",
|
|
9
|
+
"pushing",
|
|
10
|
+
"pulling",
|
|
11
|
+
"resolving",
|
|
12
|
+
"retry",
|
|
13
|
+
"blocked",
|
|
14
|
+
"local-change",
|
|
15
|
+
"remote-change",
|
|
16
|
+
"review",
|
|
17
|
+
] as const);
|
|
18
|
+
|
|
19
|
+
export type ExtensionStatusActivity =
|
|
20
|
+
| "idle"
|
|
21
|
+
| "checking"
|
|
22
|
+
| "working"
|
|
23
|
+
| "pushing"
|
|
24
|
+
| "pulling"
|
|
25
|
+
| "resolving"
|
|
26
|
+
| "retry"
|
|
27
|
+
| "blocked"
|
|
28
|
+
| "local-change"
|
|
29
|
+
| "remote-change"
|
|
30
|
+
| "review";
|
|
31
|
+
|
|
32
|
+
export interface ExtensionStatusPresentation {
|
|
33
|
+
readonly value: string;
|
|
34
|
+
readonly activity: ExtensionStatusActivity;
|
|
35
|
+
readonly reason?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ExtensionStatusMetadata {
|
|
39
|
+
get(key: string, value: string): ExtensionStatusPresentation | undefined;
|
|
40
|
+
reconcile(statuses: ReadonlyMap<string, string>): void;
|
|
41
|
+
dispose(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseMetadata(data: Record<string, unknown>): {
|
|
45
|
+
presentation?: ExtensionStatusPresentation;
|
|
46
|
+
withdrawal: boolean;
|
|
47
|
+
} {
|
|
48
|
+
if (Array.isArray(data)) return { withdrawal: false };
|
|
49
|
+
// Capture fields once: accessors and proxy traps can synchronously replace the current state.
|
|
50
|
+
const { version, value, activity, reason } = data;
|
|
51
|
+
const hasValue = Object.hasOwn(data, "value");
|
|
52
|
+
const hasActivity = "activity" in data;
|
|
53
|
+
const hasReason = "reason" in data;
|
|
54
|
+
const withdrawal = version === 1 && hasValue && value === undefined && !hasActivity && !hasReason;
|
|
55
|
+
if (
|
|
56
|
+
version !== 1 ||
|
|
57
|
+
!hasValue ||
|
|
58
|
+
typeof value !== "string" ||
|
|
59
|
+
value.length === 0 ||
|
|
60
|
+
!ACTIVITIES.has(activity as ExtensionStatusActivity)
|
|
61
|
+
)
|
|
62
|
+
return { withdrawal };
|
|
63
|
+
if (activity === "retry" || activity === "blocked") {
|
|
64
|
+
// JavaScript's $ also matches before a final newline; require the complete safe label.
|
|
65
|
+
if (typeof reason !== "string" || /^[a-z][a-z0-9-]{0,31}$/.exec(reason)?.[0] !== reason) return { withdrawal };
|
|
66
|
+
return { presentation: Object.freeze({ value, activity, reason }), withdrawal };
|
|
67
|
+
}
|
|
68
|
+
if (hasReason) return { withdrawal };
|
|
69
|
+
return { presentation: Object.freeze({ value, activity: activity as ExtensionStatusActivity }), withdrawal };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Optional display metadata; the raw Pi status map remains authoritative. */
|
|
73
|
+
export function subscribeExtensionStatusMetadata(
|
|
74
|
+
events: ExtensionAPI["events"],
|
|
75
|
+
sessionManager: object,
|
|
76
|
+
getStatuses: () => ReadonlyMap<string, string>,
|
|
77
|
+
requestRender: () => void,
|
|
78
|
+
): ExtensionStatusMetadata {
|
|
79
|
+
const records = new Map<string, ExtensionStatusPresentation>();
|
|
80
|
+
const liveKeys = new Set<string>();
|
|
81
|
+
const requestId = {};
|
|
82
|
+
let observedStatuses = new Map<string, string>();
|
|
83
|
+
try {
|
|
84
|
+
observedStatuses = new Map(getStatuses());
|
|
85
|
+
} catch {
|
|
86
|
+
// A raw-map read failure must leave the ordinary footer available.
|
|
87
|
+
}
|
|
88
|
+
let disposed = false;
|
|
89
|
+
let revision = 0;
|
|
90
|
+
const keyRevisions = new Map<string, number>();
|
|
91
|
+
const isCurrent = (key: string, owner: number) => !disposed && keyRevisions.get(key) === owner;
|
|
92
|
+
let unsubscribe: (() => void) | undefined;
|
|
93
|
+
|
|
94
|
+
const reconcile = (statuses: ReadonlyMap<string, string>, ownerKey?: string) => {
|
|
95
|
+
if (disposed) return;
|
|
96
|
+
const before = revision;
|
|
97
|
+
const snapshot = new Map(statuses);
|
|
98
|
+
if (disposed || revision !== before) return;
|
|
99
|
+
for (const [key, value] of observedStatuses) {
|
|
100
|
+
if (snapshot.get(key) === value) continue;
|
|
101
|
+
records.delete(key);
|
|
102
|
+
// An observed raw replacement must also fence off a delayed same-value snapshot.
|
|
103
|
+
liveKeys.add(key);
|
|
104
|
+
if (key !== ownerKey) keyRevisions.set(key, ++revision);
|
|
105
|
+
}
|
|
106
|
+
for (const key of snapshot.keys()) {
|
|
107
|
+
if (observedStatuses.has(key)) continue;
|
|
108
|
+
liveKeys.add(key);
|
|
109
|
+
if (key !== ownerKey) keyRevisions.set(key, ++revision);
|
|
110
|
+
}
|
|
111
|
+
observedStatuses = snapshot;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const metadata: ExtensionStatusMetadata = {
|
|
115
|
+
get(key, value) {
|
|
116
|
+
const presentation = records.get(key);
|
|
117
|
+
return presentation?.value === value ? presentation : undefined;
|
|
118
|
+
},
|
|
119
|
+
reconcile(statuses) {
|
|
120
|
+
reconcile(statuses);
|
|
121
|
+
},
|
|
122
|
+
dispose() {
|
|
123
|
+
if (disposed) return;
|
|
124
|
+
disposed = true;
|
|
125
|
+
revision++;
|
|
126
|
+
keyRevisions.clear();
|
|
127
|
+
records.clear();
|
|
128
|
+
liveKeys.clear();
|
|
129
|
+
observedStatuses.clear();
|
|
130
|
+
try {
|
|
131
|
+
unsubscribe?.();
|
|
132
|
+
} catch {
|
|
133
|
+
// Optional metadata cleanup must not prevent footer disposal.
|
|
134
|
+
}
|
|
135
|
+
unsubscribe = undefined;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const receive = (payload: unknown) => {
|
|
140
|
+
if (disposed || !payload || typeof payload !== "object") return;
|
|
141
|
+
const entered = revision;
|
|
142
|
+
let key: string;
|
|
143
|
+
let snapshot: boolean;
|
|
144
|
+
const data = payload as Record<string, unknown>;
|
|
145
|
+
try {
|
|
146
|
+
const rawKey = data.key;
|
|
147
|
+
const manager = data.sessionManager;
|
|
148
|
+
snapshot = "requestId" in data;
|
|
149
|
+
const replyId = snapshot ? data.requestId : undefined;
|
|
150
|
+
if (disposed || manager !== sessionManager || typeof rawKey !== "string" || !rawKey) return;
|
|
151
|
+
key = rawKey;
|
|
152
|
+
if ((keyRevisions.get(key) ?? 0) > entered) return;
|
|
153
|
+
if (snapshot && (replyId !== requestId || liveKeys.has(key))) return;
|
|
154
|
+
} catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Only an accepted envelope for this key can supersede its in-flight update.
|
|
159
|
+
const owner = ++revision;
|
|
160
|
+
keyRevisions.set(key, owner);
|
|
161
|
+
if (!snapshot) liveKeys.add(key);
|
|
162
|
+
const previous = records.get(key);
|
|
163
|
+
try {
|
|
164
|
+
const { presentation, withdrawal } = parseMetadata(data);
|
|
165
|
+
if (!isCurrent(key, owner)) return;
|
|
166
|
+
// Read after payload accessors, and copy before touching the cache: either operation can reenter.
|
|
167
|
+
const reading = revision;
|
|
168
|
+
const statuses = new Map(getStatuses());
|
|
169
|
+
if (!isCurrent(key, owner)) return;
|
|
170
|
+
if (revision !== reading) {
|
|
171
|
+
// An unstable read must not retain old hidden state or erase a newer sibling record.
|
|
172
|
+
records.delete(key);
|
|
173
|
+
} else {
|
|
174
|
+
reconcile(statuses, key);
|
|
175
|
+
if (!isCurrent(key, owner) || (snapshot && liveKeys.has(key))) return;
|
|
176
|
+
if (presentation && statuses.get(key) === presentation.value) {
|
|
177
|
+
records.set(key, presentation);
|
|
178
|
+
} else if (!snapshot || withdrawal) {
|
|
179
|
+
// Invalid live data and explicit withdrawals restore the existing raw-text path.
|
|
180
|
+
records.delete(key);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
if (!isCurrent(key, owner)) return;
|
|
185
|
+
if (!snapshot) records.delete(key);
|
|
186
|
+
}
|
|
187
|
+
if (isCurrent(key, owner) && previous !== records.get(key)) {
|
|
188
|
+
try {
|
|
189
|
+
requestRender();
|
|
190
|
+
} catch {
|
|
191
|
+
// A closed UI must not turn optional metadata delivery into an event-bus error.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
unsubscribe = events.on(UPDATE_CHANNEL, receive);
|
|
198
|
+
} catch {
|
|
199
|
+
// Metadata support must not prevent the footer from loading.
|
|
200
|
+
return metadata;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
events.emit(REQUEST_CHANNEL, { version: 1, sessionManager, requestId });
|
|
204
|
+
} catch {
|
|
205
|
+
// Keep the live subscription usable when a snapshot request fails.
|
|
206
|
+
}
|
|
207
|
+
return metadata;
|
|
208
|
+
}
|
package/src/extension-status.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import { getAgentDir, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
5
|
+
import { sanitizeTerminalText } from "@narumitw/pi-tui-kit/terminal-text";
|
|
6
|
+
import type { ExtensionStatusMetadata, ExtensionStatusPresentation } from "./extension-status-metadata.js";
|
|
5
7
|
import { powerlineExtensionSeparator } from "./powerline.js";
|
|
6
8
|
import { DEFAULT_EXTENSION_STATUS_ICONS } from "./settings.js";
|
|
7
9
|
import type { StatuslineConfig } from "./types.js";
|
|
@@ -10,6 +12,7 @@ export type ExtensionStatusIconAliasMap = ReadonlyMap<string, readonly string[]>
|
|
|
10
12
|
export interface ExtensionStatusRuntime {
|
|
11
13
|
duplicateExtensions: string[];
|
|
12
14
|
extensionStatusIconAliases: ExtensionStatusIconAliasMap;
|
|
15
|
+
extensionStatusMetadata?: ExtensionStatusMetadata;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
const STATUSLINE_KEY = "statusline";
|
|
@@ -32,12 +35,19 @@ export function formatExtensionStatuses(
|
|
|
32
35
|
hiddenKeys: ReadonlySet<string> = new Set(),
|
|
33
36
|
trueColor = true,
|
|
34
37
|
): string {
|
|
38
|
+
runtime.extensionStatusMetadata?.reconcile(statuses);
|
|
35
39
|
const separator = extensionStatusSeparator(config, theme, trueColor);
|
|
36
40
|
const visibleStatuses = [
|
|
37
41
|
...formatDuplicateExtensionStatus(runtime, theme),
|
|
38
42
|
...[...statuses.entries()]
|
|
39
43
|
.filter(([key, value]) => key !== STATUSLINE_KEY && !hiddenKeys.has(key) && value.trim().length > 0)
|
|
40
|
-
.map(([key, value]) =>
|
|
44
|
+
.map(([key, value]) => {
|
|
45
|
+
const presentation = runtime.extensionStatusMetadata?.get(key, value);
|
|
46
|
+
return presentation
|
|
47
|
+
? formatCompactExtensionStatus(key, value, presentation, theme, config, runtime.extensionStatusIconAliases)
|
|
48
|
+
: formatExtensionStatus(key, value, theme, config, runtime.extensionStatusIconAliases);
|
|
49
|
+
})
|
|
50
|
+
.filter((status) => status.length > 0),
|
|
41
51
|
].slice(0, 5);
|
|
42
52
|
|
|
43
53
|
return visibleStatuses.join(separator);
|
|
@@ -59,6 +69,38 @@ export function formatExtensionStatus(
|
|
|
59
69
|
return icon ? `${theme.fg(color, icon)} ${renderedText}` : renderedText;
|
|
60
70
|
}
|
|
61
71
|
|
|
72
|
+
function formatCompactExtensionStatus(
|
|
73
|
+
key: string,
|
|
74
|
+
value: string,
|
|
75
|
+
presentation: ExtensionStatusPresentation,
|
|
76
|
+
theme: Theme,
|
|
77
|
+
config: Pick<StatuslineConfig, "extensionStatusIcons">,
|
|
78
|
+
aliases: ExtensionStatusIconAliasMap,
|
|
79
|
+
): string {
|
|
80
|
+
if (presentation.activity === "idle") return "";
|
|
81
|
+
// Only the compact path strips terminal controls; raw statuses retain their existing PR links.
|
|
82
|
+
const status = splitExtensionStatusIcon(
|
|
83
|
+
stripExtensionStatusPrefix(sanitizeTerminalText(key), sanitizeTerminalText(value)),
|
|
84
|
+
);
|
|
85
|
+
const icon = sanitizeTerminalText(extensionStatusIcon(key, status.icon, config.extensionStatusIcons, aliases));
|
|
86
|
+
const bodies = {
|
|
87
|
+
checking: "…",
|
|
88
|
+
working: "↻",
|
|
89
|
+
pushing: "↑",
|
|
90
|
+
pulling: "↓",
|
|
91
|
+
resolving: "AI",
|
|
92
|
+
retry: `retry: ${presentation.reason}`,
|
|
93
|
+
blocked: `! ${presentation.reason}`,
|
|
94
|
+
"local-change": "⇡",
|
|
95
|
+
"remote-change": "⇣",
|
|
96
|
+
review: "⇕",
|
|
97
|
+
};
|
|
98
|
+
const warning = presentation.activity === "blocked" || presentation.activity === "retry";
|
|
99
|
+
const color = warning ? "warning" : extensionColor(key, value);
|
|
100
|
+
const body = theme.fg(warning ? "warning" : "muted", bodies[presentation.activity]);
|
|
101
|
+
return icon ? `${theme.fg(color, icon)} ${body}` : body;
|
|
102
|
+
}
|
|
103
|
+
|
|
62
104
|
function extensionStatusIcon(
|
|
63
105
|
key: string,
|
|
64
106
|
leadingIcon: string | undefined,
|
|
@@ -68,14 +110,16 @@ function extensionStatusIcon(
|
|
|
68
110
|
if (Object.hasOwn(configuredIcons, key)) return configuredIcons[key];
|
|
69
111
|
const namespaceIcon = configuredNamespaceIcon(key, configuredIcons);
|
|
70
112
|
if (namespaceIcon !== undefined) return namespaceIcon;
|
|
71
|
-
const compatibleKey = COMPATIBLE_STATUS_ICON_KEYS[key];
|
|
113
|
+
const compatibleKey = Object.hasOwn(COMPATIBLE_STATUS_ICON_KEYS, key) ? COMPATIBLE_STATUS_ICON_KEYS[key] : undefined;
|
|
72
114
|
if (compatibleKey && Object.hasOwn(configuredIcons, compatibleKey)) {
|
|
73
115
|
return configuredIcons[compatibleKey];
|
|
74
116
|
}
|
|
75
117
|
for (const alias of extensionStatusAliasesForKey(key, extensionStatusIconAliases)) {
|
|
76
118
|
if (Object.hasOwn(configuredIcons, alias)) return configuredIcons[alias];
|
|
77
119
|
}
|
|
78
|
-
return
|
|
120
|
+
return (
|
|
121
|
+
leadingIcon ?? (Object.hasOwn(DEFAULT_EXTENSION_STATUS_ICONS, key) ? DEFAULT_EXTENSION_STATUS_ICONS[key] : "🔌")
|
|
122
|
+
);
|
|
79
123
|
}
|
|
80
124
|
|
|
81
125
|
function configuredNamespaceIcon(key: string, configuredIcons: Readonly<Record<string, string>>): string | undefined {
|
|
@@ -105,7 +149,9 @@ function statusKeyMatchesStatusBase(key: string, statusBase: string): boolean {
|
|
|
105
149
|
|
|
106
150
|
export function wrapExtensionStatusline(status: string, width: number): string[] {
|
|
107
151
|
if (!status || width <= 0) return [];
|
|
108
|
-
return wrapTextWithAnsi(status, width)
|
|
152
|
+
return wrapTextWithAnsi(status, width).map((line) =>
|
|
153
|
+
visibleWidth(line) > width ? truncateToWidth(line, width, "") : line,
|
|
154
|
+
);
|
|
109
155
|
}
|
|
110
156
|
|
|
111
157
|
function formatDuplicateExtensionStatus(runtime: ExtensionStatusRuntime, theme: Theme): string[] {
|
package/src/git-status.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { isAbsolute } from "node:path";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
4
|
const GIT_STATUS_TIMEOUT_MS = 3_000;
|
|
5
|
+
// Bound combined stdout/stderr while allowing large worktree listings and one absolute root path.
|
|
6
|
+
const GIT_STATUS_MAX_BUFFER = 1024 * 1024;
|
|
7
|
+
const GIT_ROOT_MAX_BUFFER = 16 * 1024;
|
|
5
8
|
|
|
6
9
|
export interface GitStatusSummary {
|
|
7
10
|
root?: string;
|
|
@@ -22,19 +25,21 @@ export async function readGitStatus(
|
|
|
22
25
|
pi.exec("git", ["--no-optional-locks", "status", "--porcelain=v1", "--branch", "--untracked-files=normal"], {
|
|
23
26
|
cwd,
|
|
24
27
|
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
28
|
+
maxBuffer: GIT_STATUS_MAX_BUFFER,
|
|
25
29
|
signal,
|
|
26
30
|
}),
|
|
27
31
|
pi
|
|
28
32
|
.exec("git", ["rev-parse", "--path-format=absolute", "--show-toplevel"], {
|
|
29
33
|
cwd,
|
|
30
34
|
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
35
|
+
maxBuffer: GIT_ROOT_MAX_BUFFER,
|
|
31
36
|
signal,
|
|
32
37
|
})
|
|
33
38
|
.catch(() => undefined),
|
|
34
39
|
]);
|
|
35
|
-
if (statusResult.code !== 0 || statusResult.killed) return undefined;
|
|
40
|
+
if (statusResult.truncated || statusResult.code !== 0 || statusResult.killed) return undefined;
|
|
36
41
|
const summary = parseGitStatusPorcelain(statusResult.stdout);
|
|
37
|
-
if (rootResult && rootResult.code === 0 && !rootResult.killed) {
|
|
42
|
+
if (rootResult && !rootResult.truncated && rootResult.code === 0 && !rootResult.killed) {
|
|
38
43
|
const root = parseGitRoot(rootResult.stdout);
|
|
39
44
|
if (root) summary.root = root;
|
|
40
45
|
}
|
package/src/render.ts
CHANGED
|
@@ -47,6 +47,7 @@ export function renderStatusline(
|
|
|
47
47
|
): string {
|
|
48
48
|
if (width <= 0) return "";
|
|
49
49
|
|
|
50
|
+
const inlineStatuses = legacyInlineStatuses(footerData.getExtensionStatuses(), runtime);
|
|
50
51
|
const usageSummary = summarizeFooterUsage(ctx.sessionManager.getEntries());
|
|
51
52
|
const rows: Array<{ configuredSegments: number; segments: RenderSegment[] }> = [
|
|
52
53
|
{ configuredSegments: 0, segments: [] },
|
|
@@ -60,7 +61,7 @@ export function renderStatusline(
|
|
|
60
61
|
const row = rows.at(-1);
|
|
61
62
|
if (!row) continue;
|
|
62
63
|
row.configuredSegments += 1;
|
|
63
|
-
const rendered = buildSegment(name, ctx, footerData, config, runtime, usageSummary);
|
|
64
|
+
const rendered = buildSegment(name, ctx, footerData, config, runtime, usageSummary, inlineStatuses);
|
|
64
65
|
if (rendered && rendered.text.length > 0) row.segments.push(rendered);
|
|
65
66
|
}
|
|
66
67
|
|
|
@@ -84,7 +85,7 @@ export function renderExtensionStatusline(
|
|
|
84
85
|
trueColor = true,
|
|
85
86
|
): string[] {
|
|
86
87
|
const statuses = footerData.getExtensionStatuses();
|
|
87
|
-
const prContext = prContextFromStatuses(statuses);
|
|
88
|
+
const prContext = prContextFromStatuses(legacyInlineStatuses(statuses, runtime));
|
|
88
89
|
const rendersPrInline = prContext !== undefined && mainLine.includes(prContext);
|
|
89
90
|
const status = formatExtensionStatuses(
|
|
90
91
|
statuses,
|
|
@@ -97,6 +98,16 @@ export function renderExtensionStatusline(
|
|
|
97
98
|
return wrapExtensionStatusline(status, width);
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
function legacyInlineStatuses(
|
|
102
|
+
statuses: ReadonlyMap<string, string>,
|
|
103
|
+
runtime: ExtensionStatusRuntime,
|
|
104
|
+
): ReadonlyMap<string, string> {
|
|
105
|
+
const metadata = runtime.extensionStatusMetadata;
|
|
106
|
+
if (!metadata) return statuses;
|
|
107
|
+
metadata.reconcile(statuses);
|
|
108
|
+
return new Map([...statuses].filter(([key, value]) => !metadata.get(key, value)));
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
function buildSegment(
|
|
101
112
|
name: SegmentName,
|
|
102
113
|
ctx: ExtensionContext,
|
|
@@ -104,6 +115,7 @@ function buildSegment(
|
|
|
104
115
|
config: StatuslineConfig,
|
|
105
116
|
runtime: RuntimeState,
|
|
106
117
|
usageSummary: FooterUsageSummary,
|
|
118
|
+
inlineStatuses: ReadonlyMap<string, string>,
|
|
107
119
|
): RenderSegment | undefined {
|
|
108
120
|
switch (name) {
|
|
109
121
|
case "brand":
|
|
@@ -124,7 +136,7 @@ function buildSegment(
|
|
|
124
136
|
return segment(name, runtime.thinkingLevel, config, thinkingColor(runtime.thinkingLevel), "header");
|
|
125
137
|
case "branch": {
|
|
126
138
|
const branch = footerData.getGitBranch();
|
|
127
|
-
const pr = branch ? prContextFromStatuses(
|
|
139
|
+
const pr = branch ? prContextFromStatuses(inlineStatuses) : undefined;
|
|
128
140
|
return segment(name, formatGitBranchValue(branch, runtime.gitStatus, pr), config, "accent", "git");
|
|
129
141
|
}
|
|
130
142
|
case "cwd":
|