bermudis-pi-goodies 0.24.2 → 0.25.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/README.md CHANGED
@@ -26,7 +26,7 @@ extensions. One entry point, thirteen independent features.
26
26
  After publishing the package to npm:
27
27
 
28
28
  ```bash
29
- pi install npm:bermudis-pi-goodies@0.24.2
29
+ pi install npm:bermudis-pi-goodies@0.25.0
30
30
  ```
31
31
 
32
32
  Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
@@ -357,6 +357,12 @@ session. `/side-exit` closes it and chooses what the main agent receives.
357
357
 
358
358
  How it works:
359
359
 
360
+ - **Badge**: while a side session is open, the footer's model readout becomes
361
+ `side: (zai) glm-5.3 • max` — the provider-balance footer prepends the
362
+ badge to the stats line instead of adding a separate `side:` status line
363
+ (which remains the fallback when that footer is not mounted). The readout
364
+ tracks mid-side ctrl+l switches, and the badge clears on exit or when
365
+ switching to a session that is not in a side consultation.
360
366
  - **Entry**: `/side` opens pi's own model selector (falls back to a plain
361
367
  selector when pi's internal runtime is not reachable; in headless modes pass
362
368
  the model explicitly as `/side provider/model-id[:level]`). The main agent's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/bermudi/agent-extensions.git",
