feat(theme): make spinner frames overridable per custom theme

Adds a `symbols.spinnerFrames` field to the custom theme JSON schema so themes can override the loader/tool-execution spinner that drives `theme.spinnerFrames` / `theme.getSpinnerFrames(type)`. Accepts either a flat `string[]` (used for both spinner types) or an object `{ status?, activity? }` to override each type independently; anything not specified falls back to the active symbol preset (unicode/nerd/ascii). The override is plumbed through `createTheme` and the `Theme` constructor, normalized via `normalizeSpinnerFramesOverride`, and read in `getSpinnerFrames`.

Mirrors the field in `theme-schema.json`, documents it in `docs/theme.md`, and adds a test covering flat-array, per-type, validation-rejection, and preset-fallback paths.

Fixes #1553
This commit is contained in:
roboomp
2026-05-30 20:59:35 +00:00
parent 1c968a7bfc
commit 2eab0644d7
5 changed files with 164 additions and 3 deletions
@@ -404,8 +404,37 @@
"additionalProperties": {
"type": "string"
}
},
"spinnerFrames": {
"description": "Override the spinner frames. Use a flat array to set both `status` and `activity`, or an object to override each independently. Frames are advanced ~12.5fps for status spinners and ~60fps for activity spinners.",
"oneOf": [
{
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 }
},
{
"type": "object",
"properties": {
"status": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 }
},
"activity": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 }
}
},
"additionalProperties": false,
"anyOf": [
{ "required": ["status"] },
{ "required": ["activity"] }
]
}
]
}
},
"additionalProperties": false
}
},
+39 -2
View File
@@ -807,6 +807,25 @@ const SPINNER_FRAMES: Record<SymbolPreset, Record<SpinnerType, string[]>> = {
},
};
/**
* Shape accepted by `themeJson.symbols.spinnerFrames`. A flat array applies to
* both spinner types; an object lets a theme override `status` and/or
* `activity` independently. Anything not specified falls back to the symbol
* preset's default frames.
*/
type SpinnerFramesOverride = string[] | { status?: string[]; activity?: string[] };
function normalizeSpinnerFramesOverride(
value: SpinnerFramesOverride | undefined,
): Partial<Record<SpinnerType, string[]>> {
if (value === undefined) return {};
if (Array.isArray(value)) return { status: value, activity: value };
const result: Partial<Record<SpinnerType, string[]>> = {};
if (value.status) result.status = value.status;
if (value.activity) result.activity = value.activity;
return result;
}
// ============================================================================
// Types & Schema
// ============================================================================
@@ -893,6 +912,19 @@ const themeColorsSchema = z.object(
},
);
const spinnerFramesArraySchema = z.array(z.string().min(1)).min(1);
const spinnerFramesSchema = z.union([
spinnerFramesArraySchema,
z
.object({
status: spinnerFramesArraySchema.optional(),
activity: spinnerFramesArraySchema.optional(),
})
.refine(value => value.status !== undefined || value.activity !== undefined, {
message: "spinnerFrames object must define `status` and/or `activity`",
}),
]);
const symbolPresetSchema = z.enum(["unicode", "nerd", "ascii"]);
const themeJsonSchema = z.object({
@@ -911,6 +943,7 @@ const themeJsonSchema = z.object({
.object({
preset: symbolPresetSchema.optional(),
overrides: z.record(z.string(), z.string()).optional(),
spinnerFrames: spinnerFramesSchema.optional(),
})
.optional(),
});
@@ -1238,6 +1271,7 @@ export class Theme {
#fgColors: Record<ThemeColor, string>;
#bgColors: Record<ThemeBg, string>;
#symbols: SymbolMap;
#spinnerFramesOverrides: Partial<Record<SpinnerType, string[]>>;
constructor(
fgColors: Record<ThemeColor, string | number>,
@@ -1245,6 +1279,7 @@ export class Theme {
private readonly mode: ColorMode,
private readonly symbolPreset: SymbolPreset,
symbolOverrides: Partial<Record<SymbolKey, string>>,
spinnerFramesOverrides: Partial<Record<SpinnerType, string[]>> = {},
) {
this.#fgColors = {} as Record<ThemeColor, string>;
for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
@@ -1264,6 +1299,7 @@ export class Theme {
logger.debug("Invalid symbol key in override", { key, availableKeys: Object.keys(this.#symbols) });
}
}
this.#spinnerFramesOverrides = spinnerFramesOverrides;
}
fg(color: ThemeColor, text: string): string {
@@ -1539,7 +1575,7 @@ export class Theme {
* Get spinner frames by type.
*/
getSpinnerFrames(type: SpinnerType = "status"): string[] {
return SPINNER_FRAMES[this.symbolPreset][type];
return this.#spinnerFramesOverrides[type] ?? SPINNER_FRAMES[this.symbolPreset][type];
}
/**
@@ -1711,7 +1747,8 @@ function createTheme(themeJson: ThemeJson, options: CreateThemeOptions = {}): Th
// Extract symbol configuration - settings override takes precedence over theme
const symbolPreset: SymbolPreset = symbolPresetOverride ?? themeJson.symbols?.preset ?? "unicode";
const symbolOverrides = themeJson.symbols?.overrides ?? {};
return new Theme(fgColors, bgColors, colorMode, symbolPreset, symbolOverrides);
const spinnerFramesOverrides = normalizeSpinnerFramesOverride(themeJson.symbols?.spinnerFrames);
return new Theme(fgColors, bgColors, colorMode, symbolPreset, symbolOverrides, spinnerFramesOverrides);
}
async function loadTheme(name: string, options: CreateThemeOptions = {}): Promise<Theme> {