SPB Git

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%
8.5 KB · 316 lines typescript
Raw Blame History
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