import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent"; import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@mariozechner/pi-tui"; interface SubmissionEntry { label: string; value: string; description: string; absolutePath: string; sizeKb: number; modifiedAt: string; modifiedMs: number; preview: SubmissionPreview; } interface SubmissionPreview { title: string; headings: string[]; summary: string; } type SortMode = "name" | "newest" | "largest"; function stripHtml(html: string): string { return html .replace(/]*>[\s\S]*?<\/script>/gi, " ") .replace(/]*>[\s\S]*?<\/style>/gi, " ") .replace(/<[^>]+>/g, " ") .replace(/ /g, " ") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/'/g, "'") .replace(/"/g, '"') .replace(/\s+/g, " ") .trim(); } function extractPreview(absolutePath: string): SubmissionPreview { const html = readFileSync(absolutePath, "utf8"); const titleMatch = html.match(/]*>([\s\S]*?)<\/title>/i); const headingMatches = Array.from(html.matchAll(/]*>([\s\S]*?)<\/h[1-3]>/gi)) .map((match) => stripHtml(match[1] ?? "")) .filter(Boolean) .slice(0, 6); const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); const summary = stripHtml(bodyMatch?.[1] ?? html).slice(0, 320); return { title: stripHtml(titleMatch?.[1] ?? "Untitled submission"), headings: headingMatches, summary: summary || "No summary could be extracted from the HTML.", }; } function findSubmissionFiles(rootDir: string): SubmissionEntry[] { const entries: SubmissionEntry[] = []; function walk(currentDir: string) { for (const name of readdirSync(currentDir)) { const absolutePath = join(currentDir, name); const stats = statSync(absolutePath); if (stats.isDirectory()) { walk(absolutePath); continue; } if (!stats.isFile() || !name.endsWith(".html")) continue; const rel = relative(rootDir, absolutePath) || name; const sizeKb = Math.max(1, Math.round(stats.size / 1024)); entries.push({ label: rel, value: absolutePath, description: `${sizeKb} KB`, absolutePath, sizeKb, modifiedAt: stats.mtime.toISOString().replace("T", " ").slice(0, 16), modifiedMs: stats.mtimeMs, preview: extractPreview(absolutePath), }); } } walk(rootDir); entries.sort((a, b) => a.label.localeCompare(b.label)); return entries; } async function openInBrowser(pi: ExtensionAPI, absolutePath: string): Promise { const url = pathToFileURL(absolutePath).href; if (process.platform === "darwin") { await pi.exec("open", [url]); return; } if (process.platform === "win32") { await pi.exec("cmd", ["/c", "start", "", url]); return; } await pi.exec("xdg-open", [url]); } class SubmissionBrowserComponent implements Component { private selected = 0; private scroll = 0; private filterQuery = ""; private sortMode: SortMode = "name"; private cachedWidth?: number; private cachedLines?: string[]; constructor( private readonly theme: Theme, private readonly rootDir: string, private readonly files: SubmissionEntry[], private readonly done: (value: string | null) => void, ) {} private getFilteredFiles(): SubmissionEntry[] { const query = this.filterQuery.trim().toLowerCase(); let filtered = this.files.filter((file) => { if (!query) return true; const haystack = [file.label, file.preview.title, file.preview.summary, ...file.preview.headings].join(" ").toLowerCase(); return haystack.includes(query); }); filtered = [...filtered].sort((a, b) => { switch (this.sortMode) { case "newest": return b.modifiedMs - a.modifiedMs || a.label.localeCompare(b.label); case "largest": return b.sizeKb - a.sizeKb || a.label.localeCompare(b.label); case "name": default: return a.label.localeCompare(b.label); } }); return filtered; } private selectedFile(): SubmissionEntry | undefined { const files = this.getFilteredFiles(); if (files.length === 0) return undefined; if (this.selected >= files.length) this.selected = files.length - 1; if (this.selected < 0) this.selected = 0; return files[this.selected]; } private cycleSort(): void { this.sortMode = this.sortMode === "name" ? "newest" : this.sortMode === "newest" ? "largest" : "name"; this.selected = 0; this.scroll = 0; this.invalidate(); } handleInput(data: string): void { const visibleFiles = this.getFilteredFiles(); if (matchesKey(data, Key.escape)) { if (this.filterQuery) { this.filterQuery = ""; this.selected = 0; this.scroll = 0; this.invalidate(); return; } this.done(null); return; } if (matchesKey(data, Key.ctrl("s"))) { this.cycleSort(); return; } if (matchesKey(data, Key.backspace)) { if (this.filterQuery.length > 0) { this.filterQuery = this.filterQuery.slice(0, -1); this.selected = 0; this.scroll = 0; this.invalidate(); } return; } if (matchesKey(data, Key.up)) { this.selected = Math.max(0, this.selected - 1); if (this.selected < this.scroll) this.scroll = this.selected; this.invalidate(); return; } if (matchesKey(data, Key.down)) { this.selected = Math.min(Math.max(0, visibleFiles.length - 1), this.selected + 1); this.invalidate(); return; } if (matchesKey(data, Key.enter)) { this.done(this.selectedFile()?.value ?? null); return; } if (data.length === 1 && data.charCodeAt(0) >= 32) { this.filterQuery += data; this.selected = 0; this.scroll = 0; this.invalidate(); } } render(width: number): string[] { if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; const border = (text: string) => this.theme.fg("border", text); const lines: string[] = []; const innerWidth = Math.max(40, width - 2); const listWidth = Math.max(24, Math.min(44, Math.floor(innerWidth * 0.42))); const gapWidth = 3; const previewWidth = Math.max(20, innerWidth - listWidth - gapWidth); const visibleFiles = this.getFilteredFiles(); const visibleRows = Math.max(4, Math.min(12, Math.max(visibleFiles.length, 1))); if (this.selected < this.scroll) this.scroll = this.selected; if (this.selected >= this.scroll + visibleRows) this.scroll = this.selected - visibleRows + 1; lines.push(truncateToWidth(`${this.theme.fg("accent", this.theme.bold("Submission Browser"))} ${this.theme.fg("muted", this.rootDir)}`, width)); lines.push(truncateToWidth(this.theme.fg("dim", "Type to filter. Ctrl+S changes sort order. Enter opens the selected report."), width)); lines.push(truncateToWidth(`${this.theme.fg("text", this.theme.bold("Filter"))}: ${this.filterQuery ? this.theme.fg("accent", this.filterQuery) : this.theme.fg("dim", "(type to search title, path, headings, summary)")} ${this.theme.fg("text", this.theme.bold("Sort"))}: ${this.theme.fg("accent", this.sortMode)}`, width)); lines.push(border("─".repeat(Math.max(1, width)))); const selectedFile = this.selectedFile(); const listLines: string[] = []; if (visibleFiles.length === 0) { listLines.push(this.theme.fg("warning", "No submissions match the current filter.")); listLines.push(this.theme.fg("dim", "Backspace removes characters. Esc clears the filter.")); } const start = this.scroll; const end = Math.min(visibleFiles.length, start + visibleRows); for (let index = start; index < end; index++) { const file = visibleFiles[index]!; const isSelected = index === this.selected; const prefix = isSelected ? this.theme.fg("accent", "❯ ") : " "; const label = isSelected ? this.theme.fg("accent", file.label) : this.theme.fg("text", file.label); const meta = this.theme.fg("dim", `${file.sizeKb} KB • ${file.modifiedAt}`); listLines.push(truncateToWidth(prefix + label, listWidth)); listLines.push(truncateToWidth(` ${meta}`, listWidth)); } while (listLines.length < visibleRows * 2) listLines.push(""); const previewLines: string[] = []; if (selectedFile) { previewLines.push(...wrapTextWithAnsi(this.theme.fg("accent", this.theme.bold(selectedFile.preview.title)), previewWidth)); previewLines.push(this.theme.fg("muted", `${selectedFile.label} • ${selectedFile.sizeKb} KB • ${selectedFile.modifiedAt}`)); previewLines.push(""); previewLines.push(this.theme.fg("text", this.theme.bold("Key sections"))); if (selectedFile.preview.headings.length === 0) { previewLines.push(this.theme.fg("dim", "No headings found in this HTML file.")); } else { for (const heading of selectedFile.preview.headings) { previewLines.push(...wrapTextWithAnsi(`${this.theme.fg("accent", "•")} ${this.theme.fg("text", heading)}`, previewWidth)); } } previewLines.push(""); previewLines.push(this.theme.fg("text", this.theme.bold("Summary preview"))); previewLines.push(...wrapTextWithAnsi(this.theme.fg("muted", selectedFile.preview.summary), previewWidth)); } else { previewLines.push(this.theme.fg("warning", "No submission selected.")); } const bodyHeight = Math.max(listLines.length, previewLines.length); while (listLines.length < bodyHeight) listLines.push(""); while (previewLines.length < bodyHeight) previewLines.push(""); for (let i = 0; i < bodyHeight; i++) { const left = truncateToWidth(listLines[i] ?? "", listWidth); const leftPad = left + " ".repeat(Math.max(0, listWidth - visibleWidth(left))); const right = truncateToWidth(previewLines[i] ?? "", previewWidth); lines.push(`${leftPad}${" ".repeat(gapWidth)}${right}`); } lines.push(border("─".repeat(Math.max(1, width)))); lines.push(truncateToWidth(this.theme.fg("dim", "↑↓ navigate • type filter • Backspace delete • Ctrl+S sort • Enter open • Esc clear/cancel"), width)); this.cachedWidth = width; this.cachedLines = lines; return lines; } invalidate(): void { this.cachedWidth = undefined; this.cachedLines = undefined; } } async function showSubmissionPicker(ctx: ExtensionCommandContext, rootDir: string, files: SubmissionEntry[]): Promise { const selected = await ctx.ui.custom((_tui, theme, _kb, done) => new SubmissionBrowserComponent(theme, rootDir, files, done)); if (!selected) return null; return files.find((file) => file.value === selected) ?? null; } export default function submissionsBrowserExtension(pi: ExtensionAPI) { pi.registerCommand("submissions", { description: "Browse submission HTML files in a directory and open one in the browser", handler: async (args, ctx) => { const targetArg = args.trim(); const rootDir = resolve(ctx.cwd, targetArg || ".submissions"); if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) { ctx.ui.notify(`Submission directory not found: ${rootDir}`, "error"); return; } const files = findSubmissionFiles(rootDir); if (files.length === 0) { ctx.ui.notify(`No submission HTML files found in ${rootDir}`, "warning"); return; } const selected = await showSubmissionPicker(ctx, rootDir, files); if (!selected) return; try { await openInBrowser(pi, selected.absolutePath); ctx.ui.notify(`Opened ${selected.label}`, "info"); } catch (error) { const message = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Failed to open browser: ${message}`, "error"); } }, }); }