import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent"; import { Box, type Component, Markdown, matchesKey, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; import { prioritizedHintLine } from "./hints.ts"; import { isAttemptPromoted, latestAttempt, type BtwThread } from "./threads.ts"; const HEIGHT_RATIO = 0.6; const MIN_HEIGHT = 12; /** A pager deliberately without an Editor or any writable-thread callbacks. */ export class BtwHistoryView implements Component { private cursor = 0; private scrollOffset = 0; private settled = false; private readonly attemptIds = new Map(); constructor( private readonly tui: TUI, private readonly theme: Theme, private readonly thread: BtwThread, private readonly onContinue: () => void, private readonly onClose: () => void, ) {} getTitle(): string { const total = this.thread.entries.length; const entry = this.thread.entries[this.cursor]; const attempt = entry ? this.displayedAttempt(entry) : undefined; const suffix = entry && attempt && entry.attempts.length > 1 ? ` · attempt ${entry.attempts.findIndex((item) => item.id === attempt.id) + 1}/${entry.attempts.length}` : ""; return total ? `history · read-only · ‹ ${this.cursor + 1}/${total} ›${suffix}` : "history · read-only"; } handleInput(data: string): void { if (this.settled) return; if (matchesKey(data, "escape")) return this.settle(this.onClose); if (data === "c") return this.settle(this.onContinue); if (matchesKey(data, "shift+left")) return this.moveAttempt(-1); if (matchesKey(data, "shift+right")) return this.moveAttempt(1); if (matchesKey(data, "left") || matchesKey(data, "pageUp")) return this.move(-1); if (matchesKey(data, "right") || matchesKey(data, "pageDown")) return this.move(1); if (matchesKey(data, "up")) return this.scroll(-1); if (matchesKey(data, "down")) return this.scroll(1); } private settle(callback: () => void): void { this.settled = true; callback(); } private move(delta: number): void { if (!this.thread.entries.length) return; this.cursor = Math.max(0, Math.min(this.thread.entries.length - 1, this.cursor + delta)); this.scrollOffset = 0; this.tui.requestRender(); } private displayedAttempt(entry: BtwThread["entries"][number]) { const selected = this.attemptIds.get(entry.id); return entry.attempts.find((attempt) => attempt.id === selected) ?? latestAttempt(entry); } private moveAttempt(delta: number): void { const entry = this.thread.entries[this.cursor]; if (!entry) return; const current = this.displayedAttempt(entry); const index = entry.attempts.findIndex((attempt) => attempt.id === current.id); const next = entry.attempts[Math.max(0, Math.min(entry.attempts.length - 1, index + delta))]; if (next) this.attemptIds.set(entry.id, next.id); this.scrollOffset = 0; this.tui.requestRender(); } private scroll(delta: number): void { this.scrollOffset = Math.max(0, this.scrollOffset + delta); this.tui.requestRender(); } invalidate(): void {} render(width: number): string[] { const entry = this.thread.entries[this.cursor]; const box = new Box(1, 1, (text) => text); if (!entry) { box.addChild(new Text(this.theme.fg("dim", "no answers in this thread"), 0, 0)); } else { const attempt = this.displayedAttempt(entry); box.addChild(new Text(`${this.theme.fg("accent", "❯")} ${this.theme.bold(this.theme.fg("userMessageText", entry.question))}`, 0, 0)); box.addChild(new Spacer(1)); box.addChild(new Text(this.theme.fg("customMessageLabel", this.theme.bold("btw")), 0, 0)); box.addChild(new Markdown(attempt.answer || "(empty)", 2, 0, getMarkdownTheme())); if (attempt.error) box.addChild(new Text(this.theme.fg("error", `⚠️ ${attempt.error}`), 2, 0)); if (isAttemptPromoted(entry, attempt.id)) box.addChild(new Text(this.theme.fg("success", "✓ shared to main"), 2, 0)); if (entry.attempts.length > 1) box.addChild(new Text(this.theme.fg("dim", `attempt ${entry.attempts.findIndex((item) => item.id === attempt.id) + 1}/${entry.attempts.length}`), 2, 0)); } const raw = box.render(width); const content = raw.slice(1, raw.length - 1); const rows = this.tui.terminal.rows; const target = Math.min(rows, Math.max(MIN_HEIGHT, Math.round(rows * HEIGHT_RATIO))); const innerHeight = Math.max(1, target - 2); const footer = prioritizedHintLine(this.theme, [["c", "continue"], ["esc", "close"], ["←→", "questions"], ["⇧←→", "attempts"], ["↑↓", "scroll"]], width, this.theme.fg("dim", "…")); const budget = Math.max(1, innerHeight - 1); let visible = content; if (content.length > budget) { const max = content.length - budget; this.scrollOffset = Math.min(this.scrollOffset, max); const start = this.scrollOffset; const end = start + budget; visible = content.slice(start, end); if (budget >= 3) { if (start) visible = [this.theme.fg("dim", " ↑ more"), ...visible.slice(1)]; if (end < content.length) visible = [...visible.slice(0, -1), this.theme.fg("dim", " ↓ more")]; } } else this.scrollOffset = 0; return [...visible, ...Array(Math.max(0, budget - visible.length)).fill(""), footer]; } }