import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent"; import { Box, type Component, Editor, type EditorTheme, type Focusable, Loader, Markdown, matchesKey, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; import { prioritizedHintLine } from "./hints.ts"; import { isAttemptPromoted, latestAttempt, type BtwThread } from "./threads.ts"; // Order is narrow-screen priority: action and dismissal always precede navigation. const FOOTER_HINTS: Array<[string, string]> = [ ["^p", "share"], ["esc", "close"], ["^r", "retry"], ["←→", "questions"], ["⇧←→", "attempts"], ["↑↓", "scroll"], ]; const SELECT_FOOTER_HINTS: Array<[string, string]> = [ ["⏎", "share"], ["esc", "cancel"], ["a", "share all"], ["r", "refine+share"], ["←→", "pick"], ["⇧←→", "attempts"], ["↑↓", "scroll"], ]; const HEIGHT_RATIO = 0.6; // overlay height as a share of the terminal const MIN_HEIGHT = 12; // rows, incl. the frame, before clamping to the terminal /** Pager view for one thread: one Q/A card per screen, ←/→ moves between questions. */ export class BtwThreadView implements Component, Focusable { private thread: BtwThread | null = null; private cursor = 0; // index of the entry currently shown (may equal entries.length for the pending card) private scrollOffset = 0; // lines hidden above the top of the current card (0 = card's question is at the top) private busy = false; private pendingQuestion: string | undefined; private pendingEntryId: string | null = null; private pendingText = ""; // Attempt selection belongs to this view, not persisted state. This is what // lets a reader stay on an older immutable attempt while retries arrive. private readonly attemptIds = new Map(); // The thread the in-flight busy/pending request belongs to. A thread switch // does not cancel the request (the caller's controller is still alive until // its promise settles), so busy/pendingQuestion stay set; this field is what // lets render() and totalCount() show the pending card only while the // displayed thread is the one that actually owns it. private pendingThreadId: string | null = null; // Promote select mode: Ctrl+P arms a cursor over promotable entry ids; the // ids are the source of truth (entries may grow while selecting). private selectIds: string[] | null = null; private selIdx = 0; // setThread bookkeeping: distinguishes "new answer landed" from "same thread, // same length" (e.g. a promoted flag flipping) so the cursor only jumps when // an exchange was actually added. private lastThreadId: string | null = null; private lastEntryCount = 0; private readonly editor: Editor; private readonly loader: Loader; constructor( private readonly tui: TUI, private readonly theme: Theme, private readonly cb: { onSubmit: (q: string) => void; onClose: () => void; onPromote: () => void; onPromoteSelected: (entryId: string, attemptId: string) => void; onPromoteAll: () => void; onRefineSelected: (entryId: string, attemptId: string) => void; onRetry?: (entryId: string, attemptId: string) => void; }, ) { const th = this.theme; const editorTheme: EditorTheme = { // "border" over pi-core's "borderMuted": on dark themes borderMuted is // near-invisible, and inside the overlay the editor rules double as the // content/input separator, so they must stay readable. borderColor: (s: string) => th.fg("border", s), selectList: { selectedPrefix: (s: string) => th.fg("accent", s), selectedText: (s: string) => th.fg("accent", s), description: (s: string) => th.fg("dim", s), scrollInfo: (s: string) => th.fg("dim", s), noMatch: (s: string) => th.fg("dim", s), }, }; // paddingX 1 lines the cursor/text up with the card content's Box padding. this.editor = new Editor(this.tui, editorTheme, { paddingX: 1 }); this.editor.onSubmit = (v) => { const q = v.trim(); if (!q) return; if (this.busy) { // submitValue() already cleared the editor before calling onSubmit; // restore the text so a busy-rejected submit isn't silently lost. this.editor.setText(v); return; } this.cb.onSubmit(q); }; this.loader = new Loader(this.tui, (s) => th.fg("accent", s), (s) => th.fg("muted", s)); // Loader's constructor starts its own animation interval; cancel it until // setBusy(true) actually arms it, otherwise it ticks (and outlives the // view) while nothing is busy. this.loader.stop(); } get focused(): boolean { return this.editor.focused; } set focused(focused: boolean) { this.editor.focused = focused; } setThread(thread: BtwThread | null): void { const sameThread = thread !== null && this.lastThreadId !== null && thread.id === this.lastThreadId; const newCount = thread ? thread.entries.length : 0; this.thread = thread; this.selectIds = null; if (!sameThread) { // Switching threads does NOT clear busy/pendingQuestion: the caller's // request controller is still alive until its (possibly aborted) // promise settles, so a real request is genuinely in flight. Clearing // busy here would let Enter on the new thread slip past the caller's // "one request at a time" guard while that controller lives, silently // dropping the new question. Instead render()/totalCount() key off // pendingThreadId to hide the old thread's pending card/spinner while // it's not the displayed thread, and show it again if the user comes // back before it settles. this.cursor = Math.max(0, newCount - 1); this.scrollOffset = 0; } else { const last = Math.max(0, newCount - 1); if (newCount > this.lastEntryCount && this.cursor >= last) { // The watcher was on the pending card (index == old entries.length == // last); land them on the answer that replaced it. this.cursor = last; this.scrollOffset = 0; } else { // Clamp against totalCount() (entries + pending card), not entries // alone: otherwise a cursor sitting on the pending card (index == // entries.length) gets yanked back to the last real entry whenever // the same thread is re-set with an unchanged entry count (e.g. a // re-open of the same thread while a question is in flight). this.cursor = Math.min(this.cursor, Math.max(0, this.totalCount() - 1)); } } this.lastThreadId = thread ? thread.id : null; this.lastEntryCount = newCount; this.tui.requestRender(); } setBusy(busy: boolean, label = "", pendingQuestion?: string, pendingEntryId?: string): void { this.busy = busy; this.pendingQuestion = busy ? pendingQuestion : undefined; this.pendingEntryId = busy ? (pendingEntryId ?? null) : null; this.pendingText = ""; this.pendingThreadId = busy ? (this.thread?.id ?? null) : null; if (busy) { if (pendingEntryId) { this.jumpToEntry(pendingEntryId); } else if (pendingQuestion !== undefined) { this.cursor = this.thread?.entries.length ?? 0; this.scrollOffset = 0; } this.loader.setMessage(label || "asking…"); this.loader.start(); } else { this.loader.stop(); } this.tui.requestRender(); } setProgress(text: string): void { if (!this.busy) return; this.pendingText = text; this.tui.requestRender(); } selectAttempt(entryId: string, attemptId: string): void { this.attemptIds.set(entryId, attemptId); this.jumpToEntry(entryId); } displayedAttemptId(entryId: string): string | undefined { const entry = this.thread?.entries.find((item) => item.id === entryId); return entry ? this.displayedAttempt(entry).id : undefined; } /** Arm promote-select mode over these entry ids; cursor starts on the last, and the shown card jumps to it. */ enterSelect(ids: string[], promotableAttemptIds?: Map): void { if (!ids.length) return; for (const [entryId, attemptId] of promotableAttemptIds ?? []) this.attemptIds.set(entryId, attemptId); this.selectIds = ids; this.selIdx = ids.length - 1; this.jumpToEntry(ids[this.selIdx]); } private exitSelect(): void { this.selectIds = null; this.tui.requestRender(); } private jumpToEntry(entryId: string): void { const idx = (this.thread?.entries ?? []).findIndex((e) => e.id === entryId); if (idx >= 0) this.cursor = idx; this.scrollOffset = 0; this.tui.requestRender(); } private totalCount(): number { const entries = this.thread?.entries.length ?? 0; const busyHere = this.busy && (this.thread?.id ?? null) === this.pendingThreadId; return entries + (busyHere && this.pendingQuestion !== undefined && !this.pendingEntryId ? 1 : 0); } private displayedAttempt(entry: NonNullable["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(); } /** Title for the surrounding frame; the frame absorbs what used to be an inner header. */ getTitle(): string { const total = this.totalCount(); 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 > 0 ? `btw · ‹ ${this.cursor + 1}/${total} ›${suffix}` : "btw"; } private movePrev(): void { if (this.totalCount() === 0) return; this.cursor = Math.max(0, this.cursor - 1); this.scrollOffset = 0; this.tui.requestRender(); } private moveNext(): void { const total = this.totalCount(); if (total === 0) return; this.cursor = Math.min(total - 1, this.cursor + 1); this.scrollOffset = 0; this.tui.requestRender(); } private scrollBy(delta: number): void { this.scrollOffset = Math.max(0, this.scrollOffset + delta); this.tui.requestRender(); } handleInput(data: string): void { if (this.selectIds) { const ids = this.selectIds; if (matchesKey(data, "escape")) return this.exitSelect(); if (matchesKey(data, "left")) { this.selIdx = Math.max(0, this.selIdx - 1); return this.jumpToEntry(ids[this.selIdx]); } if (matchesKey(data, "right")) { this.selIdx = Math.min(ids.length - 1, this.selIdx + 1); return this.jumpToEntry(ids[this.selIdx]); } if (matchesKey(data, "up")) return this.scrollBy(-1); if (matchesKey(data, "down")) return this.scrollBy(1); if (matchesKey(data, "shift+left")) return this.moveAttempt(-1); if (matchesKey(data, "shift+right")) return this.moveAttempt(1); const selected = this.thread?.entries.find((entry) => entry.id === ids[this.selIdx]); const attempt = selected ? this.displayedAttempt(selected) : undefined; if (matchesKey(data, "return") && attempt) { this.exitSelect(); return this.cb.onPromoteSelected(ids[this.selIdx], attempt.id); } if (data === "a") { this.exitSelect(); return this.cb.onPromoteAll(); } if (data === "r" && attempt) { this.exitSelect(); return this.cb.onRefineSelected(ids[this.selIdx], attempt.id); } return; // swallow everything else; the input line is inert while selecting } if (matchesKey(data, "ctrl+p")) return this.cb.onPromote(); if (matchesKey(data, "ctrl+r")) { const entry = this.thread?.entries[this.cursor]; if (entry) this.cb.onRetry?.(entry.id, this.displayedAttempt(entry).id); return; } if (matchesKey(data, "shift+left")) return this.moveAttempt(-1); if (matchesKey(data, "shift+right")) return this.moveAttempt(1); if (matchesKey(data, "escape")) return this.cb.onClose(); if (matchesKey(data, "pageUp")) return this.movePrev(); if (matchesKey(data, "pageDown")) return this.moveNext(); // Up/down/left/right move between cards or scroll only while the editor // is empty; the Editor itself consumes those keys for cursor movement // once there is text (including up/down for multi-line navigation). if (this.editor.getText() === "") { if (matchesKey(data, "up")) return this.scrollBy(-1); if (matchesKey(data, "down")) return this.scrollBy(1); if (matchesKey(data, "left")) return this.movePrev(); if (matchesKey(data, "right")) return this.moveNext(); } this.editor.handleInput(data); } render(width: number): string[] { const th = this.theme; const entries = this.thread?.entries ?? []; // Only show the pending card/loader while the displayed thread is the // one that actually owns the in-flight request (see pendingThreadId). const busyHere = this.busy && (this.thread?.id ?? null) === this.pendingThreadId; const pendingActive = busyHere && this.pendingQuestion !== undefined && !this.pendingEntryId; const retryPreview = busyHere && this.pendingEntryId !== null; const total = entries.length + (pendingActive ? 1 : 0); if (this.cursor > total - 1) this.cursor = Math.max(0, total - 1); const showingPending = pendingActive && this.cursor === entries.length; const BOX_PADDING_Y = 1; const identityBg = (t: string) => t; const contentBox = new Box(1, BOX_PADDING_Y, identityBg); if (total === 0) { contentBox.addChild(new Text(th.fg("dim", "ask anything about this session below"), 0, 0)); } else if (showingPending) { contentBox.addChild( new Text(`${th.fg("accent", "❯")} ${th.bold(th.fg("userMessageText", this.pendingQuestion ?? ""))}`, 0, 0), ); contentBox.addChild(new Spacer(1)); contentBox.addChild(new Text(th.fg("customMessageLabel", th.bold("btw")), 0, 0)); if (this.pendingText) contentBox.addChild(new Markdown(this.pendingText, 2, 0, getMarkdownTheme())); contentBox.addChild(this.loader); } else { const e = entries[this.cursor]; const attempt = this.displayedAttempt(e); const marker = this.selectIds && e.id === this.selectIds[this.selIdx] ? th.fg("accent", "▸ ") : ""; contentBox.addChild( new Text(`${marker}${th.fg("accent", "❯")} ${th.bold(th.fg("userMessageText", e.question))}`, 0, 0), ); contentBox.addChild(new Spacer(1)); contentBox.addChild(new Text(th.fg("customMessageLabel", th.bold("btw")), 0, 0)); if (retryPreview && e.id === this.pendingEntryId) { contentBox.addChild(new Text(th.fg("warning", "retry preview (not saved yet)"), 2, 0)); contentBox.addChild(this.pendingText ? new Markdown(this.pendingText, 2, 0, getMarkdownTheme()) : this.loader); } else { contentBox.addChild(new Markdown(attempt.answer || "(empty)", 2, 0, getMarkdownTheme())); if (attempt.error) contentBox.addChild(new Text(th.fg("error", `⚠️ ${attempt.error}`), 2, 0)); if (isAttemptPromoted(e, attempt.id)) contentBox.addChild(new Text(th.fg("success", "✓ shared to main"), 2, 0)); } if (e.attempts.length > 1) contentBox.addChild(new Text(th.fg("dim", `attempt ${e.attempts.findIndex((item) => item.id === attempt.id) + 1}/${e.attempts.length}`), 2, 0)); } const raw = contentBox.render(width); // Box always wraps its children with BOX_PADDING_Y blank rows on top and // bottom; strip both so contentLines holds only real content rows. const contentLines = raw.slice(BOX_PADDING_Y, raw.length - BOX_PADDING_Y); const inputLines = this.editor.render(width); const refineLoader = busyHere && !pendingActive && !retryPreview ? this.loader.render(width) : []; const rows = this.tui.terminal.rows; // Invariant: the box height is fixed by the terminal size alone, never by // card content — only the inner window scrolls. One deliberate exception: // the editor and footer are never sacrificed to the cap, so on a tiny // terminal a tall multi-line draft (or the refine loader) can push the box // past the target rather than hide the input. const target = Math.min(rows, Math.max(MIN_HEIGHT, Math.round(rows * HEIGHT_RATIO))); const innerH = Math.max(1, target - 2); // rows this view must return (frame adds 2) const contentBudget = Math.max(1, innerH - inputLines.length - refineLoader.length - 2 /* spacer + footer */); let shownContent = contentLines; if (contentLines.length > contentBudget) { const maxOffset = contentLines.length - contentBudget; if (this.scrollOffset > maxOffset) this.scrollOffset = maxOffset; const start = this.scrollOffset; const end = start + contentBudget; shownContent = contentLines.slice(start, end); // Indicators each consume a content row; only spend one when there's // room to spare it without hiding the card's first line under a tiny budget. if (contentBudget >= 3) { if (start > 0) shownContent = [th.fg("dim", " ↑ more"), ...shownContent.slice(1)]; if (end < contentLines.length) shownContent = [...shownContent.slice(0, -1), th.fg("dim", " ↓ more")]; } } else { this.scrollOffset = 0; } const footerHints = this.selectIds ? SELECT_FOOTER_HINTS : FOOTER_HINTS; const footer = prioritizedHintLine(th, footerHints, width, th.fg("dim", "…")); // Pad so the view always returns the same row count for a given // terminal/editor state: content stays top-anchored, editor+footer sit // at the bottom of the box. const pad = Math.max(0, contentBudget - shownContent.length); return [...shownContent, ...Array(pad).fill(""), "", ...refineLoader, ...inputLines, footer]; } invalidate(): void { this.editor.invalidate(); } }