@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 +31 -0
- package/README.md +57 -0
- package/index.ts +9 -0
- package/package.json +39 -0
- package/src/format.ts +63 -0
- package/src/runtime.ts +181 -0
- package/test/all.test.ts +2 -0
- package/test/formatting.test.ts +109 -0
- package/test/runtime.test.ts +321 -0
- package/tsconfig.json +5 -0
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
|
+
}
|
package/test/all.test.ts
ADDED
|
@@ -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
|
+
});
|