@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/dist/index.ts CHANGED
@@ -8,8 +8,9 @@ import {
8
8
  loadStatuslineSettings,
9
9
  loadStatuslineSettingsForAgent,
10
10
  resolvePreset,
11
- settingsFilePath
12
- } from "./chunks/chunk-LARLXEUL.ts";
11
+ settingsFilePath,
12
+ summarizeFooterUsage
13
+ } from "./chunks/chunk-HJJWE7KU.ts";
13
14
 
14
15
  // src/statusline.ts
15
16
  import { homedir } from "node:os";
@@ -20,7 +21,8 @@ import { getCapabilities } from "@earendil-works/pi-tui";
20
21
  import { existsSync, readFileSync } from "node:fs";
21
22
  import { dirname, isAbsolute, join, resolve } from "node:path";
22
23
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
23
- 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";
24
26
 
25
27
  // src/powerline.ts
26
28
  import { visibleWidth } from "@earendil-works/pi-tui";
@@ -170,10 +172,14 @@ function extensionStatusSeparator(config, theme, trueColor) {
170
172
  return powerlineExtensionSeparator(theme, config.palettePreset, trueColor);
171
173
  }
172
174
  function formatExtensionStatuses(statuses, theme, config, runtime, hiddenKeys = /* @__PURE__ */ new Set(), trueColor = true) {
175
+ runtime.extensionStatusMetadata?.reconcile(statuses);
173
176
  const separator = extensionStatusSeparator(config, theme, trueColor);
174
177
  const visibleStatuses = [
175
178
  ...formatDuplicateExtensionStatus(runtime, theme),
176
- ...[...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)
177
183
  ].slice(0, 5);
178
184
  return visibleStatuses.join(separator);
179
185
  }
@@ -186,18 +192,41 @@ function formatExtensionStatus(key, value, theme, config, extensionStatusIconAli
186
192
  const renderedText = theme.fg(textColor, text);
187
193
  return icon ? `${theme.fg(color, icon)} ${renderedText}` : renderedText;
188
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
+ }
189
218
  function extensionStatusIcon(key, leadingIcon, configuredIcons, extensionStatusIconAliases) {
190
219
  if (Object.hasOwn(configuredIcons, key)) return configuredIcons[key];
191
220
  const namespaceIcon = configuredNamespaceIcon(key, configuredIcons);
192
221
  if (namespaceIcon !== void 0) return namespaceIcon;
193
- const compatibleKey = COMPATIBLE_STATUS_ICON_KEYS[key];
222
+ const compatibleKey = Object.hasOwn(COMPATIBLE_STATUS_ICON_KEYS, key) ? COMPATIBLE_STATUS_ICON_KEYS[key] : void 0;
194
223
  if (compatibleKey && Object.hasOwn(configuredIcons, compatibleKey)) {
195
224
  return configuredIcons[compatibleKey];
196
225
  }
197
226
  for (const alias of extensionStatusAliasesForKey(key, extensionStatusIconAliases)) {
198
227
  if (Object.hasOwn(configuredIcons, alias)) return configuredIcons[alias];
199
228
  }
200
- 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}");
201
230
  }
