@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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @narumitw/pi-statusline
2
2
 
3
+ ## 0.51.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 379ebee: Add optional, extension-neutral status metadata v1 and a quiet footer presentation. Hide normal standby, shorten activity markers, and retain safe retry and blocked reasons while preserving configured icons and readable raw-status fallback. Keep sync authorization, scheduling, request limits, settings, notifications, and recovery behavior unchanged.
8
+
3
9
  ## 0.50.4
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -129,6 +129,12 @@ If the last remaining segment is itself wider than the row, that row renders emp
129
129
  - Context color changes to warning at 70% and error at 90%.
130
130
  - Git state is cached outside footer rendering and stale session results are ignored.
131
131
 
132
+ ### Compact extension activity
133
+
134
+ Extensions can optionally supply [semantic status metadata v1](./docs/extension-status-protocol.md). Normal standby then disappears; checking uses `…`, work uses `↻`, and transfer directions use `↑` / `↓`. Pending-change arrows remain `⇡` / `⇣`; `⇕` means review is needed. Model resolution uses `AI`, while retries and blocked work retain short reasons such as `retry: limit` or `! auth`.
135
+
136
+ Configured icons keep their existing precedence, including an empty override. Other extensions, unsupported metadata, and other footers retain their readable raw-status behavior. This changes display only, not the publisher's work, authorization, or recovery policy.
137
+
132
138
  ### Usage and context
133
139
 
134
140
  - `context` renders one-decimal current usage and the model window, such as `2.4%/272k`.
package/dist/index.ts CHANGED
@@ -21,7 +21,8 @@ import { getCapabilities } from "@earendil-works/pi-tui";
21
21
  import { existsSync, readFileSync } from "node:fs";
22
22
  import { dirname, isAbsolute, join, resolve } from "node:path";
23
23
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
24
- import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
24
+ import { truncateToWidth, visibleWidth as visibleWidth2, wrapTextWithAnsi } from "@earendil-works/pi-tui";
25
+ import { sanitizeTerminalText } from "@narumitw/pi-tui-kit/terminal-text";
25
26
 
26
27
  // src/powerline.ts
27
28
  import { visibleWidth } from "@earendil-works/pi-tui";
@@ -171,10 +172,14 @@ function extensionStatusSeparator(config, theme, trueColor) {
171
172
  return powerlineExtensionSeparator(theme, config.palettePreset, trueColor);
172
173
  }
173
174
  function formatExtensionStatuses(statuses, theme, config, runtime, hiddenKeys = /* @__PURE__ */ new Set(), trueColor = true) {
175
+ runtime.extensionStatusMetadata?.reconcile(statuses);
174
176
  const separator = extensionStatusSeparator(config, theme, trueColor);
175
177
  const visibleStatuses = [
176
178
  ...formatDuplicateExtensionStatus(runtime, theme),
177
- ...[...statuses.entries()].filter(([key, value]) => key !== STATUSLINE_KEY && !hiddenKeys.has(key) && value.trim().length > 0).map(([key, value]) => formatExtensionStatus(key, value, theme, config, runtime.extensionStatusIconAliases))
179
+ ...[...statuses.entries()].filter(([key, value]) => key !== STATUSLINE_KEY && !hiddenKeys.has(key) && value.trim().length > 0).map(([key, value]) => {
180
+ const presentation = runtime.extensionStatusMetadata?.get(key, value);
181
+ return presentation ? formatCompactExtensionStatus(key, value, presentation, theme, config, runtime.extensionStatusIconAliases) : formatExtensionStatus(key, value, theme, config, runtime.extensionStatusIconAliases);
182
+ }).filter((status) => status.length > 0)
178
183
  ].slice(0, 5);
179
184
  return visibleStatuses.join(separator);
180
185
  }
@@ -187,18 +192,41 @@ function formatExtensionStatus(key, value, theme, config, extensionStatusIconAli
187
192
  const renderedText = theme.fg(textColor, text);
188
193
  return icon ? `${theme.fg(color, icon)} ${renderedText}` : renderedText;
189
194
  }
