WIP: Add theming system with /theme command

- Consolidated theme system into single src/theme/ directory
- Created Theme class with fg(), bg(), bold(), italic(), underline()
- Added dark and light built-in themes with 36 color tokens
- Support for custom themes in ~/.pi/agent/themes/*.json
- JSON schema for theme validation
- Theme selector UI with /theme command
- Save theme preference to settings
- Uses chalk for text formatting to preserve colors

TODO:
- Replace hardcoded colors throughout TUI components
- Apply markdown theming to Markdown components
- Add theme support to all TUI elements
This commit is contained in:
Mario Zechner 2025-11-20 23:16:05 +01:00
parent 93a60b7969
commit cc88095140
13 changed files with 937 additions and 11 deletions

View file

@ -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",
...
}
```

View file

@ -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",

View file

@ -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)

View file

@ -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();
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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)"
}
]
}
}
}

View file

@ -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<typeof ColorValueSchema>;
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<typeof ThemeJsonSchema>;
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<string, ColorValue>,
visited = new Set<string>(),
): 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<T extends Record<string, ColorValue>>(
colors: T,
vars: Record<string, ColorValue> = {},
): Record<keyof T, string | number> {
const resolved: Record<string, string | number> = {};
for (const [key, value] of Object.entries(colors)) {
resolved[key] = resolveVarRefs(value, vars);
}
return resolved as Record<keyof T, string | number>;
}
// ============================================================================
// Theme Class
// ============================================================================
const RESET = "\x1b[0m";
export class Theme {
private fgColors: Map<ThemeColor, string>;
private bgColors: Map<ThemeBg, string>;
private mode: ColorMode;
constructor(
fgColors: Record<ThemeColor, string | number>,
bgColors: Record<ThemeBg, string | number>,
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<string, ThemeJson> | undefined;
function getBuiltinThemes(): Record<string, ThemeJson> {
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<string>(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<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
const bgColorKeys: Set<string> = 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),
};
}

View file

@ -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;
}
}

View file

@ -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(