spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/tui/theme.ts4 * Description: The one default theme (khaelor-dark + light variant) — capability ladder, NO_COLOR degradation, never color-alone.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type { Background, ColorDepth } from "./renderer/capabilities.js";1112/**13 * Style roles (TUI_DESIGN §12). Color reinforces state; it never carries it14 * alone — every state pairs a symbol or word, so monochrome (`NO_COLOR`,15 * `TERM=dumb`, non-TTY) simply renders roles as identity.16 */17export type StyleRole =18 | "text"19 | "dim"20 | "accent"21 | "accentDim" // the 2 Hz status-dot alternate shade — the only animation22 | "success"23 | "warning"24 | "error"25 | "code"26 | "violet" // thinking state, model segment, brand gradient start27 | "cyan" // reading state, branch segment, brand gradient end28 | "magenta" // verifying state29 | "teal" // process tools, context segment30 | "orange" // cost segment, waiting state31 | "bold"32 | "italic"33 | "strike"34 | "underline"35 | "invert"36 | "synKeyword"37 | "synString"38 | "synComment"39 | "synNumber"40 | "synFunction"41 | "synType";4243/** Named per-character truecolor gradients (fallback: solid color → identity). */44export type GradientName = "brand";4546/** Subtle full-line background tints (truecolor only; identity elsewhere). */47export type LineTint = "add" | "del";4849export interface Theme {50 name: string;51 colorDepth: ColorDepth;52 background: Background;53 /** Style `s` for `role`. Monochrome themes return `s` unchanged. */54 paint(role: StyleRole, s: string): string;55 /**56 * Per-character truecolor gradient across the visible characters of a plain57 * (unstyled) string. ANSI-256/16 degrade to a solid brand color; monochrome58 * is identity — hierarchy never depends on the gradient (TUI_DESIGN §12).59 */60 paintGradient(name: GradientName, s: string): string;61 /** Subtle diff-line background tint. Truecolor only; identity elsewhere. */62 tintLine(kind: LineTint, s: string): string;63}6465type Rgb = readonly [number, number, number];6667interface Palette {68 text: Rgb;69 dim: Rgb;70 accent: Rgb;71 accentDim: Rgb;72 success: Rgb;73 warning: Rgb;74 error: Rgb;75 code: Rgb;76 violet: Rgb;77 cyan: Rgb;78 magenta: Rgb;79 teal: Rgb;80 orange: Rgb;81 synKeyword: Rgb;82 synString: Rgb;83 synComment: Rgb;84 synNumber: Rgb;85 synFunction: Rgb;86 synType: Rgb;87}8889/** khaelor-dark — the vibrant default (TUI_DESIGN §12). */90const DARK: Palette = {91 text: [216, 222, 233], // #d8dee992 dim: [107, 114, 128], // #6b728093 accent: [122, 162, 247], // #7aa2f794 accentDim: [61, 89, 161], // #3d59a195 success: [158, 206, 106], // #9ece6a96 warning: [224, 175, 104], // #e0af6897 error: [247, 118, 142], // #f7768e98 code: [154, 165, 206],99 violet: [187, 154, 247], // #bb9af7100 cyan: [125, 207, 255], // #7dcfff101 magenta: [255, 121, 198], // #ff79c6102 teal: [115, 218, 202], // #73daca103 orange: [255, 158, 100], // #ff9e64104 synKeyword: [187, 154, 247], // violet105 synString: [158, 206, 106], // green106 synComment: [107, 114, 128], // dim107 synNumber: [255, 158, 100], // orange108 synFunction: [125, 207, 255], // cyan109 synType: [42, 195, 222], // #2ac3de110};111112/** khaelor-light — same hue system, adjusted for light backgrounds. */113const LIGHT: Palette = {114 text: [46, 52, 64],115 dim: [122, 130, 144],116 accent: [46, 89, 168],117 accentDim: [130, 152, 199],118 success: [77, 124, 15],119 warning: [161, 98, 7],120 error: [190, 24, 60],121 code: [88, 96, 128],122 violet: [124, 58, 237], // #7c3aed123 cyan: [14, 116, 144], // #0e7490124 magenta: [190, 24, 93], // #be185d125 teal: [15, 118, 110], // #0f766e126 orange: [194, 65, 12], // #c2410c127 synKeyword: [124, 58, 237],128 synString: [77, 124, 15],129 synComment: [122, 130, 144],130 synNumber: [194, 65, 12],131 synFunction: [14, 116, 144],132 synType: [4, 120, 87], // #047857133};134135/** ANSI-256 quantization of the two palettes (precomputed, no math at paint time). */136const DARK_256: Record<keyof Palette, number> = {137 text: 253,138 dim: 243,139 accent: 111,140 accentDim: 61,141 success: 149,142 warning: 179,143 error: 211,144 code: 146,145 violet: 141,146 cyan: 117,147 magenta: 212,148 teal: 79,149 orange: 215,150 synKeyword: 141,151 synString: 149,152 synComment: 243,153 synNumber: 215,154 synFunction: 117,155 synType: 44,156};157158const LIGHT_256: Record<keyof Palette, number> = {159 text: 237,160 dim: 245,161 accent: 25,162 accentDim: 110,163 success: 64,164 warning: 130,165 error: 161,166 code: 60,167 violet: 91,168 cyan: 30,169 magenta: 162,170 teal: 29,171 orange: 166,172 synKeyword: 91,173 synString: 64,174 synComment: 245,175 synNumber: 166,176 synFunction: 30,177 synType: 29,178};179180/** ANSI-16 role mapping (foreground SGR codes). */181const ANSI16: Record<keyof Palette, number> = {182 text: 39, // default foreground183 dim: 90,184 accent: 94,185 accentDim: 34,186 success: 32,187 warning: 33,188 error: 31,189 code: 36,190 violet: 95,191 cyan: 96,192 magenta: 35,193 teal: 36,194 orange: 33,195 synKeyword: 95,196 synString: 32,197 synComment: 90,198 synNumber: 33,199 synFunction: 96,200 synType: 36,201};202203/** Gradient endpoints per named gradient (roles resolved against the palette). */204const GRADIENTS: Record<GradientName, [keyof Palette, keyof Palette]> = {205 brand: ["violet", "cyan"],206};207208/** Diff-line background tints — subtle, truecolor only. */209const TINTS_DARK: Record<LineTint, Rgb> = { add: [26, 42, 32], del: [46, 26, 32] };210const TINTS_LIGHT: Record<LineTint, Rgb> = { add: [230, 246, 232], del: [250, 228, 232] };211212const FORMAT_OPEN: Partial<Record<StyleRole, string>> = {213 bold: "\x1b[1m",214 italic: "\x1b[3m",215 strike: "\x1b[9m",216 underline: "\x1b[4m",217 invert: "\x1b[7m",218};219220const FORMAT_CLOSE: Partial<Record<StyleRole, string>> = {221 bold: "\x1b[22m",222 italic: "\x1b[23m",223 strike: "\x1b[29m",224 underline: "\x1b[24m",225 invert: "\x1b[27m",226};227228const CLOSE_FG = "\x1b[39m";229230function isColorRole(role: StyleRole): role is keyof Palette {231 return !(role in FORMAT_OPEN);232}233234export interface ResolveThemeOptions {235 colorDepth: ColorDepth;236 background?: Background;237}238239/**240 * Resolve the theme for the detected capabilities. One exceptional default241 * (dark), a light variant when the background is detectably light, and full242 * monochrome degradation — `paint` is then the identity function, emitting243 * zero SGR codes (TUI_DESIGN §12).244 */245export function resolveTheme(options: ResolveThemeOptions): Theme {246 const background: Background = options.background ?? "dark";247 const depth = options.colorDepth;248 const name = background === "dark" ? "khaelor-dark" : "khaelor-light";249250 if (depth === "mono") {251 return {252 name: `${name} (mono)`,253 colorDepth: depth,254 background,255 paint: (_role, s) => s,256 paintGradient: (_name, s) => s,257 tintLine: (_kind, s) => s,258 };259 }260261 const palette = background === "dark" ? DARK : LIGHT;262 const palette256 = background === "dark" ? DARK_256 : LIGHT_256;263 const tints = background === "dark" ? TINTS_DARK : TINTS_LIGHT;264265 const open = (role: keyof Palette): string => {266 if (depth === "truecolor") {267 const [r, g, b] = palette[role];268 return `\x1b[38;2;${r};${g};${b}m`;269 }270 if (depth === "ansi256") return `\x1b[38;5;${palette256[role]}m`;271 const code = ANSI16[role];272 return code === 39 ? "" : `\x1b[${code}m`;273 };274275 return {276 name,277 colorDepth: depth,278 background,279 paint(role: StyleRole, s: string): string {280 if (s === "") return s;281 if (isColorRole(role)) {282 const o = open(role);283 return o === "" ? s : o + s + CLOSE_FG;284 }285 return (FORMAT_OPEN[role] as string) + s + (FORMAT_CLOSE[role] as string);286 },287 paintGradient(gradient: GradientName, s: string): string {288 if (s === "") return s;289 const [fromRole, toRole] = GRADIENTS[gradient];290 if (depth !== "truecolor") {291 // Solid brand color — the gradient is truecolor-only polish.292 const o = open(fromRole);293 return o === "" ? s : o + s + CLOSE_FG;294 }295 const from = palette[fromRole];296 const to = palette[toRole];297 const chars = [...s];298 const steps = Math.max(1, chars.length - 1);299 let out = "";300 for (let i = 0; i < chars.length; i++) {301 const t = i / steps;302 const r = Math.round(from[0] + (to[0] - from[0]) * t);303 const g = Math.round(from[1] + (to[1] - from[1]) * t);304 const b = Math.round(from[2] + (to[2] - from[2]) * t);305 out += `\x1b[38;2;${r};${g};${b}m${chars[i] as string}`;306 }307 return out + CLOSE_FG;308 },309 tintLine(kind: LineTint, s: string): string {310 if (s === "" || depth !== "truecolor") return s;311 const [r, g, b] = tints[kind];312 return `\x1b[48;2;${r};${g};${b}m${s}\x1b[49m`;313 },314 };315}316