195
+ function formatCompactExtensionStatus(key, value, presentation, theme, config, aliases) {
196
+ if (presentation.activity === "idle") return "";
197
+ const status = splitExtensionStatusIcon(
198
+ stripExtensionStatusPrefix(sanitizeTerminalText(key), sanitizeTerminalText(value))
199
+ );
200
+ const icon = sanitizeTerminalText(extensionStatusIcon(key, status.icon, config.extensionStatusIcons, aliases));
201
+ const bodies = {
202
+ checking: "\u2026",
203
+ working: "\u21BB",
204
+ pushing: "\u2191",
205
+ pulling: "\u2193",
206
+ resolving: "AI",
207
+ retry: `retry: ${presentation.reason}`,
208
+ blocked: `! ${presentation.reason}`,
209
+ "local-change": "\u21E1",
210
+ "remote-change": "\u21E3",
211
+ review: "\u21D5"
212
+ };
213
+ const warning = presentation.activity === "blocked" || presentation.activity === "retry";
214
+ const color = warning ? "warning" : extensionColor(key, value);
215
+ const body = theme.fg(warning ? "warning" : "muted", bodies[presentation.activity]);
216
+ return icon ? `${theme.fg(color, icon)} ${body}` : body;
217
+ }
190
218
  function extensionStatusIcon(key, leadingIcon, configuredIcons, extensionStatusIconAliases) {
191
219
  if (Object.hasOwn(configuredIcons, key)) return configuredIcons[key];
192
220
  const namespaceIcon = configuredNamespaceIcon(key, configuredIcons);
193
221
  if (namespaceIcon !== void 0) return namespaceIcon;
194
- const compatibleKey = COMPATIBLE_STATUS_ICON_KEYS[key];
222
+ const compatibleKey = Object.hasOwn(COMPATIBLE_STATUS_ICON_KEYS, key) ? COMPATIBLE_STATUS_ICON_KEYS[key] : void 0;
195
223
  if (compatibleKey && Object.hasOwn(configuredIcons, compatibleKey)) {
196
224
  return configuredIcons[compatibleKey];
197
225
  }
198
226
  for (const alias of extensionStatusAliasesForKey(key, extensionStatusIconAliases)) {
199
227
  if (Object.hasOwn(configuredIcons, alias)) return configuredIcons[alias];
200
228
  }
201
- return leadingIcon ?? DEFAULT_EXTENSION_STATUS_ICONS[key] ?? "\u{1F50C}";
229
+ return leadingIcon ?? (Object.hasOwn(DEFAULT_EXTENSION_STATUS_ICONS, key) ? DEFAULT_EXTENSION_STATUS_ICONS[key] : "\u{1F50C}");
202
230
  }
