@eiei114/pi-sub-status 1.5.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 ADDED
@@ -0,0 +1,31 @@
1
+ # @marckrenn/pi-sub-status
2
+
3
+ ## 1.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#56](https://github.com/marckrenn/pi-sub/pull/56) [`864cc1b`](https://github.com/marckrenn/pi-sub/commit/864cc1bbc91897d934c0545a29f508862231963c) - Prioritize usage windows that match the active model before emitting `sub-core:update-current`, so compact status clients show the correct quota windows (including Codex Spark and Antigravity model-specific windows).
8
+
9
+ Also make settings list navigation compatible with both old and new `@mariozechner/pi-tui` keybinding APIs, preventing crashes in submenus on older Pi runtimes where `getEditorKeybindings()` is unavailable.
10
+
11
+ Thanks [@dnouri](https://github.com/dnouri) for [#54](https://github.com/marckrenn/pi-sub/pull/54).
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies [[`864cc1b`](https://github.com/marckrenn/pi-sub/commit/864cc1bbc91897d934c0545a29f508862231963c)]:
16
+ - @marckrenn/pi-sub-core@1.5.0
17
+ - @marckrenn/pi-sub-shared@1.5.0
18
+
19
+ ## 1.4.0
20
+
21
+ ### Minor Changes
22
+
23
+ - [#49](https://github.com/marckrenn/pi-sub/pull/49) [`8723b10`](https://github.com/marckrenn/pi-sub/commit/8723b10a240e1bf4e2ee20703c4b81f6968c44ae) Thanks [@marckrenn](https://github.com/marckrenn)! - Add `@marckrenn/pi-sub-status`, a compact status-line client that renders `sub-core` usage updates via `ctx.ui.setStatus(...)`.
24
+
25
+ Thanks [@dnouri](https://github.com/dnouri) for PR [#48](https://github.com/marckrenn/pi-sub/pull/48).
26
+
27
+ ### Patch Changes
28
+
29
+ - Updated dependencies []:
30
+ - @marckrenn/pi-sub-core@1.3.1
31
+ - @marckrenn/pi-sub-shared@1.3.1
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # sub-status
2
+
3
+ Compact status-line client for [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent).
4
+
5
+ `sub-status` is a small passive companion to `sub-core`: it renders current quota usage via `ctx.ui.setStatus(...)`, without widget UI, commands, or settings UI in v1.
6
+
7
+ On startup it follows the same bootstrap pattern as `sub-bar`, requests the current `sub-core` state, and then listens for `sub-core:ready` / `sub-core:update-current` to keep the compact line up to date. It stays deliberately quiet: no placeholder text when state is unavailable, and the status clears entirely when no usable usage snapshot exists.
8
+
9
+ ## Installation
10
+
11
+ Install via the pi package manager:
12
+
13
+ ```bash
14
+ pi install npm:@eiei114/pi-sub-status
15
+ ```
16
+
17
+ Use `-l` to install into project settings instead of global:
18
+
19
+ ```bash
20
+ pi install -l npm:@eiei114/pi-sub-status
21
+ ```
22
+
23
+ `sub-status` follows the same package metadata/bootstrap pattern as `sub-bar`: it depends on `sub-core`, declares the same extra extension paths in package metadata, and probes/auto-loads `sub-core` at runtime for resilience.
24
+
25
+ ## Relationship to the other packages
26
+
27
+ - `sub-core` is the shared source of truth for provider detection, fetching, cache/state, and events.
28
+ - `sub-bar` is the rich widget UI and remains the default visual package.
29
+ - `sub-status` is an optional compact client for status-line-friendly and RPC-friendly hosts.
30
+
31
+ Installing `sub-status` alongside `sub-bar` is expected to be supported: `sub-bar` owns the rich widget, while `sub-status` owns a compact status line.
32
+
33
+ ## Current v1 scope
34
+
35
+ - Shows windows only
36
+ - Shows the first two windows only
37
+ - Prefers reset descriptions when available, otherwise falls back to window labels
38
+ - Shows percentages for each window
39
+ - Appends compact stale / incident suffix text when relevant
40
+ - Updates from `sub-core` startup/current-state events
41
+ - Clears the status entirely when no usable current state exists
42
+
43
+ ## Not in v1
44
+
45
+ - Commands
46
+ - Settings UI
47
+ - `setWidget`
48
+ - `ctx.ui.custom(...)`
49
+ - Provider/model labels in the compact line
50
+ - Hybrid label + reset output in the compact line
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ npm run check -w @eiei114/pi-sub-status
56
+ npm run test -w @eiei114/pi-sub-status
57
+ ```
package/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { createStatusRuntime, type RuntimeDependencies } from "./src/runtime.js";
3
+
4
+ /**
5
+ * Create the compact status-line client.
6
+ */
7
+ export default function createExtension(pi: ExtensionAPI, dependencies?: RuntimeDependencies): void {
8
+ createStatusRuntime(pi, dependencies);
9
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@eiei114/pi-sub-status",
3
+ "version": "1.5.0",
4
+ "description": "Compact status-line client for pi subscription usage",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "type": "module",
9
+ "license": "MIT",
10
+ "publishConfig": {
11
+ "access": "public",
12
+ "registry": "https://registry.npmjs.org"
13
+ },
14
+ "pi": {
15
+ "extensions": [
16
+ "./index.ts",
17
+ "node_modules/@eiei114/pi-sub-core/index.ts",
18
+ "../pi-sub-core/index.ts"
19
+ ]
20
+ },
21
+ "scripts": {
22
+ "check": "tsc --noEmit",
23
+ "check:watch": "tsc --noEmit --watch",
24
+ "test": "tsx test/all.test.ts",
25
+ "test:watch": "tsx watch test/all.test.ts"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.0.0",
29
+ "tsx": "^4.19.2",
30
+ "typescript": "^5.8.0"
31
+ },
32
+ "dependencies": {
33
+ "@eiei114/pi-sub-core": "^1.5.0",
34
+ "@eiei114/pi-sub-shared": "^1.5.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@mariozechner/pi-coding-agent": "*"
38
+ }
39
+ }
package/src/format.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { ProviderStatus, RateWindow, UsageSnapshot } from "@eiei114/pi-sub-shared";
2
+
3
+ function clampPercent(value: number): number {
4
+ return Math.max(0, Math.min(100, Math.round(value)));
5
+ }
6
+
7
+ function formatWindow(window: RateWindow): string {
8
+ const percent = `${clampPercent(window.usedPercent)}%`;
9
+ const label = window.resetDescription?.trim() || window.label.trim();
10
+ return label ? `${label} ${percent}` : percent;
11
+ }
12
+
13
+ function mapIncident(status?: ProviderStatus): string | undefined {
14
+ if (!status || status.indicator === "none") return undefined;
15
+ switch (status.indicator) {
16
+ case "minor":
17
+ return "degraded";
18
+ case "major":
19
+ case "critical":
20
+ return "outage";
21
+ case "maintenance":
22
+ return "maintenance";
23
+ case "unknown":
24
+ default:
25
+ return "unknown";
26
+ }
27
+ }
28
+
29
+ function isStale(usage: UsageSnapshot): boolean {
30
+ return Boolean(usage.error && usage.lastSuccessAt);
31
+ }
32
+
33
+ function isSyntheticStaleStatus(usage: UsageSnapshot): boolean {
34
+ // sub-core currently uses a minor status with an elapsed description for stale fallback data.
35
+ return isStale(usage) && usage.status?.indicator === "minor";
36
+ }
37
+
38
+ /**
39
+ * Format the current usage snapshot into a compact status-line string.
40
+ */
41
+ export function formatCompactStatus(usage: UsageSnapshot | undefined): string | undefined {
42
+ if (!usage || usage.windows.length === 0) {
43
+ return undefined;
44
+ }
45
+
46
+ const parts = usage.windows.slice(0, 2).map(formatWindow);
47
+ if (parts.length === 0) {
48
+ return undefined;
49
+ }
50
+
51
+ if (isStale(usage)) {
52
+ parts.push("stale");
53
+ }
54
+
55
+ if (!isSyntheticStaleStatus(usage)) {
56
+ const incident = mapIncident(usage.status);
57
+ if (incident) {
58
+ parts.push(incident);
59
+ }
60
+ }
61
+
62
+ return parts.join(" · ");
63
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,181 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { SubCoreState } from "@eiei114/pi-sub-shared";
3
+ import { formatCompactStatus } from "./format.js";
4
+
5
+ const STATUS_KEY = "sub-status:usage";
6
+ const DEFAULT_PROBE_TIMEOUT_MS = 200;
7
+ const DEFAULT_REQUEST_TIMEOUT_MS = 1000;
8
+
9
+ type SubCoreRequest = {
10
+ type?: "current";
11
+ includeSettings?: boolean;
12
+ reply: (payload: { state: SubCoreState }) => void;
13
+ };
14
+
15
+ /**
16
+ * Optional dependencies for testing and runtime probing.
17
+ */
18
+ export type RuntimeDependencies = {
19
+ probeTimeoutMs?: number;
20
+ requestTimeoutMs?: number;
21
+ importModule?: (specifier: string) => Promise<unknown>;
22
+ logWarning?: (message: string, error: unknown) => void;
23
+ };
24
+
25
+ function resolveTimeout(value: number | undefined, fallback: number): number {
26
+ return value ?? fallback;
27
+ }
28
+
29
+ function getCreateCore(module: unknown): ((api: ExtensionAPI) => void | Promise<void>) | undefined {
30
+ const candidate = (module as { default?: unknown }).default;
31
+ return typeof candidate === "function"
32
+ ? (candidate as (api: ExtensionAPI) => void | Promise<void>)
33
+ : undefined;
34
+ }
35
+
36
+ function requestSubCoreCurrent<T>(
37
+ pi: ExtensionAPI,
38
+ timeoutMs: number,
39
+ onReply: (payload: { state: SubCoreState }) => T,
40
+ onTimeout: T
41
+ ): Promise<T> {
42
+ return new Promise((resolve) => {
43
+ let settled = false;
44
+ const timer = setTimeout(() => {
45
+ if (settled) return;
46
+ settled = true;
47
+ resolve(onTimeout);
48
+ }, timeoutMs);
49
+ timer.unref?.();
50
+
51
+ const request: SubCoreRequest = {
52
+ type: "current",
53
+ reply: (payload) => {
54
+ if (settled) return;
55
+ settled = true;
56
+ clearTimeout(timer);
57
+ resolve(onReply(payload));
58
+ },
59
+ };
60
+
61
+ pi.events.emit("sub-core:request", request);
62
+ });
63
+ }
64
+
65
+ function probeSubCore(pi: ExtensionAPI, timeoutMs: number): Promise<boolean> {
66
+ return requestSubCoreCurrent(pi, timeoutMs, () => true, false);
67
+ }
68
+
69
+ function requestCoreState(pi: ExtensionAPI, timeoutMs: number): Promise<SubCoreState | undefined> {
70
+ return requestSubCoreCurrent(pi, timeoutMs, (payload) => payload.state, undefined);
71
+ }
72
+
73
+ async function loadSubCoreFactory(
74
+ importModule: (specifier: string) => Promise<unknown>,
75
+ logWarning: (message: string, error: unknown) => void
76
+ ): Promise<((api: ExtensionAPI) => void | Promise<void>) | undefined> {
77
+ const specifiers = [new URL("../node_modules/@eiei114/pi-sub-core/index.ts", import.meta.url).toString(), "@eiei114/pi-sub-core"];
78
+ let failure: unknown = new Error("sub-core module did not export a default extension factory");
79
+
80
+ for (const specifier of specifiers) {
81
+ try {
82
+ const module = await importModule(specifier);
83
+ const createCore = getCreateCore(module);
84
+ if (createCore) {
85
+ return createCore;
86
+ }
87
+ failure = new Error(`${specifier} did not export a default extension factory`);
88
+ } catch (error) {
89
+ failure = error;
90
+ }
91
+ }
92
+
93
+ logWarning("Failed to auto-load sub-core", failure);
94
+ return undefined;
95
+ }
96
+
97
+ /**
98
+ * Wire sub-status into the sub-core event flow for the current pi session.
99
+ */
100
+ export function createStatusRuntime(pi: ExtensionAPI, dependencies: RuntimeDependencies = {}): void {
101
+ let lastContext: ExtensionContext | undefined;
102
+ let lastRenderedStatus: string | undefined;
103
+ let subCoreBootstrapAttempted = false;
104
+ let currentStateVersion = 0;
105
+
106
+ const probeTimeoutMs = resolveTimeout(dependencies.probeTimeoutMs, DEFAULT_PROBE_TIMEOUT_MS);
107
+ const requestTimeoutMs = resolveTimeout(dependencies.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS);
108
+ const importModule = dependencies.importModule ?? ((specifier: string) => import(specifier));
109
+ const logWarning = dependencies.logWarning ?? ((message: string, error: unknown) => console.warn(`${message}:`, error));
110
+
111
+ function renderStatus(ctx: ExtensionContext, state: SubCoreState | undefined): void {
112
+ const nextStatus = formatCompactStatus(state?.usage);
113
+ if (nextStatus === lastRenderedStatus) {
114
+ return;
115
+ }
116
+ ctx.ui.setStatus(STATUS_KEY, nextStatus);
117
+ lastRenderedStatus = nextStatus;
118
+ }
119
+
120
+ async function ensureSubCoreLoaded(): Promise<void> {
121
+ if (subCoreBootstrapAttempted) {
122
+ return;
123
+ }
124
+ subCoreBootstrapAttempted = true;
125
+
126
+ if (await probeSubCore(pi, probeTimeoutMs)) {
127
+ return;
128
+ }
129
+
130
+ const createCore = await loadSubCoreFactory(importModule, logWarning);
131
+ if (!createCore) {
132
+ return;
133
+ }
134
+ await createCore(pi);
135
+ }
136
+
137
+ function renderCurrentState(state: SubCoreState | undefined): void {
138
+ currentStateVersion += 1;
139
+ if (!lastContext) {
140
+ return;
141
+ }
142
+ renderStatus(lastContext, state);
143
+ }
144
+
145
+ pi.events.on("sub-core:ready", (payload) => {
146
+ const event = payload as { state?: SubCoreState };
147
+ renderCurrentState(event.state);
148
+ });
149
+
150
+ pi.events.on("sub-core:update-current", (payload) => {
151
+ const event = payload as { state?: SubCoreState };
152
+ renderCurrentState(event.state);
153
+ });
154
+
155
+ pi.on("session_start", (_event, ctx) => {
156
+ lastContext = ctx;
157
+ const requestStateVersion = currentStateVersion;
158
+
159
+ void (async () => {
160
+ await ensureSubCoreLoaded();
161
+ if (lastContext !== ctx) {
162
+ return;
163
+ }
164
+ const state = await requestCoreState(pi, requestTimeoutMs);
165
+ if (lastContext !== ctx || currentStateVersion !== requestStateVersion) {
166
+ return;
167
+ }
168
+ renderStatus(ctx, state);
169
+ })();
170
+ });
171
+
172
+ pi.on("session_shutdown", () => {
173
+ if (lastContext) {
174
+ renderStatus(lastContext, undefined);
175
+ }
176
+ lastContext = undefined;
177
+ lastRenderedStatus = undefined;
178
+ subCoreBootstrapAttempted = false;
179
+ currentStateVersion = 0;
180
+ });
181
+ }
@@ -0,0 +1,2 @@
1
+ import "./formatting.test.js";
2
+ import "./runtime.test.js";
@@ -0,0 +1,109 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { ProviderStatus, UsageSnapshot } from "@eiei114/pi-sub-shared";
4
+ import { formatCompactStatus } from "../src/format.js";
5
+
6
+ function buildUsage(overrides?: Partial<UsageSnapshot>): UsageSnapshot {
7
+ return {
8
+ provider: "anthropic",
9
+ displayName: "Anthropic (Claude)",
10
+ windows: [
11
+ { label: "5h", usedPercent: 3, resetDescription: "3h4m" },
12
+ { label: "Week", usedPercent: 7, resetDescription: "6d11h" },
13
+ { label: "Extra", usedPercent: 11, resetDescription: "Tomorrow" },
14
+ ],
15
+ ...overrides,
16
+ };
17
+ }
18
+
19
+ function withStatus(indicator: ProviderStatus["indicator"]): UsageSnapshot {
20
+ return buildUsage({ status: { indicator } });
21
+ }
22
+
23
+ test("formats the first two windows using reset descriptions when available", () => {
24
+ assert.equal(formatCompactStatus(buildUsage()), "3h4m 3% · 6d11h 7%");
25
+ });
26
+
27
+ test("falls back to the window label when reset description is missing", () => {
28
+ const usage = buildUsage({
29
+ windows: [
30
+ { label: "5h", usedPercent: 3, resetDescription: "3h4m" },
31
+ { label: "Week", usedPercent: 7 },
32
+ ],
33
+ });
34
+
35
+ assert.equal(formatCompactStatus(usage), "3h4m 3% · Week 7%");
36
+ });
37
+
38
+ test("formats a single window without provider label noise", () => {
39
+ const usage = buildUsage({ windows: [{ label: "Month", usedPercent: 42 }] });
40
+
41
+ assert.equal(formatCompactStatus(usage), "Month 42%");
42
+ });
43
+
44
+ test("formats rounded percent after reset descriptions", () => {
45
+ const usage = buildUsage({ windows: [{ label: "Month", usedPercent: 42.6, resetDescription: "2d" }] });
46
+
47
+ assert.equal(formatCompactStatus(usage), "2d 43%");
48
+ });
49
+
50
+ test("appends stale suffix text for fallback data", () => {
51
+ const usage = buildUsage({
52
+ error: { code: "FETCH_FAILED", message: "Fetch failed" },
53
+ lastSuccessAt: Date.now() - 60_000,
54
+ });
55
+
56
+ assert.equal(formatCompactStatus(usage), "3h4m 3% · 6d11h 7% · stale");
57
+ });
58
+
59
+ test("maps minor incidents to degraded suffix text", () => {
60
+ assert.equal(formatCompactStatus(withStatus("minor")), "3h4m 3% · 6d11h 7% · degraded");
61
+ });
62
+
63
+ test("maps major incidents to outage suffix text", () => {
64
+ assert.equal(formatCompactStatus(withStatus("major")), "3h4m 3% · 6d11h 7% · outage");
65
+ });
66
+
67
+ test("maps critical incidents to outage suffix text", () => {
68
+ assert.equal(formatCompactStatus(withStatus("critical")), "3h4m 3% · 6d11h 7% · outage");
69
+ });
70
+
71
+ test("maps maintenance incidents to maintenance suffix text", () => {
72
+ assert.equal(formatCompactStatus(withStatus("maintenance")), "3h4m 3% · 6d11h 7% · maintenance");
73
+ });
74
+
75
+ test("maps unknown incidents to unknown suffix text", () => {
76
+ assert.equal(formatCompactStatus(withStatus("unknown")), "3h4m 3% · 6d11h 7% · unknown");
77
+ });
78
+
79
+ test("does not append noise for operational status", () => {
80
+ assert.equal(formatCompactStatus(withStatus("none")), "3h4m 3% · 6d11h 7%");
81
+ });
82
+
83
+ test("stale fallback suppresses synthetic incident text", () => {
84
+ const usage = buildUsage({
85
+ status: { indicator: "minor", description: "5m ago" },
86
+ error: { code: "FETCH_FAILED", message: "Fetch failed" },
87
+ lastSuccessAt: Date.now() - 60_000,
88
+ });
89
+
90
+ assert.equal(formatCompactStatus(usage), "3h4m 3% · 6d11h 7% · stale");
91
+ });
92
+
93
+ test("formats combined stale and real incident suffixes when both are present", () => {
94
+ const usage = buildUsage({
95
+ status: { indicator: "maintenance" },
96
+ error: { code: "HTTP_ERROR", message: "HTTP 500", httpStatus: 500 },
97
+ lastSuccessAt: Date.now() - 60_000,
98
+ });
99
+
100
+ assert.equal(formatCompactStatus(usage), "3h4m 3% · 6d11h 7% · stale · maintenance");
101
+ });
102
+
103
+ test("returns undefined for missing usage", () => {
104
+ assert.equal(formatCompactStatus(undefined), undefined);
105
+ });
106
+
107
+ test("returns undefined for usage with no windows", () => {
108
+ assert.equal(formatCompactStatus(buildUsage({ windows: [] })), undefined);
109
+ });
@@ -0,0 +1,321 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createEventBus, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
4
+ import type { SubCoreState, UsageSnapshot } from "@eiei114/pi-sub-shared";
5
+ import { createStatusRuntime } from "../src/runtime.js";
6
+
7
+ type StatusCall = {
8
+ key: string;
9
+ text: string | undefined;
10
+ };
11
+
12
+ type WarningCall = {
13
+ message: string;
14
+ error: unknown;
15
+ };
16
+
17
+ type CallLog<T> = {
18
+ calls: T[];
19
+ push: (value: T) => void;
20
+ waitForCount: (count: number, timeoutMs?: number) => Promise<void>;
21
+ };
22
+
23
+ type FakePi = ExtensionAPI & {
24
+ commands: string[];
25
+ emitEvent: (event: string, ctx: ExtensionContext) => Promise<void>;
26
+ };
27
+
28
+ function buildUsage(overrides?: Partial<UsageSnapshot>): UsageSnapshot {
29
+ return {
30
+ provider: "anthropic",
31
+ displayName: "Anthropic (Claude)",
32
+ windows: [
33
+ { label: "5h", usedPercent: 3, resetDescription: "3h4m" },
34
+ { label: "Week", usedPercent: 7, resetDescription: "6d11h" },
35
+ ],
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ function createCallLog<T>(): CallLog<T> {
41
+ const calls: T[] = [];
42
+ const waiters = new Set<() => void>();
43
+
44
+ return {
45
+ calls,
46
+ push(value) {
47
+ calls.push(value);
48
+ for (const resolve of waiters) {
49
+ resolve();
50
+ }
51
+ },
52
+ async waitForCount(count: number, timeoutMs = 200) {
53
+ if (calls.length >= count) {
54
+ return;
55
+ }
56
+
57
+ await new Promise<void>((resolve, reject) => {
58
+ const timer = setTimeout(() => {
59
+ waiters.delete(checkCount);
60
+ reject(new Error(`condition not met within ${timeoutMs}ms`));
61
+ }, timeoutMs);
62
+
63
+ const checkCount = () => {
64
+ if (calls.length < count) {
65
+ return;
66
+ }
67
+ clearTimeout(timer);
68
+ waiters.delete(checkCount);
69
+ resolve();
70
+ };
71
+
72
+ waiters.add(checkCount);
73
+ });
74
+ },
75
+ };
76
+ }
77
+
78
+ function createContext(options?: { hasUI?: boolean }): { ctx: ExtensionContext; statusCalls: StatusCall[]; waitForStatusCalls: (count: number, timeoutMs?: number) => Promise<void> } {
79
+ const statusLog = createCallLog<StatusCall>();
80
+ const ctx = {
81
+ ui: {
82
+ select: async () => undefined,
83
+ confirm: async () => false,
84
+ input: async () => undefined,
85
+ notify: () => {},
86
+ setStatus: (key: string, text: string | undefined) => {
87
+ statusLog.push({ key, text });
88
+ },
89
+ setWorkingMessage: () => {},
90
+ setWidget: () => {},
91
+ setFooter: () => {},
92
+ setHeader: () => {},
93
+ setTitle: () => {},
94
+ custom: async () => undefined,
95
+ setEditorText: () => {},
96
+ },
97
+ hasUI: options?.hasUI ?? false,
98
+ cwd: "/tmp/project",
99
+ sessionManager: {} as ExtensionContext["sessionManager"],
100
+ modelRegistry: {} as ExtensionContext["modelRegistry"],
101
+ model: undefined,
102
+ isIdle: () => true,
103
+ abort: () => {},
104
+ hasPendingMessages: () => false,
105
+ shutdown: () => {},
106
+ getContextUsage: () => undefined,
107
+ compact: () => {},
108
+ getSystemPrompt: () => "",
109
+ } as ExtensionContext;
110
+
111
+ return { ctx, statusCalls: statusLog.calls, waitForStatusCalls: statusLog.waitForCount };
112
+ }
113
+
114
+ function createFakePi(): FakePi {
115
+ const handlers = new Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>();
116
+ const commands: string[] = [];
117
+
118
+ const pi = {
119
+ events: createEventBus(),
120
+ commands,
121
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) {
122
+ const current = handlers.get(event) ?? [];
123
+ current.push(handler);
124
+ handlers.set(event, current);
125
+ },
126
+ async emitEvent(event: string, ctx: ExtensionContext) {
127
+ for (const handler of handlers.get(event) ?? []) {
128
+ await handler({ type: event }, ctx);
129
+ }
130
+ },
131
+ registerCommand(name: string) {
132
+ commands.push(name);
133
+ },
134
+ registerTool: () => {
135
+ throw new Error("registerTool should not be called by sub-status");
136
+ },
137
+ registerShortcut: () => {
138
+ throw new Error("registerShortcut should not be called by sub-status");
139
+ },
140
+ registerFlag: () => {},
141
+ getFlag: () => undefined,
142
+ registerMessageRenderer: () => {},
143
+ sendMessage: () => {},
144
+ sendUserMessage: () => {},
145
+ appendEntry: () => {},
146
+ setSessionName: () => {},
147
+ getSessionName: () => undefined,
148
+ setLabel: () => {},
149
+ exec: async () => ({ code: 0, stdout: "", stderr: "" }),
150
+ getActiveTools: () => [],
151
+ getAllTools: () => [],
152
+ setActiveTools: () => {},
153
+ setModel: async () => true,
154
+ getThinkingLevel: () => "high",
155
+ setThinkingLevel: () => {},
156
+ registerProvider: () => {},
157
+ } as unknown as FakePi;
158
+
159
+ return pi;
160
+ }
161
+
162
+ function registerCurrentStateReply(pi: FakePi, state: SubCoreState): void {
163
+ pi.events.on("sub-core:request", (payload) => {
164
+ const request = payload as { reply: (payload: { state: SubCoreState }) => void };
165
+ request.reply({ state });
166
+ });
167
+ }
168
+
169
+ function createDeferred<T>(): {
170
+ promise: Promise<T>;
171
+ resolve: (value: T) => void;
172
+ } {
173
+ let resolve!: (value: T) => void;
174
+ const promise = new Promise<T>((res) => {
175
+ resolve = res;
176
+ });
177
+ return { promise, resolve };
178
+ }
179
+
180
+ test("requests current state on startup and renders compact status without UI gating", async () => {
181
+ const pi = createFakePi();
182
+ const { ctx, statusCalls, waitForStatusCalls } = createContext({ hasUI: false });
183
+ registerCurrentStateReply(pi, { usage: buildUsage() });
184
+
185
+ createStatusRuntime(pi);
186
+ await pi.emitEvent("session_start", ctx);
187
+ await waitForStatusCalls(1);
188
+
189
+ assert.deepEqual(statusCalls, [{ key: "sub-status:usage", text: "3h4m 3% · 6d11h 7%" }]);
190
+ assert.deepEqual(pi.commands, []);
191
+ });
192
+
193
+ test("updates the status on sub-core:update-current and suppresses duplicate writes", async () => {
194
+ const pi = createFakePi();
195
+ const { ctx, statusCalls, waitForStatusCalls } = createContext();
196
+ registerCurrentStateReply(pi, { usage: buildUsage() });
197
+
198
+ createStatusRuntime(pi);
199
+ await pi.emitEvent("session_start", ctx);
200
+ await waitForStatusCalls(1);
201
+
202
+ pi.events.emit("sub-core:update-current", { state: { usage: buildUsage({ windows: [{ label: "Month", usedPercent: 42 }] }) } });
203
+ await waitForStatusCalls(2);
204
+ pi.events.emit("sub-core:update-current", { state: { usage: buildUsage({ windows: [{ label: "Month", usedPercent: 42 }] }) } });
205
+
206
+ assert.deepEqual(statusCalls, [
207
+ { key: "sub-status:usage", text: "3h4m 3% · 6d11h 7%" },
208
+ { key: "sub-status:usage", text: "Month 42%" },
209
+ ]);
210
+ });
211
+
212
+ test("clears the status when current state becomes unusable and on session shutdown", async () => {
213
+ const pi = createFakePi();
214
+ const { ctx, statusCalls, waitForStatusCalls } = createContext();
215
+ registerCurrentStateReply(pi, { usage: buildUsage() });
216
+
217
+ createStatusRuntime(pi);
218
+ await pi.emitEvent("session_start", ctx);
219
+ await waitForStatusCalls(1);
220
+
221
+ pi.events.emit("sub-core:update-current", { state: { usage: buildUsage({ windows: [] }) } });
222
+ await waitForStatusCalls(2);
223
+ await pi.emitEvent("session_shutdown", ctx);
224
+
225
+ assert.deepEqual(statusCalls, [
226
+ { key: "sub-status:usage", text: "3h4m 3% · 6d11h 7%" },
227
+ { key: "sub-status:usage", text: undefined },
228
+ ]);
229
+ });
230
+
231
+ test("keeps a newer sub-core ready update when the startup request replies later with stale state", async () => {
232
+ const pi = createFakePi();
233
+ const { ctx, statusCalls, waitForStatusCalls } = createContext();
234
+ const startupRequest = createDeferred<{ reply: (payload: { state: SubCoreState }) => void }>();
235
+ let requestCount = 0;
236
+
237
+ pi.events.on("sub-core:request", (payload) => {
238
+ const request = payload as { reply: (payload: { state: SubCoreState }) => void };
239
+ requestCount += 1;
240
+ if (requestCount === 1) {
241
+ request.reply({ state: {} });
242
+ return;
243
+ }
244
+ startupRequest.resolve(request);
245
+ });
246
+
247
+ createStatusRuntime(pi, { probeTimeoutMs: 1, requestTimeoutMs: 50 });
248
+ await pi.emitEvent("session_start", ctx);
249
+ const delayedRequest = await startupRequest.promise;
250
+
251
+ pi.events.emit("sub-core:ready", { state: { usage: buildUsage() } });
252
+ await waitForStatusCalls(1);
253
+ assert.deepEqual(statusCalls, [{ key: "sub-status:usage", text: "3h4m 3% · 6d11h 7%" }]);
254
+
255
+ delayedRequest.reply({ state: {} });
256
+ await new Promise((resolve) => setImmediate(resolve));
257
+
258
+ assert.deepEqual(statusCalls, [{ key: "sub-status:usage", text: "3h4m 3% · 6d11h 7%" }]);
259
+ });
260
+
261
+ test("tries bundled sub-core first and falls back to package resolution when probing fails", async () => {
262
+ const pi = createFakePi();
263
+ const { ctx, statusCalls, waitForStatusCalls } = createContext();
264
+ const imports: string[] = [];
265
+
266
+ const importModule = async (specifier: string): Promise<unknown> => {
267
+ imports.push(specifier);
268
+ if (specifier.includes("node_modules/@eiei114/pi-sub-core/index.ts")) {
269
+ throw new Error("missing bundled core");
270
+ }
271
+ if (specifier === "@eiei114/pi-sub-core") {
272
+ return {
273
+ default(api: ExtensionAPI) {
274
+ (api.events as FakePi["events"]).on("sub-core:request", (payload) => {
275
+ const request = payload as { reply: (payload: { state: SubCoreState }) => void };
276
+ request.reply({ state: { usage: buildUsage({ windows: [{ label: "Month", usedPercent: 42 }] }) } });
277
+ });
278
+ },
279
+ };
280
+ }
281
+ throw new Error(`unexpected import: ${specifier}`);
282
+ };
283
+
284
+ createStatusRuntime(pi, {
285
+ probeTimeoutMs: 1,
286
+ requestTimeoutMs: 1,
287
+ importModule,
288
+ });
289
+ await pi.emitEvent("session_start", ctx);
290
+ await waitForStatusCalls(1);
291
+
292
+ assert.ok(imports[0].includes("node_modules/@eiei114/pi-sub-core/index.ts"));
293
+ assert.equal(imports[1], "@eiei114/pi-sub-core");
294
+ assert.deepEqual(statusCalls, [{ key: "sub-status:usage", text: "Month 42%" }]);
295
+ });
296
+
297
+ test("warns once when sub-core cannot be auto-loaded from either runtime import path", async () => {
298
+ const pi = createFakePi();
299
+ const { ctx, statusCalls } = createContext();
300
+ const warningLog = createCallLog<WarningCall>();
301
+ const importLog = createCallLog<string>();
302
+
303
+ const importModule = async (specifier: string): Promise<unknown> => {
304
+ importLog.push(specifier);
305
+ return {};
306
+ };
307
+
308
+ createStatusRuntime(pi, {
309
+ probeTimeoutMs: 1,
310
+ requestTimeoutMs: 1,
311
+ importModule,
312
+ logWarning: (message, error) => warningLog.push({ message, error }),
313
+ });
314
+ await pi.emitEvent("session_start", ctx);
315
+ await warningLog.waitForCount(1);
316
+ await importLog.waitForCount(2);
317
+
318
+ assert.equal(warningLog.calls[0].message, "Failed to auto-load sub-core");
319
+ assert.equal(importLog.calls[1], "@eiei114/pi-sub-core");
320
+ assert.deepEqual(statusCalls, []);
321
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["index.ts", "src/**/*.ts"],
4
+ "exclude": ["node_modules"]
5
+ }