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/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