@eiei114/pi-sub-bar 1.5.1
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 +201 -0
- package/README.md +200 -0
- package/index.ts +1103 -0
- package/package.json +39 -0
- package/src/core-settings.ts +25 -0
- package/src/dividers.ts +48 -0
- package/src/errors.ts +71 -0
- package/src/formatting.ts +937 -0
- package/src/paths.ts +21 -0
- package/src/providers/extras.ts +21 -0
- package/src/providers/metadata.ts +199 -0
- package/src/providers/settings.ts +359 -0
- package/src/providers/windows.ts +23 -0
- package/src/settings/display.ts +786 -0
- package/src/settings/menu.ts +183 -0
- package/src/settings/themes.ts +378 -0
- package/src/settings/ui.ts +1388 -0
- package/src/settings-types.ts +651 -0
- package/src/settings-ui.ts +5 -0
- package/src/settings.ts +176 -0
- package/src/share.ts +75 -0
- package/src/status.ts +103 -0
- package/src/storage.ts +61 -0
- package/src/types.ts +25 -0
- package/src/ui/keybindings.ts +92 -0
- package/src/ui/settings-list.ts +304 -0
- package/src/usage/types.ts +5 -0
- package/src/utils.ts +42 -0
- package/test/all.test.ts +6 -0
- package/test/dividers.test.ts +34 -0
- package/test/formatting.test.ts +437 -0
- package/test/keybindings.test.ts +59 -0
- package/test/providers.test.ts +42 -0
- package/test/settings.test.ts +336 -0
- package/test/status.test.ts +27 -0
- package/tsconfig.json +5 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sub-bar - Usage Widget Extension
|
|
3
|
+
* Shows current provider's usage in a widget above the editor.
|
|
4
|
+
* Only shows stats for the currently selected provider.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
import { Container, Input, SelectList, Spacer, Text, truncateToWidth, wrapTextWithAnsi, visibleWidth } from "@mariozechner/pi-tui";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { ProviderName, ProviderUsageEntry, SubCoreAllState, SubCoreState, UsageSnapshot } from "./src/types.js";
|
|
13
|
+
import { type Settings, type BaseTextColor, type WidgetBackgroundColor } from "./src/settings-types.js";
|
|
14
|
+
import { isBackgroundColor, resolveBackgroundColor, resolveBaseTextColor, resolveDividerColor } from "./src/settings-types.js";
|
|
15
|
+
import { buildDividerLine } from "./src/dividers.js";
|
|
16
|
+
import type { CoreSettings } from "@eiei114/pi-sub-shared";
|
|
17
|
+
import type { KeyId } from "@mariozechner/pi-tui";
|
|
18
|
+
import { formatUsageStatus, formatUsageStatusWithWidth } from "./src/formatting.js";
|
|
19
|
+
import type { ContextInfo } from "./src/formatting.js";
|
|
20
|
+
import { clearSettingsCache, loadSettings, saveSettings, SETTINGS_PATH } from "./src/settings.js";
|
|
21
|
+
import { showSettingsUI } from "./src/settings-ui.js";
|
|
22
|
+
import { decodeDisplayShareString } from "./src/share.js";
|
|
23
|
+
import { upsertDisplayTheme } from "./src/settings/themes.js";
|
|
24
|
+
import { getFallbackCoreSettings } from "./src/core-settings.js";
|
|
25
|
+
|
|
26
|
+
type SubCoreRequest =
|
|
27
|
+
| {
|
|
28
|
+
type?: "current";
|
|
29
|
+
includeSettings?: boolean;
|
|
30
|
+
reply: (payload: { state: SubCoreState; settings?: CoreSettings }) => void;
|
|
31
|
+
}
|
|
32
|
+
| {
|
|
33
|
+
type: "entries";
|
|
34
|
+
force?: boolean;
|
|
35
|
+
reply: (payload: { entries: ProviderUsageEntry[] }) => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type SubCoreAction = {
|
|
39
|
+
type: "refresh" | "cycleProvider";
|
|
40
|
+
force?: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function applyBackground(lines: string[], theme: Theme, color: WidgetBackgroundColor, width: number): string[] {
|
|
44
|
+
if (color === "none") return lines;
|
|
45
|
+
const bgAnsi = isBackgroundColor(color)
|
|
46
|
+
? theme.getBgAnsi(color as Parameters<Theme["getBgAnsi"]>[0])
|
|
47
|
+
: theme.getFgAnsi(resolveDividerColor(color)).replace(/\x1b\[38;/g, "\x1b[48;").replace(/\x1b\[39m/g, "\x1b[49m");
|
|
48
|
+
if (!bgAnsi || bgAnsi === "\x1b[49m") return lines;
|
|
49
|
+
return lines.map((line) => {
|
|
50
|
+
const padding = Math.max(0, width - visibleWidth(line));
|
|
51
|
+
return `${bgAnsi}${line}${" ".repeat(padding)}\x1b[49m`;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function applyBaseTextColor(theme: Theme, color: BaseTextColor, text: string): string {
|
|
56
|
+
if (isBackgroundColor(color)) {
|
|
57
|
+
const fgAnsi = theme
|
|
58
|
+
.getBgAnsi(color as Parameters<Theme["getBgAnsi"]>[0])
|
|
59
|
+
.replace(/\x1b\[48;/g, "\x1b[38;")
|
|
60
|
+
.replace(/\x1b\[49m/g, "\x1b[39m");
|
|
61
|
+
return `${fgAnsi}${text}\x1b[39m`;
|
|
62
|
+
}
|
|
63
|
+
return theme.fg(resolveDividerColor(color), text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type PiSettings = {
|
|
67
|
+
enabledModels?: unknown;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const AGENT_SETTINGS_ENV = "PI_CODING_AGENT_DIR";
|
|
71
|
+
const DEFAULT_AGENT_DIR = join(homedir(), ".pi", "agent");
|
|
72
|
+
const PROJECT_SETTINGS_DIR = ".pi";
|
|
73
|
+
const SETTINGS_FILE_NAME = "settings.json";
|
|
74
|
+
|
|
75
|
+
let scopedModelPatternsCache: { cwd: string; patterns: string[] } | undefined;
|
|
76
|
+
|
|
77
|
+
function expandTilde(value: string): string {
|
|
78
|
+
if (value === "~") return homedir();
|
|
79
|
+
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveAgentSettingsPath(): string {
|
|
84
|
+
const envDir = process.env[AGENT_SETTINGS_ENV];
|
|
85
|
+
const agentDir = envDir ? expandTilde(envDir) : DEFAULT_AGENT_DIR;
|
|
86
|
+
return join(agentDir, SETTINGS_FILE_NAME);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readPiSettings(path: string): PiSettings | null {
|
|
90
|
+
try {
|
|
91
|
+
if (!fs.existsSync(path)) return null;
|
|
92
|
+
const content = fs.readFileSync(path, "utf-8");
|
|
93
|
+
return JSON.parse(content) as PiSettings;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function loadScopedModelPatterns(cwd: string): string[] {
|
|
100
|
+
if (scopedModelPatternsCache?.cwd === cwd) {
|
|
101
|
+
return scopedModelPatternsCache.patterns;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const globalSettings = readPiSettings(resolveAgentSettingsPath());
|
|
105
|
+
const projectSettingsPath = join(cwd, PROJECT_SETTINGS_DIR, SETTINGS_FILE_NAME);
|
|
106
|
+
const projectSettings = readPiSettings(projectSettingsPath);
|
|
107
|
+
|
|
108
|
+
let enabledModels = Array.isArray(globalSettings?.enabledModels)
|
|
109
|
+
? (globalSettings?.enabledModels as string[])
|
|
110
|
+
: undefined;
|
|
111
|
+
|
|
112
|
+
if (projectSettings && Object.prototype.hasOwnProperty.call(projectSettings, "enabledModels")) {
|
|
113
|
+
enabledModels = Array.isArray(projectSettings.enabledModels)
|
|
114
|
+
? (projectSettings.enabledModels as string[])
|
|
115
|
+
: [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const patterns = !enabledModels || enabledModels.length === 0
|
|
119
|
+
? []
|
|
120
|
+
: enabledModels.filter((value) => typeof value === "string");
|
|
121
|
+
scopedModelPatternsCache = { cwd, patterns };
|
|
122
|
+
return patterns;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create the extension
|
|
127
|
+
*/
|
|
128
|
+
export default function createExtension(pi: ExtensionAPI) {
|
|
129
|
+
let lastContext: ExtensionContext | undefined;
|
|
130
|
+
let settings: Settings = loadSettings();
|
|
131
|
+
let uiEnabled = true;
|
|
132
|
+
let currentUsage: UsageSnapshot | undefined;
|
|
133
|
+
let usageEntries: Partial<Record<ProviderName, UsageSnapshot>> = {};
|
|
134
|
+
let coreAvailable = false;
|
|
135
|
+
let coreSettings: CoreSettings = getFallbackCoreSettings(settings);
|
|
136
|
+
let fetchFailureTimer: NodeJS.Timeout | undefined;
|
|
137
|
+
const antigravityHiddenModels = new Set(["tab_flash_lite_preview"]);
|
|
138
|
+
let settingsWatcher: fs.FSWatcher | undefined;
|
|
139
|
+
let settingsPoll: NodeJS.Timeout | undefined;
|
|
140
|
+
let settingsDebounce: NodeJS.Timeout | undefined;
|
|
141
|
+
let settingsSnapshot = "";
|
|
142
|
+
let settingsMtimeMs = 0;
|
|
143
|
+
let settingsWatchStarted = false;
|
|
144
|
+
let subCoreBootstrapAttempted = false;
|
|
145
|
+
|
|
146
|
+
async function probeSubCore(timeoutMs = 200): Promise<boolean> {
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
let resolved = false;
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
if (!resolved) {
|
|
151
|
+
resolved = true;
|
|
152
|
+
resolve(false);
|
|
153
|
+
}
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
|
|
156
|
+
const request: SubCoreRequest = {
|
|
157
|
+
type: "current",
|
|
158
|
+
reply: () => {
|
|
159
|
+
if (resolved) return;
|
|
160
|
+
resolved = true;
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
resolve(true);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
pi.events.emit("sub-core:request", request);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function ensureSubCoreLoaded(): Promise<void> {
|
|
170
|
+
if (subCoreBootstrapAttempted) return;
|
|
171
|
+
subCoreBootstrapAttempted = true;
|
|
172
|
+
const hasCore = await probeSubCore();
|
|
173
|
+
if (hasCore) return;
|
|
174
|
+
try {
|
|
175
|
+
const bundledUrl = new URL("./node_modules/@eiei114/pi-sub-core/index.ts", import.meta.url);
|
|
176
|
+
const module = await import(bundledUrl.toString());
|
|
177
|
+
const createCore = module.default as undefined | ((api: ExtensionAPI) => void | Promise<void>);
|
|
178
|
+
if (typeof createCore === "function") {
|
|
179
|
+
void createCore(pi);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// Fall back to package resolution
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const module = await import("@eiei114/pi-sub-core");
|
|
187
|
+
const createCore = module.default as undefined | ((api: ExtensionAPI) => void | Promise<void>);
|
|
188
|
+
if (typeof createCore === "function") {
|
|
189
|
+
void createCore(pi);
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.warn("Failed to auto-load sub-core:", error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async function promptImportAction(ctx: ExtensionContext): Promise<"save-apply" | "save" | "cancel"> {
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
|
200
|
+
const items = [
|
|
201
|
+
{ value: "save-apply", label: "Save & apply", description: "save and use this theme" },
|
|
202
|
+
{ value: "save", label: "Save", description: "save without applying" },
|
|
203
|
+
{ value: "cancel", label: "Cancel", description: "discard import" },
|
|
204
|
+
];
|
|
205
|
+
const list = new SelectList(items, items.length, {
|
|
206
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
207
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
208
|
+
description: (t: string) => theme.fg("muted", t),
|
|
209
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
210
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
211
|
+
});
|
|
212
|
+
list.onSelect = (item) => {
|
|
213
|
+
done(undefined);
|
|
214
|
+
resolve(item.value as "save-apply" | "save" | "cancel");
|
|
215
|
+
};
|
|
216
|
+
list.onCancel = () => {
|
|
217
|
+
done(undefined);
|
|
218
|
+
resolve("cancel");
|
|
219
|
+
};
|
|
220
|
+
return list;
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function promptImportString(ctx: ExtensionContext): Promise<string | undefined> {
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
|
228
|
+
const input = new Input();
|
|
229
|
+
input.focused = true;
|
|
230
|
+
input.onSubmit = (value) => {
|
|
231
|
+
done(undefined);
|
|
232
|
+
resolve(value.trim());
|
|
233
|
+
};
|
|
234
|
+
input.onEscape = () => {
|
|
235
|
+
done(undefined);
|
|
236
|
+
resolve(undefined);
|
|
237
|
+
};
|
|
238
|
+
const container = new Container();
|
|
239
|
+
container.addChild(new Text(theme.fg("muted", "Paste Theme Share string"), 1, 0));
|
|
240
|
+
container.addChild(new Spacer(1));
|
|
241
|
+
container.addChild(input);
|
|
242
|
+
return {
|
|
243
|
+
render: (width: number) => container.render(width),
|
|
244
|
+
invalidate: () => container.invalidate(),
|
|
245
|
+
handleInput: (data: string) => input.handleInput(data),
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function promptImportName(ctx: ExtensionContext): Promise<string | undefined> {
|
|
252
|
+
while (true) {
|
|
253
|
+
const name = await ctx.ui.input("Theme name", "Theme");
|
|
254
|
+
if (name === undefined) return undefined;
|
|
255
|
+
const trimmed = name.trim();
|
|
256
|
+
if (trimmed) return trimmed;
|
|
257
|
+
ctx.ui.notify("Enter a theme name", "warning");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const THEME_GIST_FILE_BASE = "pi-sub-bar Theme";
|
|
262
|
+
const THEME_GIST_STATUS_KEY = "sub-bar:share";
|
|
263
|
+
|
|
264
|
+
function buildThemeGistFileName(name: string): string {
|
|
265
|
+
const trimmed = name.trim();
|
|
266
|
+
if (!trimmed) return THEME_GIST_FILE_BASE;
|
|
267
|
+
const safeName = trimmed.replace(/[\\/:*?"<>|]+/g, "-").trim();
|
|
268
|
+
return safeName ? `${THEME_GIST_FILE_BASE} ${safeName}` : THEME_GIST_FILE_BASE;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function createThemeGist(ctx: ExtensionContext, name: string, shareString: string): Promise<string | null> {
|
|
272
|
+
const notify = (message: string, level: "info" | "warning" | "error") => {
|
|
273
|
+
if (ctx.hasUI) {
|
|
274
|
+
ctx.ui.notify(message, level);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (level === "error") {
|
|
278
|
+
console.error(message);
|
|
279
|
+
} else if (level === "warning") {
|
|
280
|
+
console.warn(message);
|
|
281
|
+
} else {
|
|
282
|
+
console.log(message);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const authResult = await pi.exec("gh", ["auth", "status"]);
|
|
288
|
+
if (authResult.code !== 0) {
|
|
289
|
+
notify("GitHub CLI is not logged in. Run 'gh auth login' first.", "error");
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
notify("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/", "error");
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const tempDir = fs.mkdtempSync(join(tmpdir(), "pi-sub-bar-"));
|
|
298
|
+
const fileName = buildThemeGistFileName(name);
|
|
299
|
+
const filePath = join(tempDir, fileName);
|
|
300
|
+
fs.writeFileSync(filePath, shareString, "utf-8");
|
|
301
|
+
|
|
302
|
+
if (ctx.hasUI) {
|
|
303
|
+
ctx.ui.setStatus(THEME_GIST_STATUS_KEY, "Creating gist...");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const result = await pi.exec("gh", ["gist", "create", "--public=false", filePath]);
|
|
308
|
+
if (result.code !== 0) {
|
|
309
|
+
const errorMsg = result.stderr?.trim() || "Unknown error";
|
|
310
|
+
notify(`Failed to create gist: ${errorMsg}`, "error");
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
const gistUrl = result.stdout?.trim();
|
|
314
|
+
if (!gistUrl) {
|
|
315
|
+
notify("Failed to create gist: empty response", "error");
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
return gistUrl;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
notify(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`, "error");
|
|
321
|
+
return null;
|
|
322
|
+
} finally {
|
|
323
|
+
if (ctx.hasUI) {
|
|
324
|
+
ctx.ui.setStatus(THEME_GIST_STATUS_KEY, undefined);
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
328
|
+
} catch {
|
|
329
|
+
// Ignore cleanup errors
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function shareThemeString(
|
|
335
|
+
ctx: ExtensionContext,
|
|
336
|
+
name: string,
|
|
337
|
+
shareString: string,
|
|
338
|
+
mode: "prompt" | "gist" | "string" = "prompt",
|
|
339
|
+
): Promise<void> {
|
|
340
|
+
const trimmedName = name.trim();
|
|
341
|
+
const notify = (message: string, level: "info" | "warning" | "error") => {
|
|
342
|
+
if (ctx.hasUI) {
|
|
343
|
+
ctx.ui.notify(message, level);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (level === "error") {
|
|
347
|
+
console.error(message);
|
|
348
|
+
} else if (level === "warning") {
|
|
349
|
+
console.warn(message);
|
|
350
|
+
} else {
|
|
351
|
+
console.log(message);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
let resolvedMode = mode;
|
|
355
|
+
if (resolvedMode === "prompt") {
|
|
356
|
+
if (!ctx.hasUI) {
|
|
357
|
+
resolvedMode = "string";
|
|
358
|
+
} else {
|
|
359
|
+
const wantsGist = await ctx.ui.confirm("Share Theme", "Upload to a secret GitHub gist?");
|
|
360
|
+
resolvedMode = wantsGist ? "gist" : "string";
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (resolvedMode === "gist") {
|
|
365
|
+
const gistUrl = await createThemeGist(ctx, trimmedName, shareString);
|
|
366
|
+
if (gistUrl) {
|
|
367
|
+
pi.sendMessage({
|
|
368
|
+
customType: "sub-bar",
|
|
369
|
+
content: `Theme gist:\n${gistUrl}`,
|
|
370
|
+
display: true,
|
|
371
|
+
});
|
|
372
|
+
notify("Theme gist posted to chat", "info");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
notify("Posting share string instead.", "warning");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
pi.sendMessage({
|
|
379
|
+
customType: "sub-bar",
|
|
380
|
+
content: `Theme share string:\n${shareString}`,
|
|
381
|
+
display: true,
|
|
382
|
+
});
|
|
383
|
+
notify("Theme share string posted to chat", "info");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function readSettingsFile(): string | undefined {
|
|
387
|
+
try {
|
|
388
|
+
return fs.readFileSync(SETTINGS_PATH, "utf-8");
|
|
389
|
+
} catch {
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function applySettingsFromDisk(): void {
|
|
395
|
+
clearSettingsCache();
|
|
396
|
+
const loaded = loadSettings();
|
|
397
|
+
settings = {
|
|
398
|
+
...settings,
|
|
399
|
+
version: loaded.version,
|
|
400
|
+
display: loaded.display,
|
|
401
|
+
providers: loaded.providers,
|
|
402
|
+
displayThemes: loaded.displayThemes,
|
|
403
|
+
displayUserTheme: loaded.displayUserTheme,
|
|
404
|
+
pinnedProvider: loaded.pinnedProvider,
|
|
405
|
+
keybindings: loaded.keybindings,
|
|
406
|
+
};
|
|
407
|
+
coreSettings = getFallbackCoreSettings(settings);
|
|
408
|
+
updateFetchFailureTicker();
|
|
409
|
+
void ensurePinnedEntries(settings.pinnedProvider ?? null);
|
|
410
|
+
if (lastContext) {
|
|
411
|
+
renderCurrent(lastContext);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function refreshSettingsSnapshot(): void {
|
|
416
|
+
const content = readSettingsFile();
|
|
417
|
+
if (!content || content === settingsSnapshot) return;
|
|
418
|
+
try {
|
|
419
|
+
JSON.parse(content);
|
|
420
|
+
} catch {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
settingsSnapshot = content;
|
|
424
|
+
applySettingsFromDisk();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function checkSettingsFile(): void {
|
|
428
|
+
try {
|
|
429
|
+
const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false });
|
|
430
|
+
if (!stat || !stat.mtimeMs) return;
|
|
431
|
+
if (stat.mtimeMs === settingsMtimeMs) return;
|
|
432
|
+
settingsMtimeMs = stat.mtimeMs;
|
|
433
|
+
refreshSettingsSnapshot();
|
|
434
|
+
} catch {
|
|
435
|
+
// Ignore missing files
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function scheduleSettingsRefresh(): void {
|
|
440
|
+
if (settingsDebounce) clearTimeout(settingsDebounce);
|
|
441
|
+
settingsDebounce = setTimeout(() => checkSettingsFile(), 200);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function startSettingsWatch(): void {
|
|
445
|
+
if (settingsWatchStarted) return;
|
|
446
|
+
settingsWatchStarted = true;
|
|
447
|
+
if (!settingsSnapshot) {
|
|
448
|
+
const content = readSettingsFile();
|
|
449
|
+
if (content) {
|
|
450
|
+
settingsSnapshot = content;
|
|
451
|
+
try {
|
|
452
|
+
const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false });
|
|
453
|
+
if (stat?.mtimeMs) settingsMtimeMs = stat.mtimeMs;
|
|
454
|
+
} catch {
|
|
455
|
+
// Ignore
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
settingsWatcher = fs.watch(SETTINGS_PATH, scheduleSettingsRefresh);
|
|
461
|
+
settingsWatcher.unref?.();
|
|
462
|
+
} catch {
|
|
463
|
+
settingsWatcher = undefined;
|
|
464
|
+
}
|
|
465
|
+
settingsPoll = setInterval(() => checkSettingsFile(), 2000);
|
|
466
|
+
settingsPoll.unref?.();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function formatUsageContent(
|
|
470
|
+
ctx: ExtensionContext,
|
|
471
|
+
theme: Theme,
|
|
472
|
+
usage: UsageSnapshot | undefined,
|
|
473
|
+
contentWidth: number,
|
|
474
|
+
message?: string,
|
|
475
|
+
options?: { forceNoFill?: boolean; forceLeftAlignment?: boolean; forceOverflow?: "truncate" | "wrap"; useStatusSafePadding?: boolean }
|
|
476
|
+
): string[] {
|
|
477
|
+
const paddingLeft = settings.display.paddingLeft ?? 0;
|
|
478
|
+
const configuredPaddingRight = settings.display.paddingRight ?? 0;
|
|
479
|
+
const useStatusSafePadding = options?.useStatusSafePadding ?? false;
|
|
480
|
+
const resolvedPaddingRight = useStatusSafePadding ? 0 : configuredPaddingRight;
|
|
481
|
+
const innerWidth = Math.max(1, contentWidth - paddingLeft - resolvedPaddingRight);
|
|
482
|
+
const configuredAlignment = settings.display.alignment ?? "left";
|
|
483
|
+
const alignment = options?.forceLeftAlignment ? "left" : configuredAlignment;
|
|
484
|
+
const configuredOverflow = settings.display.overflow ?? "truncate";
|
|
485
|
+
const overflow = options?.forceOverflow ?? configuredOverflow;
|
|
486
|
+
const configuredHasFill = settings.display.barWidth === "fill" || settings.display.dividerBlanks === "fill";
|
|
487
|
+
const hasFill = options?.forceNoFill ? false : configuredHasFill;
|
|
488
|
+
const wantsSplit = options?.forceNoFill ? false : alignment === "split";
|
|
489
|
+
const shouldAlign = !hasFill && !wantsSplit && (alignment === "center" || alignment === "right");
|
|
490
|
+
const baseTextColor = resolveBaseTextColor(settings.display.baseTextColor);
|
|
491
|
+
const scopedModelPatterns = loadScopedModelPatterns(ctx.cwd);
|
|
492
|
+
const modelInfo = ctx.model
|
|
493
|
+
? { provider: ctx.model.provider, id: ctx.model.id, scopedModelPatterns }
|
|
494
|
+
: { scopedModelPatterns };
|
|
495
|
+
|
|
496
|
+
// Get context usage info from pi framework
|
|
497
|
+
const ctxUsage = ctx.getContextUsage?.();
|
|
498
|
+
const contextInfo: ContextInfo | undefined = ctxUsage && ctxUsage.contextWindow > 0
|
|
499
|
+
? { tokens: ctxUsage.tokens, contextWindow: ctxUsage.contextWindow, percent: ctxUsage.percent }
|
|
500
|
+
: undefined;
|
|
501
|
+
|
|
502
|
+
const formatted = message
|
|
503
|
+
? applyBaseTextColor(theme, baseTextColor, message)
|
|
504
|
+
: (!usage)
|
|
505
|
+
? undefined
|
|
506
|
+
: (hasFill || wantsSplit)
|
|
507
|
+
? formatUsageStatusWithWidth(theme, usage, innerWidth, modelInfo, settings, { labelGapFill: wantsSplit }, contextInfo)
|
|
508
|
+
: formatUsageStatus(theme, usage, modelInfo, settings, contextInfo);
|
|
509
|
+
|
|
510
|
+
const alignLine = (line: string) => {
|
|
511
|
+
if (!shouldAlign) return line;
|
|
512
|
+
const lineWidth = visibleWidth(line);
|
|
513
|
+
if (lineWidth >= innerWidth) return line;
|
|
514
|
+
const padding = innerWidth - lineWidth;
|
|
515
|
+
const leftPad = alignment === "center" ? Math.floor(padding / 2) : padding;
|
|
516
|
+
return " ".repeat(leftPad) + line;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
let lines: string[] = [];
|
|
520
|
+
if (!formatted) {
|
|
521
|
+
lines = [];
|
|
522
|
+
} else if (overflow === "wrap") {
|
|
523
|
+
lines = wrapTextWithAnsi(formatted, innerWidth).map(alignLine);
|
|
524
|
+
} else {
|
|
525
|
+
const trimmed = alignLine(truncateToWidth(formatted, innerWidth, theme.fg("dim", "...")));
|
|
526
|
+
lines = [trimmed];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const effectivePaddingLeft = paddingLeft;
|
|
530
|
+
const effectivePaddingRight = useStatusSafePadding ? 0 : configuredPaddingRight;
|
|
531
|
+
if (effectivePaddingLeft > 0 || effectivePaddingRight > 0) {
|
|
532
|
+
const buildStatusSafePadding = (count: number) => {
|
|
533
|
+
const zeroWidth = "\u200B";
|
|
534
|
+
if (count <= 0) return "";
|
|
535
|
+
let out = "";
|
|
536
|
+
for (let i = 0; i < count; i++) {
|
|
537
|
+
out += " ";
|
|
538
|
+
out += zeroWidth;
|
|
539
|
+
}
|
|
540
|
+
if (count > 0) {
|
|
541
|
+
out += zeroWidth;
|
|
542
|
+
}
|
|
543
|
+
return out;
|
|
544
|
+
};
|
|
545
|
+
const leftPad = useStatusSafePadding
|
|
546
|
+
? buildStatusSafePadding(effectivePaddingLeft)
|
|
547
|
+
: " ".repeat(effectivePaddingLeft);
|
|
548
|
+
const rightPad = useStatusSafePadding
|
|
549
|
+
? ""
|
|
550
|
+
: " ".repeat(effectivePaddingRight);
|
|
551
|
+
lines = lines.map((line) => `${leftPad}${line}${rightPad}`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return lines;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function buildStatusEdgeDivider(theme: Theme): string {
|
|
558
|
+
const dividerChar = settings.display.dividerCharacter ?? "│";
|
|
559
|
+
if (dividerChar === "none") return "";
|
|
560
|
+
const dividerColor: ThemeColor = resolveDividerColor(settings.display.dividerColor);
|
|
561
|
+
const dividerGlyph = dividerChar === "blank" ? " " : dividerChar;
|
|
562
|
+
if (!dividerGlyph) return "";
|
|
563
|
+
const blanks = typeof settings.display.dividerBlanks === "number" ? settings.display.dividerBlanks : 1;
|
|
564
|
+
const spacing = " ".repeat(Math.max(0, blanks));
|
|
565
|
+
return `${spacing}${theme.fg(dividerColor, dividerGlyph)}${spacing}`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function renderUsageWidget(ctx: ExtensionContext, usage: UsageSnapshot | undefined, message?: string): void {
|
|
569
|
+
if (!ctx.hasUI || !uiEnabled) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const placement = settings.display.widgetPlacement ?? "belowEditor";
|
|
574
|
+
|
|
575
|
+
if (placement === "status") {
|
|
576
|
+
ctx.ui.setWidget("usage", undefined);
|
|
577
|
+
if (!usage && !message) {
|
|
578
|
+
ctx.ui.setStatus("sub-bar", "");
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const theme = ctx.ui.theme;
|
|
582
|
+
const terminalWidth = process.stdout.columns || 80;
|
|
583
|
+
// In status-line placement we must not use fill-based layouts (they assume full terminal width).
|
|
584
|
+
// The Pi footer concatenates *all* extension statuses onto one line and then truncates,
|
|
585
|
+
// so we render at natural width here to avoid padding that would overflow when other
|
|
586
|
+
// status hooks are present.
|
|
587
|
+
const lines = formatUsageContent(ctx, theme, usage, terminalWidth, message, {
|
|
588
|
+
forceNoFill: true,
|
|
589
|
+
forceLeftAlignment: true,
|
|
590
|
+
forceOverflow: "truncate",
|
|
591
|
+
useStatusSafePadding: true,
|
|
592
|
+
});
|
|
593
|
+
if (lines.length === 0) {
|
|
594
|
+
ctx.ui.setStatus("sub-bar", "");
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
let statusLine = lines.join(" ");
|
|
598
|
+
const edgeDivider = buildStatusEdgeDivider(theme);
|
|
599
|
+
if (edgeDivider) {
|
|
600
|
+
if (settings.display.statusLeadingDivider) {
|
|
601
|
+
statusLine = `${edgeDivider}${statusLine}`;
|
|
602
|
+
}
|
|
603
|
+
if (settings.display.statusTrailingDivider) {
|
|
604
|
+
statusLine = `${statusLine}${edgeDivider}`;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
ctx.ui.setStatus("sub-bar", truncateToWidth(statusLine, terminalWidth, theme.fg("dim", "...")));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
ctx.ui.setStatus("sub-bar", "");
|
|
612
|
+
if (!usage && !message) {
|
|
613
|
+
ctx.ui.setWidget("usage", undefined);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const widgetPlacement = placement === "aboveEditor" ? "aboveEditor" : "belowEditor";
|
|
618
|
+
const setWidgetWithPlacement = (ctx.ui as unknown as { setWidget: (...args: unknown[]) => void }).setWidget;
|
|
619
|
+
setWidgetWithPlacement(
|
|
620
|
+
"usage",
|
|
621
|
+
(_tui: unknown, theme: Theme) => ({
|
|
622
|
+
render(width: number) {
|
|
623
|
+
const safeWidth = Math.max(1, width);
|
|
624
|
+
const showTopDivider = settings.display.showTopDivider ?? false;
|
|
625
|
+
const showBottomDivider = settings.display.showBottomDivider ?? true;
|
|
626
|
+
const dividerChar = settings.display.dividerCharacter ?? "•";
|
|
627
|
+
const dividerColor: ThemeColor = resolveDividerColor(settings.display.dividerColor);
|
|
628
|
+
const dividerConnect = settings.display.dividerFooterJoin ?? false;
|
|
629
|
+
const dividerLine = theme.fg(dividerColor, "─".repeat(safeWidth));
|
|
630
|
+
|
|
631
|
+
let lines = formatUsageContent(ctx, theme, usage, safeWidth, message);
|
|
632
|
+
|
|
633
|
+
if (showTopDivider) {
|
|
634
|
+
const baseLine = lines.length > 0 ? lines[0] : "";
|
|
635
|
+
const topLine = dividerConnect
|
|
636
|
+
? buildDividerLine(safeWidth, baseLine, dividerChar, dividerConnect, "top", dividerColor, theme)
|
|
637
|
+
: dividerLine;
|
|
638
|
+
lines = [topLine, ...lines];
|
|
639
|
+
}
|
|
640
|
+
if (showBottomDivider) {
|
|
641
|
+
const baseLine = lines.length > 0 ? lines[lines.length - 1] : "";
|
|
642
|
+
const footerLine = dividerConnect
|
|
643
|
+
? buildDividerLine(safeWidth, baseLine, dividerChar, dividerConnect, "bottom", dividerColor, theme)
|
|
644
|
+
: dividerLine;
|
|
645
|
+
lines = [...lines, footerLine];
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const backgroundColor = resolveBackgroundColor(settings.display.backgroundColor);
|
|
649
|
+
return applyBackground(lines, theme, backgroundColor, safeWidth);
|
|
650
|
+
},
|
|
651
|
+
invalidate() {},
|
|
652
|
+
}),
|
|
653
|
+
{ placement: widgetPlacement },
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function resolveDisplayedUsage(): UsageSnapshot | undefined {
|
|
658
|
+
const pinned = settings.pinnedProvider ?? null;
|
|
659
|
+
if (pinned) {
|
|
660
|
+
return usageEntries[pinned] ?? currentUsage;
|
|
661
|
+
}
|
|
662
|
+
return currentUsage;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function syncAntigravityModels(usage?: UsageSnapshot): void {
|
|
666
|
+
if (!usage || usage.provider !== "antigravity") return;
|
|
667
|
+
const normalizeModel = (label: string) => label.toLowerCase().replace(/\s+/g, "_");
|
|
668
|
+
const labels = usage.windows
|
|
669
|
+
.map((window) => window.label?.trim())
|
|
670
|
+
.filter((label): label is string => Boolean(label))
|
|
671
|
+
.filter((label) => !antigravityHiddenModels.has(normalizeModel(label)));
|
|
672
|
+
const uniqueModels = Array.from(new Set(labels));
|
|
673
|
+
const antigravitySettings = settings.providers.antigravity;
|
|
674
|
+
const visibility = { ...(antigravitySettings.modelVisibility ?? {}) };
|
|
675
|
+
const modelSet = new Set(uniqueModels);
|
|
676
|
+
let changed = false;
|
|
677
|
+
|
|
678
|
+
for (const model of uniqueModels) {
|
|
679
|
+
if (!(model in visibility)) {
|
|
680
|
+
visibility[model] = false;
|
|
681
|
+
changed = true;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
for (const existing of Object.keys(visibility)) {
|
|
686
|
+
if (!modelSet.has(existing)) {
|
|
687
|
+
delete visibility[existing];
|
|
688
|
+
changed = true;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const currentOrder = antigravitySettings.modelOrder ?? [];
|
|
693
|
+
const orderChanged = currentOrder.length !== uniqueModels.length
|
|
694
|
+
|| currentOrder.some((model, index) => model !== uniqueModels[index]);
|
|
695
|
+
if (orderChanged) {
|
|
696
|
+
changed = true;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (!changed) return;
|
|
700
|
+
antigravitySettings.modelVisibility = visibility;
|
|
701
|
+
antigravitySettings.modelOrder = uniqueModels;
|
|
702
|
+
saveSettings(settings);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function updateEntries(entries: ProviderUsageEntry[] | undefined): void {
|
|
706
|
+
if (!entries) return;
|
|
707
|
+
const next: Partial<Record<ProviderName, UsageSnapshot>> = {};
|
|
708
|
+
for (const entry of entries) {
|
|
709
|
+
if (!entry.usage) continue;
|
|
710
|
+
next[entry.provider] = entry.usage;
|
|
711
|
+
}
|
|
712
|
+
usageEntries = next;
|
|
713
|
+
syncAntigravityModels(next.antigravity);
|
|
714
|
+
updateFetchFailureTicker();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function updateFetchFailureTicker(): void {
|
|
718
|
+
if (!uiEnabled) {
|
|
719
|
+
if (fetchFailureTimer) {
|
|
720
|
+
clearInterval(fetchFailureTimer);
|
|
721
|
+
fetchFailureTimer = undefined;
|
|
722
|
+
}
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const usage = resolveDisplayedUsage();
|
|
726
|
+
const shouldTick = Boolean(usage?.error && usage.lastSuccessAt);
|
|
727
|
+
if (shouldTick && !fetchFailureTimer) {
|
|
728
|
+
fetchFailureTimer = setInterval(() => {
|
|
729
|
+
if (!lastContext) return;
|
|
730
|
+
renderCurrent(lastContext);
|
|
731
|
+
}, 60000);
|
|
732
|
+
fetchFailureTimer.unref?.();
|
|
733
|
+
}
|
|
734
|
+
if (!shouldTick && fetchFailureTimer) {
|
|
735
|
+
clearInterval(fetchFailureTimer);
|
|
736
|
+
fetchFailureTimer = undefined;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function renderCurrent(ctx: ExtensionContext): void {
|
|
741
|
+
if (!coreAvailable) {
|
|
742
|
+
renderUsageWidget(ctx, undefined, "pi-sub-core required. install with: pi install npm:@eiei114/pi-sub-core");
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const usage = resolveDisplayedUsage();
|
|
746
|
+
renderUsageWidget(ctx, usage);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function updateUsage(usage: UsageSnapshot | undefined): void {
|
|
750
|
+
currentUsage = usage;
|
|
751
|
+
syncAntigravityModels(usage);
|
|
752
|
+
updateFetchFailureTicker();
|
|
753
|
+
if (lastContext) {
|
|
754
|
+
renderCurrent(lastContext);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function applyCoreSettings(next?: CoreSettings): void {
|
|
759
|
+
if (!next) return;
|
|
760
|
+
coreSettings = next;
|
|
761
|
+
settings.behavior = next.behavior ?? settings.behavior;
|
|
762
|
+
settings.statusRefresh = next.statusRefresh ?? settings.statusRefresh;
|
|
763
|
+
settings.providerOrder = next.providerOrder ?? settings.providerOrder;
|
|
764
|
+
settings.defaultProvider = next.defaultProvider ?? settings.defaultProvider;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function applyCoreSettingsPatch(patch: Partial<CoreSettings>): void {
|
|
768
|
+
if (patch.providers) {
|
|
769
|
+
for (const [provider, value] of Object.entries(patch.providers)) {
|
|
770
|
+
const key = provider as ProviderName;
|
|
771
|
+
const current = coreSettings.providers[key];
|
|
772
|
+
if (!current) continue;
|
|
773
|
+
coreSettings.providers[key] = { ...current, ...value };
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (patch.behavior) {
|
|
777
|
+
coreSettings.behavior = { ...coreSettings.behavior, ...patch.behavior };
|
|
778
|
+
}
|
|
779
|
+
if (patch.statusRefresh) {
|
|
780
|
+
coreSettings.statusRefresh = { ...coreSettings.statusRefresh, ...patch.statusRefresh };
|
|
781
|
+
}
|
|
782
|
+
if (patch.providerOrder) {
|
|
783
|
+
coreSettings.providerOrder = [...patch.providerOrder];
|
|
784
|
+
}
|
|
785
|
+
if (patch.defaultProvider !== undefined) {
|
|
786
|
+
coreSettings.defaultProvider = patch.defaultProvider;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function emitCoreAction(action: SubCoreAction): void {
|
|
791
|
+
pi.events.emit("sub-core:action", action);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function requestCoreState(timeoutMs = 1000): Promise<SubCoreState | undefined> {
|
|
795
|
+
return new Promise((resolve) => {
|
|
796
|
+
let resolved = false;
|
|
797
|
+
const timer = setTimeout(() => {
|
|
798
|
+
if (!resolved) {
|
|
799
|
+
resolved = true;
|
|
800
|
+
resolve(undefined);
|
|
801
|
+
}
|
|
802
|
+
}, timeoutMs);
|
|
803
|
+
|
|
804
|
+
const request: SubCoreRequest = {
|
|
805
|
+
type: "current",
|
|
806
|
+
includeSettings: true,
|
|
807
|
+
reply: (payload) => {
|
|
808
|
+
if (resolved) return;
|
|
809
|
+
resolved = true;
|
|
810
|
+
clearTimeout(timer);
|
|
811
|
+
applyCoreSettings(payload.settings);
|
|
812
|
+
resolve(payload.state);
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
pi.events.emit("sub-core:request", request);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function requestCoreEntries(timeoutMs = 1000): Promise<ProviderUsageEntry[] | undefined> {
|
|
821
|
+
return new Promise((resolve) => {
|
|
822
|
+
let resolved = false;
|
|
823
|
+
const timer = setTimeout(() => {
|
|
824
|
+
if (!resolved) {
|
|
825
|
+
resolved = true;
|
|
826
|
+
resolve(undefined);
|
|
827
|
+
}
|
|
828
|
+
}, timeoutMs);
|
|
829
|
+
|
|
830
|
+
const request: SubCoreRequest = {
|
|
831
|
+
type: "entries",
|
|
832
|
+
reply: (payload) => {
|
|
833
|
+
if (resolved) return;
|
|
834
|
+
resolved = true;
|
|
835
|
+
clearTimeout(timer);
|
|
836
|
+
resolve(payload.entries);
|
|
837
|
+
},
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
pi.events.emit("sub-core:request", request);
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function ensurePinnedEntries(pinned: ProviderName | null): Promise<void> {
|
|
845
|
+
if (!pinned) return;
|
|
846
|
+
if (usageEntries[pinned]) return;
|
|
847
|
+
const entries = await requestCoreEntries();
|
|
848
|
+
updateEntries(entries);
|
|
849
|
+
if (lastContext) {
|
|
850
|
+
renderCurrent(lastContext);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
pi.events.on("sub-core:update-all", (payload) => {
|
|
855
|
+
coreAvailable = true;
|
|
856
|
+
const state = payload as { state?: SubCoreAllState };
|
|
857
|
+
updateEntries(state.state?.entries);
|
|
858
|
+
if (lastContext) {
|
|
859
|
+
renderCurrent(lastContext);
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
pi.events.on("sub-core:update-current", (payload) => {
|
|
864
|
+
coreAvailable = true;
|
|
865
|
+
const state = payload as { state?: SubCoreState };
|
|
866
|
+
updateUsage(state.state?.usage);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
pi.events.on("sub-core:ready", (payload) => {
|
|
870
|
+
coreAvailable = true;
|
|
871
|
+
const state = payload as { state?: SubCoreState; settings?: CoreSettings };
|
|
872
|
+
applyCoreSettings(state.settings);
|
|
873
|
+
updateUsage(state.state?.usage);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
pi.events.on("sub-core:settings:updated", (payload) => {
|
|
877
|
+
const update = payload as { settings?: CoreSettings };
|
|
878
|
+
applyCoreSettings(update.settings);
|
|
879
|
+
if (lastContext) {
|
|
880
|
+
renderCurrent(lastContext);
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// Register command to open settings
|
|
885
|
+
pi.registerCommand("sub-bar:settings", {
|
|
886
|
+
description: "Open sub-bar settings",
|
|
887
|
+
handler: async (_args, ctx) => {
|
|
888
|
+
const newSettings = await showSettingsUI(ctx, {
|
|
889
|
+
coreSettings,
|
|
890
|
+
onOpenCoreSettings: async () => {
|
|
891
|
+
ctx.ui.setEditorText("/sub-core:settings");
|
|
892
|
+
},
|
|
893
|
+
onSettingsChange: async (updatedSettings) => {
|
|
894
|
+
const previousPinned = settings.pinnedProvider ?? null;
|
|
895
|
+
settings = updatedSettings;
|
|
896
|
+
updateFetchFailureTicker();
|
|
897
|
+
if (settings.pinnedProvider && settings.pinnedProvider !== previousPinned) {
|
|
898
|
+
void ensurePinnedEntries(settings.pinnedProvider);
|
|
899
|
+
}
|
|
900
|
+
if (lastContext) {
|
|
901
|
+
renderCurrent(lastContext);
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
onCoreSettingsChange: async (patch, _next) => {
|
|
905
|
+
applyCoreSettingsPatch(patch);
|
|
906
|
+
pi.events.emit("sub-core:settings:patch", { patch });
|
|
907
|
+
if (lastContext) {
|
|
908
|
+
renderCurrent(lastContext);
|
|
909
|
+
}
|
|
910
|
+
},
|
|
911
|
+
onDisplayThemeApplied: (name, options) => {
|
|
912
|
+
const content = options?.source === "manual"
|
|
913
|
+
? `sub-bar Theme ${name} loaded`
|
|
914
|
+
: `sub-bar Theme ${name} loaded / applied / saved. Restore settings in /sub-bar:settings -> Themes -> Load & Manage themes`;
|
|
915
|
+
pi.sendMessage({
|
|
916
|
+
customType: "sub-bar",
|
|
917
|
+
content,
|
|
918
|
+
display: true,
|
|
919
|
+
});
|
|
920
|
+
},
|
|
921
|
+
onDisplayThemeShared: (name, shareString, mode) => shareThemeString(ctx, name, shareString, mode ?? "prompt"),
|
|
922
|
+
});
|
|
923
|
+
settings = newSettings;
|
|
924
|
+
void ensurePinnedEntries(settings.pinnedProvider ?? null);
|
|
925
|
+
if (lastContext) {
|
|
926
|
+
renderCurrent(lastContext);
|
|
927
|
+
}
|
|
928
|
+
},
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
pi.registerCommand("sub-bar:import", {
|
|
932
|
+
description: "Import a shared display theme",
|
|
933
|
+
handler: async (args, ctx) => {
|
|
934
|
+
let input = String(args ?? "").trim();
|
|
935
|
+
if (input.startsWith("/sub-bar:import")) {
|
|
936
|
+
input = input.replace(/^\/sub-bar:import\s*/i, "").trim();
|
|
937
|
+
} else if (input.startsWith("sub-bar:import")) {
|
|
938
|
+
input = input.replace(/^sub-bar:import\s*/i, "").trim();
|
|
939
|
+
}
|
|
940
|
+
if (!input) {
|
|
941
|
+
const typed = await promptImportString(ctx);
|
|
942
|
+
if (!typed) return;
|
|
943
|
+
input = typed;
|
|
944
|
+
}
|
|
945
|
+
const decoded = decodeDisplayShareString(input);
|
|
946
|
+
if (!decoded) {
|
|
947
|
+
ctx.ui.notify("Invalid theme share string", "error");
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const backup = { ...settings.display };
|
|
951
|
+
settings.display = { ...decoded.display };
|
|
952
|
+
if (lastContext) {
|
|
953
|
+
renderUsageWidget(lastContext, currentUsage);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const action = await promptImportAction(ctx);
|
|
957
|
+
let resolvedName = decoded.name;
|
|
958
|
+
if ((action === "save-apply" || action === "save") && !decoded.hasName) {
|
|
959
|
+
const providedName = await promptImportName(ctx);
|
|
960
|
+
if (!providedName) {
|
|
961
|
+
settings.display = { ...backup };
|
|
962
|
+
if (lastContext) {
|
|
963
|
+
renderUsageWidget(lastContext, currentUsage);
|
|
964
|
+
}
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
resolvedName = providedName;
|
|
968
|
+
}
|
|
969
|
+
const notifyImported = (name: string) => {
|
|
970
|
+
const message = decoded.isNewerVersion
|
|
971
|
+
? `Imported ${name} (newer version, some fields may be ignored)`
|
|
972
|
+
: `Imported ${name}`;
|
|
973
|
+
ctx.ui.notify(message, decoded.isNewerVersion ? "warning" : "info");
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
if (action === "save-apply") {
|
|
977
|
+
settings.displayUserTheme = { ...backup };
|
|
978
|
+
settings = upsertDisplayTheme(settings, resolvedName, decoded.display, "imported");
|
|
979
|
+
settings.display = { ...decoded.display };
|
|
980
|
+
saveSettings(settings);
|
|
981
|
+
if (lastContext) {
|
|
982
|
+
renderUsageWidget(lastContext, currentUsage);
|
|
983
|
+
}
|
|
984
|
+
notifyImported(resolvedName);
|
|
985
|
+
pi.sendMessage({
|
|
986
|
+
customType: "sub-bar",
|
|
987
|
+
content: `sub-bar Theme ${resolvedName} loaded`,
|
|
988
|
+
display: true,
|
|
989
|
+
});
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (action === "save") {
|
|
994
|
+
settings = upsertDisplayTheme(settings, resolvedName, decoded.display, "imported");
|
|
995
|
+
settings.display = { ...backup };
|
|
996
|
+
saveSettings(settings);
|
|
997
|
+
notifyImported(resolvedName);
|
|
998
|
+
if (lastContext) {
|
|
999
|
+
renderUsageWidget(lastContext, currentUsage);
|
|
1000
|
+
}
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
settings.display = { ...backup };
|
|
1005
|
+
if (lastContext) {
|
|
1006
|
+
renderUsageWidget(lastContext, currentUsage);
|
|
1007
|
+
}
|
|
1008
|
+
},
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
// Register shortcut to cycle providers
|
|
1012
|
+
const cycleProviderKey = settings.keybindings?.cycleProvider || "ctrl+alt+p";
|
|
1013
|
+
if (cycleProviderKey !== "none") {
|
|
1014
|
+
pi.registerShortcut(cycleProviderKey as KeyId, {
|
|
1015
|
+
description: "Cycle usage provider",
|
|
1016
|
+
handler: async () => {
|
|
1017
|
+
emitCoreAction({ type: "cycleProvider" });
|
|
1018
|
+
},
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Register shortcut to toggle reset timer format
|
|
1023
|
+
const toggleResetFormatKey = settings.keybindings?.toggleResetFormat || "ctrl+alt+r";
|
|
1024
|
+
if (toggleResetFormatKey !== "none") {
|
|
1025
|
+
pi.registerShortcut(toggleResetFormatKey as KeyId, {
|
|
1026
|
+
description: "Toggle reset timer format",
|
|
1027
|
+
handler: async () => {
|
|
1028
|
+
settings.display.resetTimeFormat = settings.display.resetTimeFormat === "datetime" ? "relative" : "datetime";
|
|
1029
|
+
saveSettings(settings);
|
|
1030
|
+
if (lastContext && currentUsage) {
|
|
1031
|
+
renderUsageWidget(lastContext, currentUsage);
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1038
|
+
lastContext = ctx;
|
|
1039
|
+
uiEnabled = ctx.hasUI;
|
|
1040
|
+
if (!uiEnabled) {
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
settings = loadSettings();
|
|
1044
|
+
coreSettings = getFallbackCoreSettings(settings);
|
|
1045
|
+
if (!settingsSnapshot) {
|
|
1046
|
+
const content = readSettingsFile();
|
|
1047
|
+
if (content) {
|
|
1048
|
+
settingsSnapshot = content;
|
|
1049
|
+
try {
|
|
1050
|
+
const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false });
|
|
1051
|
+
if (stat?.mtimeMs) settingsMtimeMs = stat.mtimeMs;
|
|
1052
|
+
} catch {
|
|
1053
|
+
// Ignore
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const watchTimer = setTimeout(() => startSettingsWatch(), 0);
|
|
1059
|
+
watchTimer.unref?.();
|
|
1060
|
+
|
|
1061
|
+
const sessionContext = ctx;
|
|
1062
|
+
void (async () => {
|
|
1063
|
+
await ensureSubCoreLoaded();
|
|
1064
|
+
if (!lastContext || lastContext !== sessionContext || !uiEnabled) return;
|
|
1065
|
+
const state = await requestCoreState();
|
|
1066
|
+
if (!lastContext || lastContext !== sessionContext || !uiEnabled) return;
|
|
1067
|
+
if (state) {
|
|
1068
|
+
coreAvailable = true;
|
|
1069
|
+
updateUsage(state.usage);
|
|
1070
|
+
if (settings.pinnedProvider) {
|
|
1071
|
+
const entries = await requestCoreEntries();
|
|
1072
|
+
if (!lastContext || lastContext !== sessionContext || !uiEnabled) return;
|
|
1073
|
+
updateEntries(entries);
|
|
1074
|
+
if (lastContext) {
|
|
1075
|
+
renderCurrent(lastContext);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
} else if (lastContext && !coreAvailable) {
|
|
1079
|
+
coreAvailable = false;
|
|
1080
|
+
renderCurrent(lastContext);
|
|
1081
|
+
}
|
|
1082
|
+
})();
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
pi.on("model_select" as unknown as "session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
1086
|
+
lastContext = ctx;
|
|
1087
|
+
if (!uiEnabled || !ctx.hasUI) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
if (currentUsage) {
|
|
1091
|
+
renderUsageWidget(ctx, currentUsage);
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
pi.on("session_shutdown", async () => {
|
|
1096
|
+
lastContext = undefined;
|
|
1097
|
+
if (fetchFailureTimer) {
|
|
1098
|
+
clearInterval(fetchFailureTimer);
|
|
1099
|
+
fetchFailureTimer = undefined;
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
}
|