@@ -4,7 +4,11 @@ import type {
4
4
  ExtensionAPI,
5
5
  ExtensionContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
+ import {
8
+ stripTerminalSequences,
9
+ truncateToWidth,
10
+ visibleWidth,
11
+ } from "@earendil-works/pi-tui";
8
12
  import { createHash, randomUUID } from "node:crypto";
9
13
  import {
10
14
  chmodSync,
@@ -26,6 +30,13 @@ import {
26
30
  timeoutSignal,
27
31
  } from "./json-file.ts";
28
32
  import { getKiloCatalogStatus, type KiloCatalogStatus } from "./kilo.ts";
33
+ import { logGoodiesEvent } from "./goodies-log.ts";
34
+ import {
35
+ getSideSessionModel,
36
+ onSideBadgeChange,
37
+ setMergedSideBadgeInstalled,
38
+ setMergedSideBadgeRendered,
39
+ } from "./side-state.ts";
29
40
 
30
41
  const KILO_API_BASE = process.env.KILO_API_URL || "https://api.kilo.ai";
31
42
  const KILO_BALANCE_ENDPOINT = `${KILO_API_BASE}/api/profile/balance`;
@@ -1284,6 +1295,50 @@ function addBalanceToWorkingDirectoryLine(
1284
1295
  return [`${left}${padding}${right}`, ...lines.slice(1)];
1285
1296
  }
1286
1297
 
1298
+ const SGR_SEQUENCE = /\x1b\[[0-9;:]*m/g;
1299
+
1300
+ /**
1301
+ * Prepend a badge to the right-aligned model readout of the stats line pi's
1302
+ * FooterComponent produced: `side: (zai) glm-5.3 • max`. The line is built
1303
+ * as `<dim statsLeft><dim padding + model>` — split at the opener of the
1304
+ * final styled run (the last SGR sequence is its closer, so the opener is
1305
+ * the second-to-last), keep the left half verbatim (it may carry a colored
1306
+ * context percentage), and rebuild the right half around the badge.
1307
+ *
1308
+ * Returns undefined when the line lacks that two-run shape — the caller then
1309
+ * leaves the line untouched and the /side badge falls back to a status line.
1310
+ * Deliberately structural, not textual: no assumption about stats or model
1311
+ * spelling, only about how pi styles the two halves.
1312
+ */
1313
+ export function mergeSideBadgeIntoStatsLine(
1314
+ line: string,
1315
+ width: number,
1316
+ theme: FooterTheme,
1317
+ prefix = "side: ",
1318
+ ): string | undefined {
1319
+ if (!line || width <= 0) return undefined;
1320
+ const runs = [...line.matchAll(SGR_SEQUENCE)];
1321
+ if (runs.length < 2) return undefined;
1322
+ const opener = runs[runs.length - 2]?.index;
1323
+ if (opener === undefined) return undefined;
1324
+ const head = line.slice(0, opener);
1325
+ const tail = stripTerminalSequences(line.slice(opener)).trim();
1326
+ if (!tail || !stripTerminalSequences(head).trim()) return undefined;
1327
+
1328
+ const labeled = `${prefix}${tail}`;
1329
+ const headWidth = visibleWidth(head);
1330
+ const room = width - headWidth - 2; // pi keeps ≥2 columns between the halves
1331
+ if (room <= 0) return undefined;
1332
+ const right =
1333
+ visibleWidth(labeled) <= room
1334
+ ? labeled
1335
+ : truncateToWidth(labeled, room, "");
1336
+ const padding = " ".repeat(
1337
+ Math.max(2, width - headWidth - visibleWidth(right)),
1338
+ );
1339
+ return `${head}${theme.fg("dim", padding + right)}`;
1340
+ }
1341
+
1287
1342
  // --- Kilo catalog badge ------------------------------------------------------
1288
1343
  // kilo.ts serves a fallback catalog when its refresh fails (see
1289
1344
  // getKiloCatalogStatus in kilo.ts). The balance footer is the only
@@ -1349,6 +1404,9 @@ export default function providerBalance(
1349
1404
  /** True while our footer component is the one mounted in the TUI. */
1350
1405
  let footerInstalled = false;
1351
1406
  let activeThinkingLevel: ActiveThinkingLevel = "off";
1407
+ /** Logged once per footer install: the /side badge merge stopped matching
1408
+ * pi's stats-line shape and the badge fell back to a status line. */
1409
+ let sideBadgeDriftLogged = false;
1352
1410
 
1353
1411
  function clearBalance(): void {
1354
1412
  balance = undefined;
@@ -1648,6 +1706,7 @@ export default function providerBalance(
1648
1706
  function installFooter(ctx: ExtensionContext): void {
1649
1707
  if (ctx.mode !== "tui") return;
1650
1708
  activeContext = ctx;
1709
+ const unsubscribeSideBadge = onSideBadgeChange(() => requestRender?.());
1651
1710
  ctx.ui.setFooter((tui, theme, footerData) => {
1652
1711
  requestRender = () => tui.requestRender();
1653
1712
  const footer = new FooterComponent(
@@ -1687,21 +1746,49 @@ export default function providerBalance(
1687
1746
  kiloBadge && balanceText
1688
1747
  ? `${kiloBadge} · ${balanceText}`
1689
1748
  : (kiloBadge ?? balanceText);
1690
- return addBalanceToWorkingDirectoryLine(
1749
+ const lines = addBalanceToWorkingDirectoryLine(
1691
1750
  footer.render(width),
1692
1751
  width,
1693
1752
  theme,
1694
1753
  rightText,
1695
1754
  );
1755
+ // Side-session badge: prepend "side: " to the stats line's
1756
+ // right-aligned model readout (the session model IS the side model
1757
+ // while a side session is open). Report whether the merge landed so
1758
+ // side.ts can drop the separate status line — or fall back to it
1759
+ // when a pi update changes the stats-line shape.
1760
+ const sideModel = getSideSessionModel();
1761
+ let merged = false;
1762
+ if (sideModel && typeof lines[1] === "string") {
1763
+ const statsLine = mergeSideBadgeIntoStatsLine(
1764
+ lines[1],
1765
+ width,
1766
+ theme,
1767
+ );
1768
+ if (statsLine !== undefined) {
1769
+ lines[1] = statsLine;
1770
+ merged = true;
1771
+ }
1772
+ }
1773
+ setMergedSideBadgeRendered(merged);
1774
+ if (sideModel && !merged && !sideBadgeDriftLogged) {
1775
+ sideBadgeDriftLogged = true;
1776
+ logGoodiesEvent({ type: "side_badge_merge_failed" });
1777
+ }
1778
+ return lines;
1696
1779
  },
1697
1780
  dispose: () => {
1698
1781
  unsubscribeBranchChange();
1782
+ unsubscribeSideBadge();
1699
1783
  footer.dispose();
1700
1784
  requestRender = undefined;
1701
1785
  footerInstalled = false;
1786
+ sideBadgeDriftLogged = false;
1787
+ setMergedSideBadgeInstalled(false);
1702
1788
  },
1703
1789
  };
1704
1790
  });
1791
+ setMergedSideBadgeInstalled(true);
1705
1792
  footerInstalled = true;
1706
1793
  }
1707
1794
 
package/side-state.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Shared side-session badge state.
3
+ *
4
+ * `side.ts` owns the truth (is a side session active, which model serves
5
+ * it); `provider-balance.ts` renders the badge merged into the stats line's
6
+ * right-hand model readout ("side: (zai) glm-5.3 • max") instead of a
7
+ * separate status line.
8
+ *
9
+ * Why the installed/rendered two-step: pi's footer internals are not public
10
+ * API — the merge rewrites the stats line pi's FooterComponent produced by
11
+ * splitting at its final styled run. If a pi update changes that line shape,
12
+ * the merge must bow out and let /side fall back to the classic status line.
13
+ * So the footer reports per-render whether the merge actually landed, and
14
+ * the badge policy (side.ts) suppresses the status line only while the
15
+ * merge is both installed and proven working.
16
+ */
17
+
18
+ export interface SideModelRef {
19
+ provider: string;
20
+ id: string;
21
+ }
22
+
23
+ let activeModel: SideModelRef | undefined;
24
+ let mergedInstalled = false;
25
+ let mergedRendered = false;
26
+
27
+ const listeners = new Set<() => void>();
28
+
29
+ function sameModel(
30
+ a: SideModelRef | undefined,
31
+ b: SideModelRef | undefined,
32
+ ): boolean {
33
+ return (
34
+ a === b ||
35
+ (a !== undefined &&
36
+ b !== undefined &&
37
+ a.provider === b.provider &&
38
+ a.id === b.id)
39
+ );
40
+ }
41
+
42
+ function emit(): void {
43
+ for (const listener of listeners) listener();
44
+ }
45
+
46
+ /** side.ts: the model serving the active side session, or undefined. */
47
+ export function setSideSessionModel(model: SideModelRef | undefined): void {
48
+ if (sameModel(activeModel, model)) return;
49
+ activeModel = model;
50
+ emit();
51
+ }
52
+
53
+ export function getSideSessionModel(): SideModelRef | undefined {
54
+ return activeModel;
55
+ }
56
+
57
+ /** provider-balance: its footer now owns (or handed back) the stats line. */
58
+ export function setMergedSideBadgeInstalled(installed: boolean): void {
59
+ if (mergedInstalled === installed) return;
60
+ mergedInstalled = installed;
61
+ if (!installed) mergedRendered = false;
62
+ emit();
63
+ }
64
+
65
+ /**
66
+ * provider-balance: whether the latest render with a side session actually
67
+ * merged the badge into the stats line. No-op when unchanged, so the
68
+ * render → emit → applyStatus → render cycle always terminates.
69
+ */
70
+ export function setMergedSideBadgeRendered(rendered: boolean): void {
71
+ if (mergedRendered === rendered) return;
72
+ mergedRendered = rendered;
73
+ emit();
74
+ }
75
+
76
+ /** True while the stats-line merge is mounted and proven working. */
77
+ export function isMergedSideBadgeActive(): boolean {
78
+ return mergedInstalled && mergedRendered;
79
+ }
80
+
81
+ export function onSideBadgeChange(listener: () => void): () => void {
82
+ listeners.add(listener);
83
+ return () => {
84
+ listeners.delete(listener);
85
+ };
86
+ }
87
+
88
+ /** Test-only: restore the pristine module state (undefined/false/false). */
89
+ export function resetSideBadgeState(): void {
90
+ activeModel = undefined;
91
+ mergedInstalled = false;
92
+ mergedRendered = false;
93
+ listeners.clear();
94
+ }
package/side.ts CHANGED
@@ -48,6 +48,13 @@ import {
48
48
  import { describeError, extractTextParts } from "./json-file.ts";
49
49
  import { buildTrajectory, renderTrajectory } from "./copy-trajectory.ts";
50
50
  import { logGoodiesEvent } from "./goodies-log.ts";
51
+ import {
52
+ getSideSessionModel,
53
+ isMergedSideBadgeActive,
54
+ onSideBadgeChange,
55
+ setSideSessionModel,
56
+ type SideModelRef,
57
+ } from "./side-state.ts";
51
58
 
52
59
  type ActiveModel = NonNullable<ExtensionContext["model"]>;
53
60
  type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
@@ -491,7 +498,22 @@ function updateBadge(
491
498
  ctx: ExtensionContext,
492
499
  model: { provider: string; id: string },
493
500
  ): void {
494
- ctx.ui.setStatus("side", `side: ${modelRef(model)}`);
501
+ setSideSessionModel(model);
502
+ applySideStatus(ctx);
503
+ }
504
+
505
+ /**
506
+ * Mirror the side state into pi's status line — unless the provider-balance
507
+ * footer is currently rendering the badge merged into the stats line's model
508
+ * readout, in which case a separate status line would duplicate it.
509
+ */
510
+ export function applySideStatus(ctx: ExtensionContext): void {
511
+ const model: SideModelRef | undefined = getSideSessionModel();
512
+ const text =
513
+ model && !isMergedSideBadgeActive()
514
+ ? `side: ${modelRef(model)}`
515
+ : undefined;
516
+ ctx.ui.setStatus("side", text);
495
517
  }
496
518
 
497
519
  function sideMarkerFromBranch(
@@ -557,6 +579,9 @@ async function summarizeDelta(
557
579
  // ---------------------------------------------------------------------------
558
580
 
559
581
  let modelRegistryRef: ExtensionContext["modelRegistry"] | undefined;
582
+ /** Latest live context, for re-applying the badge when the merged footer
583
+ * toggles; cleared on session_shutdown before the ctx goes stale. */
584
+ let lastBadgeCtx: ExtensionContext | undefined;
560
585
 
561
586
  export default function side(pi: ExtensionAPI): void {
562
587
  // Lens: rewrite side-agent requests so the main trajectory arrives as an
@@ -599,7 +624,8 @@ export default function side(pi: ExtensionAPI): void {
599
624
  activeSideModel(ctx.sessionManager.getBranch(), marker.data),
600
625
  );
601
626
  } else {
602
- ctx.ui.setStatus("side", undefined);
627
+ setSideSessionModel(undefined);
628
+ applySideStatus(ctx);
603
629
  }
604
630
  });
605
631
 
@@ -608,6 +634,7 @@ export default function side(pi: ExtensionAPI): void {
608
634
  // ctrl+l switch updates it; the marker keeps the original).
609
635
  pi.on("session_start", (_event, ctx) => {
610
636
  modelRegistryRef = ctx.modelRegistry; // for /side argument completions
637
+ lastBadgeCtx = ctx;
611
638
  const marker = sideMarkerFromBranch(ctx);
612
639
  if (marker) {
613
640
  const active = activeSideModel(
@@ -616,9 +643,26 @@ export default function side(pi: ExtensionAPI): void {
616
643
  );
617
644
  updateBadge(ctx, active);
618
645
  logGoodiesEvent({ type: "side_resumed", model: modelRef(active) });
646
+ } else {
647
+ // Switched/new/resumed into a non-side session: drop any badge the
648
+ // previous session left behind (both the status line and the state
649
+ // the merged footer badge reads).
650
+ setSideSessionModel(undefined);
651
+ applySideStatus(ctx);
619
652
  }
620
653
  });
621
654
 
655
+ // The footer-rendered badge needs a re-render when side state changes;
656
+ // the status-line fallback needs re-applying when the merged footer
657
+ // appears or disappears (install order between the two modules is not
658
+ // guaranteed to leave the badge applied with the right policy).
659
+ pi.on("session_shutdown", () => {
660
+ lastBadgeCtx = undefined;
661
+ });
662
+ onSideBadgeChange(() => {
663
+ if (lastBadgeCtx) applySideStatus(lastBadgeCtx);
664
+ });
665
+
622
666
  pi.registerEntryRenderer<SideMarkerData>(
623
667
  SIDE_MARKER_TYPE,
624
668
  (entry, _options, theme) => {
@@ -889,7 +933,8 @@ export default function side(pi: ExtensionAPI): void {
889
933
  "warning",
890
934
  );
891
935
  }
892
- ctx.ui.setStatus("side", undefined);
936
+ setSideSessionModel(undefined);
937
+ applySideStatus(ctx);
893
938
 
894
939
  if (
895
940
  (mode === "trajectory" || mode === "summary") &&