@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.
@@ -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
+ }
@@ -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]) => formatExtensionStatus(key, value, theme, config, runtime.extensionStatusIconAliases)),
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 leadingIcon ?? DEFAULT_EXTENSION_STATUS_ICONS[key] ?? "🔌";
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(footerData.getExtensionStatuses()) : undefined;
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
- ctx.ui.setFooter((tui, theme, footerData) => {
157
- runtime.requestRender = () => tui.requestRender();
158
-
159
- const refreshFooterGitStatus = () => refreshGitStatus(cwd, generation);
160
- const branchUnsubscribe = footerData.onBranchChange(() => {
161
- runtime.gitStatus = undefined;
162
- abortGitStatusRefresh("Statusline Git branch changed");
163
- clearGitStatusDebounce();
164
- refreshFooterGitStatus();
165
- tui.requestRender();
166
- });
167
- const clock = setInterval(() => {
168
- clearGitStatusDebounce();
169
- refreshFooterGitStatus();
170
- tui.requestRender();
171
- }, GIT_STATUS_REFRESH_INTERVAL_MS);
172
- clock.unref();
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
- loaded = loadStatuslineSettingsForAgent(agentDir);
246
- const settingsNotice = consumeStatuslineSettingsNotice();
247
- if (settingsNotice) ctx.ui.notify(settingsNotice, "warning");
248
- if (loaded.diagnostics.length > 0) {
249
- ctx.ui.notify(formatSettingsDiagnostics(loaded), "warning");
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
  });