/** * Shared base for sectioned panels with fzf-style search — the `/hooks` * panel and the preset editor's hook picker. Single-sources the search * lifecycle (normal → editing → filtered work mode: enter/resume/exit/append/ * pop/apply/restore), the `/query▕` render line, the sectioned-body * composition (`render`/`bodyLines`/windowing/`renderSectionHeader`/ * `moveFocus`), the section-header jump-key hints (`Tab`/`Shift+Tab`), AND * the shared input (search-mode block + normal-mode navigation: * `handleSearchInput`/`handleNavInput`) so the two views share one * implementation (no drift; see AGENTS.md "Prefer one shared implementation * over two"). * * The search swaps `groups`/`nav` (not the renderer): during search they point * at filtered subsets of the same catalog-entry object references; `bodyLines`, * `renderSection`, and windowing run unchanged against the filtered model. * Ranking is per-section (`filterSection`) so section grouping and order stay * stable while matches rank inside each section; empty sections drop out. * * Subclasses implement the abstract rendering hooks (`renderHeaderLines`, * `hintLine`, `renderSection`) and may override the optional hooks * (`emptyBodyLines`, `modeBodyLines`, `detailPrefixFor`, `detailSuffixFor`, * `sectionHeaderKeys`). * The base owns the composition: `render` → `bodyLines` → `renderSectionHeader` * + `renderSection` + `layoutSectionedBody` windowing. */ import { DynamicBorder, type KeybindingsManager, type Theme, } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey, truncateToWidth, type KeyId } from "@earendil-works/pi-tui"; import type { CatalogEntry } from "../hook-catalog/index.js"; import { SectionNavigator, filterPrintable, layoutSectionedBody, renderHookDetail, fitLines, } from "./components.js"; import { filterSection } from "./fuzzy.js"; /** An ordered list of hooks sharing a `source` (a display section). */ export interface Group { source: string; hooks: CatalogEntry[]; } /** * Source sort order: `local` first, then the rest alphabetically. Shared by * every sectioned panel so section order is stable and identical across views. */ function sortSources(sources: string[]): string[] { return sources.sort((a, b) => { if (a === "local") return -1; if (b === "local") return 1; return a.localeCompare(b); }); } /** * Group Hooks by `source` (local first, then repositories alpha). * Presets are **not** hoisted — callers that want a Presets section build it * themselves. Shared so the two views agree on section order. */ export function groupHooksBySource(entries: readonly CatalogEntry[]): Group[] { const bySource = new Map(); for (const a of entries) { const list = bySource.get(a.source) ?? []; list.push(a); bySource.set(a.source, list); } return sortSources([...bySource.keys()]).map( (source) => ({ source, hooks: bySource.get(source)! }), ); } /** * Abstract base for a sectioned panel over catalog entries with search. * Owns the search lifecycle, the query render line, and the sectioned-body * composition (`render`/`bodyLines`/windowing/headers/`moveFocus`). * Subclasses implement the abstract hooks and may override the optional ones. */ export abstract class SectionedPanel { groups!: Group[]; nav!: SectionNavigator; protected searchActive = false; /** * Whether query input is active (editing). `searchActive` alone means a * search filter is applied; filtered work mode is active search with * `searchEditing === false` — the retained result set stays on screen and * row/panel actions are reachable again. Composes: editing ⇒ search * active, but search active does not imply editing. */ protected searchEditing = false; protected query = ""; protected savedGroups: Group[] | null = null; protected savedNav: SectionNavigator | null = null; protected keybindings?: KeybindingsManager; setKeybindings(keybindings: KeybindingsManager): void { this.keybindings = keybindings; } /** Whether search state is currently exposed (used by tests). */ get isSearchActive(): boolean { return this.searchActive; } // ── Abstract hooks (subclass must implement) ────────────────────── /** Whether entering search is allowed (non-empty, non-broken model). */ protected abstract canSearch(): boolean; /** Theme accessor (set by the subclass before the first render). */ protected abstract get theme(): Theme; /** Header lines above the body (title, subtitle, blank). */ protected abstract renderHeaderLines(): string[]; /** The hint line(s) rendered at the bottom by `render`. */ protected abstract hintLine(width?: number): string[]; /** Render one section's rows (active or dimmed-inactive). */ protected abstract renderSection( width: number, group: Group, focused: boolean, selectedIndex: number, start?: number, end?: number, ): string[]; /** * Toggle the focused row's state — enable/disable in `/hooks`, membership * `✓` in the preset editor. Called by the shared `Enter` binding (search + * normal mode), so both views toggle with the same key. */ protected abstract toggleFocused(): void; // ── Optional hooks (subclass may override) ──────────────────────── /** Whether the panel has zero items to show (drives the empty-state branch). */ protected isEmpty(): boolean { return this.groups.every((g) => g.hooks.length === 0); } /** Body lines for the empty state. Default: header only. */ protected emptyBodyLines(_width: number): string[] { return this.renderHeaderLines(); } /** * Body lines for a panel-specific mode that takes over the body (e.g. the * `/hooks` panel's confirm-remove). Return `null` when the mode is * inactive so the normal render path runs. */ protected modeBodyLines(_width: number): string[] | null { return null; } /** Optional warning/note lines before focused-row hook metadata. */ protected detailPrefixFor(_a: CatalogEntry, _width: number): string[] { return []; } /** Optional contextual lines after focused-row hook metadata. */ protected detailSuffixFor(_a: CatalogEntry, _width: number): string[] { return []; } /** * Complete focused-row detail block used for viewport accounting. The same * prefix → metadata → suffix order is passed to `renderDetailList` by each * sectioned-panel renderer. */ protected detailBlockFor(a: CatalogEntry | undefined, width: number): string[] { if (!a) return []; return [ ...this.detailPrefixFor(a, width), ...renderHookDetail(this.theme, width, a), ...this.detailSuffixFor(a, width), ]; } /** * Jump-key hints shown after the section header. The base shows `Tab` / * `Shift+Tab` on the section a Tab / Shift+Tab cycle would land on (next / * prev relative to the focused section), so every sectioned panel advertises * the same cross-section navigation. Subclasses may override to prepend * panel-specific jump keys (e.g. `/hooks` adds `p` for the Presets section). */ protected sectionHeaderKeys(index: number): string[] { const n = this.groups.length; if (n < 2) return []; const focused = this.nav.focusedSection; if (index === focused) return []; const nextTarget = (focused + 1) % n; const prevTarget = (focused - 1 + n) % n; const keys: string[] = []; if (index === nextTarget) keys.push("Tab"); if (index === prevTarget) keys.push("Shift+Tab"); return keys; } // ── Composition (shared by every sectioned panel) ───────────────── /** * `render` is the single emission point: it always returns header + body + * a blank separator + the framed persistent footer. The individual branches * in `bodyLines` never append footer pieces, so no mode (empty / mode / * bounded / unbounded) can forget its full-width muted hooks. */ render(width: number, terminalHeight?: number): string[] { const hintLines = this.hintLine(width); const borderColor = (s: string) => this.theme.fg("borderMuted", s); const footerLines = [ ...new DynamicBorder(borderColor).render(width), ...hintLines, ...new DynamicBorder(borderColor).render(width), ]; const body = this.bodyLines(width, terminalHeight, footerLines.length); let rendered = [...body, "", ...footerLines]; if (terminalHeight !== undefined && rendered.length > terminalHeight) { // Preserve both full-width hooks and the hint instead of slicing the // fixed footer away when a long detail block exceeds the budget. const footer = ["", ...footerLines]; const bodyBudget = Math.max(0, terminalHeight - footer.length); const compactBody = [...body]; // Blank separators are decorative; remove them before sacrificing a // section header, selected row, or the fixed framed footer. while (compactBody.length > bodyBudget) { const blank = compactBody.lastIndexOf(""); if (blank < 0) break; compactBody.splice(blank, 1); } // The muted count subtitle is next least important; retain the title. if (compactBody.length > bodyBudget && compactBody.length > 1) { compactBody.splice(1, 1); } rendered = [...compactBody.slice(0, bodyBudget), ...footer] .slice(0, terminalHeight); } return fitLines(rendered, width); } /** Header + content for the current mode, WITHOUT the trailing hint. */ protected bodyLines( width: number, terminalHeight: number | undefined, footerLen: number, ): string[] { // Search filtered to zero matches must take precedence over the normal // empty-panel branch: a zero-match view shows "No matches", never the // empty-panel message. if (this.searchActive && this.groups.length === 0) { return [ ...this.renderHeaderLines(), this.renderSearchQueryLine(width), this.theme.fg("warning", " No matches"), ]; } if (this.isEmpty()) return this.emptyBodyLines(width); const mode = this.modeBodyLines(width); if (mode) return mode; if (terminalHeight === undefined) { return this.renderUnboundedBody(width); } // Header and footer (1 blank + top hook + hint line(s) + bottom hook) // are reserved. The active section is the anchor; adjacent section // headers are always shown. // Detail lines for the selected hook are rendered inline directly // below the highlighted hook row, so the active section's line budget // includes both hook rows and the selected row's details. The // viewport geometry (which sections fit, the focused section's row // window) is `layoutSectionedBody`. const headerLines = this.renderHeaderLines(); const queryLine = this.searchActive ? this.renderSearchQueryLine(width) : null; const available = terminalHeight - headerLines.length - (queryLine ? 1 : 0) - 1 - footerLen; const focusedSection = this.nav.focusedSection; const activeGroup = this.groups[focusedSection]; const activeLen = activeGroup.hooks.length; const selectedHook = activeGroup.hooks[this.nav.focusedIndex]; const detailBlock = this.detailBlockFor(selectedHook, width); const layout = layoutSectionedBody({ sectionCount: this.groups.length, focusedSection, focusedIndex: this.nav.focusedIndex, activeLen, detailBlockHeight: detailBlock.length, available, }); let coreLines: string[] = [ this.renderSectionHeader(focusedSection), ...this.renderSection( width, activeGroup, true, this.nav.focusedIndex, layout.activeWindow[0], layout.activeWindow[1], ), ]; if (layout.windowed) { coreLines.push( this.theme.fg("dim", ` (${this.nav.focusedIndex + 1}/${activeLen})`), ); } // Always show the immediate previous and next section headers. if (layout.showPrev) { coreLines = [this.renderSectionHeader(focusedSection - 1), "", ...coreLines]; } if (layout.showNext) { coreLines = [...coreLines, "", this.renderSectionHeader(focusedSection + 1)]; } // Add any farther sections that fit. let lines: string[] = [...coreLines]; for (const idx of layout.inactiveAbove) { lines = [...this.renderInactiveSectionHeader(idx), "", ...lines]; } for (const idx of layout.inactiveBelow) { lines = [...lines, "", ...this.renderInactiveSectionHeader(idx)]; } return [...headerLines, ...(queryLine ? [queryLine] : []), ...lines]; } /** Unbounded body (no terminal height) — used by tests / headless renders. */ protected renderUnboundedBody(width: number): string[] { const lines: string[] = []; if (this.searchActive) lines.push(this.renderSearchQueryLine(width)); for (let i = 0; i < this.groups.length; i++) { const g = this.groups[i]!; const isFocused = i === this.nav.focusedSection; lines.push(this.renderSectionHeader(i)); lines.push(...this.renderSection(width, g, isFocused, this.nav.focusedIndex)); if (i < this.groups.length - 1) lines.push(""); } return [...this.renderHeaderLines(), ...lines]; } /** Section header: `Local` / repo key, accent when focused, muted otherwise. */ protected renderSectionHeader(index: number): string { const group = this.groups[index]; const focused = index === this.nav.focusedSection; const header = group.source === "local" ? "Local" : group.source; const color = focused ? "accent" : "muted"; const keys = this.sectionHeaderKeys(index); const keyHint = keys.length ? " " + keys.map((k) => this.theme.fg("accent", k)).join(this.theme.fg("dim", " · ")) : ""; return ` ${this.theme.fg(color, header)}${keyHint}`; } /** Inactive section header (just the header line, no key hints). */ protected renderInactiveSectionHeader(index: number): string[] { return [this.renderSectionHeader(index)]; } /** Move focus one step, crossing to the adjacent section at the boundary. */ protected moveFocus(dir: "up" | "down"): void { if (this.nav.cross(dir)) return; this.nav.moveWithin(dir); } // ── Shared input (search + navigation) ──────────────────────────── // The search-mode block and the normal-mode navigation keys (`/` search, // Enter toggle, Tab/Shift+Tab cycle, arrows) are identical across every // sectioned panel — only the panel-specific action keys differ (and live in // the subclass `handleInput`). Each subclass calls `handleSearchInput` first // (search pre-empts everything) and `handleNavInput` last (panel-specific // keys take precedence over plain navigation), so the shared keys are // single-sourced here (no drift; see AGENTS.md). /** * Handle the shared search keys. Search has three states — normal, * query editing (`searchEditing`), and filtered work mode (active search * without `searchEditing`): * * - Editing: configured confirm locks the current filter into work mode * (retaining the query, filtered Sections, focus, and saved normal-view * snapshot); configured cancel exits search completely. Backspace edits, * arrows / Tab / Shift+Tab navigate, and other printable input extends * the query (consumed). Enter never acts on a row while editing. * - Filtered work mode: configured cancel performs the complete search * exit (restore saved model, clear query), `/` resumes editing the * retained query, Enter/arrows/Tab act on the result set, and every * other key FALLS THROUGH (`false`) for panel-specific action routing * without ever implicitly editing the query. * * Returns `true` iff the key was consumed — callers return `undefined` * and stay open. No-op (returns `false`) when search is inactive, so the * subclass can call it unconditionally as the first input check. */ protected handleSearchInput(data: string): boolean { if (!this.searchActive) return false; if (this.searchEditing) { if (this.matchesCancel(data)) { this.exitSearch(); return true; } if (this.matchesSelect(data, "tui.select.confirm", "enter")) { this.searchEditing = false; return true; } if (matchesKey(data, "backspace")) { this.popQuery(); return true; } if (this.matchesSelect(data, "tui.select.up", "up")) { this.moveFocus("up"); return true; } if (this.matchesSelect(data, "tui.select.down", "down")) { this.moveFocus("down"); return true; } if (matchesKey(data, Key.tab)) { this.nav.cycleSection("next"); return true; } if (matchesKey(data, Key.shift("tab"))) { this.nav.cycleSection("prev"); return true; } this.appendQuery(data); // filterPrintable inside; no-op for bare controls return true; } // Filtered work mode: the focused result set stays on screen; only the // explicit transitions below act on it. if (this.matchesCancel(data)) { this.exitSearch(); return true; } if (matchesKey(data, "/")) { this.resumeEditing(); return true; } if (this.matchesSelect(data, "tui.select.confirm", "enter")) { this.toggleFocused(); return true; } if (this.matchesSelect(data, "tui.select.up", "up")) { this.moveFocus("up"); return true; } if (this.matchesSelect(data, "tui.select.down", "down")) { this.moveFocus("down"); return true; } if (matchesKey(data, Key.tab)) { this.nav.cycleSection("next"); return true; } if (matchesKey(data, Key.shift("tab"))) { this.nav.cycleSection("prev"); return true; } return false; // panel-specific routing; never implicitly edits the query } /** * Handle the normal-mode navigation keys (`/` search, Enter toggle, * Tab/Shift+Tab cycle, arrows). Returns `true` iff consumed. Call this * last in the subclass `handleInput`, after the panel-specific keys, so * panel actions take precedence over plain navigation. */ protected handleNavInput(data: string): boolean { if (matchesKey(data, "/")) { this.enterSearch(); return true; } if (this.matchesSelect(data, "tui.select.confirm", "enter")) { this.toggleFocused(); return true; } if (matchesKey(data, Key.tab)) { this.nav.cycleSection("next"); return true; } if (matchesKey(data, Key.shift("tab"))) { this.nav.cycleSection("prev"); return true; } if (this.matchesSelect(data, "tui.select.up", "up")) { this.moveFocus("up"); return true; } if (this.matchesSelect(data, "tui.select.down", "down")) { this.moveFocus("down"); return true; } return false; } protected matchesCancel(data: string): boolean { return this.matchesSelect(data, "tui.select.cancel", Key.escape); } private matchesSelect( data: string, id: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel", fallback: KeyId, ): boolean { return typeof this.keybindings?.matches === "function" ? this.keybindings.matches(data, id) : matchesKey(data, fallback); } // ── Search lifecycle ─────────────────────────────────────────────── /** Enter fuzzy-search mode. No-op when there's nothing to search. */ protected enterSearch(): void { if (!this.canSearch()) return; this.searchActive = true; this.searchEditing = true; this.query = ""; this.savedGroups = this.groups; this.savedNav = this.nav; this.applyFilter(); } /** Exit search; restore focus to the highlighted match in the unfiltered view. */ protected exitSearch(): void { const kept = this.groups[this.nav.focusedSection]?.hooks[this.nav.focusedIndex]; this.groups = this.savedGroups ?? this.groups; this.nav = this.savedNav ?? this.nav; this.savedGroups = null; this.savedNav = null; this.searchActive = false; this.searchEditing = false; this.query = ""; if (kept) this.restoreFocus(kept); } /** Append printable input to the query and re-filter. */ protected appendQuery(data: string): void { const filtered = filterPrintable(data); if (!filtered) return; this.query += filtered; this.applyFilter(); } /** Pop the last query char and re-filter (no-op on an empty query). */ protected popQuery(): void { if (this.query.length === 0) return; this.query = this.query.slice(0, -1); this.applyFilter(); } /** * Rebuild filtered `groups` / `nav` from `savedGroups` + the current * query, then restore focus to the previously-highlighted hook * (best-effort). Empty/whitespace-only query reproduces every section * unchanged. Filtering keeps the same catalog-entry references, so * `restoreFocus` can use identity (`indexOf`) lookup. */ protected applyFilter(): void { const keep = this.groups[this.nav.focusedSection]?.hooks[this.nav.focusedIndex]; const filtered = (this.savedGroups ?? this.groups) .map((g) => ({ source: g.source, hooks: filterSection(this.query, g.hooks), })) .filter((g) => g.hooks.length > 0); this.groups = filtered; this.nav = new SectionNavigator(filtered.map((g) => ({ items: g.hooks }))); this.restoreFocus(keep); } /** * Resume editing the retained query from filtered work mode. The query * text, filtered groups, focus, and saved normal-view snapshot are all * preserved unchanged — only the editing state flips back on. */ protected resumeEditing(): void { this.searchEditing = true; } /** Point `nav` at `a`'s section/index in the current (filtered) groups. */ protected restoreFocus(a: CatalogEntry | undefined): void { if (!a) { if (this.groups.length) { this.nav.focus = 0; this.nav.selection[0] = 0; } return; } for (let s = 0; s < this.groups.length; s++) { const idx = this.groups[s]!.hooks.indexOf(a); // identity (===) if (idx >= 0) { this.nav.focus = s; this.nav.selection[s] = idx; return; } } // `a` filtered out: stay in its source section if any filtered group // shares `a.source` (by string — section indices shift as empty // sections drop, so positional lookup is unsafe), else first match. const sec = this.groups.findIndex((g) => g.source === a.source); if (sec >= 0) { this.nav.focus = sec; this.nav.selection[sec] = 0; return; } if (this.groups.length) { this.nav.focus = 0; this.nav.selection[0] = 0; } } /** * The `/query` line shown at the top of the body during search. Editing * renders the existing accent `/query` with a block cursor; filtered work * mode renders dim `/query` without a cursor so the retained filter is * legible but unmistakably no longer being typed into. */ protected renderSearchQueryLine(width: number): string { const prompt = "/" + this.query; const truncated = truncateToWidth(prompt, Math.max(1, width - 3)); if (this.searchEditing) { return ` ${this.theme.fg("accent", truncated)}▏`; } return ` ${this.theme.fg("dim", truncated)}`; } }