mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 19:05:11 +00:00
Reorganize file structure: core/, utils/, modes/interactive/components/, modes/interactive/theme/
This commit is contained in:
parent
00982705f2
commit
83a6c26969
56 changed files with 133 additions and 128 deletions
73
packages/coding-agent/src/modes/interactive/theme/dark.json
Normal file
73
packages/coding-agent/src/modes/interactive/theme/dark.json
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
|
||||
"name": "dark",
|
||||
"vars": {
|
||||
"cyan": "#00d7ff",
|
||||
"blue": "#5f87ff",
|
||||
"green": "#b5bd68",
|
||||
"red": "#cc6666",
|
||||
"yellow": "#ffff00",
|
||||
"gray": "#808080",
|
||||
"dimGray": "#666666",
|
||||
"darkGray": "#505050",
|
||||
"accent": "#8abeb7",
|
||||
"userMsgBg": "#343541",
|
||||
"toolPendingBg": "#282832",
|
||||
"toolSuccessBg": "#283228",
|
||||
"toolErrorBg": "#3c2828"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "accent",
|
||||
"border": "blue",
|
||||
"borderAccent": "cyan",
|
||||
"borderMuted": "darkGray",
|
||||
"success": "green",
|
||||
"error": "red",
|
||||
"warning": "yellow",
|
||||
"muted": "gray",
|
||||
"dim": "dimGray",
|
||||
"text": "",
|
||||
|
||||
"userMessageBg": "userMsgBg",
|
||||
"userMessageText": "",
|
||||
"toolPendingBg": "toolPendingBg",
|
||||
"toolSuccessBg": "toolSuccessBg",
|
||||
"toolErrorBg": "toolErrorBg",
|
||||
"toolTitle": "",
|
||||
"toolOutput": "gray",
|
||||
|
||||
"mdHeading": "#f0c674",
|
||||
"mdLink": "#81a2be",
|
||||
"mdLinkUrl": "dimGray",
|
||||
"mdCode": "accent",
|
||||
"mdCodeBlock": "green",
|
||||
"mdCodeBlockBorder": "gray",
|
||||
"mdQuote": "gray",
|
||||
"mdQuoteBorder": "gray",
|
||||
"mdHr": "gray",
|
||||
"mdListBullet": "accent",
|
||||
|
||||
"toolDiffAdded": "green",
|
||||
"toolDiffRemoved": "red",
|
||||
"toolDiffContext": "gray",
|
||||
|
||||
"syntaxComment": "gray",
|
||||
"syntaxKeyword": "cyan",
|
||||
"syntaxFunction": "blue",
|
||||
"syntaxVariable": "",
|
||||
"syntaxString": "green",
|
||||
"syntaxNumber": "yellow",
|
||||
"syntaxType": "cyan",
|
||||
"syntaxOperator": "",
|
||||
"syntaxPunctuation": "gray",
|
||||
|
||||
"thinkingOff": "darkGray",
|
||||
"thinkingMinimal": "#6e6e6e",
|
||||
"thinkingLow": "#5f87af",
|
||||
"thinkingMedium": "#81a2be",
|
||||
"thinkingHigh": "#b294bb",
|
||||
"thinkingXhigh": "#d183e8",
|
||||
|
||||
"bashMode": "green"
|
||||
}
|
||||
}
|
||||
72
packages/coding-agent/src/modes/interactive/theme/light.json
Normal file
72
packages/coding-agent/src/modes/interactive/theme/light.json
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
|
||||
"name": "light",
|
||||
"vars": {
|
||||
"teal": "#5f8787",
|
||||
"blue": "#5f87af",
|
||||
"green": "#87af87",
|
||||
"red": "#af5f5f",
|
||||
"yellow": "#d7af5f",
|
||||
"mediumGray": "#6c6c6c",
|
||||
"dimGray": "#8a8a8a",
|
||||
"lightGray": "#b0b0b0",
|
||||
"userMsgBg": "#e8e8e8",
|
||||
"toolPendingBg": "#e8e8f0",
|
||||
"toolSuccessBg": "#e8f0e8",
|
||||
"toolErrorBg": "#f0e8e8"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "teal",
|
||||
"border": "blue",
|
||||
"borderAccent": "teal",
|
||||
"borderMuted": "lightGray",
|
||||
"success": "green",
|
||||
"error": "red",
|
||||
"warning": "yellow",
|
||||
"muted": "mediumGray",
|
||||
"dim": "dimGray",
|
||||
"text": "",
|
||||
|
||||
"userMessageBg": "userMsgBg",
|
||||
"userMessageText": "",
|
||||
"toolPendingBg": "toolPendingBg",
|
||||
"toolSuccessBg": "toolSuccessBg",
|
||||
"toolErrorBg": "toolErrorBg",
|
||||
"toolTitle": "",
|
||||
"toolOutput": "mediumGray",
|
||||
|
||||
"mdHeading": "yellow",
|
||||
"mdLink": "blue",
|
||||
"mdLinkUrl": "dimGray",
|
||||
"mdCode": "teal",
|
||||
"mdCodeBlock": "green",
|
||||
"mdCodeBlockBorder": "mediumGray",
|
||||
"mdQuote": "mediumGray",
|
||||
"mdQuoteBorder": "mediumGray",
|
||||
"mdHr": "mediumGray",
|
||||
"mdListBullet": "green",
|
||||
|
||||
"toolDiffAdded": "green",
|
||||
"toolDiffRemoved": "red",
|
||||
"toolDiffContext": "mediumGray",
|
||||
|
||||
"syntaxComment": "mediumGray",
|
||||
"syntaxKeyword": "teal",
|
||||
"syntaxFunction": "blue",
|
||||
"syntaxVariable": "",
|
||||
"syntaxString": "green",
|
||||
"syntaxNumber": "yellow",
|
||||
"syntaxType": "teal",
|
||||
"syntaxOperator": "",
|
||||
"syntaxPunctuation": "mediumGray",
|
||||
|
||||
"thinkingOff": "lightGray",
|
||||
"thinkingMinimal": "#9e9e9e",
|
||||
"thinkingLow": "#5f87af",
|
||||
"thinkingMedium": "#5f8787",
|
||||
"thinkingHigh": "#875f87",
|
||||
"thinkingXhigh": "#8b008b",
|
||||
|
||||
"bashMode": "green"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
{
|
||||
"$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",
|
||||
"dim",
|
||||
"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"
|
||||
},
|
||||
"dim": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Very dimmed text (more subtle than muted)"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"thinkingOff": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Thinking level border: off"
|
||||
},
|
||||
"thinkingMinimal": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Thinking level border: minimal"
|
||||
},
|
||||
"thinkingLow": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Thinking level border: low"
|
||||
},
|
||||
"thinkingMedium": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Thinking level border: medium"
|
||||
},
|
||||
"thinkingHigh": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Thinking level border: high"
|
||||
},
|
||||
"thinkingXhigh": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Thinking level border: xhigh (OpenAI codex-max only)"
|
||||
},
|
||||
"bashMode": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Editor border color in bash mode"
|
||||
}
|
||||
},
|
||||
"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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
597
packages/coding-agent/src/modes/interactive/theme/theme.ts
Normal file
597
packages/coding-agent/src/modes/interactive/theme/theme.ts
Normal file
|
|
@ -0,0 +1,597 @@
|
|||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@mariozechner/pi-tui";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { TypeCompiler } from "@sinclair/typebox/compiler";
|
||||
import chalk from "chalk";
|
||||
import { getCustomThemesDir, getThemesDir } from "../../../utils/config.js";
|
||||
|
||||
// ============================================================================
|
||||
// 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 (10 colors)
|
||||
accent: ColorValueSchema,
|
||||
border: ColorValueSchema,
|
||||
borderAccent: ColorValueSchema,
|
||||
borderMuted: ColorValueSchema,
|
||||
success: ColorValueSchema,
|
||||
error: ColorValueSchema,
|
||||
warning: ColorValueSchema,
|
||||
muted: ColorValueSchema,
|
||||
dim: ColorValueSchema,
|
||||
text: ColorValueSchema,
|
||||
// Backgrounds & Content Text (7 colors)
|
||||
userMessageBg: ColorValueSchema,
|
||||
userMessageText: ColorValueSchema,
|
||||
toolPendingBg: ColorValueSchema,
|
||||
toolSuccessBg: ColorValueSchema,
|
||||
toolErrorBg: ColorValueSchema,
|
||||
toolTitle: ColorValueSchema,
|
||||
toolOutput: ColorValueSchema,
|
||||
// Markdown (10 colors)
|
||||
mdHeading: ColorValueSchema,
|
||||
mdLink: ColorValueSchema,
|
||||
mdLinkUrl: 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,
|
||||
// Thinking Level Borders (6 colors)
|
||||
thinkingOff: ColorValueSchema,
|
||||
thinkingMinimal: ColorValueSchema,
|
||||
thinkingLow: ColorValueSchema,
|
||||
thinkingMedium: ColorValueSchema,
|
||||
thinkingHigh: ColorValueSchema,
|
||||
thinkingXhigh: ColorValueSchema,
|
||||
// Bash Mode (1 color)
|
||||
bashMode: ColorValueSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
type ThemeJson = Static<typeof ThemeJsonSchema>;
|
||||
|
||||
const validateThemeJson = TypeCompiler.Compile(ThemeJsonSchema);
|
||||
|
||||
export type ThemeColor =
|
||||
| "accent"
|
||||
| "border"
|
||||
| "borderAccent"
|
||||
| "borderMuted"
|
||||
| "success"
|
||||
| "error"
|
||||
| "warning"
|
||||
| "muted"
|
||||
| "dim"
|
||||
| "text"
|
||||
| "userMessageText"
|
||||
| "toolTitle"
|
||||
| "toolOutput"
|
||||
| "mdHeading"
|
||||
| "mdLink"
|
||||
| "mdLinkUrl"
|
||||
| "mdCode"
|
||||
| "mdCodeBlock"
|
||||
| "mdCodeBlockBorder"
|
||||
| "mdQuote"
|
||||
| "mdQuoteBorder"
|
||||
| "mdHr"
|
||||
| "mdListBullet"
|
||||
| "toolDiffAdded"
|
||||
| "toolDiffRemoved"
|
||||
| "toolDiffContext"
|
||||
| "syntaxComment"
|
||||
| "syntaxKeyword"
|
||||
| "syntaxFunction"
|
||||
| "syntaxVariable"
|
||||
| "syntaxString"
|
||||
| "syntaxNumber"
|
||||
| "syntaxType"
|
||||
| "syntaxOperator"
|
||||
| "syntaxPunctuation"
|
||||
| "thinkingOff"
|
||||
| "thinkingMinimal"
|
||||
| "thinkingLow"
|
||||
| "thinkingMedium"
|
||||
| "thinkingHigh"
|
||||
| "thinkingXhigh"
|
||||
| "bashMode";
|
||||
|
||||
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";
|
||||
}
|
||||
// Windows Terminal supports truecolor
|
||||
if (process.env.WT_SESSION) {
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
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}\x1b[39m`; // Reset only foreground color
|
||||
}
|
||||
|
||||
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}\x1b[49m`; // Reset only background color
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
getThinkingBorderColor(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): (str: string) => string {
|
||||
// Map thinking levels to dedicated theme colors
|
||||
switch (level) {
|
||||
case "off":
|
||||
return (str: string) => this.fg("thinkingOff", str);
|
||||
case "minimal":
|
||||
return (str: string) => this.fg("thinkingMinimal", str);
|
||||
case "low":
|
||||
return (str: string) => this.fg("thinkingLow", str);
|
||||
case "medium":
|
||||
return (str: string) => this.fg("thinkingMedium", str);
|
||||
case "high":
|
||||
return (str: string) => this.fg("thinkingHigh", str);
|
||||
case "xhigh":
|
||||
return (str: string) => this.fg("thinkingXhigh", str);
|
||||
default:
|
||||
return (str: string) => this.fg("thinkingOff", str);
|
||||
}
|
||||
}
|
||||
|
||||
getBashModeBorderColor(): (str: string) => string {
|
||||
return (str: string) => this.fg("bashMode", str);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Theme Loading
|
||||
// ============================================================================
|
||||
|
||||
let BUILTIN_THEMES: Record<string, ThemeJson> | undefined;
|
||||
|
||||
function getBuiltinThemes(): Record<string, ThemeJson> {
|
||||
if (!BUILTIN_THEMES) {
|
||||
const themesDir = getThemesDir();
|
||||
const darkPath = path.join(themesDir, "dark.json");
|
||||
const lightPath = path.join(themesDir, "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;
|
||||
}
|
||||
|
||||
export function getAvailableThemes(): string[] {
|
||||
const themes = new Set<string>(Object.keys(getBuiltinThemes()));
|
||||
const customThemesDir = getCustomThemesDir();
|
||||
if (fs.existsSync(customThemesDir)) {
|
||||
const files = fs.readdirSync(customThemesDir);
|
||||
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 customThemesDir = getCustomThemesDir();
|
||||
const themePath = path.join(customThemesDir, `${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 missingColors: string[] = [];
|
||||
const otherErrors: string[] = [];
|
||||
|
||||
for (const e of errors) {
|
||||
// Check for missing required color properties
|
||||
const match = e.path.match(/^\/colors\/(\w+)$/);
|
||||
if (match && e.message.includes("Required")) {
|
||||
missingColors.push(match[1]);
|
||||
} else {
|
||||
otherErrors.push(` - ${e.path}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
let errorMessage = `Invalid theme "${name}":\n`;
|
||||
if (missingColors.length > 0) {
|
||||
errorMessage += `\nMissing required color tokens:\n`;
|
||||
errorMessage += missingColors.map((c) => ` - ${c}`).join("\n");
|
||||
errorMessage += `\n\nPlease add these colors to your theme's "colors" object.`;
|
||||
errorMessage += `\nSee the built-in themes (dark.json, light.json) for reference values.`;
|
||||
}
|
||||
if (otherErrors.length > 0) {
|
||||
errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
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)) {
|
||||
const result = bg < 8 ? "dark" : "light";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function getDefaultTheme(): string {
|
||||
return detectTerminalBackground();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Global Theme Instance
|
||||
// ============================================================================
|
||||
|
||||
export let theme: Theme;
|
||||
let currentThemeName: string | undefined;
|
||||
let themeWatcher: fs.FSWatcher | undefined;
|
||||
let onThemeChangeCallback: (() => void) | undefined;
|
||||
|
||||
export function initTheme(themeName?: string): void {
|
||||
const name = themeName ?? getDefaultTheme();
|
||||
currentThemeName = name;
|
||||
try {
|
||||
theme = loadTheme(name);
|
||||
startThemeWatcher();
|
||||
} catch (error) {
|
||||
// Theme is invalid - fall back to dark theme silently
|
||||
currentThemeName = "dark";
|
||||
theme = loadTheme("dark");
|
||||
// Don't start watcher for fallback theme
|
||||
}
|
||||
}
|
||||
|
||||
export function setTheme(name: string): { success: boolean; error?: string } {
|
||||
currentThemeName = name;
|
||||
try {
|
||||
theme = loadTheme(name);
|
||||
startThemeWatcher();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
// Theme is invalid - fall back to dark theme
|
||||
currentThemeName = "dark";
|
||||
theme = loadTheme("dark");
|
||||
// Don't start watcher for fallback theme
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function onThemeChange(callback: () => void): void {
|
||||
onThemeChangeCallback = callback;
|
||||
}
|
||||
|
||||
function startThemeWatcher(): void {
|
||||
// Stop existing watcher if any
|
||||
if (themeWatcher) {
|
||||
themeWatcher.close();
|
||||
themeWatcher = undefined;
|
||||
}
|
||||
|
||||
// Only watch if it's a custom theme (not built-in)
|
||||
if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") {
|
||||
return;
|
||||
}
|
||||
|
||||
const customThemesDir = getCustomThemesDir();
|
||||
const themeFile = path.join(customThemesDir, `${currentThemeName}.json`);
|
||||
|
||||
// Only watch if the file exists
|
||||
if (!fs.existsSync(themeFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
themeWatcher = fs.watch(themeFile, (eventType) => {
|
||||
if (eventType === "change") {
|
||||
// Debounce rapid changes
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// Reload the theme
|
||||
theme = loadTheme(currentThemeName!);
|
||||
// Notify callback (to invalidate UI)
|
||||
if (onThemeChangeCallback) {
|
||||
onThemeChangeCallback();
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors (file might be in invalid state while being edited)
|
||||
}
|
||||
}, 100);
|
||||
} else if (eventType === "rename") {
|
||||
// File was deleted or renamed - fall back to default theme
|
||||
setTimeout(() => {
|
||||
if (!fs.existsSync(themeFile)) {
|
||||
currentThemeName = "dark";
|
||||
theme = loadTheme("dark");
|
||||
if (themeWatcher) {
|
||||
themeWatcher.close();
|
||||
themeWatcher = undefined;
|
||||
}
|
||||
if (onThemeChangeCallback) {
|
||||
onThemeChangeCallback();
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore errors starting watcher
|
||||
}
|
||||
}
|
||||
|
||||
export function stopThemeWatcher(): void {
|
||||
if (themeWatcher) {
|
||||
themeWatcher.close();
|
||||
themeWatcher = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TUI Helpers
|
||||
// ============================================================================
|
||||
|
||||
export function getMarkdownTheme(): MarkdownTheme {
|
||||
return {
|
||||
heading: (text: string) => theme.fg("mdHeading", text),
|
||||
link: (text: string) => theme.fg("mdLink", text),
|
||||
linkUrl: (text: string) => theme.fg("mdLinkUrl", 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),
|
||||
bold: (text: string) => theme.bold(text),
|
||||
italic: (text: string) => theme.italic(text),
|
||||
underline: (text: string) => theme.underline(text),
|
||||
strikethrough: (text: string) => chalk.strikethrough(text),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSelectListTheme(): SelectListTheme {
|
||||
return {
|
||||
selectedPrefix: (text: string) => theme.fg("accent", text),
|
||||
selectedText: (text: string) => theme.fg("accent", text),
|
||||
description: (text: string) => theme.fg("muted", text),
|
||||
scrollInfo: (text: string) => theme.fg("muted", text),
|
||||
noMatch: (text: string) => theme.fg("muted", text),
|
||||
};
|
||||
}
|
||||
|
||||
export function getEditorTheme(): EditorTheme {
|
||||
return {
|
||||
borderColor: (text: string) => theme.fg("borderMuted", text),
|
||||
selectList: getSelectListTheme(),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue