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:
@@ -85,6 +85,7 @@ If omitted, export code derives defaults from resolved theme colors.
|
||||
|
||||
- `symbols.preset` sets a theme-level default symbol set.
|
||||
- `symbols.overrides` can override individual `SymbolKey` values.
|
||||
- `symbols.spinnerFrames` overrides the loading spinner frames. Accepts either a flat `string[]` (applied to both spinner types) or an object `{ "status"?: string[], "activity"?: string[] }` to override each type independently. Any type not specified falls back to the symbol preset's default frames. `status` drives the ~12.5fps spinner used by loaders and tool-execution indicators; `activity` drives the ~60fps spinner used by markdown progress bars and similar high-frequency UI.
|
||||
|
||||
Runtime precedence:
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added a `symbols.spinnerFrames` field to custom theme JSON so themes can override the loader/tool-execution spinner. Accepts either a flat `string[]` (used for both spinner types) or `{ "status"?: string[], "activity"?: string[] }` to override each independently; anything not specified falls back to the symbol preset. Documented in `docs/theme.md` and validated by `theme-schema.json`. ([#1553](https://github.com/can1357/oh-my-pi/issues/1553))
|
||||
|
||||
## [15.6.0] - 2026-05-30
|
||||
### Added
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getConfigRootDir, getCustomThemesDir, setAgentDir } from "@oh-my-pi/pi-utils";
|
||||
import { getThemeByName } from "../src/modes/theme/theme";
|
||||
|
||||
// Path of the built-in dark theme JSON, used as a known-valid base we can
|
||||
// extend with custom `symbols.spinnerFrames` shapes.
|
||||
const DARK_THEME_PATH = path.join(import.meta.dir, "..", "src", "modes", "theme", "dark.json");
|
||||
|
||||
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
|
||||
|
||||
let tmpAgentDir: string;
|
||||
|
||||
async function writeCustomTheme(name: string, extraSymbols: Record<string, unknown>): Promise<void> {
|
||||
const dark = (await Bun.file(DARK_THEME_PATH).json()) as Record<string, unknown>;
|
||||
const base = (dark.symbols ?? {}) as Record<string, unknown>;
|
||||
const themeJson = {
|
||||
...dark,
|
||||
name,
|
||||
symbols: { ...base, ...extraSymbols },
|
||||
};
|
||||
const themesDir = getCustomThemesDir();
|
||||
await fs.mkdir(themesDir, { recursive: true });
|
||||
await Bun.write(path.join(themesDir, `${name}.json`), JSON.stringify(themeJson, null, 2));
|
||||
}
|
||||
|
||||
describe("theme symbols.spinnerFrames", () => {
|
||||
beforeEach(async () => {
|
||||
tmpAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-spinner-frames-"));
|
||||
setAgentDir(tmpAgentDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalAgentDir) {
|
||||
setAgentDir(originalAgentDir);
|
||||
} else {
|
||||
setAgentDir(fallbackAgentDir);
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
}
|
||||
await fs.rm(tmpAgentDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("flat-array override applies to both status and activity spinners", async () => {
|
||||
const frames = ["◐", "◓", "◑", "◒"];
|
||||
await writeCustomTheme("custom-flat", { spinnerFrames: frames });
|
||||
|
||||
const theme = await getThemeByName("custom-flat");
|
||||
expect(theme).toBeDefined();
|
||||
expect(theme!.getSpinnerFrames("status")).toEqual(frames);
|
||||
expect(theme!.getSpinnerFrames("activity")).toEqual(frames);
|
||||
// Default getter is the status spinner.
|
||||
expect(theme!.spinnerFrames).toEqual(frames);
|
||||
});
|
||||
|
||||
it("object override sets each spinner type independently and falls back to preset", async () => {
|
||||
const statusFrames = ["A", "B", "C"];
|
||||
// `unicode` preset's activity frames — the default we expect to surface
|
||||
// when only `status` is overridden.
|
||||
const presetActivity = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
await writeCustomTheme("custom-status-only", { spinnerFrames: { status: statusFrames } });
|
||||
|
||||
const theme = await getThemeByName("custom-status-only");
|
||||
expect(theme).toBeDefined();
|
||||
expect(theme!.getSpinnerFrames("status")).toEqual(statusFrames);
|
||||
expect(theme!.getSpinnerFrames("activity")).toEqual(presetActivity);
|
||||
});
|
||||
|
||||
it("rejects empty arrays and empty objects at validation time", async () => {
|
||||
await writeCustomTheme("custom-empty-array", { spinnerFrames: [] });
|
||||
await expect(getThemeByName("custom-empty-array")).resolves.toBeUndefined();
|
||||
|
||||
await writeCustomTheme("custom-empty-object", { spinnerFrames: {} });
|
||||
await expect(getThemeByName("custom-empty-object")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls through to preset frames when `spinnerFrames` is absent", async () => {
|
||||
// `dark` ships with `symbols.preset: "unicode"`; we only assert that the
|
||||
// default status frames match the preset table when no override is set.
|
||||
await writeCustomTheme("custom-no-override", {});
|
||||
|
||||
const theme = await getThemeByName("custom-no-override");
|
||||
expect(theme).toBeDefined();
|
||||
const status = theme!.getSpinnerFrames("status");
|
||||
expect(status.length).toBeGreaterThan(1);
|
||||
expect(status).not.toContain("A");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user