@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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Settings persistence for sub-bar
3
+ */
4
+
5
+ import * as path from "node:path";
6
+ import type { Settings } from "./settings-types.js";
7
+ import { getDefaultSettings, mergeSettings } from "./settings-types.js";
8
+ import { getStorage } from "./storage.js";
9
+ import { getLegacySettingsPath, getSettingsPath } from "./paths.js";
10
+
11
+ /**
12
+ * Settings file path
13
+ */
14
+ export const SETTINGS_PATH = getSettingsPath();
15
+ const LEGACY_SETTINGS_PATH = getLegacySettingsPath();
16
+
17
+ /**
18
+ * In-memory settings cache
19
+ */
20
+ let cachedSettings: Settings | undefined;
21
+
22
+ /**
23
+ * Ensure the settings directory exists
24
+ */
25
+ function ensureSettingsDir(): void {
26
+ const storage = getStorage();
27
+ const dir = path.dirname(SETTINGS_PATH);
28
+ storage.ensureDir(dir);
29
+ }
30
+
31
+ /**
32
+ * Parse settings file contents
33
+ */
34
+ function parseSettings(content: string): Settings {
35
+ const loaded = JSON.parse(content) as Partial<Settings>;
36
+ return mergeSettings({
37
+ version: loaded.version,
38
+ display: loaded.display,
39
+ providers: loaded.providers,
40
+ displayThemes: loaded.displayThemes,
41
+ displayUserTheme: loaded.displayUserTheme,
42
+ pinnedProvider: loaded.pinnedProvider,
43
+ keybindings: loaded.keybindings,
44
+ } as Partial<Settings>);
45
+ }
46
+
47
+ function loadSettingsFromDisk(settingsPath: string): Settings | null {
48
+ const storage = getStorage();
49
+ if (storage.exists(settingsPath)) {
50
+ const content = storage.readFile(settingsPath);
51
+ if (content) {
52
+ return parseSettings(content);
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function tryLoadSettings(settingsPath: string): Settings | null {
59
+ try {
60
+ return loadSettingsFromDisk(settingsPath);
61
+ } catch (error) {
62
+ console.error(`Failed to load settings from ${settingsPath}:`, error);
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Load settings from disk
69
+ */
70
+ export function loadSettings(): Settings {
71
+ if (cachedSettings) {
72
+ return cachedSettings;
73
+ }
74
+
75
+ const diskSettings = tryLoadSettings(SETTINGS_PATH);
76
+ if (diskSettings) {
77
+ cachedSettings = diskSettings;
78
+ return cachedSettings;
79
+ }
80
+
81
+ const legacySettings = tryLoadSettings(LEGACY_SETTINGS_PATH);
82
+ if (legacySettings) {
83
+ const saved = saveSettings(legacySettings);
84
+ if (saved) {
85
+ getStorage().removeFile(LEGACY_SETTINGS_PATH);
86
+ }
87
+ cachedSettings = legacySettings;
88
+ return cachedSettings;
89
+ }
90
+
91
+ // Return defaults if file doesn't exist or failed to load
92
+ cachedSettings = getDefaultSettings();
93
+ return cachedSettings;
94
+ }
95
+
96
+ /**
97
+ * Save settings to disk
98
+ */
99
+ export function saveSettings(settings: Settings): boolean {
100
+ const storage = getStorage();
101
+ try {
102
+ ensureSettingsDir();
103
+ let next = settings;
104
+ if (cachedSettings) {
105
+ const diskSettings = loadSettingsFromDisk(SETTINGS_PATH);
106
+ if (diskSettings) {
107
+ const displayChanged = JSON.stringify(settings.display) !== JSON.stringify(cachedSettings.display);
108
+ const providersChanged = JSON.stringify(settings.providers) !== JSON.stringify(cachedSettings.providers);
109
+ const themesChanged = JSON.stringify(settings.displayThemes) !== JSON.stringify(cachedSettings.displayThemes);
110
+ const userThemeChanged = JSON.stringify(settings.displayUserTheme) !== JSON.stringify(cachedSettings.displayUserTheme);
111
+ const pinnedChanged = settings.pinnedProvider !== cachedSettings.pinnedProvider;
112
+ const keybindingsChanged = JSON.stringify(settings.keybindings) !== JSON.stringify(cachedSettings.keybindings);
113
+
114
+ next = {
115
+ ...diskSettings,
116
+ version: settings.version,
117
+ display: displayChanged ? settings.display : diskSettings.display,
118
+ providers: providersChanged ? settings.providers : diskSettings.providers,
119
+ displayThemes: themesChanged ? settings.displayThemes : diskSettings.displayThemes,
120
+ displayUserTheme: userThemeChanged ? settings.displayUserTheme : diskSettings.displayUserTheme,
121
+ pinnedProvider: pinnedChanged ? settings.pinnedProvider : diskSettings.pinnedProvider,
122
+ keybindings: keybindingsChanged ? settings.keybindings : diskSettings.keybindings,
123
+ };
124
+ }
125
+ }
126
+ const content = JSON.stringify({
127
+ version: next.version,
128
+ display: next.display,
129
+ providers: next.providers,
130
+ displayThemes: next.displayThemes,
131
+ displayUserTheme: next.displayUserTheme,
132
+ pinnedProvider: next.pinnedProvider,
133
+ keybindings: next.keybindings,
134
+ }, null, 2);
135
+ storage.writeFile(SETTINGS_PATH, content);
136
+ cachedSettings = next;
137
+ return true;
138
+ } catch (error) {
139
+ console.error(`Failed to save settings to ${SETTINGS_PATH}:`, error);
140
+ return false;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Reset settings to defaults
146
+ */
147
+ export function resetSettings(): Settings {
148
+ const defaults = getDefaultSettings();
149
+ const current = getSettings();
150
+ const next = {
151
+ ...current,
152
+ display: defaults.display,
153
+ providers: defaults.providers,
154
+ displayThemes: defaults.displayThemes,
155
+ displayUserTheme: defaults.displayUserTheme,
156
+ pinnedProvider: defaults.pinnedProvider,
157
+ keybindings: defaults.keybindings,
158
+ version: defaults.version,
159
+ };
160
+ saveSettings(next);
161
+ return next;
162
+ }
163
+
164
+ /**
165
+ * Get current settings (cached)
166
+ */
167
+ export function getSettings(): Settings {
168
+ return loadSettings();
169
+ }
170
+
171
+ /**
172
+ * Clear the settings cache (force reload on next access)
173
+ */
174
+ export function clearSettingsCache(): void {
175
+ cachedSettings = undefined;
176
+ }
package/src/share.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Display theme share helpers.
3
+ */
4
+
5
+ import type { Settings } from "./settings-types.js";
6
+ import { mergeSettings } from "./settings-types.js";
7
+
8
+ const SHARE_SEPARATOR = ":";
9
+ const DISPLAY_SHARE_VERSION = 1;
10
+
11
+ export interface DisplaySharePayload {
12
+ v: number;
13
+ display: Settings["display"];
14
+ }
15
+
16
+ export interface DecodedDisplayShare {
17
+ name: string;
18
+ display: Settings["display"];
19
+ version: number;
20
+ isNewerVersion: boolean;
21
+ hasName: boolean;
22
+ }
23
+
24
+ function encodeDisplaySharePayload(display: Settings["display"]): string {
25
+ const payload: DisplaySharePayload = { v: DISPLAY_SHARE_VERSION, display };
26
+ return Buffer.from(JSON.stringify(payload)).toString("base64url");
27
+ }
28
+
29
+ export function buildDisplayShareString(name: string, display: Settings["display"]): string {
30
+ const encoded = encodeDisplaySharePayload(display);
31
+ const trimmedName = name.trim() || "custom";
32
+ return `${trimmedName}${SHARE_SEPARATOR}${encoded}`;
33
+ }
34
+
35
+ export function buildDisplayShareStringWithoutName(display: Settings["display"]): string {
36
+ return encodeDisplaySharePayload(display);
37
+ }
38
+
39
+ export function decodeDisplayShareString(input: string): DecodedDisplayShare | null {
40
+ const trimmed = input.trim();
41
+ if (!trimmed) return null;
42
+ let name = "custom";
43
+ let hasName = false;
44
+ let payload = trimmed;
45
+ const separatorIndex = trimmed.indexOf(SHARE_SEPARATOR);
46
+ if (separatorIndex >= 0) {
47
+ const candidateName = trimmed.slice(0, separatorIndex).trim();
48
+ payload = trimmed.slice(separatorIndex + 1).trim();
49
+ if (candidateName) {
50
+ name = candidateName;
51
+ hasName = true;
52
+ }
53
+ }
54
+ if (!payload) return null;
55
+ try {
56
+ const decoded = Buffer.from(payload, "base64url").toString("utf-8");
57
+ const parsed = JSON.parse(decoded) as unknown;
58
+ if (!parsed || typeof parsed !== "object") return null;
59
+ const displayCandidate = (parsed as DisplaySharePayload).display ?? parsed;
60
+ if (!displayCandidate || typeof displayCandidate !== "object" || Array.isArray(displayCandidate)) {
61
+ return null;
62
+ }
63
+ const merged = mergeSettings({ display: displayCandidate } as Partial<Settings>).display;
64
+ const version = typeof (parsed as DisplaySharePayload).v === "number" ? (parsed as DisplaySharePayload).v : 0;
65
+ return {
66
+ name,
67
+ display: merged,
68
+ version,
69
+ isNewerVersion: version > DISPLAY_SHARE_VERSION,
70
+ hasName,
71
+ };
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
package/src/status.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Status indicator helpers.
3
+ */
4
+
5
+ import type { ProviderStatus } from "./types.js";
6
+ import type { StatusIconPack } from "./settings-types.js";
7
+
8
+ const STATUS_ICON_PACKS: Record<Exclude<StatusIconPack, "custom">, Record<ProviderStatus["indicator"], string>> = {
9
+ minimal: {
10
+ none: "✓",
11
+ minor: "⚠",
12
+ major: "⚠",
13
+ critical: "×",
14
+ maintenance: "~",
15
+ unknown: "?",
16
+ },
17
+ emoji: {
18
+ none: "✅",
19
+ minor: "⚠️",
20
+ major: "🟠",
21
+ critical: "🔴",
22
+ maintenance: "🔧",
23
+ unknown: "❓",
24
+ },
25
+ };
26
+
27
+ const DEFAULT_CUSTOM_ICONS = ["✓", "⚠", "×", "?"];
28
+ const CUSTOM_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
29
+
30
+ function parseCustomIcons(value?: string): [string, string, string, string] {
31
+ if (!value) return DEFAULT_CUSTOM_ICONS as [string, string, string, string];
32
+ const segments = Array.from(CUSTOM_SEGMENTER.segment(value), (entry) => entry.segment)
33
+ .map((segment) => segment.trim())
34
+ .filter(Boolean);
35
+ if (segments.length < 3) return DEFAULT_CUSTOM_ICONS as [string, string, string, string];
36
+ if (segments.length === 3) {
37
+ return [segments[0], segments[1], segments[2], DEFAULT_CUSTOM_ICONS[3]] as [string, string, string, string];
38
+ }
39
+ return [segments[0], segments[1], segments[2], segments[3]] as [string, string, string, string];
40
+ }
41
+
42
+ function buildCustomPack(custom?: string): Record<ProviderStatus["indicator"], string> {
43
+ const [ok, warn, error, unknown] = parseCustomIcons(custom);
44
+ return {
45
+ none: ok,
46
+ minor: warn,
47
+ major: error,
48
+ critical: error,
49
+ maintenance: warn,
50
+ unknown,
51
+ };
52
+ }
53
+
54
+ export function getStatusIcon(
55
+ status: ProviderStatus | undefined,
56
+ pack: StatusIconPack,
57
+ custom?: string,
58
+ ): string {
59
+ if (!status) return "";
60
+ if (pack === "custom") {
61
+ return buildCustomPack(custom)[status.indicator] ?? "";
62
+ }
63
+ return STATUS_ICON_PACKS[pack][status.indicator] ?? "";
64
+ }
65
+
66
+ export function getStatusLabel(
67
+ status: ProviderStatus | undefined,
68
+ useAbbreviated = false,
69
+ ): string {
70
+ if (!status) return "";
71
+ if (useAbbreviated) {
72
+ switch (status.indicator) {
73
+ case "none":
74
+ return "Status OK";
75
+ case "minor":
76
+ return "Status Degr.";
77
+ case "major":
78
+ case "critical":
79
+ return "Status Crit.";
80
+ case "maintenance":
81
+ return "Status Maint.";
82
+ case "unknown":
83
+ default:
84
+ return "Status Unk.";
85
+ }
86
+ }
87
+ if (status.description) return status.description;
88
+ switch (status.indicator) {
89
+ case "none":
90
+ return "Operational";
91
+ case "minor":
92
+ return "Degraded";
93
+ case "major":
94
+ return "Outage";
95
+ case "critical":
96
+ return "Outage";
97
+ case "maintenance":
98
+ return "Maintenance";
99
+ case "unknown":
100
+ default:
101
+ return "Status Unknown";
102
+ }
103
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Storage abstraction for settings persistence.
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+
8
+ export interface StorageAdapter {
9
+ readFile(path: string): string | undefined;
10
+ writeFile(path: string, contents: string): void;
11
+ writeFileExclusive(path: string, contents: string): boolean;
12
+ exists(path: string): boolean;
13
+ removeFile(path: string): void;
14
+ ensureDir(path: string): void;
15
+ }
16
+
17
+ export function createFsStorage(): StorageAdapter {
18
+ return {
19
+ readFile(filePath: string): string | undefined {
20
+ try {
21
+ return fs.readFileSync(filePath, "utf-8");
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ },
26
+ writeFile(filePath: string, contents: string): void {
27
+ fs.writeFileSync(filePath, contents, "utf-8");
28
+ },
29
+ writeFileExclusive(filePath: string, contents: string): boolean {
30
+ try {
31
+ fs.writeFileSync(filePath, contents, { flag: "wx" });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ },
37
+ exists(filePath: string): boolean {
38
+ return fs.existsSync(filePath);
39
+ },
40
+ removeFile(filePath: string): void {
41
+ try {
42
+ fs.unlinkSync(filePath);
43
+ } catch {
44
+ // Ignore remove errors
45
+ }
46
+ },
47
+ ensureDir(dirPath: string): void {
48
+ fs.mkdirSync(path.resolve(dirPath), { recursive: true });
49
+ },
50
+ };
51
+ }
52
+
53
+ let activeStorage: StorageAdapter = createFsStorage();
54
+
55
+ export function getStorage(): StorageAdapter {
56
+ return activeStorage;
57
+ }
58
+
59
+ export function setStorage(storage: StorageAdapter): void {
60
+ activeStorage = storage;
61
+ }
package/src/types.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Core types for the sub-bar extension
3
+ */
4
+
5
+ export type {
6
+ ProviderName,
7
+ StatusIndicator,
8
+ ProviderStatus,
9
+ RateWindow,
10
+ UsageSnapshot,
11
+ UsageError,
12
+ UsageErrorCode,
13
+ ProviderUsageEntry,
14
+ SubCoreState,
15
+ SubCoreAllState,
16
+ SubCoreEvents,
17
+ } from "@eiei114/pi-sub-shared";
18
+
19
+ export { PROVIDERS } from "@eiei114/pi-sub-shared";
20
+
21
+ export type ModelInfo = {
22
+ provider?: string;
23
+ id?: string;
24
+ scopedModelPatterns?: string[];
25
+ };
@@ -0,0 +1,92 @@
1
+ import * as PiTui from "@mariozechner/pi-tui";
2
+
3
+ export type SettingsListAction =
4
+ | "selectUp"
5
+ | "selectDown"
6
+ | "cursorLeft"
7
+ | "cursorRight"
8
+ | "selectConfirm"
9
+ | "selectCancel";
10
+
11
+ export interface SettingsKeybindings {
12
+ matches(data: string, action: SettingsListAction): boolean;
13
+ }
14
+
15
+ interface CompatibleApi {
16
+ getEditorKeybindings?: () => { matches(data: string, action: string): boolean };
17
+ getKeybindings?: () => { matches(data: string, action: string): boolean };
18
+ matchesKey?: (data: string, key: string) => boolean;
19
+ }
20
+
21
+ const LEGACY_ACTION_MAP: Record<SettingsListAction, string> = {
22
+ selectUp: "tui.select.up",
23
+ selectDown: "tui.select.down",
24
+ cursorLeft: "tui.editor.cursorLeft",
25
+ cursorRight: "tui.editor.cursorRight",
26
+ selectConfirm: "tui.select.confirm",
27
+ selectCancel: "tui.select.cancel",
28
+ };
29
+
30
+ const DEFAULT_ACTION_KEYS: Record<SettingsListAction, string | string[]> = {
31
+ selectUp: "up",
32
+ selectDown: "down",
33
+ cursorLeft: ["left", "ctrl+b"],
34
+ cursorRight: ["right", "ctrl+f"],
35
+ selectConfirm: "enter",
36
+ selectCancel: ["escape", "ctrl+c"],
37
+ };
38
+
39
+ function matchesKeyWithFallback(
40
+ data: string,
41
+ key: string,
42
+ matchesKey?: (data: string, key: string) => boolean,
43
+ ): boolean {
44
+ if (matchesKey) {
45
+ return matchesKey(data, key);
46
+ }
47
+
48
+ if (key === "enter") return data === "\r" || data === "\n";
49
+ if (key === "escape") return data === "\u001b";
50
+ if (key === "up") return data === "\u001b[A";
51
+ if (key === "down") return data === "\u001b[B";
52
+ if (key === "left") return data === "\u001b[D";
53
+ if (key === "right") return data === "\u001b[C";
54
+ return data === key;
55
+ }
56
+
57
+ function matchesDefaultAction(
58
+ data: string,
59
+ action: SettingsListAction,
60
+ matchesKey?: (data: string, key: string) => boolean,
61
+ ): boolean {
62
+ const keys = DEFAULT_ACTION_KEYS[action];
63
+ const list = Array.isArray(keys) ? keys : [keys];
64
+ return list.some((key) => matchesKeyWithFallback(data, key, matchesKey));
65
+ }
66
+
67
+ export function createSettingsKeybindings(api: CompatibleApi): SettingsKeybindings {
68
+ const editor = api.getEditorKeybindings?.();
69
+ if (editor && typeof editor.matches === "function") {
70
+ return {
71
+ matches: (data, action) => editor.matches(data, action),
72
+ };
73
+ }
74
+
75
+ const legacy = api.getKeybindings?.();
76
+ if (legacy && typeof legacy.matches === "function") {
77
+ return {
78
+ matches: (data, action) => {
79
+ const legacyAction = LEGACY_ACTION_MAP[action];
80
+ return legacy.matches(data, legacyAction);
81
+ },
82
+ };
83
+ }
84
+
85
+ return {
86
+ matches: (data, action) => matchesDefaultAction(data, action, api.matchesKey),
87
+ };
88
+ }
89
+
90
+ export function getSettingsKeybindings(): SettingsKeybindings {
91
+ return createSettingsKeybindings(PiTui as CompatibleApi);
92
+ }