pi-model-profiles 0.2.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.
@@ -0,0 +1,334 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { SETTINGS_PATH, THEMES_DIR } from "./constants.js";
5
+
6
+ interface ThemeLike {
7
+ name?: unknown;
8
+ fg?: unknown;
9
+ bold?: unknown;
10
+ }
11
+
12
+ interface RawThemeFile {
13
+ name?: unknown;
14
+ vars?: unknown;
15
+ colors?: unknown;
16
+ export?: unknown;
17
+ }
18
+
19
+ type ModalThemeSlot =
20
+ | "text"
21
+ | "accent"
22
+ | "muted"
23
+ | "dim"
24
+ | "success"
25
+ | "warning"
26
+ | "error"
27
+ | "border"
28
+ | "borderMuted"
29
+ | "selectedText"
30
+ | "selectedBg"
31
+ | "panelBg";
32
+
33
+ interface ModalThemePalette {
34
+ text?: string;
35
+ accent?: string;
36
+ muted?: string;
37
+ dim?: string;
38
+ success?: string;
39
+ warning?: string;
40
+ error?: string;
41
+ border?: string;
42
+ borderMuted?: string;
43
+ selectedText?: string;
44
+ selectedBg?: string;
45
+ panelBg?: string;
46
+ }
47
+
48
+ interface ThemeSourceMaps {
49
+ vars: Record<string, unknown>;
50
+ colors: Record<string, unknown>;
51
+ export: Record<string, unknown>;
52
+ }
53
+
54
+ export interface ResolvedModalTheme {
55
+ name: string;
56
+ warnings: string[];
57
+ color(slot: ModalThemeSlot, text: string, options?: { background?: ModalThemeSlot; bold?: boolean }): string;
58
+ bold(text: string): string;
59
+ }
60
+
61
+ const ANSI_RESET = "\x1b[0m";
62
+ const ANSI_BOLD = "\x1b[1m";
63
+ const NAMED_COLOR_CODES: Record<string, number> = {
64
+ black: 16,
65
+ white: 255,
66
+ red: 196,
67
+ green: 46,
68
+ blue: 45,
69
+ yellow: 226,
70
+ cyan: 51,
71
+ magenta: 201,
72
+ gray: 245,
73
+ grey: 245,
74
+ orange: 166,
75
+ darkGray: 239,
76
+ amber: 208,
77
+ };
78
+
79
+ // Box-drawing characters for TUI borders
80
+ export const BOX = {
81
+ CORNER_TL: "╭",
82
+ CORNER_TR: "╮",
83
+ CORNER_BL: "╰",
84
+ CORNER_BR: "╯",
85
+ H_LINE: "─",
86
+ V_LINE: "│",
87
+ T_DOWN: "├",
88
+ T_UP: "┤",
89
+ T_RIGHT: "├",
90
+ T_LEFT: "┤",
91
+ CROSS: "┼",
92
+ } as const;
93
+
94
+ function toRecord(value: unknown): Record<string, unknown> {
95
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
96
+ return {};
97
+ }
98
+ return value as Record<string, unknown>;
99
+ }
100
+
101
+ function normalizeOptionalString(value: unknown): string | undefined {
102
+ if (typeof value !== "string") {
103
+ return undefined;
104
+ }
105
+ const trimmed = value.trim();
106
+ return trimmed ? trimmed : undefined;
107
+ }
108
+
109
+ function normalizeThemeName(theme: ThemeLike): string {
110
+ return normalizeOptionalString(theme.name) ?? "current";
111
+ }
112
+
113
+ function expandHexColor(value: string): string | undefined {
114
+ const trimmed = value.trim();
115
+ if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) {
116
+ return trimmed;
117
+ }
118
+ if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) {
119
+ const [red, green, blue] = trimmed.slice(1).split("");
120
+ return `#${red}${red}${green}${green}${blue}${blue}`;
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ function resolveThemeReference(reference: string, source: ThemeSourceMaps, visited: Set<string>): string | undefined {
126
+ const directHex = expandHexColor(reference);
127
+ if (directHex) {
128
+ return directHex;
129
+ }
130
+
131
+ const normalizedName = reference.trim();
132
+ if (!normalizedName) {
133
+ return undefined;
134
+ }
135
+
136
+ const lowerName = normalizedName.toLowerCase();
137
+ if (NAMED_COLOR_CODES[lowerName] !== undefined) {
138
+ return lowerName;
139
+ }
140
+
141
+ if (visited.has(normalizedName)) {
142
+ return undefined;
143
+ }
144
+ visited.add(normalizedName);
145
+
146
+ const chained =
147
+ normalizeOptionalString(source.vars[normalizedName])
148
+ ?? normalizeOptionalString(source.colors[normalizedName])
149
+ ?? normalizeOptionalString(source.export[normalizedName]);
150
+ if (!chained) {
151
+ return undefined;
152
+ }
153
+
154
+ return resolveThemeReference(chained, source, visited);
155
+ }
156
+
157
+ function toAnsiForeground(color: string): string {
158
+ const hex = expandHexColor(color);
159
+ if (hex) {
160
+ const red = Number.parseInt(hex.slice(1, 3), 16);
161
+ const green = Number.parseInt(hex.slice(3, 5), 16);
162
+ const blue = Number.parseInt(hex.slice(5, 7), 16);
163
+ return `\x1b[38;2;${red};${green};${blue}m`;
164
+ }
165
+
166
+ const code = NAMED_COLOR_CODES[color.toLowerCase()] ?? NAMED_COLOR_CODES.white;
167
+ return `\x1b[38;5;${code}m`;
168
+ }
169
+
170
+ function toAnsiBackground(color: string): string {
171
+ const hex = expandHexColor(color);
172
+ if (hex) {
173
+ const red = Number.parseInt(hex.slice(1, 3), 16);
174
+ const green = Number.parseInt(hex.slice(3, 5), 16);
175
+ const blue = Number.parseInt(hex.slice(5, 7), 16);
176
+ return `\x1b[48;2;${red};${green};${blue}m`;
177
+ }
178
+
179
+ const code = NAMED_COLOR_CODES[color.toLowerCase()] ?? NAMED_COLOR_CODES.black;
180
+ return `\x1b[48;5;${code}m`;
181
+ }
182
+
183
+ function formatWithFallback(theme: ThemeLike, colorName: string, text: string): string {
184
+ try {
185
+ if (typeof theme.fg === "function") {
186
+ const formatter = theme.fg as (resolvedColor: string, value: string) => string;
187
+ return formatter(colorName, text);
188
+ }
189
+ } catch {
190
+ // Fall back to plain text.
191
+ }
192
+ return text;
193
+ }
194
+
195
+ function formatBold(theme: ThemeLike, text: string): string {
196
+ try {
197
+ if (typeof theme.bold === "function") {
198
+ const formatter = theme.bold as (value: string) => string;
199
+ return formatter(text);
200
+ }
201
+ } catch {
202
+ // Fall back to ANSI bold below.
203
+ }
204
+ return `${ANSI_BOLD}${text}${ANSI_RESET}`;
205
+ }
206
+
207
+ function loadSelectedThemeName(settingsPath: string, warnings: string[]): string | undefined {
208
+ if (!existsSync(settingsPath)) {
209
+ return undefined;
210
+ }
211
+
212
+ try {
213
+ const raw = readFileSync(settingsPath, "utf-8");
214
+ const parsed = toRecord(JSON.parse(raw) as unknown);
215
+ return normalizeOptionalString(parsed.theme);
216
+ } catch (error) {
217
+ const message = error instanceof Error ? error.message : String(error);
218
+ warnings.push(`Failed to read model profiles theme settings from '${settingsPath}': ${message}`);
219
+ return undefined;
220
+ }
221
+ }
222
+
223
+ function loadThemePalette(themeName: string | undefined, themesDir: string, warnings: string[]): { name: string; palette: ModalThemePalette } {
224
+ const fallbackName = themeName ?? "current";
225
+ if (!themeName) {
226
+ return { name: fallbackName, palette: {} };
227
+ }
228
+
229
+ const candidatePath = join(themesDir, themeName.endsWith(".json") ? themeName : `${themeName}.json`);
230
+ if (!existsSync(candidatePath)) {
231
+ warnings.push(`Theme '${themeName}' was not found in '${themesDir}'. Falling back to the active Pi theme.`);
232
+ return { name: fallbackName, palette: {} };
233
+ }
234
+
235
+ try {
236
+ const rawTheme = JSON.parse(readFileSync(candidatePath, "utf-8")) as RawThemeFile;
237
+ const source: ThemeSourceMaps = {
238
+ vars: toRecord(rawTheme.vars),
239
+ colors: toRecord(rawTheme.colors),
240
+ export: toRecord(rawTheme.export),
241
+ };
242
+ const resolveColor = (key: string, fallbackKey?: string): string | undefined => {
243
+ const direct = normalizeOptionalString(source.colors[key]) ?? normalizeOptionalString(source.export[key]);
244
+ if (direct) {
245
+ return resolveThemeReference(direct, source, new Set<string>());
246
+ }
247
+
248
+ if (!fallbackKey) {
249
+ return undefined;
250
+ }
251
+ const fallback = normalizeOptionalString(source.colors[fallbackKey]) ?? normalizeOptionalString(source.export[fallbackKey]);
252
+ return fallback ? resolveThemeReference(fallback, source, new Set<string>()) : undefined;
253
+ };
254
+
255
+ return {
256
+ name: normalizeOptionalString(rawTheme.name) ?? themeName,
257
+ palette: {
258
+ text: resolveColor("text"),
259
+ accent: resolveColor("accent"),
260
+ muted: resolveColor("muted"),
261
+ dim: resolveColor("dim"),
262
+ success: resolveColor("success"),
263
+ warning: resolveColor("warning"),
264
+ error: resolveColor("error"),
265
+ border: resolveColor("border", "borderAccent"),
266
+ borderMuted: resolveColor("borderMuted", "border"),
267
+ selectedText: resolveColor("text"),
268
+ selectedBg: resolveColor("selectedBg") ?? resolveColor("customMessageBg") ?? resolveColor("borderMuted") ?? resolveColor("accent"),
269
+ panelBg: resolveColor("cardBg") ?? resolveColor("customMessageBg") ?? resolveColor("userMessageBg"),
270
+ },
271
+ };
272
+ } catch (error) {
273
+ const message = error instanceof Error ? error.message : String(error);
274
+ warnings.push(`Failed to parse theme '${themeName}': ${message}. Falling back to the active Pi theme.`);
275
+ return { name: fallbackName, palette: {} };
276
+ }
277
+ }
278
+
279
+ function applyAnsi(text: string, foreground: string | undefined, background: string | undefined, bold: boolean): string {
280
+ const prefix = `${bold ? ANSI_BOLD : ""}${foreground ?? ""}${background ?? ""}`;
281
+ if (!prefix) {
282
+ return text;
283
+ }
284
+ return `${prefix}${text}${ANSI_RESET}`;
285
+ }
286
+
287
+ export function loadModalTheme(
288
+ theme: ThemeLike,
289
+ options: { settingsPath?: string; themesDir?: string } = {},
290
+ ): ResolvedModalTheme {
291
+ const warnings: string[] = [];
292
+ const settingsPath = options.settingsPath ?? SETTINGS_PATH;
293
+ const themesDir = options.themesDir ?? THEMES_DIR;
294
+ const selectedThemeName = loadSelectedThemeName(settingsPath, warnings);
295
+ const resolvedPalette = loadThemePalette(selectedThemeName, themesDir, warnings);
296
+ const palette = resolvedPalette.palette;
297
+ const fallbackSlotMap: Record<ModalThemeSlot, string> = {
298
+ text: "fg",
299
+ accent: "accent",
300
+ muted: "muted",
301
+ dim: "dim",
302
+ success: "success",
303
+ warning: "warning",
304
+ error: "error",
305
+ border: "border",
306
+ borderMuted: "borderMuted",
307
+ selectedText: "fg",
308
+ selectedBg: "accent",
309
+ panelBg: "bg",
310
+ };
311
+
312
+ return {
313
+ name: resolvedPalette.name || normalizeThemeName(theme),
314
+ warnings,
315
+ color(slot, text, options = {}) {
316
+ const foregroundColor = palette[slot];
317
+ const backgroundColor = options.background ? palette[options.background] : undefined;
318
+ if (foregroundColor || backgroundColor) {
319
+ return applyAnsi(
320
+ text,
321
+ foregroundColor ? toAnsiForeground(foregroundColor) : undefined,
322
+ backgroundColor ? toAnsiBackground(backgroundColor) : undefined,
323
+ options.bold === true,
324
+ );
325
+ }
326
+
327
+ const fallbackText = options.bold ? formatBold(theme, text) : text;
328
+ return formatWithFallback(theme, fallbackSlotMap[slot], fallbackText);
329
+ },
330
+ bold(text) {
331
+ return formatBold(theme, text);
332
+ },
333
+ };
334
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Type-safe wrappers for pi extension APIs.
3
+ *
4
+ * These wrappers exist because the pi-coding-agent `skipLibCheck` tsconfig
5
+ * setting can prevent TypeScript from fully resolving chained re-exports
6
+ * from the library's internal module structure. The wrapper functions
7
+ * provide explicit type signatures that TypeScript can verify at compile time
8
+ * while delegating to the actual runtime APIs.
9
+ */
10
+
11
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
12
+
13
+ /**
14
+ * ModelRegistry interface - subset of methods we need.
15
+ * Defined locally to avoid skipLibCheck resolution issues.
16
+ */
17
+ interface ModelRegistry {
18
+ refresh(): void;
19
+ }
20
+
21
+ interface ResourcesDiscoverEvent {
22
+ type: "resources_discover";
23
+ cwd: string;
24
+ reason: "startup" | "reload";
25
+ }
26
+
27
+ /**
28
+ * Register a handler for the resources_discover lifecycle event.
29
+ *
30
+ * The `resources_discover` event fires after `session_start` with
31
+ * `reason: "startup"` on initial load and `reason: "reload"` when
32
+ * the user runs `/reload`.
33
+ *
34
+ * @param pi - The extension API instance
35
+ * @param handler - Handler called with the event and extension context
36
+ */
37
+ export function onResourcesDiscover(
38
+ pi: ExtensionAPI,
39
+ handler: (event: ResourcesDiscoverEvent, ctx: ExtensionContext) => void,
40
+ ): void {
41
+ (pi as unknown as { on(event: "resources_discover", handler: (event: ResourcesDiscoverEvent, ctx: ExtensionContext) => void): void }).on("resources_discover", handler);
42
+ }
43
+
44
+ /**
45
+ * Refresh the model registry to reload model definitions from disk.
46
+ *
47
+ * This is needed as a workaround because `AgentSession.reload()` does not
48
+ * call `ModelRegistry.refresh()`, so custom model profiles in `models.json`
49
+ * are not refreshed when `/reload` is executed.
50
+ *
51
+ * @param ctx - The extension context providing access to the model registry
52
+ */
53
+ export function refreshModelRegistry(ctx: ExtensionContext): void {
54
+ const registry = (ctx as unknown as { modelRegistry: ModelRegistry }).modelRegistry;
55
+ registry.refresh();
56
+ }
@@ -0,0 +1,83 @@
1
+ import type { ProfileFieldKey, ProfileFields } from "./types.js";
2
+
3
+ function toRecord(value: unknown): Record<string, unknown> {
4
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
5
+ return {};
6
+ }
7
+ return value as Record<string, unknown>;
8
+ }
9
+
10
+ function normalizeOptionalString(value: unknown): string | undefined {
11
+ if (typeof value !== "string") {
12
+ return undefined;
13
+ }
14
+ const trimmed = value.trim();
15
+ return trimmed ? trimmed : undefined;
16
+ }
17
+
18
+ function normalizeTemperatureValue(value: unknown): number | undefined {
19
+ if (typeof value === "number") {
20
+ return Number.isFinite(value) ? value : undefined;
21
+ }
22
+ if (typeof value === "string" && value.trim()) {
23
+ const parsed = Number.parseFloat(value);
24
+ return Number.isFinite(parsed) ? parsed : undefined;
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ export function normalizeProfileFields(value: unknown): ProfileFields {
30
+ const source = toRecord(value);
31
+ const fields: ProfileFields = {};
32
+
33
+ const model = normalizeOptionalString(source.model);
34
+ if (model) {
35
+ fields.model = model;
36
+ }
37
+
38
+ const temperature = normalizeTemperatureValue(source.temperature);
39
+ if (temperature !== undefined) {
40
+ fields.temperature = temperature;
41
+ }
42
+
43
+ const reasoningEffort = normalizeOptionalString(source.reasoningEffort);
44
+ if (reasoningEffort) {
45
+ fields.reasoningEffort = reasoningEffort;
46
+ }
47
+
48
+ return fields;
49
+ }
50
+
51
+ export function hasProfileFields(fields: ProfileFields): boolean {
52
+ return fields.model !== undefined || fields.temperature !== undefined || fields.reasoningEffort !== undefined;
53
+ }
54
+
55
+ export function describeProfileFields(fields: ProfileFields): string {
56
+ const parts: string[] = [];
57
+ if (fields.model !== undefined) {
58
+ parts.push("model");
59
+ }
60
+ if (fields.temperature !== undefined) {
61
+ parts.push("temperature");
62
+ }
63
+ if (fields.reasoningEffort !== undefined) {
64
+ parts.push("reasoning");
65
+ }
66
+ return parts.length > 0 ? parts.join(", ") : "clears model overrides";
67
+ }
68
+
69
+ export function formatProfileFieldValue(key: ProfileFieldKey, fields: ProfileFields): string {
70
+ switch (key) {
71
+ case "model":
72
+ return fields.model ?? "(absent)";
73
+ case "temperature":
74
+ return fields.temperature !== undefined ? String(fields.temperature) : "(absent)";
75
+ case "reasoningEffort":
76
+ return fields.reasoningEffort ?? "(absent)";
77
+ }
78
+ }
79
+
80
+ export function sanitizeProfileName(value: string): string | null {
81
+ const trimmed = value.trim();
82
+ return trimmed ? trimmed : null;
83
+ }