Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 1 addition & 12 deletions src/tui/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand Down
10 changes: 7 additions & 3 deletions src/tui/compact-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -184,9 +188,9 @@ export async function compactMessages(options: CompactOptions): Promise<CompactR
await onKnowledgeExtracted(items);
} catch (err) {
// Best-effort: log but do not propagate so callers always get a result.
process.stderr.write(
`[compact-handler] onKnowledgeExtracted callback failed: ${String(err)}\n`,
);
logger.warn("compact-handler: onKnowledgeExtracted callback failed", {
error: String(err),
});
}
}

Expand Down
7 changes: 5 additions & 2 deletions src/tui/theme/palettes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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 [
Expand Down
138 changes: 135 additions & 3 deletions src/tui/theme/palettes.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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;
Expand Down Expand Up @@ -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",
Expand All @@ -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<string, Palette>();

/** 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":
Expand All @@ -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;
}
}
}
71 changes: 71 additions & 0 deletions src/tui/theme/theme-loader.test.ts
Original file line number Diff line number Diff line change
@@ -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<Palette>;
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();
});
});
Loading
Loading