diff --git a/AGENTS.md b/AGENTS.md index 4dc7c614..c6e36eaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,4 +10,5 @@ - Always run `npm run check` in the project's root directory after making code changes. - You must NEVER run `npm run dev` yourself. Doing is means you failed the user hard. - Do NOT commit unless asked to by the user -- Keep you answers short and concise and to the point. \ No newline at end of file +- Keep you answers short and concise and to the point. +- Do NOT use inline imports ala `await import("./theme/theme.js");` \ No newline at end of file diff --git a/packages/coding-agent/docs/theme.md b/packages/coding-agent/docs/theme.md index b0db97a3..d9b9c470 100644 --- a/packages/coding-agent/docs/theme.md +++ b/packages/coding-agent/docs/theme.md @@ -79,7 +79,7 @@ Themes are defined in JSON files with the following structure: ```json { - "$schema": "https://pi.mariozechner.at/theme-schema.json", + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json", "name": "my-theme", "vars": { "blue": "#0066cc", @@ -194,7 +194,7 @@ Custom themes are loaded from `~/.pi/agent/themes/*.json`. 3. **Define all colors:** ```json { - "$schema": "https://pi.mariozechner.at/theme-schema.json", + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json", "name": "my-theme", "vars": { "primary": "#00aaff", @@ -370,13 +370,13 @@ Error loading theme 'my-theme': For editor support, the JSON schema is available at: ``` -https://pi.mariozechner.at/theme-schema.json +https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json ``` Add to your theme file for auto-completion and validation: ```json { - "$schema": "https://pi.mariozechner.at/theme-schema.json", + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json", ... } ``` diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 0aeb57f8..6fc3e6c5 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -14,7 +14,8 @@ ], "scripts": { "clean": "rm -rf dist", - "build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js", + "build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && npm run copy-theme-assets", + "copy-theme-assets": "cp src/theme/*.json dist/theme/", "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", "check": "tsgo --noEmit", "test": "vitest --run", diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 67324c4e..61587b7e 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -10,6 +10,7 @@ import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js" import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js"; import { SessionManager } from "./session-manager.js"; import { SettingsManager } from "./settings-manager.js"; +import { initTheme } from "./theme/theme.js"; import { codingTools } from "./tools/index.js"; import { SessionSelectorComponent } from "./tui/session-selector.js"; import { TuiRenderer } from "./tui/tui-renderer.js"; @@ -563,6 +564,11 @@ export async function main(args: string[]) { return; } + // Initialize theme (before any TUI rendering) + const settingsManager = new SettingsManager(); + const themeName = settingsManager.getTheme(); + initTheme(themeName); + // Setup session manager const sessionManager = new SessionManager(parsed.continue && !parsed.resume, parsed.session); @@ -582,9 +588,6 @@ export async function main(args: string[]) { sessionManager.setSessionFile(selectedSession); } - // Settings manager - const settingsManager = new SettingsManager(); - // Determine initial model using priority system: // 1. CLI args (--provider and --model) // 2. Restored from session (if --continue or --resume) diff --git a/packages/coding-agent/src/settings-manager.ts b/packages/coding-agent/src/settings-manager.ts index 9f83b612..bc83640e 100644 --- a/packages/coding-agent/src/settings-manager.ts +++ b/packages/coding-agent/src/settings-manager.ts @@ -7,6 +7,7 @@ export interface Settings { defaultProvider?: string; defaultModel?: string; queueMode?: "all" | "one-at-a-time"; + theme?: string; } export class SettingsManager { @@ -88,4 +89,13 @@ export class SettingsManager { this.settings.queueMode = mode; this.save(); } + + getTheme(): string | undefined { + return this.settings.theme; + } + + setTheme(theme: string): void { + this.settings.theme = theme; + this.save(); + } } diff --git a/packages/coding-agent/src/theme/dark.json b/packages/coding-agent/src/theme/dark.json new file mode 100644 index 00000000..716bd1f8 --- /dev/null +++ b/packages/coding-agent/src/theme/dark.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json", + "name": "dark", + "vars": { + "cyan": "#00d7ff", + "blue": "#0087ff", + "green": "#00ff00", + "red": "#ff0000", + "yellow": "#ffff00", + "gray": 242, + "darkGray": 238, + "userMsgBg": "#343541", + "toolPendingBg": "#282832", + "toolSuccessBg": "#283228", + "toolErrorBg": "#3c2828" + }, + "colors": { + "accent": "cyan", + "border": "blue", + "borderAccent": "cyan", + "borderMuted": "darkGray", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "gray", + "text": "", + + "userMessageBg": "userMsgBg", + "userMessageText": "", + "toolPendingBg": "toolPendingBg", + "toolSuccessBg": "toolSuccessBg", + "toolErrorBg": "toolErrorBg", + "toolText": "", + + "mdHeading": "cyan", + "mdLink": "blue", + "mdCode": "cyan", + "mdCodeBlock": "", + "mdCodeBlockBorder": "gray", + "mdQuote": "gray", + "mdQuoteBorder": "gray", + "mdHr": "gray", + "mdListBullet": "cyan", + + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "gray", + + "syntaxComment": "gray", + "syntaxKeyword": "cyan", + "syntaxFunction": "blue", + "syntaxVariable": "", + "syntaxString": "green", + "syntaxNumber": "yellow", + "syntaxType": "cyan", + "syntaxOperator": "", + "syntaxPunctuation": "gray" + } +} diff --git a/packages/coding-agent/src/theme/light.json b/packages/coding-agent/src/theme/light.json new file mode 100644 index 00000000..321e3921 --- /dev/null +++ b/packages/coding-agent/src/theme/light.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json", + "name": "light", + "vars": { + "darkCyan": "#008899", + "darkBlue": "#0066cc", + "darkGreen": "#008800", + "darkRed": "#cc0000", + "darkYellow": "#aa8800", + "mediumGray": 242, + "lightGray": 250, + "userMsgBg": "#e8e8e8", + "toolPendingBg": "#e8e8f0", + "toolSuccessBg": "#e8f0e8", + "toolErrorBg": "#f0e8e8" + }, + "colors": { + "accent": "darkCyan", + "border": "darkBlue", + "borderAccent": "darkCyan", + "borderMuted": "lightGray", + "success": "darkGreen", + "error": "darkRed", + "warning": "darkYellow", + "muted": "mediumGray", + "text": "", + + "userMessageBg": "userMsgBg", + "userMessageText": "", + "toolPendingBg": "toolPendingBg", + "toolSuccessBg": "toolSuccessBg", + "toolErrorBg": "toolErrorBg", + "toolText": "", + + "mdHeading": "darkCyan", + "mdLink": "darkBlue", + "mdCode": "darkCyan", + "mdCodeBlock": "", + "mdCodeBlockBorder": "mediumGray", + "mdQuote": "mediumGray", + "mdQuoteBorder": "mediumGray", + "mdHr": "mediumGray", + "mdListBullet": "darkCyan", + + "toolDiffAdded": "darkGreen", + "toolDiffRemoved": "darkRed", + "toolDiffContext": "mediumGray", + + "syntaxComment": "mediumGray", + "syntaxKeyword": "darkCyan", + "syntaxFunction": "darkBlue", + "syntaxVariable": "", + "syntaxString": "darkGreen", + "syntaxNumber": "darkYellow", + "syntaxType": "darkCyan", + "syntaxOperator": "", + "syntaxPunctuation": "mediumGray" + } +} diff --git a/packages/coding-agent/src/theme/theme-schema.json b/packages/coding-agent/src/theme/theme-schema.json new file mode 100644 index 00000000..d9cc47b2 --- /dev/null +++ b/packages/coding-agent/src/theme/theme-schema.json @@ -0,0 +1,241 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Pi Coding Agent Theme", + "description": "Theme schema for Pi coding agent", + "type": "object", + "required": ["name", "colors"], + "properties": { + "$schema": { + "type": "string", + "description": "JSON schema reference" + }, + "name": { + "type": "string", + "description": "Theme name" + }, + "vars": { + "type": "object", + "description": "Reusable color variables", + "additionalProperties": { + "oneOf": [ + { + "type": "string", + "description": "Hex color (#RRGGBB), variable reference, or empty string for terminal default" + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "256-color palette index (0-255)" + } + ] + } + }, + "colors": { + "type": "object", + "description": "Theme color definitions (all required)", + "required": [ + "accent", + "border", + "borderAccent", + "borderMuted", + "success", + "error", + "warning", + "muted", + "text", + "userMessageBg", + "userMessageText", + "toolPendingBg", + "toolSuccessBg", + "toolErrorBg", + "toolText", + "mdHeading", + "mdLink", + "mdCode", + "mdCodeBlock", + "mdCodeBlockBorder", + "mdQuote", + "mdQuoteBorder", + "mdHr", + "mdListBullet", + "toolDiffAdded", + "toolDiffRemoved", + "toolDiffContext", + "syntaxComment", + "syntaxKeyword", + "syntaxFunction", + "syntaxVariable", + "syntaxString", + "syntaxNumber", + "syntaxType", + "syntaxOperator", + "syntaxPunctuation" + ], + "properties": { + "accent": { + "$ref": "#/$defs/colorValue", + "description": "Primary accent color (logo, selected items, cursor)" + }, + "border": { + "$ref": "#/$defs/colorValue", + "description": "Normal borders" + }, + "borderAccent": { + "$ref": "#/$defs/colorValue", + "description": "Highlighted borders" + }, + "borderMuted": { + "$ref": "#/$defs/colorValue", + "description": "Subtle borders" + }, + "success": { + "$ref": "#/$defs/colorValue", + "description": "Success states" + }, + "error": { + "$ref": "#/$defs/colorValue", + "description": "Error states" + }, + "warning": { + "$ref": "#/$defs/colorValue", + "description": "Warning states" + }, + "muted": { + "$ref": "#/$defs/colorValue", + "description": "Secondary/dimmed text" + }, + "text": { + "$ref": "#/$defs/colorValue", + "description": "Default text color (usually empty string)" + }, + "userMessageBg": { + "$ref": "#/$defs/colorValue", + "description": "User message background" + }, + "userMessageText": { + "$ref": "#/$defs/colorValue", + "description": "User message text color" + }, + "toolPendingBg": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box (pending state)" + }, + "toolSuccessBg": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box (success state)" + }, + "toolErrorBg": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box (error state)" + }, + "toolText": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box text color" + }, + "mdHeading": { + "$ref": "#/$defs/colorValue", + "description": "Markdown heading text" + }, + "mdLink": { + "$ref": "#/$defs/colorValue", + "description": "Markdown link text" + }, + "mdCode": { + "$ref": "#/$defs/colorValue", + "description": "Markdown inline code" + }, + "mdCodeBlock": { + "$ref": "#/$defs/colorValue", + "description": "Markdown code block content" + }, + "mdCodeBlockBorder": { + "$ref": "#/$defs/colorValue", + "description": "Markdown code block fences" + }, + "mdQuote": { + "$ref": "#/$defs/colorValue", + "description": "Markdown blockquote text" + }, + "mdQuoteBorder": { + "$ref": "#/$defs/colorValue", + "description": "Markdown blockquote border" + }, + "mdHr": { + "$ref": "#/$defs/colorValue", + "description": "Markdown horizontal rule" + }, + "mdListBullet": { + "$ref": "#/$defs/colorValue", + "description": "Markdown list bullets/numbers" + }, + "toolDiffAdded": { + "$ref": "#/$defs/colorValue", + "description": "Added lines in tool diffs" + }, + "toolDiffRemoved": { + "$ref": "#/$defs/colorValue", + "description": "Removed lines in tool diffs" + }, + "toolDiffContext": { + "$ref": "#/$defs/colorValue", + "description": "Context lines in tool diffs" + }, + "syntaxComment": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: comments" + }, + "syntaxKeyword": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: keywords" + }, + "syntaxFunction": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: function names" + }, + "syntaxVariable": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: variable names" + }, + "syntaxString": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: string literals" + }, + "syntaxNumber": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: number literals" + }, + "syntaxType": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: type names" + }, + "syntaxOperator": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: operators" + }, + "syntaxPunctuation": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: punctuation" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$defs": { + "colorValue": { + "oneOf": [ + { + "type": "string", + "description": "Hex color (#RRGGBB), variable reference, or empty string for terminal default" + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "256-color palette index (0-255)" + } + ] + } + } +} diff --git a/packages/coding-agent/src/theme/theme.ts b/packages/coding-agent/src/theme/theme.ts new file mode 100644 index 00000000..9c9bfb02 --- /dev/null +++ b/packages/coding-agent/src/theme/theme.ts @@ -0,0 +1,414 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { MarkdownTheme } from "@mariozechner/pi-tui"; +import { type Static, Type } from "@sinclair/typebox"; +import { TypeCompiler } from "@sinclair/typebox/compiler"; +import chalk from "chalk"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ============================================================================ +// Types & Schema +// ============================================================================ + +const ColorValueSchema = Type.Union([ + Type.String(), // hex "#ff0000", var ref "primary", or empty "" + Type.Integer({ minimum: 0, maximum: 255 }), // 256-color index +]); + +type ColorValue = Static; + +const ThemeJsonSchema = Type.Object({ + $schema: Type.Optional(Type.String()), + name: Type.String(), + vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)), + colors: Type.Object({ + // Core UI (9 colors) + accent: ColorValueSchema, + border: ColorValueSchema, + borderAccent: ColorValueSchema, + borderMuted: ColorValueSchema, + success: ColorValueSchema, + error: ColorValueSchema, + warning: ColorValueSchema, + muted: ColorValueSchema, + text: ColorValueSchema, + // Backgrounds & Content Text (6 colors) + userMessageBg: ColorValueSchema, + userMessageText: ColorValueSchema, + toolPendingBg: ColorValueSchema, + toolSuccessBg: ColorValueSchema, + toolErrorBg: ColorValueSchema, + toolText: ColorValueSchema, + // Markdown (9 colors) + mdHeading: ColorValueSchema, + mdLink: ColorValueSchema, + mdCode: ColorValueSchema, + mdCodeBlock: ColorValueSchema, + mdCodeBlockBorder: ColorValueSchema, + mdQuote: ColorValueSchema, + mdQuoteBorder: ColorValueSchema, + mdHr: ColorValueSchema, + mdListBullet: ColorValueSchema, + // Tool Diffs (3 colors) + toolDiffAdded: ColorValueSchema, + toolDiffRemoved: ColorValueSchema, + toolDiffContext: ColorValueSchema, + // Syntax Highlighting (9 colors) + syntaxComment: ColorValueSchema, + syntaxKeyword: ColorValueSchema, + syntaxFunction: ColorValueSchema, + syntaxVariable: ColorValueSchema, + syntaxString: ColorValueSchema, + syntaxNumber: ColorValueSchema, + syntaxType: ColorValueSchema, + syntaxOperator: ColorValueSchema, + syntaxPunctuation: ColorValueSchema, + }), +}); + +type ThemeJson = Static; + +const validateThemeJson = TypeCompiler.Compile(ThemeJsonSchema); + +export type ThemeColor = + | "accent" + | "border" + | "borderAccent" + | "borderMuted" + | "success" + | "error" + | "warning" + | "muted" + | "text" + | "userMessageText" + | "toolText" + | "mdHeading" + | "mdLink" + | "mdCode" + | "mdCodeBlock" + | "mdCodeBlockBorder" + | "mdQuote" + | "mdQuoteBorder" + | "mdHr" + | "mdListBullet" + | "toolDiffAdded" + | "toolDiffRemoved" + | "toolDiffContext" + | "syntaxComment" + | "syntaxKeyword" + | "syntaxFunction" + | "syntaxVariable" + | "syntaxString" + | "syntaxNumber" + | "syntaxType" + | "syntaxOperator" + | "syntaxPunctuation"; + +export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg"; + +type ColorMode = "truecolor" | "256color"; + +// ============================================================================ +// Color Utilities +// ============================================================================ + +function detectColorMode(): ColorMode { + const colorterm = process.env.COLORTERM; + if (colorterm === "truecolor" || colorterm === "24bit") { + return "truecolor"; + } + const term = process.env.TERM || ""; + if (term.includes("256color")) { + return "256color"; + } + return "256color"; +} + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const cleaned = hex.replace("#", ""); + if (cleaned.length !== 6) { + throw new Error(`Invalid hex color: ${hex}`); + } + const r = parseInt(cleaned.substring(0, 2), 16); + const g = parseInt(cleaned.substring(2, 4), 16); + const b = parseInt(cleaned.substring(4, 6), 16); + if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) { + throw new Error(`Invalid hex color: ${hex}`); + } + return { r, g, b }; +} + +function rgbTo256(r: number, g: number, b: number): number { + const rIndex = Math.round((r / 255) * 5); + const gIndex = Math.round((g / 255) * 5); + const bIndex = Math.round((b / 255) * 5); + return 16 + 36 * rIndex + 6 * gIndex + bIndex; +} + +function hexTo256(hex: string): number { + const { r, g, b } = hexToRgb(hex); + return rgbTo256(r, g, b); +} + +function fgAnsi(color: string | number, mode: ColorMode): string { + if (color === "") return "\x1b[39m"; + if (typeof color === "number") return `\x1b[38;5;${color}m`; + if (color.startsWith("#")) { + if (mode === "truecolor") { + const { r, g, b } = hexToRgb(color); + return `\x1b[38;2;${r};${g};${b}m`; + } else { + const index = hexTo256(color); + return `\x1b[38;5;${index}m`; + } + } + throw new Error(`Invalid color value: ${color}`); +} + +function bgAnsi(color: string | number, mode: ColorMode): string { + if (color === "") return "\x1b[49m"; + if (typeof color === "number") return `\x1b[48;5;${color}m`; + if (color.startsWith("#")) { + if (mode === "truecolor") { + const { r, g, b } = hexToRgb(color); + return `\x1b[48;2;${r};${g};${b}m`; + } else { + const index = hexTo256(color); + return `\x1b[48;5;${index}m`; + } + } + throw new Error(`Invalid color value: ${color}`); +} + +function resolveVarRefs( + value: ColorValue, + vars: Record, + visited = new Set(), +): string | number { + if (typeof value === "number" || value === "" || value.startsWith("#")) { + return value; + } + if (visited.has(value)) { + throw new Error(`Circular variable reference detected: ${value}`); + } + if (!(value in vars)) { + throw new Error(`Variable reference not found: ${value}`); + } + visited.add(value); + return resolveVarRefs(vars[value], vars, visited); +} + +function resolveThemeColors>( + colors: T, + vars: Record = {}, +): Record { + const resolved: Record = {}; + for (const [key, value] of Object.entries(colors)) { + resolved[key] = resolveVarRefs(value, vars); + } + return resolved as Record; +} + +// ============================================================================ +// Theme Class +// ============================================================================ + +const RESET = "\x1b[0m"; + +export class Theme { + private fgColors: Map; + private bgColors: Map; + private mode: ColorMode; + + constructor( + fgColors: Record, + bgColors: Record, + mode: ColorMode, + ) { + this.mode = mode; + this.fgColors = new Map(); + for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) { + this.fgColors.set(key, fgAnsi(value, mode)); + } + this.bgColors = new Map(); + for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) { + this.bgColors.set(key, bgAnsi(value, mode)); + } + } + + fg(color: ThemeColor, text: string): string { + const ansi = this.fgColors.get(color); + if (!ansi) throw new Error(`Unknown theme color: ${color}`); + return `${ansi}${text}${RESET}`; + } + + bg(color: ThemeBg, text: string): string { + const ansi = this.bgColors.get(color); + if (!ansi) throw new Error(`Unknown theme background color: ${color}`); + return `${ansi}${text}${RESET}`; + } + + bold(text: string): string { + return chalk.bold(text); + } + + italic(text: string): string { + return chalk.italic(text); + } + + underline(text: string): string { + return chalk.underline(text); + } + + getFgAnsi(color: ThemeColor): string { + const ansi = this.fgColors.get(color); + if (!ansi) throw new Error(`Unknown theme color: ${color}`); + return ansi; + } + + getBgAnsi(color: ThemeBg): string { + const ansi = this.bgColors.get(color); + if (!ansi) throw new Error(`Unknown theme background color: ${color}`); + return ansi; + } + + getColorMode(): ColorMode { + return this.mode; + } +} + +// ============================================================================ +// Theme Loading +// ============================================================================ + +let BUILTIN_THEMES: Record | undefined; + +function getBuiltinThemes(): Record { + if (!BUILTIN_THEMES) { + const darkPath = path.join(__dirname, "dark.json"); + const lightPath = path.join(__dirname, "light.json"); + BUILTIN_THEMES = { + dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson, + light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson, + }; + } + return BUILTIN_THEMES; +} + +function getThemesDir(): string { + return path.join(os.homedir(), ".pi", "agent", "themes"); +} + +export function getAvailableThemes(): string[] { + const themes = new Set(Object.keys(getBuiltinThemes())); + const themesDir = getThemesDir(); + if (fs.existsSync(themesDir)) { + const files = fs.readdirSync(themesDir); + for (const file of files) { + if (file.endsWith(".json")) { + themes.add(file.slice(0, -5)); + } + } + } + return Array.from(themes).sort(); +} + +function loadThemeJson(name: string): ThemeJson { + const builtinThemes = getBuiltinThemes(); + if (name in builtinThemes) { + return builtinThemes[name]; + } + const themesDir = getThemesDir(); + const themePath = path.join(themesDir, `${name}.json`); + if (!fs.existsSync(themePath)) { + throw new Error(`Theme not found: ${name}`); + } + const content = fs.readFileSync(themePath, "utf-8"); + let json: unknown; + try { + json = JSON.parse(content); + } catch (error) { + throw new Error(`Failed to parse theme ${name}: ${error}`); + } + if (!validateThemeJson.Check(json)) { + const errors = Array.from(validateThemeJson.Errors(json)); + const errorMessages = errors.map((e) => ` - ${e.path}: ${e.message}`).join("\n"); + throw new Error(`Invalid theme ${name}:\n${errorMessages}`); + } + return json as ThemeJson; +} + +function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme { + const colorMode = mode ?? detectColorMode(); + const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars); + const fgColors: Record = {} as Record; + const bgColors: Record = {} as Record; + const bgColorKeys: Set = new Set(["userMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg"]); + for (const [key, value] of Object.entries(resolvedColors)) { + if (bgColorKeys.has(key)) { + bgColors[key as ThemeBg] = value; + } else { + fgColors[key as ThemeColor] = value; + } + } + return new Theme(fgColors, bgColors, colorMode); +} + +function loadTheme(name: string, mode?: ColorMode): Theme { + const themeJson = loadThemeJson(name); + return createTheme(themeJson, mode); +} + +function detectTerminalBackground(): "dark" | "light" { + const colorfgbg = process.env.COLORFGBG || ""; + if (colorfgbg) { + const parts = colorfgbg.split(";"); + if (parts.length >= 2) { + const bg = parseInt(parts[1], 10); + if (!Number.isNaN(bg)) { + return bg < 8 ? "dark" : "light"; + } + } + } + return "dark"; +} + +function getDefaultTheme(): string { + return detectTerminalBackground(); +} + +// ============================================================================ +// Global Theme Instance +// ============================================================================ + +export let theme: Theme; + +export function initTheme(themeName?: string): void { + const name = themeName ?? getDefaultTheme(); + theme = loadTheme(name); +} + +export function setTheme(name: string): void { + theme = loadTheme(name); +} + +// ============================================================================ +// TUI Helpers +// ============================================================================ + +export function getMarkdownTheme(): MarkdownTheme { + return { + heading: (text: string) => theme.fg("mdHeading", text), + link: (text: string) => theme.fg("mdLink", text), + code: (text: string) => theme.fg("mdCode", text), + codeBlock: (text: string) => theme.fg("mdCodeBlock", text), + codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text), + quote: (text: string) => theme.fg("mdQuote", text), + quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text), + hr: (text: string) => theme.fg("mdHr", text), + listBullet: (text: string) => theme.fg("mdListBullet", text), + }; +} diff --git a/packages/coding-agent/src/tui/theme-selector.ts b/packages/coding-agent/src/tui/theme-selector.ts new file mode 100644 index 00000000..862e55f0 --- /dev/null +++ b/packages/coding-agent/src/tui/theme-selector.ts @@ -0,0 +1,51 @@ +import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; +import { getAvailableThemes, theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +/** + * Component that renders a theme selector + */ +export class ThemeSelectorComponent extends Container { + private selectList: SelectList; + + constructor(currentTheme: string, onSelect: (themeName: string) => void, onCancel: () => void) { + super(); + + // Get available themes and create select items + const themes = getAvailableThemes(); + const themeItems: SelectItem[] = themes.map((name) => ({ + value: name, + label: name, + description: name === currentTheme ? "(current)" : undefined, + })); + + // Add top border + this.addChild(new DynamicBorder((text) => theme.fg("border", text))); + + // Create selector + this.selectList = new SelectList(themeItems, 10); + + // Preselect current theme + const currentIndex = themes.indexOf(currentTheme); + if (currentIndex !== -1) { + this.selectList.setSelectedIndex(currentIndex); + } + + this.selectList.onSelect = (item) => { + onSelect(item.value); + }; + + this.selectList.onCancel = () => { + onCancel(); + }; + + this.addChild(this.selectList); + + // Add bottom border + this.addChild(new DynamicBorder((text) => theme.fg("border", text))); + } + + getSelectList(): SelectList { + return this.selectList; + } +} diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index d65144e6..644efcf2 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -21,6 +21,7 @@ import { getApiKeyForModel, getAvailableModels } from "../model-config.js"; import { listOAuthProviders, login, logout } from "../oauth/index.js"; import type { SessionManager } from "../session-manager.js"; import type { SettingsManager } from "../settings-manager.js"; +import { setTheme } from "../theme/theme.js"; import { AssistantMessageComponent } from "./assistant-message.js"; import { CustomEditor } from "./custom-editor.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -28,6 +29,7 @@ import { FooterComponent } from "./footer.js"; import { ModelSelectorComponent } from "./model-selector.js"; import { OAuthSelectorComponent } from "./oauth-selector.js"; import { QueueModeSelectorComponent } from "./queue-mode-selector.js"; +import { ThemeSelectorComponent } from "./theme-selector.js"; import { ThinkingSelectorComponent } from "./thinking-selector.js"; import { ToolExecutionComponent } from "./tool-execution.js"; import { UserMessageComponent } from "./user-message.js"; @@ -71,6 +73,9 @@ export class TuiRenderer { // Queue mode selector private queueModeSelector: QueueModeSelectorComponent | null = null; + // Theme selector + private themeSelector: ThemeSelectorComponent | null = null; + // Model selector private modelSelector: ModelSelectorComponent | null = null; @@ -160,11 +165,17 @@ export class TuiRenderer { description: "Select message queue mode (opens selector UI)", }; + const themeCommand: SlashCommand = { + name: "theme", + description: "Select color theme (opens selector UI)", + }; + // Setup autocomplete for file paths and slash commands const autocompleteProvider = new CombinedAutocompleteProvider( [ thinkingCommand, modelCommand, + themeCommand, exportCommand, sessionCommand, changelogCommand, @@ -365,6 +376,13 @@ export class TuiRenderer { return; } + // Check for /theme command + if (text === "/theme") { + this.showThemeSelector(); + this.editor.setText(""); + return; + } + // Normal message submission - validate model and API key first const currentModel = this.agent.state.model; if (!currentModel) { @@ -929,6 +947,51 @@ export class TuiRenderer { this.ui.setFocus(this.editor); } + private showThemeSelector(): void { + // Get current theme from settings + const currentTheme = this.settingsManager.getTheme() || "dark"; + + // Create theme selector + this.themeSelector = new ThemeSelectorComponent( + currentTheme, + (themeName) => { + // Apply the selected theme + setTheme(themeName); + + // Save theme to settings + this.settingsManager.setTheme(themeName); + + // Show confirmation message with proper spacing + this.chatContainer.addChild(new Spacer(1)); + const confirmText = new Text(chalk.dim(`Theme: ${themeName}`), 1, 0); + this.chatContainer.addChild(confirmText); + + // Hide selector and show editor again + this.hideThemeSelector(); + this.ui.requestRender(); + }, + () => { + // Just hide the selector + this.hideThemeSelector(); + this.ui.requestRender(); + }, + ); + + // Replace editor with selector + this.editorContainer.clear(); + this.editorContainer.addChild(this.themeSelector); + this.ui.setFocus(this.themeSelector.getSelectList()); + this.ui.requestRender(); + } + + private hideThemeSelector(): void { + // Replace selector with editor in the container + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.themeSelector = null; + this.ui.setFocus(this.editor); + } + private showModelSelector(): void { // Create model selector with current model this.modelSelector = new ModelSelectorComponent( diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 018e7643..bef6e253 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -25,22 +25,46 @@ export interface DefaultTextStyle { underline?: boolean; } +/** + * Theme functions for markdown elements. + * Each function takes text and returns styled text with ANSI codes. + */ +export interface MarkdownTheme { + heading: (text: string) => string; + link: (text: string) => string; + code: (text: string) => string; + codeBlock: (text: string) => string; + codeBlockBorder: (text: string) => string; + quote: (text: string) => string; + quoteBorder: (text: string) => string; + hr: (text: string) => string; + listBullet: (text: string) => string; +} + export class Markdown implements Component { private text: string; private paddingX: number; // Left/right padding private paddingY: number; // Top/bottom padding private defaultTextStyle?: DefaultTextStyle; + private theme?: MarkdownTheme; // Cache for rendered output private cachedText?: string; private cachedWidth?: number; private cachedLines?: string[]; - constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, defaultTextStyle?: DefaultTextStyle) { + constructor( + text: string = "", + paddingX: number = 1, + paddingY: number = 1, + defaultTextStyle?: DefaultTextStyle, + theme?: MarkdownTheme, + ) { this.text = text; this.paddingX = paddingX; this.paddingY = paddingY; this.defaultTextStyle = defaultTextStyle; + this.theme = theme; } setText(text: string): void { diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 93c5aa4f..4ed15ca9 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -11,7 +11,7 @@ export { export { Editor, type TextEditorConfig } from "./components/editor.js"; export { Input } from "./components/input.js"; export { Loader } from "./components/loader.js"; -export { Markdown } from "./components/markdown.js"; +export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js"; export { type SelectItem, SelectList } from "./components/select-list.js"; export { Spacer } from "./components/spacer.js"; export { Text } from "./components/text.js";