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 +7 -1
- package/package.json +1 -1
- package/provider-balance.ts +89 -2
- package/side-state.ts +94 -0
- package/side.ts +48 -3
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.
|
|
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
package/provider-balance.ts
CHANGED
|
@@ -4,7 +4,11 @@ import type {
|
|
|
4
4
|
ExtensionAPI,
|
|
5
5
|
ExtensionContext,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
936
|
+
setSideSessionModel(undefined);
|
|
937
|
+
applySideStatus(ctx);
|
|
893
938
|
|
|
894
939
|
if (
|
|
895
940
|
(mode === "trajectory" || mode === "summary") &&
|