203
231
  function configuredNamespaceIcon(key, configuredIcons) {
204
232
  let match;
@@ -221,7 +249,9 @@ function statusKeyMatchesStatusBase(key, statusBase) {
221
249
  }
222
250
  function wrapExtensionStatusline(status, width) {
223
251
  if (!status || width <= 0) return [];
224
- return wrapTextWithAnsi(status, width);
252
+ return wrapTextWithAnsi(status, width).map(
253
+ (line) => visibleWidth2(line) > width ? truncateToWidth(line, width, "") : line
254
+ );
225
255
  }
226
256
  function formatDuplicateExtensionStatus(runtime, theme) {
227
257
  if (runtime.duplicateExtensions.length === 0) return [];
@@ -358,6 +388,155 @@ function resolveSourcePath(source, baseDirectory) {
358
388
  return isAbsolute(source) ? source : resolve(baseDirectory, source);
359
389
  }
360
390
 
391
+ // src/extension-status-metadata.ts
392
+ var UPDATE_CHANNEL = "extension-status:v1:update";
393
+ var REQUEST_CHANNEL = "extension-status:v1:request";
394
+ var ACTIVITIES = /* @__PURE__ */ new Set([
395
+ "idle",
396
+ "checking",
397
+ "working",
398
+ "pushing",
399
+ "pulling",
400
+ "resolving",
401
+ "retry",
402
+ "blocked",
403
+ "local-change",
404
+ "remote-change",
405
+ "review"
406
+ ]);
407
+ function parseMetadata(data) {
408
+ if (Array.isArray(data)) return { withdrawal: false };
409
+ const { version, value, activity, reason } = data;
410
+ const hasValue = Object.hasOwn(data, "value");
411
+ const hasActivity = "activity" in data;
412
+ const hasReason = "reason" in data;
413
+ const withdrawal = version === 1 && hasValue && value === void 0 && !hasActivity && !hasReason;
414
+ if (version !== 1 || !hasValue || typeof value !== "string" || value.length === 0 || !ACTIVITIES.has(activity))
415
+ return { withdrawal };
416
+ if (activity === "retry" || activity === "blocked") {
417
+ if (typeof reason !== "string" || /^[a-z][a-z0-9-]{0,31}$/.exec(reason)?.[0] !== reason) return { withdrawal };
418
+ return { presentation: Object.freeze({ value, activity, reason }), withdrawal };
419
+ }
420
+ if (hasReason) return { withdrawal };
421
+ return { presentation: Object.freeze({ value, activity }), withdrawal };
422
+ }
423
+ function subscribeExtensionStatusMetadata(events, sessionManager, getStatuses, requestRender) {
424
+ const records = /* @__PURE__ */ new Map();
425
+ const liveKeys = /* @__PURE__ */ new Set();
426
+ const requestId = {};
427
+ let observedStatuses = /* @__PURE__ */ new Map();
428
+ try {
429
+ observedStatuses = new Map(getStatuses());
430
+ } catch {
431
+ }
432
+ let disposed = false;
433
+ let revision = 0;
434
+ const keyRevisions = /* @__PURE__ */ new Map();
435
+ const isCurrent = (key, owner) => !disposed && keyRevisions.get(key) === owner;
436
+ let unsubscribe;
437
+ const reconcile = (statuses, ownerKey) => {
438
+ if (disposed) return;
439
+ const before = revision;
440
+ const snapshot = new Map(statuses);
441
+ if (disposed || revision !== before) return;
442
+ for (const [key, value] of observedStatuses) {
443
+ if (snapshot.get(key) === value) continue;
444
+ records.delete(key);
445
+ liveKeys.add(key);
446
+ if (key !== ownerKey) keyRevisions.set(key, ++revision);
447
+ }
448
+ for (const key of snapshot.keys()) {
449
+ if (observedStatuses.has(key)) continue;
450
+ liveKeys.add(key);
451
+ if (key !== ownerKey) keyRevisions.set(key, ++revision);
452
+ }
453
+ observedStatuses = snapshot;
454
+ };
455
+ const metadata = {
456
+ get(key, value) {
457
+ const presentation = records.get(key);
458
+ return presentation?.value === value ? presentation : void 0;
459
+ },
460
+ reconcile(statuses) {
461
+ reconcile(statuses);
462
+ },
463
+ dispose() {
464
+ if (disposed) return;
465
+ disposed = true;
466
+ revision++;
467
+ keyRevisions.clear();
468
+ records.clear();
469
+ liveKeys.clear();
470
+ observedStatuses.clear();
471
+ try {
472
+ unsubscribe?.();
473
+ } catch {
474
+ }
475
+ unsubscribe = void 0;
476
+ }
477
+ };
478
+ const receive = (payload) => {
479
+ if (disposed || !payload || typeof payload !== "object") return;
480
+ const entered = revision;
481
+ let key;
482
+ let snapshot;
483
+ const data = payload;
484
+ try {
485
+ const rawKey = data.key;
486
+ const manager = data.sessionManager;
487
+ snapshot = "requestId" in data;
488
+ const replyId = snapshot ? data.requestId : void 0;
489
+ if (disposed || manager !== sessionManager || typeof rawKey !== "string" || !rawKey) return;
490
+ key = rawKey;
491
+ if ((keyRevisions.get(key) ?? 0) > entered) return;
492
+ if (snapshot && (replyId !== requestId || liveKeys.has(key))) return;
493
+ } catch {
494
+ return;
495
+ }
496
+ const owner = ++revision;
497
+ keyRevisions.set(key, owner);
498
+ if (!snapshot) liveKeys.add(key);
499
+ const previous = records.get(key);
500
+ try {
501
+ const { presentation, withdrawal } = parseMetadata(data);
502
+ if (!isCurrent(key, owner)) return;
503
+ const reading = revision;
504
+ const statuses = new Map(getStatuses());
505
+ if (!isCurrent(key, owner)) return;
506
+ if (revision !== reading) {
507
+ records.delete(key);
508
+ } else {
509
+ reconcile(statuses, key);
510
+ if (!isCurrent(key, owner) || snapshot && liveKeys.has(key)) return;
511
+ if (presentation && statuses.get(key) === presentation.value) {
512
+ records.set(key, presentation);
513
+ } else if (!snapshot || withdrawal) {
514
+ records.delete(key);
515
+ }
516
+ }
517
+ } catch {
518
+ if (!isCurrent(key, owner)) return;
519
+ if (!snapshot) records.delete(key);
520
+ }
521
+ if (isCurrent(key, owner) && previous !== records.get(key)) {
522
+ try {
523
+ requestRender();
524
+ } catch {
525
+ }
526
+ }
527
+ };
528
+ try {
529
+ unsubscribe = events.on(UPDATE_CHANNEL, receive);
530
+ } catch {
531
+ return metadata;
532
+ }
533
+ try {
534
+ events.emit(REQUEST_CHANNEL, { version: 1, sessionManager, requestId });
535
+ } catch {
536
+ }
537
+ return metadata;
538
+ }
539
+
361
540
  // src/git-status.ts
362
541
  import { isAbsolute as isAbsolute2 } from "node:path";
363
542
  var GIT_STATUS_TIMEOUT_MS = 3e3;
@@ -455,21 +634,21 @@ function formatCount(value) {
455
634
  }
456
635
 
457
636
  // src/render.ts
458
- import { sliceByColumn, visibleWidth as visibleWidth2 } from "@earendil-works/pi-tui";
459
- import { sanitizeTerminalText as sanitizeTerminalText2 } from "@narumitw/pi-tui-kit/terminal-text";
637
+ import { sliceByColumn, visibleWidth as visibleWidth3 } from "@earendil-works/pi-tui";
638
+ import { sanitizeTerminalText as sanitizeTerminalText3 } from "@narumitw/pi-tui-kit/terminal-text";
460
639
 
461
640
  // src/directory.ts
462
641
  import { basename, isAbsolute as isAbsolute3, relative, sep } from "node:path";
463
- import { sanitizeTerminalText } from "@narumitw/pi-tui-kit/terminal-text";
642
+ import { sanitizeTerminalText as sanitizeTerminalText2 } from "@narumitw/pi-tui-kit/terminal-text";
464
643
  var DEFAULT_TRUNCATION_LENGTH = 3;
465
644
  var DEFAULT_HOME_SYMBOL = "~";
466
645
  function formatDirectoryPath(cwd, homeDir, gitRoot) {
467
646
  const contracted = gitRoot && (!homeDir || !samePath(gitRoot, homeDir)) ? contractRepositoryPath(cwd, gitRoot) ?? contractPath(cwd, homeDir, DEFAULT_HOME_SYMBOL) : contractPath(cwd, homeDir, DEFAULT_HOME_SYMBOL);
468
- const safeContracted = sanitizeTerminalText(contracted);
647
+ const safeContracted = sanitizeTerminalText2(contracted);
469
648
  const components = safeContracted.split("/").filter((component) => component.length > 0);
470
649
  const displayed = components.length > DEFAULT_TRUNCATION_LENGTH ? components.slice(-DEFAULT_TRUNCATION_LENGTH).join("/") : safeContracted;
471
650
  const native = sep === "/" ? displayed : displayed.replaceAll("/", sep);
472
- return sanitizeTerminalText(native || basename(cwd) || cwd);
651
+ return sanitizeTerminalText2(native || basename(cwd) || cwd);
473
652
  }
474
653
  function toSlashPath(value) {
475
654
  return sep === "\\" ? value.replaceAll("\\", "/") : value;
@@ -501,6 +680,7 @@ var GITHUB_PR_KEY = "github-pr";
501
680
  var GITHUB_PR_STATUS_KEYS = /* @__PURE__ */ new Set([GITHUB_PR_KEY]);
502
681
  function renderStatusline(width, ctx, footerData, _theme, config, runtime, trueColor = true) {
503
682
  if (width <= 0) return "";
683
+ const inlineStatuses = legacyInlineStatuses(footerData.getExtensionStatuses(), runtime);
504
684
  const usageSummary = summarizeFooterUsage(ctx.sessionManager.getEntries());
505
685
  const rows = [
506
686
  { configuredSegments: 0, segments: [] }
@@ -513,7 +693,7 @@ function renderStatusline(width, ctx, footerData, _theme, config, runtime, trueC
513
693
  const row = rows.at(-1);
514
694
  if (!row) continue;
515
695
  row.configuredSegments += 1;
516
- const rendered = buildSegment(name, ctx, footerData, config, runtime, usageSummary);
696
+ const rendered = buildSegment(name, ctx, footerData, config, runtime, usageSummary, inlineStatuses);
517
697
  if (rendered && rendered.text.length > 0) row.segments.push(rendered);
518
698
  }
519
699
  const segments = [];
@@ -526,7 +706,7 @@ function renderStatusline(width, ctx, footerData, _theme, config, runtime, trueC
526
706
  }
527
707
  function renderExtensionStatusline(width, footerData, theme, config, runtime, mainLine, trueColor = true) {
528
708
  const statuses = footerData.getExtensionStatuses();
529
- const prContext = prContextFromStatuses(statuses);
709
+ const prContext = prContextFromStatuses(legacyInlineStatuses(statuses, runtime));
530
710
  const rendersPrInline = prContext !== void 0 && mainLine.includes(prContext);
531
711
  const status = formatExtensionStatuses(
532
712
  statuses,
@@ -538,7 +718,13 @@ function renderExtensionStatusline(width, footerData, theme, config, runtime, ma
538
718
  );
539
719
  return wrapExtensionStatusline(status, width);
540
720
  }
541
- function buildSegment(name, ctx, footerData, config, runtime, usageSummary) {
721
+ function legacyInlineStatuses(statuses, runtime) {
722
+ const metadata = runtime.extensionStatusMetadata;
723
+ if (!metadata) return statuses;
724
+ metadata.reconcile(statuses);
725
+ return new Map([...statuses].filter(([key, value]) => !metadata.get(key, value)));
726
+ }
727
+ function buildSegment(name, ctx, footerData, config, runtime, usageSummary, inlineStatuses) {
542
728
  switch (name) {
543
729
  case "brand":
544
730
  return segment(name, "\u03C0", config, "accent", "header", true);
@@ -558,7 +744,7 @@ function buildSegment(name, ctx, footerData, config, runtime, usageSummary) {
558
744
  return segment(name, runtime.thinkingLevel, config, thinkingColor(runtime.thinkingLevel), "header");
559
745
  case "branch": {
560
746
  const branch = footerData.getGitBranch();
561
- const pr = branch ? prContextFromStatuses(footerData.getExtensionStatuses()) : void 0;
747
+ const pr = branch ? prContextFromStatuses(inlineStatuses) : void 0;
562
748
  return segment(name, formatGitBranchValue(branch, runtime.gitStatus, pr), config, "accent", "git");
563
749
  }
564
750
  case "cwd":
@@ -663,9 +849,9 @@ function boundUIPromptTitleLength(title) {
663
849
  return end < title.length ? `${title.slice(0, ellipsisEnd)}\u2026` : title;
664
850
  }
665
851
  function formatUIPromptTitle(title) {
666
- const safeTitle = title ? sanitizeTerminalText2(title).trim() : "";
852
+ const safeTitle = title ? sanitizeTerminalText3(title).trim() : "";
667
853
  const boundedTitle = boundUIPromptTitleLength(safeTitle);
668
- if (visibleWidth2(boundedTitle) <= MAX_UI_PROMPT_TITLE_WIDTH) return boundedTitle;
854
+ if (visibleWidth3(boundedTitle) <= MAX_UI_PROMPT_TITLE_WIDTH) return boundedTitle;
669
855
  return `${sliceByColumn(boundedTitle, 0, MAX_UI_PROMPT_TITLE_WIDTH - 1, true)}\u2026`;
670
856
  }
671
857
  function formatToolActivity(runtime) {
@@ -734,11 +920,11 @@ function formatTime() {
734
920
  }
735
921
  var graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
736
922
  function truncateModel(model, length, symbol, direction) {
737
- const safeModel = sanitizeTerminalText2(model);
923
+ const safeModel = sanitizeTerminalText3(model);
738
924
  if (length === 0) return safeModel;
739
925
  const graphemes = [...graphemeSegmenter.segment(safeModel)].map(({ segment: segment2 }) => segment2);
740
926
  if (graphemes.length <= length) return safeModel;
741
- const safeSymbol = sanitizeTerminalText2(symbol);
927
+ const safeSymbol = sanitizeTerminalText3(symbol);
742
928
  switch (direction) {
743
929
  case "start":
744
930
  return `${safeSymbol}${graphemes.slice(-length).join("")}`;
@@ -775,6 +961,7 @@ function statusline(pi) {
775
961
  let loaded;
776
962
  let previewPalettePreset;
777
963
  let activeSessionManager;
964
+ let disposeFooter;
778
965
  const runtime = {
779
966
  turnCount: 0,
780
967
  activeTools: /* @__PURE__ */ new Map(),
@@ -851,6 +1038,7 @@ function statusline(pi) {
851
1038
  scheduleGitStatusRefresh(activeGitStatusTarget.cwd, activeGitStatusTarget.generation);
852
1039
  };
853
1040
  const installFooter = (ctx) => {
1041
+ disposeFooter?.();
854
1042
  const generation = ++sessionGeneration;
855
1043
  menuController.abort(new DOMException("Statusline session context replaced", "AbortError"));
856
1044
  menuController = new AbortController();
@@ -864,46 +1052,92 @@ function statusline(pi) {
864
1052
  runtime.gitStatus = void 0;
865
1053
  runtime.duplicateExtensions = [];
866
1054
  runtime.extensionStatusIconAliases = EMPTY_EXTENSION_STATUS_ICON_ALIASES2;
1055
+ runtime.extensionStatusMetadata = void 0;
1056
+ runtime.requestRender = void 0;
867
1057
  ctx.ui.setStatus(STATUSLINE_KEY2, void 0);
868
1058
  if (!activeGitStatusTarget || !loaded) return;
869
1059
  const installedPackages = readInstalledExtensionPackages(cwd);
870
1060
  runtime.duplicateExtensions = findDuplicateExtensions(installedPackages);
871
1061
  runtime.extensionStatusIconAliases = buildExtensionStatusIconAliases(installedPackages);
872
- ctx.ui.setFooter((tui, theme, footerData) => {
873
- runtime.requestRender = () => tui.requestRender();
874
- const refreshFooterGitStatus = () => refreshGitStatus(cwd, generation);
875
- const branchUnsubscribe = footerData.onBranchChange(() => {
876
- runtime.gitStatus = void 0;
877
- abortGitStatusRefresh("Statusline Git branch changed");
878
- clearGitStatusDebounce();
879
- refreshFooterGitStatus();
880
- tui.requestRender();
881
- });
882
- const clock = setInterval(() => {
883
- clearGitStatusDebounce();
884
- refreshFooterGitStatus();
885
- tui.requestRender();
886
- }, GIT_STATUS_REFRESH_INTERVAL_MS);
887
- clock.unref();
888
- return {
889
- dispose() {
890
- branchUnsubscribe();
891
- clearInterval(clock);
892
- if (isActiveGitStatusTarget(cwd, generation)) {
893
- activeGitStatusTarget = void 0;
894
- abortGitStatusRefresh("Statusline footer disposed");
895
- clearGitStatusDebounce();
896
- pendingGitStatusRefresh = void 0;
897
- runtime.gitStatus = void 0;
898
- runtime.duplicateExtensions = [];
899
- runtime.extensionStatusIconAliases = EMPTY_EXTENSION_STATUS_ICON_ALIASES2;
900
- runtime.requestRender = void 0;
1062
+ const sessionManager = ctx.sessionManager;
1063
+ const createFooter = (tui, theme, footerData) => {
1064
+ if (generation !== sessionGeneration || sessionManager !== activeSessionManager) {
1065
+ return { render: () => [], invalidate() {
1066
+ } };
1067
+ }
1068
+ disposeFooter?.();
1069
+ activeGitStatusTarget = { cwd, generation };
1070
+ let disposed = false;
1071
+ let branchUnsubscribe;
1072
+ let clock;
1073
+ let metadata;
1074
+ const isCurrentFooter = () => !disposed && generation === sessionGeneration && sessionManager === activeSessionManager;
1075
+ const dispose = () => {
1076
+ if (disposed) return;
1077
+ disposed = true;
1078
+ try {
1079
+ metadata?.dispose();
1080
+ } finally {
1081
+ try {
1082
+ branchUnsubscribe?.();
1083
+ } finally {
1084
+ if (clock) clearInterval(clock);
1085
+ if (disposeFooter === dispose) {
1086
+ disposeFooter = void 0;
1087
+ runtime.extensionStatusMetadata = void 0;
1088
+ runtime.requestRender = void 0;
1089
+ }
1090
+ if (isActiveGitStatusTarget(cwd, generation)) {
1091
+ activeGitStatusTarget = void 0;
1092
+ abortGitStatusRefresh("Statusline footer disposed");
1093
+ clearGitStatusDebounce();
1094
+ pendingGitStatusRefresh = void 0;
1095
+ runtime.gitStatus = void 0;
1096
+ }
901
1097
  }
902
- },
1098
+ }
1099
+ };
1100
+ disposeFooter = dispose;
1101
+ runtime.requestRender = () => {
1102
+ if (isCurrentFooter()) tui.requestRender();
1103
+ };
1104
+ try {
1105
+ const refreshFooterGitStatus = () => refreshGitStatus(cwd, generation);
1106
+ branchUnsubscribe = footerData.onBranchChange(() => {
1107
+ if (!isCurrentFooter()) return;
1108
+ runtime.gitStatus = void 0;
1109
+ abortGitStatusRefresh("Statusline Git branch changed");
1110
+ clearGitStatusDebounce();
1111
+ refreshFooterGitStatus();
1112
+ refresh();
1113
+ });
1114
+ clock = setInterval(() => {
1115
+ if (!isCurrentFooter()) return;
1116
+ clearGitStatusDebounce();
1117
+ refreshFooterGitStatus();
1118
+ refresh();
1119
+ }, GIT_STATUS_REFRESH_INTERVAL_MS);
1120
+ clock.unref();
1121
+ metadata = subscribeExtensionStatusMetadata(
1122
+ pi.events,
1123
+ sessionManager,
1124
+ () => footerData.getExtensionStatuses(),
1125
+ () => {
1126
+ if (isCurrentFooter()) refresh();
1127
+ }
1128
+ );
1129
+ if (isCurrentFooter()) runtime.extensionStatusMetadata = metadata;
1130
+ else metadata.dispose();
1131
+ } catch (error) {
1132
+ dispose();
1133
+ throw error;
1134
+ }
1135
+ return {
1136
+ dispose,
903
1137
  invalidate() {
904
1138
  },
905
1139
  render(width) {
906
- if (!loaded) return [];
1140
+ if (!isCurrentFooter() || !loaded) return [];
907
1141
  const config = previewPalettePreset ? { ...loaded.config, palettePreset: previewPalettePreset } : loaded.config;
908
1142
  const trueColor = getCapabilities().trueColor;
909
1143
  const mainLine = renderStatusline(width, ctx, footerData, theme, config, runtime, trueColor);
@@ -912,7 +1146,13 @@ function statusline(pi) {
912
1146
  return lines;
913
1147
  }
914
1148
  };
915
- });
1149
+ };
1150
+ try {
1151
+ ctx.ui.setFooter(createFooter);
1152
+ } catch (error) {
1153
+ disposeFooter?.();
1154
+ throw error;
1155
+ }
916
1156
  refreshGitStatus(cwd, generation);
917
1157
  };
918
1158
  const agentDir = getAgentDir2();
@@ -953,14 +1193,19 @@ function statusline(pi) {
953
1193
  runtime.activeTools.clear();
954
1194
  runtime.isStreaming = false;
955
1195
  runtime.uiPrompt = void 0;
956
- loaded = loadStatuslineSettingsForAgent(agentDir);
957
- const settingsNotice = consumeStatuslineSettingsNotice();
958
- if (settingsNotice) ctx.ui.notify(settingsNotice, "warning");
959
- if (loaded.diagnostics.length > 0) {
960
- ctx.ui.notify(formatSettingsDiagnostics(loaded), "warning");
1196
+ try {
1197
+ loaded = loadStatuslineSettingsForAgent(agentDir);
1198
+ const settingsNotice = consumeStatuslineSettingsNotice();
1199
+ if (settingsNotice) ctx.ui.notify(settingsNotice, "warning");
1200
+ if (loaded.diagnostics.length > 0) {
1201
+ ctx.ui.notify(formatSettingsDiagnostics(loaded), "warning");
1202
+ }
1203
+ runtime.thinkingLevel = pi.getThinkingLevel();
1204
+ installFooter(ctx);
1205
+ } catch (error) {
1206
+ disposeFooter?.();
1207
+ throw error;
961
1208
  }
962
- runtime.thinkingLevel = pi.getThinkingLevel();
963
- installFooter(ctx);
964
1209
  });
965
1210
  pi.on("session_tree", (_event, ctx) => {
966
1211
  installFooter(ctx);
@@ -968,6 +1213,7 @@ function statusline(pi) {
968
1213
  });
969
1214
  pi.on("session_shutdown", (_event, ctx) => {
970
1215
  if (!ownsRuntime(ctx)) return;
1216
+ disposeFooter?.();
971
1217
  sessionGeneration += 1;
972
1218
  menuController.abort(new DOMException("Statusline session shut down", "AbortError"));
973
1219
  activeSessionManager = void 0;
@@ -982,7 +1228,7 @@ function statusline(pi) {
982
1228
  runtime.uiPrompt = void 0;
983
1229
  runtime.duplicateExtensions = [];
984
1230
  runtime.extensionStatusIconAliases = EMPTY_EXTENSION_STATUS_ICON_ALIASES2;
985
- ctx.ui.setFooter(void 0);
1231
+ if (ctx.mode === "tui") ctx.ui.setFooter(void 0);
986
1232
  ctx.ui.setStatus(STATUSLINE_KEY2, void 0);
987
1233
  runtime.requestRender = void 0;
988
1234
  });