From 7b90cbc66243f24febfadc8066658161fdd027c0 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Tue, 14 Jul 2026 13:14:34 +0200 Subject: [PATCH 1/2] feat(tui): custom themes, colorblind palettes, and monochrome mode Generalize the theme system so users can define custom palettes in ~/.mayros/themes/*.json. Each file must contain the 22 palette color tokens as #RRGGBB hex strings; the loader validates, caches by mtime, and registers them so /theme and config.ui.theme accept custom names alongside the built-ins. Add three new built-in palettes: - colorblind-dark and colorblind-light: Okabe-Ito hues that avoid the red/green pairing for error/success (error = vermilion, success = sky blue), so deuteranopic and protanopic users get distinguishable semantic colors. - monochrome: all tokens use the same foreground, for NO_COLOR environments or users who prefer a minimal terminal. Generalize ThemePreset from a closed union to string (with BuiltinPreset for the 13 built-ins), add a custom palette registry, and relax the /theme handler and config guards to accept custom names. The loader runs at TUI startup before the config theme is resolved. --- src/tui/theme/palettes.test.ts | 7 +- src/tui/theme/palettes.ts | 138 ++++++++++++++++++++++++++- src/tui/theme/theme-loader.test.ts | 71 ++++++++++++++ src/tui/theme/theme-loader.ts | 147 +++++++++++++++++++++++++++++ src/tui/tui-command-handlers.ts | 17 ++-- src/tui/tui.ts | 15 ++- 6 files changed, 381 insertions(+), 14 deletions(-) create mode 100644 src/tui/theme/theme-loader.test.ts create mode 100644 src/tui/theme/theme-loader.ts diff --git a/src/tui/theme/palettes.test.ts b/src/tui/theme/palettes.test.ts index 61f74354..cb3d8728 100644 --- a/src/tui/theme/palettes.test.ts +++ b/src/tui/theme/palettes.test.ts @@ -27,8 +27,8 @@ const PALETTE_KEYS: (keyof Palette)[] = [ ]; describe("Theme Palettes", () => { - it("has 10 theme presets", () => { - expect(THEME_PRESETS).toHaveLength(10); + it("has the expected number of theme presets", () => { + expect(THEME_PRESETS).toHaveLength(13); }); it("includes all expected presets", () => { @@ -40,6 +40,9 @@ describe("Theme Palettes", () => { expect(THEME_PRESETS).toContain("solarized-light"); expect(THEME_PRESETS).toContain("atom-one-dark"); expect(THEME_PRESETS).toContain("ayu-dark"); + expect(THEME_PRESETS).toContain("colorblind-dark"); + expect(THEME_PRESETS).toContain("colorblind-light"); + expect(THEME_PRESETS).toContain("monochrome"); }); for (const preset of [ diff --git a/src/tui/theme/palettes.ts b/src/tui/theme/palettes.ts index 7cb0997d..e388739d 100644 --- a/src/tui/theme/palettes.ts +++ b/src/tui/theme/palettes.ts @@ -1,7 +1,11 @@ -export type ThemePreset = +/** Built-in theme presets shipped with the TUI. */ +export type BuiltinPreset = | "dark" | "light" | "high-contrast" + | "colorblind-dark" + | "colorblind-light" + | "monochrome" | "dracula" | "github-dark" | "github-light" @@ -10,6 +14,13 @@ export type ThemePreset = | "atom-one-dark" | "ayu-dark"; +/** + * A theme preset name. Either a built-in or a custom theme loaded from + * `~/.mayros/themes/*.json`. The string is resolved through the palette + * registry (builtin switch → custom registry → dark fallback). + */ +export type ThemePreset = string; + export type Palette = { text: string; dim: string; @@ -285,10 +296,99 @@ export const AYU_DARK_PALETTE: Palette = { success: "#AAD94C", }; -export const THEME_PRESETS: ThemePreset[] = [ +/** + * Colorblind-optimized dark palette using Okabe-Ito hues. + * Avoids red/green pairing for error/success — error uses vermilion + * (orange-red), success uses sky blue. Accent is amber. + */ +export const COLORBLIND_DARK_PALETTE: Palette = { + text: "#E8E3D5", + dim: "#7B7F87", + accent: "#E69F00", + accentSoft: "#F0E442", + border: "#3C414B", + userBg: "#2B2F36", + userText: "#F3EEE0", + systemText: "#9BA3B2", + toolPendingBg: "#1F2A2F", + toolSuccessBg: "#1E2D23", + toolErrorBg: "#2F1F1F", + toolTitle: "#E69F00", + toolOutput: "#E1DACB", + quote: "#56B4E9", + quoteBorder: "#3B4D6B", + code: "#F0E442", + codeBlock: "#1E232A", + codeBorder: "#343A45", + link: "#56B4E9", + filePath: "#56B4E9", + error: "#D55E00", + success: "#56B4E9", +}; + +/** Colorblind-optimized light palette (Okabe-Ito on light background). */ +export const COLORBLIND_LIGHT_PALETTE: Palette = { + text: "#2B2F36", + dim: "#6B7080", + accent: "#0072B2", + accentSoft: "#009E73", + border: "#D0D5DD", + userBg: "#F0F1F3", + userText: "#2B2F36", + systemText: "#6B7080", + toolPendingBg: "#EDF0F3", + toolSuccessBg: "#E6F4EA", + toolErrorBg: "#FCE8E6", + toolTitle: "#0072B2", + toolOutput: "#3C414B", + quote: "#0072B2", + quoteBorder: "#B8CCE0", + code: "#D55E00", + codeBlock: "#EDF0F3", + codeBorder: "#D0D5DD", + link: "#0072B2", + filePath: "#0072B2", + error: "#D55E00", + success: "#0072B2", +}; + +/** + * Monochrome palette — all tokens use the same foreground. Semantic + * differentiation is lost (no color), useful for `NO_COLOR` environments + * or users who prefer a minimal terminal. + */ +export const MONOCHROME_PALETTE: Palette = { + text: "#FFFFFF", + dim: "#AAAAAA", + accent: "#FFFFFF", + accentSoft: "#CCCCCC", + border: "#888888", + userBg: "#666666", + userText: "#FFFFFF", + systemText: "#CCCCCC", + toolPendingBg: "#444444", + toolSuccessBg: "#444444", + toolErrorBg: "#444444", + toolTitle: "#FFFFFF", + toolOutput: "#CCCCCC", + quote: "#CCCCCC", + quoteBorder: "#888888", + code: "#FFFFFF", + codeBlock: "#333333", + codeBorder: "#666666", + link: "#FFFFFF", + filePath: "#FFFFFF", + error: "#FFFFFF", + success: "#FFFFFF", +}; + +export const BUILTIN_THEME_PRESETS: BuiltinPreset[] = [ "dark", "light", "high-contrast", + "colorblind-dark", + "colorblind-light", + "monochrome", "dracula", "github-dark", "github-light", @@ -298,12 +398,41 @@ export const THEME_PRESETS: ThemePreset[] = [ "ayu-dark", ]; +/** Backward-compatible alias for the built-in preset list. */ +export const THEME_PRESETS: BuiltinPreset[] = BUILTIN_THEME_PRESETS; + +// --- Custom theme registry ------------------------------------------------- + +/** Registry of custom palettes loaded from `~/.mayros/themes/*.json`. */ +const customPaletteRegistry = new Map(); + +/** Register a custom palette under a name. */ +export function registerCustomPalette(name: string, palette: Palette): void { + customPaletteRegistry.set(name, palette); +} + +/** Clear all custom palettes (used by the theme loader before re-scanning). */ +export function clearCustomPalettes(): void { + customPaletteRegistry.clear(); +} + +/** List all currently registered custom theme names. */ +export function listCustomThemeNames(): string[] { + return [...customPaletteRegistry.keys()]; +} + export function resolvePalette(preset: ThemePreset): Palette { switch (preset) { case "light": return LIGHT_PALETTE; case "high-contrast": return HIGH_CONTRAST_PALETTE; + case "colorblind-dark": + return COLORBLIND_DARK_PALETTE; + case "colorblind-light": + return COLORBLIND_LIGHT_PALETTE; + case "monochrome": + return MONOCHROME_PALETTE; case "dracula": return DRACULA_PALETTE; case "github-dark": @@ -318,7 +447,10 @@ export function resolvePalette(preset: ThemePreset): Palette { return ATOM_ONE_DARK_PALETTE; case "ayu-dark": return AYU_DARK_PALETTE; - default: + default: { + const custom = customPaletteRegistry.get(preset); + if (custom) return custom; return DARK_PALETTE; + } } } diff --git a/src/tui/theme/theme-loader.test.ts b/src/tui/theme/theme-loader.test.ts new file mode 100644 index 00000000..4abe5db8 --- /dev/null +++ b/src/tui/theme/theme-loader.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { validatePalette } from "./theme-loader.js"; +import type { Palette } from "./palettes.js"; + +/** + * Theme loader unit tests. + * + * The discoverCustomThemes function hits the filesystem and is tested + * implicitly via integration; these tests cover the pure validation logic + * that gates which JSON files become registered palettes. + */ + +const VALID_PALETTE: Palette = { + text: "#E8E3D5", + dim: "#7B7F87", + accent: "#F6C453", + accentSoft: "#F2A65A", + border: "#3C414B", + userBg: "#2B2F36", + userText: "#F3EEE0", + systemText: "#9BA3B2", + toolPendingBg: "#1F2A2F", + toolSuccessBg: "#1E2D23", + toolErrorBg: "#2F1F1F", + toolTitle: "#F6C453", + toolOutput: "#E1DACB", + quote: "#8CC8FF", + quoteBorder: "#3B4D6B", + code: "#F0C987", + codeBlock: "#1E232A", + codeBorder: "#343A45", + link: "#7DD3A5", + filePath: "#87CEEB", + error: "#F97066", + success: "#7DD3A5", +}; + +describe("validatePalette", () => { + it("accepts a valid palette with all 22 hex tokens", () => { + expect(validatePalette(VALID_PALETTE)).not.toBeNull(); + }); + + it("rejects null", () => { + expect(validatePalette(null)).toBeNull(); + }); + + it("rejects a non-object", () => { + expect(validatePalette("not a palette")).toBeNull(); + }); + + it("rejects a palette missing a key", () => { + const incomplete = { ...VALID_PALETTE } as Partial; + delete incomplete.success; + expect(validatePalette(incomplete)).toBeNull(); + }); + + it("rejects a palette with a non-hex color", () => { + const bad = { ...VALID_PALETTE, accent: "red" }; + expect(validatePalette(bad)).toBeNull(); + }); + + it("rejects a palette with a 3-digit hex", () => { + const bad = { ...VALID_PALETTE, accent: "#F63" }; + expect(validatePalette(bad)).toBeNull(); + }); + + it("accepts lowercase hex", () => { + const lower = { ...VALID_PALETTE, accent: "#f6c453" }; + expect(validatePalette(lower)).not.toBeNull(); + }); +}); diff --git a/src/tui/theme/theme-loader.ts b/src/tui/theme/theme-loader.ts new file mode 100644 index 00000000..d4d6d546 --- /dev/null +++ b/src/tui/theme/theme-loader.ts @@ -0,0 +1,147 @@ +/** + * Custom theme loader. + * + * Discovers user-defined theme palettes from `~/.mayros/themes/*.json` and + * registers them so they can be selected via `/theme ` or configured + * as `config.ui.theme = ""`. Each file must contain a JSON object with + * the 22 palette color tokens (all `#RRGGBB` hex strings). + * + * The loader caches by file mtime and rescans on demand, mirroring the + * markdown-commands discovery pattern. + */ + +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "../../config/paths.js"; +import { + clearCustomPalettes, + listCustomThemeNames, + registerCustomPalette, + type Palette, +} from "./palettes.js"; + +const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/; + +const PALETTE_KEYS: ReadonlyArray = [ + "text", + "dim", + "accent", + "accentSoft", + "border", + "userBg", + "userText", + "systemText", + "toolPendingBg", + "toolSuccessBg", + "toolErrorBg", + "toolTitle", + "toolOutput", + "quote", + "quoteBorder", + "code", + "codeBlock", + "codeBorder", + "link", + "filePath", + "error", + "success", +]; + +/** Cache entry: the parsed palette + the file mtime that produced it. */ +type CacheEntry = { palette: Palette; mtimeMs: number }; + +const cache = new Map(); +let lastScanMtime = 0; + +/** Resolve the user themes directory (`~/.mayros/themes`). */ +export function resolveUserThemesDir(): string { + return path.join(resolveStateDir(), "themes"); +} + +/** Validate that a value is a valid palette object with all 22 hex tokens. */ +export function validatePalette(value: unknown): Palette | null { + if (typeof value !== "object" || value === null) return null; + const obj = value as Record; + const palette = {} as Palette; + for (const key of PALETTE_KEYS) { + const val = obj[key]; + if (typeof val !== "string" || !HEX_COLOR.test(val)) return null; + palette[key] = val; + } + return palette; +} + +/** + * Scan `~/.mayros/themes/*.json`, validate each file, and register valid + * palettes. Returns the list of names that were registered. Invalid files + * are silently skipped (best-effort, like other config discovery). + */ +export async function discoverCustomThemes(): Promise { + const dir = resolveUserThemesDir(); + let entries: import("node:fs").Dirent[]; + try { + const { readdir } = await import("node:fs/promises"); + entries = await readdir(dir, { withFileTypes: true }); + } catch { + // Directory doesn't exist or is unreadable — no custom themes. + clearCustomPalettes(); + cache.clear(); + return []; + } + + // Track the newest mtime to know if a rescan is needed. + let newestMtime = 0; + const validFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".json")); + + // Clear and re-register from scratch (simplest correct approach). + clearCustomPalettes(); + const registered: string[] = []; + + for (const entry of validFiles) { + const filePath = path.join(dir, entry.name); + const themeName = entry.name.slice(0, -5); // strip .json + + try { + const stats = await stat(filePath); + newestMtime = Math.max(newestMtime, stats.mtimeMs); + + // Use cache if the file hasn't changed. + const cached = cache.get(themeName); + if (cached && cached.mtimeMs === stats.mtimeMs) { + registerCustomPalette(themeName, cached.palette); + registered.push(themeName); + continue; + } + + const content = await readFile(filePath, "utf8"); + const parsed: unknown = JSON.parse(content); + const palette = validatePalette(parsed); + if (palette) { + cache.set(themeName, { palette, mtimeMs: stats.mtimeMs }); + registerCustomPalette(themeName, palette); + registered.push(themeName); + } + } catch { + // Invalid JSON, unreadable file, or validation failure — skip. + } + } + + // Clean stale cache entries for files that no longer exist. + const validNames = new Set(registered); + for (const name of cache.keys()) { + if (!validNames.has(name)) cache.delete(name); + } + + lastScanMtime = newestMtime; + return registered; +} + +/** List all theme names available: builtins + custom. */ +export function allThemeNames(builtins: readonly string[]): string[] { + return [...builtins, ...listCustomThemeNames()]; +} + +/** Check if a rescan is needed by comparing directory mtime. */ +export function needsRescan(): boolean { + return lastScanMtime === 0; +} diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index aa83d21c..dad08899 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -27,8 +27,8 @@ import { SessionManager, formatSessionLine } from "./session-manager.js"; import { theme } from "./theme/theme.js"; import { applyOutputStyle, isValidOutputStyle, OUTPUT_STYLE_NAMES } from "./output-styles.js"; import type { OutputStyle } from "./output-styles.js"; -import { THEME_PRESETS } from "./theme/palettes.js"; -import type { ThemePreset } from "./theme/palettes.js"; +import { THEME_PRESETS, listCustomThemeNames } from "./theme/palettes.js"; +import type { BuiltinPreset } from "./theme/palettes.js"; import { setThemePreset, getThemePreset } from "./theme/theme.js"; import type { ChatLog } from "./components/chat-log.js"; import { @@ -735,7 +735,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { })); const themeSelector = createSelectList(themeItems, themeItems.length); themeSelector.onSelect = (item) => { - setThemePreset(item.value as ThemePreset); + setThemePreset(item.value); chatLog.addSystem(`theme set to ${item.value}`); closeOverlay(); tui.requestRender(); @@ -748,11 +748,16 @@ export function createCommandHandlers(context: CommandHandlerContext) { tui.requestRender(); break; } - if (!THEME_PRESETS.includes(preset as ThemePreset)) { - chatLog.addSystem(`unknown theme. usage: /theme <${THEME_PRESETS.join("|")}>`); + if ( + !THEME_PRESETS.includes(preset as BuiltinPreset) && + !listCustomThemeNames().includes(preset) + ) { + chatLog.addSystem( + `unknown theme. usage: /theme <${THEME_PRESETS.join("|")}> or a custom theme name`, + ); break; } - setThemePreset(preset as ThemePreset); + setThemePreset(preset); chatLog.addSystem(`theme set to ${preset}`); break; } diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 88438ac2..031f2b86 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -28,8 +28,9 @@ import { WelcomeScreen } from "./components/welcome-screen.js"; import { GatewayChatClient } from "./gateway-chat.js"; import { enableKeyboardProtocol } from "./term-capabilities.js"; import { detectTerminalColorScheme } from "./term-capabilities.js"; -import type { ThemePreset } from "./theme/palettes.js"; +import type { BuiltinPreset } from "./theme/palettes.js"; import { THEME_PRESETS } from "./theme/palettes.js"; +import { discoverCustomThemes } from "./theme/theme-loader.js"; import { editorTheme, theme, setThemePreset } from "./theme/theme.js"; import { createCommandHandlers } from "./tui-command-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js"; @@ -281,12 +282,20 @@ export async function runTui(opts: TuiOptions) { return; } + // Discover custom themes from ~/.mayros/themes/*.json before resolving the + // config theme, so a custom theme name in config.ui.theme works on startup. + await discoverCustomThemes(); + const configTheme = config.ui?.theme; // "auto" (or unset) defers to terminal color-scheme detection after the TUI // instance is created; until then the default "dark" preset is in place. const themeIsAuto = !configTheme || configTheme === "auto"; - if (!themeIsAuto && THEME_PRESETS.includes(configTheme as ThemePreset)) { - setThemePreset(configTheme as ThemePreset); + if (!themeIsAuto && THEME_PRESETS.includes(configTheme as BuiltinPreset)) { + setThemePreset(configTheme); + } else if (!themeIsAuto) { + // Could be a custom theme name — set it; resolvePalette falls back to + // dark if the name isn't registered yet. + setThemePreset(configTheme); } const keybindingsConfig = config.ui?.keybindings; applyKeybindingsFromConfig(keybindingsConfig); From ab7cfbd39f9ef661fcf5532cced2b637c510d32a Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Tue, 14 Jul 2026 13:18:40 +0200 Subject: [PATCH 2/2] chore(tui): dedupe theme presets and migrate stderr to subsystem logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the duplicated THEME_PRESETS array from commands.ts (it was a local copy that had already drifted — missing the three new palettes added in the theme system update). Import the canonical list from palettes.ts instead so there is a single source of truth. Migrate the three process.stderr.write call sites in tui-command-handlers.ts (fast-mode) and compact-handler.ts to the shared subsystem logger (createSubsystemLogger("tui")), so TUI diagnostics route through the same logging infrastructure as the rest of the codebase instead of writing raw stderr. --- src/tui/commands.ts | 13 +------------ src/tui/compact-handler.ts | 10 +++++++--- src/tui/tui-command-handlers.ts | 12 ++++++++---- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/tui/commands.ts b/src/tui/commands.ts index e10f4273..42b71b19 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -3,24 +3,13 @@ import { listChatCommands, listChatCommandsForConfig } from "../auto-reply/comma import { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thinking.js"; import { discoverMarkdownCommands } from "../commands/markdown-commands.js"; import type { MayrosConfig } from "../config/types.js"; +import { THEME_PRESETS } from "./theme/palettes.js"; const VERBOSE_LEVELS = ["on", "off"]; const REASONING_LEVELS = ["on", "off"]; const ELEVATED_LEVELS = ["on", "off", "ask", "full"]; const ACTIVATION_LEVELS = ["mention", "always"]; const USAGE_FOOTER_LEVELS = ["off", "tokens", "full"]; -const THEME_PRESETS = [ - "dark", - "light", - "high-contrast", - "dracula", - "github-dark", - "github-light", - "solarized-dark", - "solarized-light", - "atom-one-dark", - "ayu-dark", -]; const OUTPUT_STYLES = ["standard", "explanatory", "learning"]; const PERMISSION_MODES = ["auto", "ask", "deny"]; diff --git a/src/tui/compact-handler.ts b/src/tui/compact-handler.ts index f3a2ec89..83c561e4 100644 --- a/src/tui/compact-handler.ts +++ b/src/tui/compact-handler.ts @@ -5,6 +5,10 @@ * then creates a summary replacing the history. */ +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const logger = createSubsystemLogger("tui"); + export type CompactOptions = { messages: Array<{ role: string; content: string }>; sessionKey: string; @@ -184,9 +188,9 @@ export async function compactMessages(options: CompactOptions): Promise