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%
9.0 KB · 323 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/tui/theme.ts4 * Description: The signature "khaelis" theme (obsidian + magma) + light variant — capability ladder, NO_COLOR degradation, never color-alone (TUI v2 §1).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type { Background, ColorDepth } from "./renderer/capabilities.js";11import { mixOklch } from "./theme/gradient.js";1213/**14 * Style roles (TUI_DESIGN §12). Color reinforces state; it never carries it15 * alone — every state pairs a symbol or word, so monochrome (`NO_COLOR`,16 * `TERM=dumb`, non-TTY) simply renders roles as identity.17 */18export type StyleRole =19  | "text"20  | "dim"21  | "accent"22  | "accentDim" // the 2 Hz status-dot alternate shade — the only animation23  | "success"24  | "warning"25  | "error"26  | "code"27  | "violet" // thinking state, model segment, brand gradient start28  | "cyan" // reading state, branch segment, brand gradient end29  | "magenta" // verifying state30  | "teal" // process tools, context segment31  | "orange" // cost segment, waiting state32  | "bold"33  | "italic"34  | "strike"35  | "underline"36  | "invert"37  | "synKeyword"38  | "synString"39  | "synComment"40  | "synNumber"41  | "synFunction"42  | "synType";4344/** Named per-character truecolor gradients (fallback: solid color → identity). */45export type GradientName = "brand" | "ember";4647/** Subtle full-line background tints (truecolor only; identity elsewhere). */48export type LineTint = "add" | "del";4950export interface Theme {51  name: string;52  colorDepth: ColorDepth;53  background: Background;54  /** Style `s` for `role`. Monochrome themes return `s` unchanged. */55  paint(role: StyleRole, s: string): string;56  /**57   * Per-character truecolor gradient across the visible characters of a plain58   * (unstyled) string. ANSI-256/16 degrade to a solid brand color; monochrome59   * is identity — hierarchy never depends on the gradient (TUI_DESIGN §12).60   */61  paintGradient(name: GradientName, s: string): string;62  /** Subtle diff-line background tint. Truecolor only; identity elsewhere. */63  tintLine(kind: LineTint, s: string): string;64}6566type Rgb = readonly [number, number, number];6768interface Palette {69  text: Rgb;70  dim: Rgb;71  accent: Rgb;72  accentDim: Rgb;73  success: Rgb;74  warning: Rgb;75  error: Rgb;76  code: Rgb;77  violet: Rgb;78  cyan: Rgb;79  magenta: Rgb;80  teal: Rgb;81  orange: Rgb;82  synKeyword: Rgb;83  synString: Rgb;84  synComment: Rgb;85  synNumber: Rgb;86  synFunction: Rgb;87  synType: Rgb;88}8990/**91 * khaelis — the signature dark palette: obsidian + magma (TUI v2 §1.1).92 * accent = ember #FF6B35, orange = ember-glow #FFB86B, teal = #2DD4BF.93 * Not another generic violet-cyan CLI theme — the warm accent "flows"94 * through phase ribbon, prompts, and gradients.95 */96const DARK: Palette = {97  text: [201, 209, 227], // #c9d1e398  dim: [91, 101, 122], // #5b657a99  accent: [255, 107, 53], // #ff6b35 — ember (magma)100  accentDim: [179, 73, 31], // ember, banked101  success: [126, 231, 135], // #7ee787102  warning: [240, 180, 41], // #f0b429103  error: [255, 92, 87], // #ff5c57104  code: [154, 165, 206],105  violet: [187, 154, 247], // #bb9af7106  cyan: [125, 207, 255], // #7dcfff107  magenta: [255, 121, 198], // #ff79c6108  teal: [45, 212, 191], // #2dd4bf — cool counterpoint to the ember109  orange: [255, 184, 107], // #ffb86b — ember-glow110  synKeyword: [187, 154, 247], // violet111  synString: [126, 231, 135], // green112  synComment: [91, 101, 122], // dim113  synNumber: [255, 184, 107], // ember-glow114  synFunction: [125, 207, 255], // cyan115  synType: [45, 212, 191], // teal116};117118/** khaelor-light — same hue system, adjusted for light backgrounds. */119const LIGHT: Palette = {120  text: [46, 52, 64],121  dim: [122, 130, 144],122  accent: [46, 89, 168],123  accentDim: [130, 152, 199],124  success: [77, 124, 15],125  warning: [161, 98, 7],126  error: [190, 24, 60],127  code: [88, 96, 128],128  violet: [124, 58, 237], // #7c3aed129  cyan: [14, 116, 144], // #0e7490130  magenta: [190, 24, 93], // #be185d131  teal: [15, 118, 110], // #0f766e132  orange: [194, 65, 12], // #c2410c133  synKeyword: [124, 58, 237],134  synString: [77, 124, 15],135  synComment: [122, 130, 144],136  synNumber: [194, 65, 12],137  synFunction: [14, 116, 144],138  synType: [4, 120, 87], // #047857139};140141/** ANSI-256 quantization of the two palettes (precomputed, no math at paint time). */142const DARK_256: Record<keyof Palette, number> = {143  text: 189,144  dim: 60,145  accent: 202, // ember146  accentDim: 130,147  success: 114,148  warning: 178,149  error: 203,150  code: 146,151  violet: 141,152  cyan: 117,153  magenta: 212,154  teal: 43,155  orange: 215, // ember-glow156  synKeyword: 141,157  synString: 114,158  synComment: 60,159  synNumber: 215,160  synFunction: 117,161  synType: 43,162};163164const LIGHT_256: Record<keyof Palette, number> = {165  text: 237,166  dim: 245,167  accent: 25,168  accentDim: 110,169  success: 64,170  warning: 130,171  error: 161,172  code: 60,173  violet: 91,174  cyan: 30,175  magenta: 162,176  teal: 29,177  orange: 166,178  synKeyword: 91,179  synString: 64,180  synComment: 245,181  synNumber: 166,182  synFunction: 30,183  synType: 29,184};185186/** ANSI-16 role mapping (foreground SGR codes). */187const ANSI16: Record<keyof Palette, number> = {188  text: 39, // default foreground189  dim: 90,190  accent: 94,191  accentDim: 34,192  success: 32,193  warning: 33,194  error: 31,195  code: 36,196  violet: 95,197  cyan: 96,198  magenta: 35,199  teal: 36,200  orange: 33,201  synKeyword: 95,202  synString: 32,203  synComment: 90,204  synNumber: 33,205  synFunction: 96,206  synType: 36,207};208209/** Gradient endpoints per named gradient (roles resolved against the palette). */210const GRADIENTS: Record<GradientName, [keyof Palette, keyof Palette]> = {211  brand: ["violet", "cyan"],212  // The signature: ember → ember-glow, flowing across wordmark, phase ribbon,213  // active panel borders, and the context gauge (TUI v2 §1.3).214  ember: ["accent", "orange"],215};216217/** Diff-line background tints — subtle, truecolor only. */218const TINTS_DARK: Record<LineTint, Rgb> = { add: [26, 42, 32], del: [46, 26, 32] };219const TINTS_LIGHT: Record<LineTint, Rgb> = { add: [230, 246, 232], del: [250, 228, 232] };220221const FORMAT_OPEN: Partial<Record<StyleRole, string>> = {222  bold: "\x1b[1m",223  italic: "\x1b[3m",224  strike: "\x1b[9m",225  underline: "\x1b[4m",226  invert: "\x1b[7m",227};228229const FORMAT_CLOSE: Partial<Record<StyleRole, string>> = {230  bold: "\x1b[22m",231  italic: "\x1b[23m",232  strike: "\x1b[29m",233  underline: "\x1b[24m",234  invert: "\x1b[27m",235};236237const CLOSE_FG = "\x1b[39m";238239function isColorRole(role: StyleRole): role is keyof Palette {240  return !(role in FORMAT_OPEN);241}242243export interface ResolveThemeOptions {244  colorDepth: ColorDepth;245  background?: Background;246}247248/**249 * Resolve the theme for the detected capabilities. One exceptional default250 * (dark), a light variant when the background is detectably light, and full251 * monochrome degradation — `paint` is then the identity function, emitting252 * zero SGR codes (TUI_DESIGN §12).253 */254export function resolveTheme(options: ResolveThemeOptions): Theme {255  const background: Background = options.background ?? "dark";256  const depth = options.colorDepth;257  const name = background === "dark" ? "khaelis" : "khaelor-light";258259  if (depth === "mono") {260    return {261      name: `${name} (mono)`,262      colorDepth: depth,263      background,264      paint: (_role, s) => s,265      paintGradient: (_name, s) => s,266      tintLine: (_kind, s) => s,267    };268  }269270  const palette = background === "dark" ? DARK : LIGHT;271  const palette256 = background === "dark" ? DARK_256 : LIGHT_256;272  const tints = background === "dark" ? TINTS_DARK : TINTS_LIGHT;273274  const open = (role: keyof Palette): string => {275    if (depth === "truecolor") {276      const [r, g, b] = palette[role];277      return `\x1b[38;2;${r};${g};${b}m`;278    }279    if (depth === "ansi256") return `\x1b[38;5;${palette256[role]}m`;280    const code = ANSI16[role];281    return code === 39 ? "" : `\x1b[${code}m`;282  };283284  return {285    name,286    colorDepth: depth,287    background,288    paint(role: StyleRole, s: string): string {289      if (s === "") return s;290      if (isColorRole(role)) {291        const o = open(role);292        return o === "" ? s : o + s + CLOSE_FG;293      }294      return (FORMAT_OPEN[role] as string) + s + (FORMAT_CLOSE[role] as string);295    },296    paintGradient(gradient: GradientName, s: string): string {297      if (s === "") return s;298      const [fromRole, toRole] = GRADIENTS[gradient];299      if (depth !== "truecolor") {300        // Solid brand color — the gradient is truecolor-only polish.301        const o = open(fromRole);302        return o === "" ? s : o + s + CLOSE_FG;303      }304      const from = palette[fromRole];305      const to = palette[toRole];306      const chars = [...s];307      const steps = Math.max(1, chars.length - 1);308      let out = "";309      for (let i = 0; i < chars.length; i++) {310        // OKLCH interpolation — perceptually smooth, no muddy midpoints (TUI v2 §1.3).311        const [r, g, b] = mixOklch(from, to, i / steps);312        out += `\x1b[38;2;${r};${g};${b}m${chars[i] as string}`;313      }314      return out + CLOSE_FG;315    },316    tintLine(kind: LineTint, s: string): string {317      if (s === "" || depth !== "truecolor") return s;318      const [r, g, b] = tints[kind];319      return `\x1b[48;2;${r};${g};${b}m${s}\x1b[49m`;320    },321  };322}323