/** * DetailChrome — deep module owning the whole Detail-window flow behind one seam. * * Previously the Highlight → Detail window flow was fragmented: TrackingEditor * observed the Candidate highlight, src/index.ts held widget state * (currentItem/scrollOffset/lastWidth), scroll math, and header-marker * re-parsing (contentLinesFrom); src/detail-render.ts owned wrapping; * src/window-presentation.ts owned the border. Fixing a wrapping bug required * holding 3 modules in one head — no locality. * * Depth: small interface (setItem / getItem / scrollBy / render) hides scroll * offset, width, MAX_LINES, wrapping, border, and header-marker parsing inside. * detail-render + window-presentation are INTERNAL seams, not part of the * interface. One place to learn — one place to test, no TUI needed. */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { SelectItem } from "@earendil-works/pi-tui"; import { contentLineCount, type DetailItem, renderDetail, scroll } from "./detail-render.js"; import { decorateWindow, type WindowThemeLike } from "./window-presentation.js"; /** Height cap of the detail window (the user's spec: up to 5 lines). */ export const MAX_LINES = 5; /** Kind tag for the header — derived from the candidate's command prefix. */ function kindOf(value: string): string { return value.startsWith("skill:") ? "skill" : "command"; } function resolveSkillPath(skillName: string): string | undefined { const name = skillName.trim(); if (!name) return undefined; const candidates: string[] = []; const cwd = process.cwd(); const home = (() => { try { return os.homedir(); } catch { return ""; } })(); // project-local candidates candidates.push(path.join(cwd, ".pi", "agent", "skills", name, "SKILL.md")); candidates.push(path.join(cwd, ".agents", "skills", name, "SKILL.md")); candidates.push(path.join(cwd, ".pi", "skills", name, "SKILL.md")); candidates.push(path.join(cwd, "skills", name, "SKILL.md")); // user-global candidates if (home) { candidates.push(path.join(home, ".pi", "agent", "skills", name, "SKILL.md")); candidates.push(path.join(home, ".agents", "skills", name, "SKILL.md")); candidates.push(path.join(home, ".pi", ".agents", "skills", name, "SKILL.md")); candidates.push(path.join(home, ".pi", "skills", name, "SKILL.md")); candidates.push( path.join(home, "stowfiles", "dotfiles", ".pi", "agent", "skills", name, "SKILL.md"), ); candidates.push( path.join( home, "stowfiles", "dotfiles", ".pi", "agent", ".agents", "skills", name, "SKILL.md", ), ); } // subskill candidates (any parent skill -> subskills/) const bases = [ path.join(cwd, ".agents", "skills"), path.join(cwd, ".pi", "agent", "skills"), path.join(cwd, ".pi", "skills"), path.join(cwd, "skills"), ]; if (home) { bases.push( path.join(home, ".agents", "skills"), path.join(home, ".pi", "agent", "skills"), path.join(home, ".pi", "skills"), ); } for (const base of bases) { try { const entries = fs.readdirSync(base, { withFileTypes: true }); for (const e of entries) { const isDir = e.isDirectory() || e.isSymbolicLink(); if (!isDir) continue; candidates.push(path.join(base, e.name, "subskills", name, "SKILL.md")); } } catch {} } for (const p of candidates) { try { if (fs.existsSync(p)) return p; } catch { // ignore } } // fallback to global symlink path so header always shows a path for skills if (home) return path.join(home, ".pi", "agent", "skills", name, "SKILL.md"); return path.join(cwd, ".pi", "agent", "skills", name, "SKILL.md"); } type SelectItemWithPath = SelectItem & { path?: string }; function parseInvocation(skillPath: string): "model" | "human" | undefined { try { const content = fs.readFileSync(skillPath, "utf-8"); const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); if (!fmMatch) return "model"; const fm = fmMatch[1] ?? ""; if (/^\s*disable-model-invocation\s*:\s*true\b/m.test(fm)) return "human"; return "model"; } catch { return undefined; } } function detailItemOf(item: SelectItemWithPath): DetailItem { const detail: DetailItem = { label: item.label, kind: kindOf(item.value), description: item.description ?? "", }; const rawPath = item.path?.trim() ?? ""; if (rawPath !== "") { detail.path = rawPath; } else if (item.value.startsWith("skill:")) { const skillName = item.value.slice("skill:".length); const resolved = resolveSkillPath(skillName); if (resolved !== undefined) detail.path = resolved; } if (detail.kind === "skill" && detail.path) { const inv = parseInvocation(detail.path); if (inv) detail.invocation = inv; } return detail; } /** Structural subset of pi's Theme used to build the window theme at render time. */ export interface RawThemeLike { fg(color: string, s: string): string; bold(s: string): string; } export class DetailChrome { private item: SelectItem | null = null; private scrollOffset = 0; private lastWidth = 0; readonly maxLines: number; constructor(maxLines: number = MAX_LINES) { this.maxLines = Math.max(1, Math.floor(maxLines)); } /** New candidate — restarts the scroll. Null hides the window. */ setItem(item: SelectItem | null): void { this.item = item; this.scrollOffset = 0; } getItem(): SelectItem | null { return this.item; } /** Current scroll offset (for tests / reading). */ getScrollOffset(): number { return this.scrollOffset; } /** Whether the current item has a non-empty description (widget install decision). */ hasContent(): boolean { return this.item !== null && (this.item.description ?? "").trim() !== ""; } /** * Move the scroll one line, clamped. No-op when the window is hidden or the * description fits. Returns the resulting offset. */ scrollBy(delta: -1 | 1): number { const item = this.item; if (!item || (item.description ?? "").trim() === "") { return this.scrollOffset; } const width = this.lastWidth > 0 ? this.lastWidth : 80; const innerWidth = Math.max(1, width - 4); this.scrollOffset = scroll( this.scrollOffset, delta, contentLineCount(detailItemOf(item), innerWidth), this.maxLines, ); return this.scrollOffset; } /** * Rendered bordered window at `width`. Returns `[]` when hidden (window * not shown). Reads the LIVE theme at render time so theme swaps apply * immediately. */ render(width: number, theme: unknown): string[] { this.lastWidth = width; const item = this.item; if (!item || (item.description ?? "").trim() === "") { return []; } const t = theme as RawThemeLike; const windowTheme: WindowThemeLike = { border: (s) => t.fg("border", s), highlight: (s) => t.fg("accent", t.bold(s)), dim: (s) => t.fg("dim", s), model: (s) => t.fg("success", s), human: (s) => t.fg("warning", s), }; const innerWidth = Math.max(1, width - 4); const lines = renderDetail(detailItemOf(item), innerWidth, this.maxLines, this.scrollOffset); return decorateWindow(lines, width, windowTheme); } }