import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui"; import type { LabelProposal } from "./types.ts"; export interface ReviewProposal extends LabelProposal { currentLabel?: string; replacement: boolean; originalIndex: number; } export function classifyReviewProposals( proposals: LabelProposal[], getLabel: (entryId: string) => string | undefined, ): ReviewProposal[] { return proposals .map((proposal, originalIndex) => { const currentLabel = getLabel(proposal.entryId); return { ...proposal, currentLabel, replacement: currentLabel !== undefined, originalIndex }; }) .sort((a, b) => Number(a.replacement) - Number(b.replacement) || a.originalIndex - b.originalIndex); } export async function reviewProposals( proposals: LabelProposal[], ctx: ExtensionCommandContext, ): Promise { if (ctx.mode !== "tui") return undefined; if (!proposals.length) return []; const items = classifyReviewProposals(proposals, (entryId) => ctx.sessionManager.getLabel(entryId)); const freshCount = items.filter((item) => !item.replacement).length; const replacementCount = items.length - freshCount; return ctx.ui.custom((tui, theme, _kb, done) => { let cursor = 0; const checked = proposals.map(() => true); return { invalidate() {}, render(width: number) { const available = Math.max(1, tui.terminal.rows - 7); const start = Math.max(0, Math.min(cursor - Math.floor(available / 2), items.length - available)); const visible = items.slice(start, start + available); const lines = [ theme.fg("accent", theme.bold("Tree label proposals")), theme.fg("dim", "↑/↓ move · Space toggle · Enter apply · Esc cancel"), theme.fg("muted", `Fresh labels: ${freshCount} · Replacements: ${replacementCount} · uncheck a replacement to keep its current label`), "", ]; let previousSection: boolean | undefined; visible.forEach((item, visibleIndex) => { const index = start + visibleIndex; if (item.replacement !== previousSection) { lines.push(theme.fg("accent", theme.bold(item.replacement ? "Replacements" : "Fresh labels"))); previousSection = item.replacement; } const marker = checked[item.originalIndex] ? "[x]" : "[ ]"; const pointer = index === cursor ? "›" : " "; const change = item.replacement ? `${JSON.stringify(item.currentLabel)} → ${JSON.stringify(item.label)}` : `new: ${JSON.stringify(item.label)}`; const main = truncateToWidth(`${pointer} ${marker} ${item.entryId} ${change} (${item.kind})`, Math.max(1, width - 2)); lines.push(index === cursor ? theme.fg("accent", theme.bold(main)) : main); if (index === cursor && item.rationale) lines.push(theme.fg("muted", truncateToWidth(` ${item.rationale}`, Math.max(1, width - 2)))); }); if (items.length > available) lines.push(theme.fg("dim", ` (${cursor + 1}/${items.length})`)); return new Text(lines.join("\n"), 1, 0).render(width); }, handleInput(data: string) { if (matchesKey(data, "up")) cursor = (cursor - 1 + items.length) % items.length; else if (matchesKey(data, "down")) cursor = (cursor + 1) % items.length; else if (matchesKey(data, "space")) checked[items[cursor].originalIndex] = !checked[items[cursor].originalIndex]; else if (matchesKey(data, "enter")) done(proposals.filter((_, index) => checked[index])); else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) done(undefined); tui.requestRender(); }, }; }); }