@narumitw/pi-statusline 0.50.4 → 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 +6 -0
- package/README.md +6 -0
- package/dist/index.ts +304 -58
- package/dist/index.ts.map +4 -4
- package/docs/configuration.md +6 -0
- package/docs/extension-status-protocol.md +92 -0
- package/package.json +2 -2
- package/src/extension-status-metadata.ts +208 -0
- package/src/extension-status.ts +51 -5
- package/src/render.ts +15 -3
- package/src/statusline.ts +102 -41
|
@@ -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/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":
|
package/src/statusline.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
findDuplicateExtensions,
|
|
10
10
|
readInstalledExtensionPackages,
|
|
11
11
|
} from "./extension-status.js";
|
|
12
|
+
import { subscribeExtensionStatusMetadata } from "./extension-status-metadata.js";
|
|
12
13
|
import { type GitStatusSummary, gitStatusSummaryEqual, readGitStatus } from "./git-status.js";
|
|
13
14
|
import { type RuntimeState, renderExtensionStatusline, renderStatusline } from "./render.js";
|
|
14
15
|
import {
|
|
@@ -42,6 +43,7 @@ export default function statusline(pi: ExtensionAPI) {
|
|
|
42
43
|
let loaded: LoadedStatuslineSettings | undefined;
|
|
43
44
|
let previewPalettePreset: PalettePreset | undefined;
|
|
44
45
|
let activeSessionManager: ExtensionContext["sessionManager"] | undefined;
|
|
46
|
+
let disposeFooter: (() => void) | undefined;
|
|
45
47
|
const runtime: RuntimeState = {
|
|
46
48
|
turnCount: 0,
|
|
47
49
|
activeTools: new Map(),
|
|
@@ -135,6 +137,7 @@ export default function statusline(pi: ExtensionAPI) {
|
|
|
135
137
|
};
|
|
136
138
|
|
|
137
139
|
const installFooter = (ctx: ExtensionContext) => {
|
|
140
|
+
disposeFooter?.();
|
|
138
141
|
const generation = ++sessionGeneration;
|
|
139
142
|
menuController.abort(new DOMException("Statusline session context replaced", "AbortError"));
|
|
140
143
|
menuController = new AbortController();
|
|
@@ -148,47 +151,93 @@ export default function statusline(pi: ExtensionAPI) {
|
|
|
148
151
|
runtime.gitStatus = undefined;
|
|
149
152
|
runtime.duplicateExtensions = [];
|
|
150
153
|
runtime.extensionStatusIconAliases = EMPTY_EXTENSION_STATUS_ICON_ALIASES;
|
|
154
|
+
runtime.extensionStatusMetadata = undefined;
|
|
155
|
+
runtime.requestRender = undefined;
|
|
151
156
|
ctx.ui.setStatus(STATUSLINE_KEY, undefined);
|
|
152
157
|
if (!activeGitStatusTarget || !loaded) return;
|
|
153
158
|
const installedPackages = readInstalledExtensionPackages(cwd);
|
|
154
159
|
runtime.duplicateExtensions = findDuplicateExtensions(installedPackages);
|
|
155
160
|
runtime.extensionStatusIconAliases = buildExtensionStatusIconAliases(installedPackages);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
161
|
+
const sessionManager = ctx.sessionManager;
|
|
162
|
+
const createFooter: NonNullable<Parameters<ExtensionContext["ui"]["setFooter"]>[0]> = (tui, theme, footerData) => {
|
|
163
|
+
if (generation !== sessionGeneration || sessionManager !== activeSessionManager) {
|
|
164
|
+
return { render: () => [], invalidate() {} };
|
|
165
|
+
}
|
|
166
|
+
disposeFooter?.();
|
|
167
|
+
activeGitStatusTarget = { cwd, generation };
|
|
168
|
+
let disposed = false;
|
|
169
|
+
let branchUnsubscribe: (() => void) | undefined;
|
|
170
|
+
let clock: ReturnType<typeof setInterval> | undefined;
|
|
171
|
+
let metadata: ReturnType<typeof subscribeExtensionStatusMetadata> | undefined;
|
|
172
|
+
const isCurrentFooter = () =>
|
|
173
|
+
!disposed && generation === sessionGeneration && sessionManager === activeSessionManager;
|
|
174
|
+
const dispose = () => {
|
|
175
|
+
if (disposed) return;
|
|
176
|
+
disposed = true;
|
|
177
|
+
try {
|
|
178
|
+
metadata?.dispose();
|
|
179
|
+
} finally {
|
|
180
|
+
try {
|
|
181
|
+
branchUnsubscribe?.();
|
|
182
|
+
} finally {
|
|
183
|
+
if (clock) clearInterval(clock);
|
|
184
|
+
if (disposeFooter === dispose) {
|
|
185
|
+
disposeFooter = undefined;
|
|
186
|
+
runtime.extensionStatusMetadata = undefined;
|
|
187
|
+
runtime.requestRender = undefined;
|
|
188
|
+
}
|
|
189
|
+
if (isActiveGitStatusTarget(cwd, generation)) {
|
|
190
|
+
activeGitStatusTarget = undefined;
|
|
191
|
+
abortGitStatusRefresh("Statusline footer disposed");
|
|
192
|
+
clearGitStatusDebounce();
|
|
193
|
+
pendingGitStatusRefresh = undefined;
|
|
194
|
+
runtime.gitStatus = undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
disposeFooter = dispose;
|
|
200
|
+
runtime.requestRender = () => {
|
|
201
|
+
if (isCurrentFooter()) tui.requestRender();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const refreshFooterGitStatus = () => refreshGitStatus(cwd, generation);
|
|
206
|
+
branchUnsubscribe = footerData.onBranchChange(() => {
|
|
207
|
+
if (!isCurrentFooter()) return;
|
|
208
|
+
runtime.gitStatus = undefined;
|
|
209
|
+
abortGitStatusRefresh("Statusline Git branch changed");
|
|
210
|
+
clearGitStatusDebounce();
|
|
211
|
+
refreshFooterGitStatus();
|
|
212
|
+
refresh();
|
|
213
|
+
});
|
|
214
|
+
clock = setInterval(() => {
|
|
215
|
+
if (!isCurrentFooter()) return;
|
|
216
|
+
clearGitStatusDebounce();
|
|
217
|
+
refreshFooterGitStatus();
|
|
218
|
+
refresh();
|
|
219
|
+
}, GIT_STATUS_REFRESH_INTERVAL_MS);
|
|
220
|
+
clock.unref();
|
|
221
|
+
metadata = subscribeExtensionStatusMetadata(
|
|
222
|
+
pi.events,
|
|
223
|
+
sessionManager,
|
|
224
|
+
() => footerData.getExtensionStatuses(),
|
|
225
|
+
() => {
|
|
226
|
+
if (isCurrentFooter()) refresh();
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
if (isCurrentFooter()) runtime.extensionStatusMetadata = metadata;
|
|
230
|
+
else metadata.dispose();
|
|
231
|
+
} catch (error) {
|
|
232
|
+
dispose();
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
173
235
|
|
|
174
236
|
return {
|
|
175
|
-
dispose
|
|
176
|
-
branchUnsubscribe();
|
|
177
|
-
clearInterval(clock);
|
|
178
|
-
if (isActiveGitStatusTarget(cwd, generation)) {
|
|
179
|
-
activeGitStatusTarget = undefined;
|
|
180
|
-
abortGitStatusRefresh("Statusline footer disposed");
|
|
181
|
-
clearGitStatusDebounce();
|
|
182
|
-
pendingGitStatusRefresh = undefined;
|
|
183
|
-
runtime.gitStatus = undefined;
|
|
184
|
-
runtime.duplicateExtensions = [];
|
|
185
|
-
runtime.extensionStatusIconAliases = EMPTY_EXTENSION_STATUS_ICON_ALIASES;
|
|
186
|
-
runtime.requestRender = undefined;
|
|
187
|
-
}
|
|
188
|
-
},
|
|
237
|
+
dispose,
|
|
189
238
|
invalidate() {},
|
|
190
239
|
render(width: number): string[] {
|
|
191
|
-
if (!loaded) return [];
|
|
240
|
+
if (!isCurrentFooter() || !loaded) return [];
|
|
192
241
|
const config = previewPalettePreset
|
|
193
242
|
? { ...loaded.config, palettePreset: previewPalettePreset }
|
|
194
243
|
: loaded.config;
|
|
@@ -199,7 +248,13 @@ export default function statusline(pi: ExtensionAPI) {
|
|
|
199
248
|
return lines;
|
|
200
249
|
},
|
|
201
250
|
};
|
|
202
|
-
}
|
|
251
|
+
};
|
|
252
|
+
try {
|
|
253
|
+
ctx.ui.setFooter(createFooter);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
disposeFooter?.();
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
203
258
|
refreshGitStatus(cwd, generation);
|
|
204
259
|
};
|
|
205
260
|
|
|
@@ -242,14 +297,19 @@ export default function statusline(pi: ExtensionAPI) {
|
|
|
242
297
|
runtime.activeTools.clear();
|
|
243
298
|
runtime.isStreaming = false;
|
|
244
299
|
runtime.uiPrompt = undefined;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
300
|
+
try {
|
|
301
|
+
loaded = loadStatuslineSettingsForAgent(agentDir);
|
|
302
|
+
const settingsNotice = consumeStatuslineSettingsNotice();
|
|
303
|
+
if (settingsNotice) ctx.ui.notify(settingsNotice, "warning");
|
|
304
|
+
if (loaded.diagnostics.length > 0) {
|
|
305
|
+
ctx.ui.notify(formatSettingsDiagnostics(loaded), "warning");
|
|
306
|
+
}
|
|
307
|
+
runtime.thinkingLevel = pi.getThinkingLevel();
|
|
308
|
+
installFooter(ctx);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
disposeFooter?.();
|
|
311
|
+
throw error;
|
|
250
312
|
}
|
|
251
|
-
runtime.thinkingLevel = pi.getThinkingLevel();
|
|
252
|
-
installFooter(ctx);
|
|
253
313
|
});
|
|
254
314
|
|
|
255
315
|
pi.on("session_tree", (_event, ctx) => {
|
|
@@ -259,6 +319,7 @@ export default function statusline(pi: ExtensionAPI) {
|
|
|
259
319
|
|
|
260
320
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
261
321
|
if (!ownsRuntime(ctx)) return;
|
|
322
|
+
disposeFooter?.();
|
|
262
323
|
sessionGeneration += 1;
|
|
263
324
|
menuController.abort(new DOMException("Statusline session shut down", "AbortError"));
|
|
264
325
|
activeSessionManager = undefined;
|
|
@@ -273,7 +334,7 @@ export default function statusline(pi: ExtensionAPI) {
|
|
|
273
334
|
runtime.uiPrompt = undefined;
|
|
274
335
|
runtime.duplicateExtensions = [];
|
|
275
336
|
runtime.extensionStatusIconAliases = EMPTY_EXTENSION_STATUS_ICON_ALIASES;
|
|
276
|
-
ctx.ui.setFooter(undefined);
|
|
337
|
+
if (ctx.mode === "tui") ctx.ui.setFooter(undefined);
|
|
277
338
|
ctx.ui.setStatus(STATUSLINE_KEY, undefined);
|
|
278
339
|
runtime.requestRender = undefined;
|
|
279
340
|
});
|