From 2eab0644d7290d59ce0a264656fcd3dc3ffba27c Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 30 May 2026 20:59:35 +0000 Subject: [PATCH] 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 --- docs/theme.md | 1 + packages/coding-agent/CHANGELOG.md | 4 + .../src/modes/theme/theme-schema.json | 31 ++++++- .../coding-agent/src/modes/theme/theme.ts | 41 ++++++++- .../test/theme-spinner-frames.test.ts | 90 +++++++++++++++++++ 5 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/test/theme-spinner-frames.test.ts diff --git a/docs/theme.md b/docs/theme.md index a7129059e..738ab3b5b 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -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: diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f5ed99c4..723fdd95e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/theme/theme-schema.json b/packages/coding-agent/src/modes/theme/theme-schema.json index d0cfa0b34..fecdd3668 100644 --- a/packages/coding-agent/src/modes/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/theme/theme-schema.json @@ -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 } }, diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index ebf02308b..0c1b2d182 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -807,6 +807,25 @@ const SPINNER_FRAMES: Record> = { }, }; +/** + * 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> { + if (value === undefined) return {}; + if (Array.isArray(value)) return { status: value, activity: value }; + const result: Partial> = {}; + 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; #bgColors: Record; #symbols: SymbolMap; + #spinnerFramesOverrides: Partial>; constructor( fgColors: Record, @@ -1245,6 +1279,7 @@ export class Theme { private readonly mode: ColorMode, private readonly symbolPreset: SymbolPreset, symbolOverrides: Partial>, + spinnerFramesOverrides: Partial> = {}, ) { this.#fgColors = {} as Record; 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 { diff --git a/packages/coding-agent/test/theme-spinner-frames.test.ts b/packages/coding-agent/test/theme-spinner-frames.test.ts new file mode 100644 index 000000000..32f97f8b8 --- /dev/null +++ b/packages/coding-agent/test/theme-spinner-frames.test.ts @@ -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): Promise { + const dark = (await Bun.file(DARK_THEME_PATH).json()) as Record; + const base = (dark.symbols ?? {}) as Record; + 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"); + }); +});