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%
4.7 KB · 154 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/tui/composer/palette.ts4 * Description: The one generic filter-list powering slash, mention, and universal palettes (TUI_DESIGN §4).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { fuzzyFilter } from "./fuzzy.js";11import type { FuzzyRanked } from "./fuzzy.js";12import { padEndAnsi, truncateAnsi, visibleWidth } from "../renderer/ansi.js";13import type { Theme } from "../theme.js";1415export interface PaletteItem {16  id: string;17  label: string;18  /** Dim description column. */19  detail?: string;20  /** Right-aligned key hint (universal palette). */21  keyHint?: string;22}2324export type PaletteKind = "slash" | "mention" | "command";2526export interface PaletteState {27  kind: PaletteKind;28  query: string;29  items: PaletteItem[];30  filtered: FuzzyRanked<PaletteItem>[];31  selected: number;32}3334export function createPalette(35  kind: PaletteKind,36  items: PaletteItem[],37  query = "",38): PaletteState {39  return {40    kind,41    query,42    items,43    filtered: fuzzyFilter(query, items, (i) => i.label),44    selected: 0,45  };46}4748export function paletteSetQuery(state: PaletteState, query: string): PaletteState {49  return {50    ...state,51    query,52    filtered: fuzzyFilter(query, state.items, (i) => i.label),53    selected: 0,54  };55}5657export function paletteSetItems(state: PaletteState, items: PaletteItem[]): PaletteState {58  return {59    ...state,60    items,61    filtered: fuzzyFilter(state.query, items, (i) => i.label),62    selected: 0,63  };64}6566export function paletteMove(state: PaletteState, delta: number): PaletteState {67  if (state.filtered.length === 0) return state;68  const n = state.filtered.length;69  return { ...state, selected: (state.selected + delta + n) % n };70}7172export function paletteSelection(state: PaletteState): PaletteItem | null {73  return state.filtered[state.selected]?.item ?? null;74}7576const MAX_VISIBLE = 12;7778/** Underline matched characters — match indication is never color-only (§12). */79function underlineMatches(label: string, indices: number[], theme: Theme): string {80  if (indices.length === 0) return label;81  const set = new Set(indices);82  let out = "";83  for (let i = 0; i < label.length; i++) {84    const ch = label[i] as string;85    out += set.has(i) ? theme.paint("underline", ch) : ch;86  }87  return out;88}8990export interface RenderPaletteOptions {91  /** Show the query row inside the panel (universal palette). */92  showQuery?: boolean;93  rows: number;94}9596/**97 * Render the palette as live-region overlay lines, anchored above the98 * composer. Max height min(12, rows − 6); selected row inverted AND marked99 * with `❯` so monochrome keeps the state.100 */101export function renderPalette(102  state: PaletteState,103  width: number,104  theme: Theme,105  options: RenderPaletteOptions,106): string[] {107  const inner = Math.max(24, Math.min(width - 6, 56));108  const maxItems = Math.max(1, Math.min(MAX_VISIBLE, options.rows - 6));109  const dim = (s: string): string => theme.paint("dim", s);110  const border = (s: string): string => theme.paint("accentDim", s);111  const out: string[] = [];112113  out.push("   " + border("┌" + "─".repeat(inner) + "┐"));114115  if (options.showQuery === true) {116    const q = ` ${theme.paint("accent", "❯")} ${state.query}`;117    out.push("   " + border("│") + padEndAnsi(truncateAnsi(q, inner), inner) + border("│"));118    out.push("   " + border("│" + "─".repeat(inner) + "│"));119  }120121  // Scroll window around the selection.122  const total = state.filtered.length;123  let start = 0;124  if (total > maxItems) {125    start = Math.min(Math.max(0, state.selected - Math.floor(maxItems / 2)), total - maxItems);126  }127  const visible = state.filtered.slice(start, start + maxItems);128129  if (visible.length === 0) {130    out.push("   " + border("│") + padEndAnsi(dim("  no matches"), inner) + border("│"));131  }132133  visible.forEach((ranked, idx) => {134    const isSelected = start + idx === state.selected;135    const marker = isSelected ? theme.paint("accent", "❯") : " ";136    const label = underlineMatches(ranked.item.label, ranked.indices, theme);137    let body = ` ${marker} ${label}`;138    if (ranked.item.detail !== undefined) body += `  ${dim(ranked.item.detail)}`;139    let line = truncateAnsi(body, inner);140    if (ranked.item.keyHint !== undefined) {141      const hint = dim(ranked.item.keyHint);142      const pad = inner - visibleWidth(line) - visibleWidth(hint) - 1;143      if (pad > 0) line += " ".repeat(pad) + hint + " ";144    }145    line = padEndAnsi(line, inner);146    if (isSelected) line = theme.paint("invert", line);147    out.push("   " + border("│") + line + border("│"));148  });149150  out.push("   " + border("└" + "─".repeat(inner) + "┘"));151  out.push("    " + dim("↑↓ navigate · Enter run · Esc close"));152  return out;153}154