/** * KHAELOR * File: src/tui/composer/palette.ts * Description: The one generic filter-list powering slash, mention, and universal palettes (TUI_DESIGN §4). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { fuzzyFilter } from "./fuzzy.js"; import type { FuzzyRanked } from "./fuzzy.js"; import { padEndAnsi, truncateAnsi, visibleWidth } from "../renderer/ansi.js"; import type { Theme } from "../theme.js"; export interface PaletteItem { id: string; label: string; /** Dim description column. */ detail?: string; /** Right-aligned key hint (universal palette). */ keyHint?: string; } export type PaletteKind = "slash" | "mention" | "command"; export interface PaletteState { kind: PaletteKind; query: string; items: PaletteItem[]; filtered: FuzzyRanked[]; selected: number; } export function createPalette( kind: PaletteKind, items: PaletteItem[], query = "", ): PaletteState { return { kind, query, items, filtered: fuzzyFilter(query, items, (i) => i.label), selected: 0, }; } export function paletteSetQuery(state: PaletteState, query: string): PaletteState { return { ...state, query, filtered: fuzzyFilter(query, state.items, (i) => i.label), selected: 0, }; } export function paletteSetItems(state: PaletteState, items: PaletteItem[]): PaletteState { return { ...state, items, filtered: fuzzyFilter(state.query, items, (i) => i.label), selected: 0, }; } export function paletteMove(state: PaletteState, delta: number): PaletteState { if (state.filtered.length === 0) return state; const n = state.filtered.length; return { ...state, selected: (state.selected + delta + n) % n }; } export function paletteSelection(state: PaletteState): PaletteItem | null { return state.filtered[state.selected]?.item ?? null; } const MAX_VISIBLE = 12; /** Underline matched characters — match indication is never color-only (§12). */ function underlineMatches(label: string, indices: number[], theme: Theme): string { if (indices.length === 0) return label; const set = new Set(indices); let out = ""; for (let i = 0; i < label.length; i++) { const ch = label[i] as string; out += set.has(i) ? theme.paint("underline", ch) : ch; } return out; } export interface RenderPaletteOptions { /** Show the query row inside the panel (universal palette). */ showQuery?: boolean; rows: number; } /** * Render the palette as live-region overlay lines, anchored above the * composer. Max height min(12, rows − 6); selected row inverted AND marked * with `❯` so monochrome keeps the state. */ export function renderPalette( state: PaletteState, width: number, theme: Theme, options: RenderPaletteOptions, ): string[] { const inner = Math.max(24, Math.min(width - 6, 56)); const maxItems = Math.max(1, Math.min(MAX_VISIBLE, options.rows - 6)); const dim = (s: string): string => theme.paint("dim", s); const border = (s: string): string => theme.paint("accentDim", s); const out: string[] = []; out.push(" " + border("┌" + "─".repeat(inner) + "┐")); if (options.showQuery === true) { const q = ` ${theme.paint("accent", "❯")} ${state.query}`; out.push(" " + border("│") + padEndAnsi(truncateAnsi(q, inner), inner) + border("│")); out.push(" " + border("│" + "─".repeat(inner) + "│")); } // Scroll window around the selection. const total = state.filtered.length; let start = 0; if (total > maxItems) { start = Math.min(Math.max(0, state.selected - Math.floor(maxItems / 2)), total - maxItems); } const visible = state.filtered.slice(start, start + maxItems); if (visible.length === 0) { out.push(" " + border("│") + padEndAnsi(dim(" no matches"), inner) + border("│")); } visible.forEach((ranked, idx) => { const isSelected = start + idx === state.selected; const marker = isSelected ? theme.paint("accent", "❯") : " "; const label = underlineMatches(ranked.item.label, ranked.indices, theme); let body = ` ${marker} ${label}`; if (ranked.item.detail !== undefined) body += ` ${dim(ranked.item.detail)}`; let line = truncateAnsi(body, inner); if (ranked.item.keyHint !== undefined) { const hint = dim(ranked.item.keyHint); const pad = inner - visibleWidth(line) - visibleWidth(hint) - 1; if (pad > 0) line += " ".repeat(pad) + hint + " "; } line = padEndAnsi(line, inner); if (isSelected) line = theme.paint("invert", line); out.push(" " + border("│") + line + border("│")); }); out.push(" " + border("└" + "─".repeat(inner) + "┘")); out.push(" " + dim("↑↓ navigate · Enter run · Esc close")); return out; }