202
231
  function configuredNamespaceIcon(key, configuredIcons) {
203
232
  let match;
@@ -220,7 +249,9 @@ function statusKeyMatchesStatusBase(key, statusBase) {
220
249
  }
221
250
  function wrapExtensionStatusline(status, width) {
222
251
  if (!status || width <= 0) return [];
223
- return wrapTextWithAnsi(status, width);
252
+ return wrapTextWithAnsi(status, width).map(
253
+ (line) => visibleWidth2(line) > width ? truncateToWidth(line, width, "") : line
254
+ );
224
255
  }
225
256
  function formatDuplicateExtensionStatus(runtime, theme) {
226
257
  if (runtime.duplicateExtensions.length === 0) return [];
@@ -357,25 +388,178 @@ function resolveSourcePath(source, baseDirectory) {
357
388
  return isAbsolute(source) ? source : resolve(baseDirectory, source);
358
389
  }
359
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
+
360
540
  // src/git-status.ts
361
541
  import { isAbsolute as isAbsolute2 } from "node:path";
362
542
  var GIT_STATUS_TIMEOUT_MS = 3e3;
543
+ var GIT_STATUS_MAX_BUFFER = 1024 * 1024;
544
+ var GIT_ROOT_MAX_BUFFER = 16 * 1024;
363
545
  async function readGitStatus(pi, cwd, signal) {
364
546
  const [statusResult, rootResult] = await Promise.all([
365
547
  pi.exec("git", ["--no-optional-locks", "status", "--porcelain=v1", "--branch", "--untracked-files=normal"], {
366
548
  cwd,
367
549
  timeout: GIT_STATUS_TIMEOUT_MS,
550
+ maxBuffer: GIT_STATUS_MAX_BUFFER,
368
551
  signal
369
552
  }),
370
553
  pi.exec("git", ["rev-parse", "--path-format=absolute", "--show-toplevel"], {
371
554
  cwd,
372
555
  timeout: GIT_STATUS_TIMEOUT_MS,
556
+ maxBuffer: GIT_ROOT_MAX_BUFFER,
373
557
  signal
374
558
  }).catch(() => void 0)
375
559
  ]);
376
- if (statusResult.code !== 0 || statusResult.killed) return void 0;
560
+ if (statusResult.truncated || statusResult.code !== 0 || statusResult.killed) return void 0;
377
561
  const summary = parseGitStatusPorcelain(statusResult.stdout);
378
- if (rootResult && rootResult.code === 0 && !rootResult.killed) {
562
+ if (rootResult && !rootResult.truncated && rootResult.code === 0 && !rootResult.killed) {
379
563
  const root = parseGitRoot(rootResult.stdout);
380
564
  if (root) summary.root = root;
381
565
  }
@@ -450,21 +634,21 @@ function formatCount(value) {
450
634
  }
451
635
 
452
636
  // src/render.ts
453
- import { sliceByColumn, visibleWidth as visibleWidth2 } from "@earendil-works/pi-tui";
454
- 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";
455
639
 
456
640
  // src/directory.ts
457
641
  import { basename, isAbsolute as isAbsolute3, relative, sep } from "node:path";
458
- import { sanitizeTerminalText } from "@narumitw/pi-tui-kit/terminal-text";
642
+ import { sanitizeTerminalText as sanitizeTerminalText2 } from "@narumitw/pi-tui-kit/terminal-text";
459
643
  var DEFAULT_TRUNCATION_LENGTH = 3;
460
644
  var DEFAULT_HOME_SYMBOL = "~";
461
645
  function formatDirectoryPath(cwd, homeDir, gitRoot) {
462
646
  const contracted = gitRoot && (!homeDir || !samePath(gitRoot, homeDir)) ? contractRepositoryPath(cwd, gitRoot) ?? contractPath(cwd, homeDir, DEFAULT_HOME_SYMBOL) : contractPath(cwd, homeDir, DEFAULT_HOME_SYMBOL);
463
- const safeContracted = sanitizeTerminalText(contracted);
647
+ const safeContracted = sanitizeTerminalText2(contracted);
464
648
  const components = safeContracted.split("/").filter((component) => component.length > 0);
465
649
  const displayed = components.length > DEFAULT_TRUNCATION_LENGTH ? components.slice(-DEFAULT_TRUNCATION_LENGTH).join("/") : safeContracted;
466
650
  const native = sep === "/" ? displayed : displayed.replaceAll("/", sep);
467
- return sanitizeTerminalText(native || basename(cwd) || cwd);
651
+ return sanitizeTerminalText2(native || basename(cwd) || cwd);
468
652
  }
469
653
  function toSlashPath(value) {
470
654
  return sep === "\\" ? value.replaceAll("\\", "/") : value;
@@ -491,44 +675,12 @@ function samePath(left, right) {
491
675
  return relative(left, right) === "";
492
676
  }
493
677
 
494
- // src/usage.ts
495
- function summarizeFooterUsage(entries) {
496
- const totals = {
497
- input: 0,
498
- output: 0,
499
- cacheRead: 0,
500
- cacheWrite: 0,
501
- cost: 0
502
- };
503
- for (const entry of entries) {
504
- let usage;
505
- if (entry.type === "message" && entry.message.role === "assistant") {
506
- usage = entry.message.usage;
507
- const input = usage.input ?? 0;
508
- const cacheRead = usage.cacheRead ?? 0;
509
- const cacheWrite = usage.cacheWrite ?? 0;
510
- const promptTokens = input + cacheRead + cacheWrite;
511
- totals.latestCacheHitRate = promptTokens > 0 ? cacheRead / promptTokens * 100 : void 0;
512
- } else if (entry.type === "message" && entry.message.role === "toolResult") {
513
- usage = entry.message.usage;
514
- } else if (entry.type === "compaction" || entry.type === "branch_summary" || entry.type === "usage") {
515
- usage = entry.usage;
516
- }
517
- if (!usage) continue;
518
- totals.input += usage.input ?? 0;
519
- totals.output += usage.output ?? 0;
520
- totals.cacheRead += usage.cacheRead ?? 0;
521
- totals.cacheWrite += usage.cacheWrite ?? 0;
522
- totals.cost += usage.cost?.total ?? 0;
523
- }
524
- return totals;
525
- }
526
-
527
678
  // src/render.ts
528
679
  var GITHUB_PR_KEY = "github-pr";
529
680
  var GITHUB_PR_STATUS_KEYS = /* @__PURE__ */ new Set([GITHUB_PR_KEY]);
530
681
  function renderStatusline(width, ctx, footerData, _theme, config, runtime, trueColor = true) {
531
682
  if (width <= 0) return "";
683
+ const inlineStatuses = legacyInlineStatuses(footerData.getExtensionStatuses(), runtime);
532
684
  const usageSummary = summarizeFooterUsage(ctx.sessionManager.getEntries());
533
685
  const rows = [
534
686
  { configuredSegments: 0, segments: [] }
@@ -541,7 +693,7 @@ function renderStatusline(width, ctx, footerData, _theme, config, runtime, trueC
541
693
  const row = rows.at(-1);
542
694
  if (!row) continue;
543
695
  row.configuredSegments += 1;
544
- const rendered = buildSegment(name, ctx, footerData, config, runtime, usageSummary);
696
+ const rendered = buildSegment(name, ctx, footerData, config, runtime, usageSummary, inlineStatuses);
545
697
  if (rendered && rendered.text.length > 0) row.segments.push(rendered);
546
698
  }
547
699
  const segments = [];
@@ -554,7 +706,7 @@ function renderStatusline(width, ctx, footerData, _theme, config, runtime, trueC
554
706
  }
555
707
  function renderExtensionStatusline(width, footerData, theme, config, runtime, mainLine, trueColor = true) {
556
708
  const statuses = footerData.getExtensionStatuses();
557
- const prContext = prContextFromStatuses(statuses);
709
+ const prContext = prContextFromStatuses(legacyInlineStatuses(statuses, runtime));
558
710
  const rendersPrInline = prContext !== void 0 && mainLine.includes(prContext);
559
711
  const status = formatExtensionStatuses(
560
712
  statuses,
@@ -566,7 +718,13 @@ function renderExtensionStatusline(width, footerData, theme, config, runtime, ma
566
718
  );
567
719
  return wrapExtensionStatusline(status, width);
568
720
  }
569
- 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) {
570
728
  switch (name) {
571
729
  case "brand":
572
730
  return segment(name, "\u03C0", config, "accent", "header", true);
@@ -586,7 +744,7 @@ function buildSegment(name, ctx, footerData, config, runtime, usageSummary) {
586
744
  return segment(name, runtime.thinkingLevel, config, thinkingColor(runtime.thinkingLevel), "header");
587
745
  case "branch": {
588
746
  const branch = footerData.getGitBranch();
589
- const pr = branch ? prContextFromStatuses(footerData.getExtensionStatuses()) : void 0;
747
+ const pr = branch ? prContextFromStatuses(inlineStatuses) : void 0;
590
748
  return segment(name, formatGitBranchValue(branch, runtime.gitStatus, pr), config, "accent", "git");
591
749
  }
592
750
  case "cwd":
@@ -691,9 +849,9 @@ function boundUIPromptTitleLength(title) {
691
849
  return end < title.length ? `${title.slice(0, ellipsisEnd)}\u2026` : title;
692
850
  }
693
851
  function formatUIPromptTitle(title) {
694
- const safeTitle = title ? sanitizeTerminalText2(title).trim() : "";
852
+ const safeTitle = title ? sanitizeTerminalText3(title).trim() : "";
695
853
  const boundedTitle = boundUIPromptTitleLength(safeTitle);
696
- if (visibleWidth2(boundedTitle) <= MAX_UI_PROMPT_TITLE_WIDTH) return boundedTitle;
854
+ if (visibleWidth3(boundedTitle) <= MAX_UI_PROMPT_TITLE_WIDTH) return boundedTitle;
697
855
  return `${sliceByColumn(boundedTitle, 0, MAX_UI_PROMPT_TITLE_WIDTH - 1, true)}\u2026`;
698
856
  }
699
857
  function formatToolActivity(runtime) {
@@ -762,11 +920,11 @@ function formatTime() {
762
920
  }
763
921
  var graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
764
922
  function truncateModel(model, length, symbol, direction) {
765
- const safeModel = sanitizeTerminalText2(model);
923
+ const safeModel = sanitizeTerminalText3(model);
766
924
  if (length === 0) return safeModel;
767
925
  const graphemes = [...graphemeSegmenter.segment(safeModel)].map(({ segment: segment2 }) => segment2);
768
926
  if (graphemes.length <= length) return safeModel;
769
- const safeSymbol = sanitizeTerminalText2(symbol);
927
+ const safeSymbol = sanitizeTerminalText3(symbol);
770
928
  switch (direction) {
771
929
  case "start":
772
930
  return `${safeSymbol}${graphemes.slice(-length).join("")}`;
@@ -792,7 +950,7 @@ var EMPTY_EXTENSION_STATUS_ICON_ALIASES2 = /* @__PURE__ */ new Map();
792
950
  var statuslineCommandsPromise;
793
951
  function loadStatuslineCommands() {
794
952
  if (!statuslineCommandsPromise) {
795
- statuslineCommandsPromise = import("./chunks/commands-KDWSOSZZ.ts").catch((error) => {
953
+ statuslineCommandsPromise = import("./chunks/commands-WV3JHCQ6.ts").catch((error) => {
796
954
  statuslineCommandsPromise = void 0;
797
955
  throw error;
798
956
  });
@@ -803,6 +961,7 @@ function statusline(pi) {
803
961
  let loaded;
804
962
  let previewPalettePreset;
805
963
  let activeSessionManager;
964
+ let disposeFooter;
806
965
  const runtime = {
807
966
  turnCount: 0,
808
967
  activeTools: /* @__PURE__ */ new Map(),
@@ -879,6 +1038,7 @@ function statusline(pi) {
879
1038
  scheduleGitStatusRefresh(activeGitStatusTarget.cwd, activeGitStatusTarget.generation);
880
1039
  };
881
1040
  const installFooter = (ctx) => {
1041
+ disposeFooter?.();
882
1042
  const generation = ++sessionGeneration;
883
1043
  menuController.abort(new DOMException("Statusline session context replaced", "AbortError"));
884
1044
  menuController = new AbortController();
@@ -892,46 +1052,92 @@ function statusline(pi) {
892
1052
  runtime.gitStatus = void 0;
893
1053
  runtime.duplicateExtensions = [];
894
1054
  runtime.extensionStatusIconAliases = EMPTY_EXTENSION_STATUS_ICON_ALIASES2;
1055
+ runtime.extensionStatusMetadata = void 0;
1056
+ runtime.requestRender = void 0;
895
1057
  ctx.ui.setStatus(STATUSLINE_KEY2, void 0);
896
1058
  if (!activeGitStatusTarget || !loaded) return;
897
1059
  const installedPackages = readInstalledExtensionPackages(cwd);
898
1060
  runtime.duplicateExtensions = findDuplicateExtensions(installedPackages);
899
1061
  runtime.extensionStatusIconAliases = buildExtensionStatusIconAliases(installedPackages);
900
- ctx.ui.setFooter((tui, theme, footerData) => {
901
- runtime.requestRender = () => tui.requestRender();
902
- const refreshFooterGitStatus = () => refreshGitStatus(cwd, generation);
903
- const branchUnsubscribe = footerData.onBranchChange(() => {
904
- runtime.gitStatus = void 0;
905
- abortGitStatusRefresh("Statusline Git branch changed");
906
- clearGitStatusDebounce();
907
- refreshFooterGitStatus();
908
- tui.requestRender();
909
- });
910
- const clock = setInterval(() => {
911
- clearGitStatusDebounce();
912
- refreshFooterGitStatus();
913
- tui.requestRender();
914
- }, GIT_STATUS_REFRESH_INTERVAL_MS);
915
- clock.unref();
916
- return {
917
- dispose() {
918
- branchUnsubscribe();
919
- clearInterval(clock);
920
- if (isActiveGitStatusTarget(cwd, generation)) {
921
- activeGitStatusTarget = void 0;
922
- abortGitStatusRefresh("Statusline footer disposed");
923
- clearGitStatusDebounce();
924
- pendingGitStatusRefresh = void 0;
925
- runtime.gitStatus = void 0;
926
- runtime.duplicateExtensions = [];
927
- runtime.extensionStatusIconAliases = EMPTY_EXTENSION_STATUS_ICON_ALIASES2;
928
- 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
+ }
929
1097
  }
930
- },
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,
931
1137
  invalidate() {
932
1138
  },
933
1139
  render(width) {
934
- if (!loaded) return [];
1140
+ if (!isCurrentFooter() || !loaded) return [];
935
1141
  const config = previewPalettePreset ? { ...loaded.config, palettePreset: previewPalettePreset } : loaded.config;
936
1142
  const trueColor = getCapabilities().trueColor;
937
1143
  const mainLine = renderStatusline(width, ctx, footerData, theme, config, runtime, trueColor);
@@ -940,7 +1146,13 @@ function statusline(pi) {
940
1146
  return lines;
941
1147
  }
942
1148
  };
943
- });
1149
+ };
1150
+ try {
1151
+ ctx.ui.setFooter(createFooter);
1152
+ } catch (error) {
1153
+ disposeFooter?.();
1154
+ throw error;
1155
+ }
944
1156
  refreshGitStatus(cwd, generation);
945
1157
  };
946
1158
  const agentDir = getAgentDir2();
@@ -981,14 +1193,19 @@ function statusline(pi) {
981
1193
  runtime.activeTools.clear();
982
1194
  runtime.isStreaming = false;
983
1195
  runtime.uiPrompt = void 0;
984
- loaded = loadStatuslineSettingsForAgent(agentDir);
985
- const settingsNotice = consumeStatuslineSettingsNotice();
986
- if (settingsNotice) ctx.ui.notify(settingsNotice, "warning");
987
- if (loaded.diagnostics.length > 0) {
988
- 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;
989
1208
  }
990
- runtime.thinkingLevel = pi.getThinkingLevel();
991
- installFooter(ctx);
992
1209
  });
993
1210
  pi.on("session_tree", (_event, ctx) => {
994
1211
  installFooter(ctx);
@@ -996,6 +1213,7 @@ function statusline(pi) {
996
1213
  });
997
1214
  pi.on("session_shutdown", (_event, ctx) => {
998
1215
  if (!ownsRuntime(ctx)) return;
1216
+ disposeFooter?.();
999
1217
  sessionGeneration += 1;
1000
1218
  menuController.abort(new DOMException("Statusline session shut down", "AbortError"));
1001
1219
  activeSessionManager = void 0;
@@ -1010,7 +1228,7 @@ function statusline(pi) {
1010
1228
  runtime.uiPrompt = void 0;
1011
1229
  runtime.duplicateExtensions = [];
1012
1230
  runtime.extensionStatusIconAliases = EMPTY_EXTENSION_STATUS_ICON_ALIASES2;
1013
- ctx.ui.setFooter(void 0);
1231
+ if (ctx.mode === "tui") ctx.ui.setFooter(void 0);
1014
1232
  ctx.ui.setStatus(STATUSLINE_KEY2, void 0);
1015
1233
  runtime.requestRender = void 0;
1016
1234
  });