import { DynamicBorder, type ExtensionContext, getMarkdownTheme, type Theme, } from "@earendil-works/pi-coding-agent"; import { Container, type Focusable, Input, Markdown, matchesKey, Text, truncateToWidth, } from "@earendil-works/pi-tui"; import type { GoodJobRecord } from "./core.js"; import type { GoodJobCounts } from "./storage.js"; function oneLine(text: string, maximum = 100): string { const compact = text.replace(/\s+/g, " ").trim(); return Array.from(compact).length > maximum ? `${Array.from(compact) .slice(0, maximum - 1) .join("")}…` : compact; } function dateLabel(timestamp: string): string { const date = new Date(timestamp); return Number.isNaN(date.valueOf()) ? "unknown date" : date.toISOString().slice(0, 10); } function projectLabel(record: GoodJobRecord): string { if (record.source.type === "import") return record.source.importer; const normalized = record.source.cwd.replaceAll("\\", "/").replace(/\/$/, ""); return normalized.split("/").at(-1) || record.source.cwd; } function markdownText(text: string): string { return text.replace(/^([#>*+-])\s/gm, "\\$1 "); } export function formatLearningMarkdown(record: GoodJobRecord): string { const evidence = record.learning.evidence .map((item) => `- ${markdownText(item)}`) .join("\n"); const behaviors = record.learning.behaviors .map((item) => `- ${markdownText(item)}`) .join("\n"); const rule = record.learning.candidateRule ? markdownText(record.learning.candidateRule) : "No durable rule proposed."; const sourceModel = record.source.type === "session" && record.source.agentModel ? `${record.source.agentModel.provider}/${record.source.agentModel.id}` : record.source.type === "import" ? record.source.importer : "unknown agent model"; return [ `## ${markdownText(record.learning.slug)}`, markdownText(record.learning.summary), "", "## Feedback", markdownText(record.feedback), "", "## Evidence", evidence, "", "## Behaviors", behaviors, "", "## Candidate rule", rule, "", `*${record.kind} · ${record.id} · ${dateLabel(record.completedAt)} · ${markdownText(projectLabel(record))} · ${markdownText(sourceModel)}*`, ].join("\n"); } function matchingRecords( records: readonly GoodJobRecord[], query: string, ): GoodJobRecord[] { const words = query.toLowerCase().trim().split(/\s+/).filter(Boolean); if (words.length === 0) return [...records]; return records.filter((record) => { const haystack = [ record.kind, record.learning.slug, record.feedback, record.learning.summary, record.learning.candidateRule ?? "", ...record.learning.behaviors, projectLabel(record), record.id, ] .join(" ") .toLowerCase(); return words.every((word) => haystack.includes(word)); }); } export class LearningListComponent implements Focusable { private readonly search = new Input(); private selected = 0; private _focused = false; constructor( private readonly theme: Theme, private readonly records: readonly GoodJobRecord[], private readonly counts: GoodJobCounts, private readonly requestRender: () => void, private readonly done: (id: string | null) => void, ) {} get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; this.search.focused = value; } handleInput(data: string): void { const matches = matchingRecords(this.records, this.search.getValue()); if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.done(null); return; } if (matchesKey(data, "up")) { this.selected = matches.length === 0 ? 0 : (this.selected - 1 + matches.length) % matches.length; this.requestRender(); return; } if (matchesKey(data, "down")) { this.selected = matches.length === 0 ? 0 : (this.selected + 1) % matches.length; this.requestRender(); return; } if (matchesKey(data, "enter")) { this.done(matches[this.selected]?.id ?? null); return; } const before = this.search.getValue(); this.search.handleInput(data); if (this.search.getValue() !== before) { this.selected = 0; this.requestRender(); } } render(width: number): string[] { const safeWidth = Math.max(1, width); const matches = matchingRecords(this.records, this.search.getValue()); this.selected = Math.min(this.selected, Math.max(0, matches.length - 1)); const visible = windowed(matches, this.selected, 7); const first = matches.indexOf(visible[0] as GoodJobRecord); const queue = this.counts.pending + this.counts.processing; const status = [ `${this.records.length} learnings`, queue ? `${queue} queued` : "", this.counts.failed ? `${this.counts.failed} failed` : "", ] .filter(Boolean) .join(" · "); const lines = [ this.theme.fg("border", "─".repeat(safeWidth)), `${this.theme.fg("accent", this.theme.bold("GJ / WTF"))} ${this.theme.fg(this.counts.failed ? "warning" : "dim", status)}`, `search ${this.search.render(Math.max(1, safeWidth - 8))[0] ?? ""}`, "", ]; if (visible.length === 0) { lines.push(this.theme.fg("warning", "No matching learnings.")); } else { for (const [offset, record] of visible.entries()) { const selected = first + offset === this.selected; const marker = selected ? "▸" : " "; const summary = `${marker} ${record.learning.slug} · ${oneLine(record.learning.summary, 100)}`; lines.push( selected ? this.theme.fg("accent", summary) : summary, this.theme.fg( "dim", ` ${record.id} · ${dateLabel(record.completedAt)} · ${projectLabel(record)}`, ), ); } } lines.push( "", this.theme.fg( "dim", "type to filter · ↑↓ navigate · enter show · esc close", ), this.theme.fg("border", "─".repeat(safeWidth)), ); return lines.map((line) => truncateToWidth(line, safeWidth)); } invalidate(): void { this.search.invalidate(); } } function windowed( items: readonly T[], selected: number, maximum: number, ): T[] { if (items.length <= maximum) return [...items]; const half = Math.floor(maximum / 2); const start = Math.min( Math.max(0, selected - half), Math.max(0, items.length - maximum), ); return items.slice(start, start + maximum); } export async function showLearningList( ctx: ExtensionContext, records: readonly GoodJobRecord[], counts: GoodJobCounts, ): Promise { if (ctx.mode !== "tui") { ctx.ui.notify( records .slice(0, 10) .map( (record) => `${record.id} ${record.learning.slug} ${dateLabel(record.completedAt)} ${oneLine(record.learning.summary)}`, ) .join("\n"), "info", ); return null; } return ctx.ui.custom( (tui, theme, _keybindings, done) => new LearningListComponent( theme, records, counts, () => tui.requestRender(), done, ), ); } export async function showLearningDetail( ctx: ExtensionContext, record: GoodJobRecord, ): Promise { const markdown = formatLearningMarkdown(record); if (ctx.mode !== "tui") { ctx.ui.notify(markdown, "info"); return; } await ctx.ui.custom((_tui, theme, _keybindings, done) => { const container = new Container(); const border = () => new DynamicBorder((text: string) => theme.fg("accent", text)); container.addChild(border()); container.addChild( new Text(theme.fg("accent", theme.bold("GJ / WTF")), 1, 0), ); container.addChild(new Markdown(markdown, 1, 1, getMarkdownTheme())); container.addChild(new Text(theme.fg("dim", "enter/esc/q close"), 1, 0)); container.addChild(border()); return { render: (width: number) => container.render(width).map((line) => truncateToWidth(line, width)), handleInput: (data: string) => { if ( matchesKey(data, "enter") || matchesKey(data, "escape") || matchesKey(data, "q") ) done(); }, invalidate: () => container.invalidate(), }; }); }