mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 16:04:03 +00:00
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:
parent
93a60b7969
commit
cc88095140
13 changed files with 937 additions and 11 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
59
packages/coding-agent/src/theme/dark.json
Normal file
59
packages/coding-agent/src/theme/dark.json
Normal 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"
|
||||
}
|
||||
}
|
||||
59
packages/coding-agent/src/theme/light.json
Normal file
59
packages/coding-agent/src/theme/light.json
Normal 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"
|
||||
}
|
||||
}
|
||||
241
packages/coding-agent/src/theme/theme-schema.json
Normal file
241
packages/coding-agent/src/theme/theme-schema.json
Normal 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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
414
packages/coding-agent/src/theme/theme.ts
Normal file
414
packages/coding-agent/src/theme/theme.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
51
packages/coding-agent/src/tui/theme-selector.ts
Normal file
51
packages/coding-agent/src/tui/theme-selector.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue