import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { type Theme } from "@earendil-works/pi-coding-agent"; import { type Component, type Focusable, matchesKey, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui"; import { getConfig } from "./config.ts"; import { contextBudget } from "./context-policy.ts"; import type { SummaryCoordinator } from "./summary-coordinator.ts"; import type { Grounding } from "./context.ts"; import { createShareDraft, renderShareDraft, type ShareDraft } from "./promote.ts"; import { runRefine, runSide } from "./shadow.ts"; import { isAttemptPromoted, type BtwAttempt, type BtwEntry, type ThreadStore } from "./threads.ts"; import { BtwThreadView } from "./thread-view.ts"; import { BtwHistoryView } from "./history-view.ts"; export type SideRunners = { runSide: typeof runSide; runRefine: typeof runRefine; }; /** Optional so direct overlay construction and existing integrations remain compatible. */ export type SummaryMaintenance = Pick; const noMaintenance: SummaryMaintenance = { beginInteractive: () => () => {}, afterSuccessfulAttempt: () => {}, }; function invoke(call: () => Promise): Promise { try { return Promise.resolve(call()); } catch (error) { return Promise.reject(error); } } function systemPrompt(ctx: ExtensionContext): string | undefined { return (ctx as ExtensionContext & { getSystemPrompt?: () => string }).getSystemPrompt?.(); } class BtwOverlay implements Component, Focusable { private readonly threadView: BtwThreadView; private controller: AbortController | null = null; /** Released by close even if a custom foreground runner never settles. */ private interactiveRelease: (() => void) | null = null; private settled = false; /** A draft/editor/send is one transaction; ignore duplicate share keys. */ private promotionPending = false; // Defensive: a thrown render/handleInput must never blank+freeze the whole pi TUI. // Capture it, show it, and let Esc dismiss. Also surfaces the cause for debugging. private lastError: string | null = null; private readonly unsubscribeScope: () => void; constructor( private readonly ctx: ExtensionContext, private readonly tui: TUI, private readonly theme: Theme, private readonly threads: ThreadStore, private readonly grounding: Grounding, private readonly done: () => void, private readonly onPromote?: ((note: string) => void) | ((note: string) => Promise), private readonly runners: SideRunners = { runSide, runRefine }, private readonly maintenance: SummaryMaintenance = noMaintenance, ) { this.threadView = new BtwThreadView(tui, theme, { onSubmit: (q) => this.ask(q), onClose: () => this.close(), onPromote: () => this.openPromote(), onPromoteSelected: (id, attemptId) => this.promoteEntry(id, attemptId), onPromoteAll: () => this.promoteAll(), onRefineSelected: (id, attemptId) => this.refineEntry(id, attemptId), onRetry: (id) => this.retryEntry(id), }); this.threadView.setThread(this.threads.getActive()); this.unsubscribeScope = this.threads.onScopeChange(() => this.close()); } get focused(): boolean { return this.threadView.focused; } set focused(focused: boolean) { this.threadView.focused = focused; } submitInitial(q: string): void { this.ensureActive(); this.threadView.setThread(this.threads.getActive()); this.ask(q); } private ensureActive(): void { if (!this.threads.getActive()) this.threads.newThread(); } private releaseInteractive(release?: () => void): void { const current = release ?? this.interactiveRelease; // close() may have already released this operation before its promise settles. if (!current || (release && this.interactiveRelease !== release)) return; current(); if (this.interactiveRelease === current) this.interactiveRelease = null; } private ask(question: string): void { if (this.controller) return; // one in-flight ask at a time this.ensureActive(); const active = this.threads.getActive(); if (!active) return; const settings = getConfig(); const prefix = this.grounding.getGroundingPrefix(this.ctx); const model = this.ctx.model; const token = this.threads.captureScopeToken(); if (!token) return; const budget = contextBudget(settings.summaryTriggerTokens, model?.contextWindow, settings.answerMaxTokens, systemPrompt(this.ctx), prefix, question); const tail = this.threads.buildBoundedContextIfCurrent(token, active.id, budget); const controller = new AbortController(); const release = this.maintenance.beginInteractive(this.ctx, token, active.id); this.interactiveRelease = release; this.controller = controller; this.threadView.setBusy(true, `asking ${model?.id ?? "model"}…`, question); invoke(() => this.runners.runSide(this.ctx, { prefix, tail, question, settings, signal: controller.signal, onProgress: (event) => { if (this.settled || !this.threads.isCurrent(token)) return; if (event.type === "model-request" || event.type === "tool-start") this.threadView.setProgress(""); if (event.type === "text") this.threadView.setProgress(event.text); } })) .then((r) => { if (this.settled || !this.threads.isCurrent(token)) return; const attempt: BtwAttempt = { id: this.threads.nextId(), mode: r.toolsUsed.length ? "deep" : "quick", answer: r.aborted ? "" : r.text.trim(), grounding: { capturedAt: stamp(), model: model?.id ?? "unknown", contextInfo: `${prefix.length} prefix msgs` }, ...(r.toolsUsed.length ? { toolsUsed: r.toolsUsed } : {}), ...(r.error ? { error: r.error } : {}), }; const entry: BtwEntry = { id: this.threads.nextId(), question, attempts: [attempt] }; const appended = !r.aborted && this.threads.appendEntryIfCurrent(token, active.id, entry); if (appended && !attempt.error && attempt.answer.trim()) this.maintenance.afterSuccessfulAttempt(this.ctx, token, active.id); if (this.threads.isCurrent(token)) this.threadView.setThread(this.threads.getActive()); }) .catch((e) => { if (this.settled || !this.threads.isCurrent(token)) return; this.threads.appendEntryIfCurrent(token, active.id, { id: this.threads.nextId(), question, attempts: [{ id: this.threads.nextId(), mode: "quick", answer: "", grounding: { capturedAt: stamp(), model: model?.id ?? "unknown", contextInfo: "" }, error: e instanceof Error ? e.message : String(e) }], }); if (this.threads.isCurrent(token)) this.threadView.setThread(this.threads.getActive()); }) .finally(() => { this.releaseInteractive(release); if (this.controller === controller) this.controller = null; if (!this.settled && this.threads.isCurrent(token)) this.threadView.setBusy(false); }); } private retryEntry(entryId: string): void { if (this.controller) { this.ctx.ui.notify("btw: busy. Retry again once the current run settles", "info"); return; } const active = this.threads.getActive(); const entry = active?.entries.find((item) => item.id === entryId); if (!active || !entry) return; const settings = getConfig(); const prefix = this.grounding.getGroundingPrefix(this.ctx); const model = this.ctx.model; const token = this.threads.captureScopeToken(); if (!token) return; // The bounded-context policy excludes the target and any summary covering // it. An uncommitted abort, stale result, or runner failure preserves the // summary; a committed successful or errored replacement invalidates it. const budget = contextBudget(settings.summaryTriggerTokens, model?.contextWindow, settings.answerMaxTokens, systemPrompt(this.ctx), prefix, entry.question); const tail = this.threads.buildBoundedContextIfCurrent(token, active.id, budget, entry.id); const controller = new AbortController(); const release = this.maintenance.beginInteractive(this.ctx, token, active.id); this.interactiveRelease = release; this.controller = controller; this.threadView.setBusy(true, `retrying ${model?.id ?? "model"}…`, entry.question, entry.id); invoke(() => this.runners.runSide(this.ctx, { prefix, tail, question: entry.question, settings, signal: controller.signal, onProgress: (event) => { if (this.settled || !this.threads.isCurrent(token)) return; if (event.type === "model-request" || event.type === "tool-start") this.threadView.setProgress(""); if (event.type === "text") this.threadView.setProgress(event.text); } })).then((result) => { if (this.settled || result.aborted || !this.threads.isCurrent(token)) return; const attempt: BtwAttempt = { id: this.threads.nextId(), mode: result.toolsUsed.length ? "deep" : "quick", answer: result.text.trim(), grounding: { capturedAt: stamp(), model: model?.id ?? "unknown", contextInfo: `${prefix.length} prefix msgs` }, ...(result.toolsUsed.length ? { toolsUsed: result.toolsUsed } : {}), ...(result.error ? { error: result.error } : {}), }; if (this.threads.appendAttemptIfCurrent(token, active.id, entry.id, entry.question, attempt)) { if (!attempt.error && attempt.answer.trim()) this.maintenance.afterSuccessfulAttempt(this.ctx, token, active.id); this.threadView.setThread(this.threads.getActive()); this.threadView.selectAttempt(entry.id, attempt.id); } else { this.threadView.setThread(this.threads.getActive()); } }).catch((error) => { if (this.settled || !this.threads.isCurrent(token)) return; const attempt: BtwAttempt = { id: this.threads.nextId(), mode: "quick", answer: "", grounding: { capturedAt: stamp(), model: model?.id ?? "unknown", contextInfo: `${prefix.length} prefix msgs` }, error: error instanceof Error ? error.message : String(error) }; if (this.threads.appendAttemptIfCurrent(token, active.id, entry.id, entry.question, attempt)) { this.threadView.setThread(this.threads.getActive()); this.threadView.selectAttempt(entry.id, attempt.id); } else { this.threadView.setThread(this.threads.getActive()); } }).finally(() => { this.releaseInteractive(release); if (this.controller === controller) this.controller = null; if (!this.settled && this.threads.isCurrent(token)) this.threadView.setBusy(false); }); } private close(): void { if (this.settled) return; this.settled = true; this.unsubscribeScope(); this.controller?.abort(); this.releaseInteractive(); // The settled guard skips setBusy(false) in ask()/refineEntry()'s finally, // so an in-flight spinner interval would otherwise outlive the overlay. this.threadView.setBusy(false); this.done(); } private promotable(): BtwEntry[] { const active = this.threads.getActive(); return active ? active.entries.filter((entry) => entry.attempts.some((attempt) => !attempt.error && attempt.answer.trim() && !isAttemptPromoted(entry, attempt.id))) : []; } private latestPromotable(entry: BtwEntry): BtwAttempt | undefined { return [...entry.attempts].reverse().find((attempt) => !attempt.error && attempt.answer.trim() && !isAttemptPromoted(entry, attempt.id)); } private openPromote(): void { if (!this.onPromote) return; const ids = this.promotable().map((e) => e.id); if (!ids.length) { this.ctx.ui.notify("btw: nothing to promote (no unshared answer)", "info"); return; } const attemptIds = new Map(this.promotable().flatMap((entry) => { const displayed = this.threadView.displayedAttemptId(entry.id); const current = displayed ? entry.attempts.find((attempt) => attempt.id === displayed) : undefined; const attempt = current && !current.error && current.answer.trim() && !isAttemptPromoted(entry, current.id) ? current : this.latestPromotable(entry); return attempt ? [[entry.id, attempt.id] as [string, string]] : []; })); this.threadView.enterSelect(ids, attemptIds); } private validDraft(draft: ShareDraft, text: string): boolean { return text.startsWith(`${draft.header}\n`) && text.slice(draft.header.length + 1).trim().length > 0; } /** Edit/revalidate/send/mark as one guarded transaction. Nothing is rebuilt after editing. */ private async share(draft: ShareDraft, threadId: string, refs: { entryId: string; attemptId: string }[], success: string, alreadyPending = false): Promise { if (!this.onPromote || (this.promotionPending && !alreadyPending)) return; const token = this.threads.captureScopeToken(); if (!token) return; this.promotionPending = true; try { let submitted = renderShareDraft(draft); while (true) { const editor = this.ctx.ui.editor; if (!editor) { this.ctx.ui.notify("btw: sharing requires the editor preview", "error"); return; } let edited: string | undefined; try { edited = await editor.call(this.ctx.ui, "Share to main — keep the first line unchanged", submitted); } catch (error) { this.ctx.ui.notify(`btw: share preview failed: ${error instanceof Error ? error.message : String(error)}`, "error"); return; } if (edited === undefined) return; submitted = edited; if (this.validDraft(draft, submitted)) break; this.ctx.ui.notify("btw: keep the original first line and add a non-empty body", "error"); } // An editor can remain open through a close/scope transition. It may not // send into a replacement scope when it eventually settles. if (this.settled || !this.threads.isCurrent(token)) return; const active = this.threads.getActive(); const valid = active?.id === threadId && refs.length > 0 && refs.every((ref) => { const entry = active.entries.find((item) => item.id === ref.entryId); const attempt = entry?.attempts.find((item) => item.id === ref.attemptId); return Boolean(entry && attempt && !attempt.error && attempt.answer.trim() && !isAttemptPromoted(entry, ref.attemptId)); }); if (!valid) { this.ctx.ui.notify("btw: share target changed; nothing was sent", "warning"); return; } try { const sent = this.onPromote(submitted); if (sent && typeof (sent as PromiseLike).then === "function") await (sent as PromiseLike); } catch (error) { this.ctx.ui.notify(`btw: share failed: ${error instanceof Error ? error.message : String(error)}`, "error"); return; } const marked = this.threads.markAttemptsPromotedIfCurrent(token, threadId, refs); if (this.settled || !marked) { this.ctx.ui.notify("btw: note sent, but share state could not be saved", "warning"); return; } this.threadView.setThread(this.threads.getActive()); this.ctx.ui.notify(success, "info"); this.tui.requestRender(); } finally { this.promotionPending = false; } } private promoteEntry(entryId: string, attemptId: string): void { const active = this.threads.getActive(); if (!active || !this.onPromote || this.promotionPending) return; const entry = active.entries.find((item) => item.id === entryId); const attempt = entry?.attempts.find((item) => item.id === attemptId); if (!entry || !attempt || attempt.error || !attempt.answer.trim() || isAttemptPromoted(entry, attempt.id)) return; void this.share(createShareDraft("single", { question: entry.question, answer: attempt.answer }), active.id, [{ entryId, attemptId }], "btw: queued one note for main; it reaches the agent on its next turn"); } private promoteAll(): void { const active = this.threads.getActive(); if (!active || !this.onPromote || this.promotionPending) return; const selected = this.promotable().flatMap((entry) => { const attempt = this.latestPromotable(entry); return attempt ? [{ entry, attempt }] : []; }); if (!selected.length) return; void this.share(createShareDraft("all", selected.map(({ entry, attempt }) => ({ question: entry.question, answer: attempt.answer }))), active.id, selected.map(({ entry, attempt }) => ({ entryId: entry.id, attemptId: attempt.id })), `btw: queued one note containing ${selected.length} answers for main; it reaches the agent on its next turn`); } private refineEntry(entryId: string, attemptId: string): void { if (this.controller || this.promotionPending) { this.ctx.ui.notify("btw: busy. Refine again once the current run settles", "info"); return; } const active = this.threads.getActive(); if (!active || !this.onPromote) return; const entry = active.entries.find((e) => e.id === entryId); if (!entry) return; const attempt = entry.attempts.find((item) => item.id === attemptId); if (!attempt || attempt.error || !attempt.answer.trim() || isAttemptPromoted(entry, attempt.id)) return; const settings = getConfig(); const prefix = this.grounding.getGroundingPrefix(this.ctx); const token = this.threads.captureScopeToken(); if (!token) return; const controller = new AbortController(); const release = this.maintenance.beginInteractive(this.ctx, token, active.id); this.interactiveRelease = release; this.controller = controller; this.promotionPending = true; this.threadView.setBusy(true, "refining…"); invoke(() => this.runners.runRefine(this.ctx, { prefix, question: entry.question, answer: attempt.answer, settings, signal: controller.signal })) .then(async (r) => { // The model phase is complete before opening an editor. Release the // coordinator lease now, not after a user leaves a draft open. this.releaseInteractive(release); if (this.controller === controller) this.controller = null; if (!this.settled && this.threads.isCurrent(token)) this.threadView.setBusy(false); if (this.settled || r.aborted || !this.threads.isCurrent(token)) return; const body = r.text.trim(); if (r.error || !body) { this.ctx.ui.notify(`btw: refine failed${r.error ? `: ${r.error}` : ""}`, "error"); return; } if (isAttemptPromoted(entry, attempt.id) || !this.threads.getAttemptIfCurrent(token, active.id, entry.id, attempt.id)) return; await this.share(createShareDraft("refined", body, { question: entry.question, answer: attempt.answer }), active.id, [{ entryId: entry.id, attemptId: attempt.id }], "btw: queued one refined note for main; it reaches the agent on its next turn", true); }) .catch((e) => { if (this.settled || !this.threads.isCurrent(token)) return; this.ctx.ui.notify(`btw: refine failed: ${e instanceof Error ? e.message : String(e)}`, "error"); }) .finally(() => { this.releaseInteractive(release); if (this.controller === controller) this.controller = null; this.promotionPending = false; if (!this.settled && this.threads.isCurrent(token)) this.threadView.setBusy(false); }); } handleInput(data: string): void { if (this.lastError) { if (matchesKey(data, "escape") || matchesKey(data, "return")) this.close(); return; } try { this.threadView.handleInput(data); } catch (e) { this.lastError = e instanceof Error ? (e.stack ?? e.message) : String(e); this.tui.requestRender(); } } render(width: number): string[] { try { if (this.lastError) return withFrame(this.errorPanel(this.lastError, "handleInput"), width, this.theme, "btw"); const inner = this.threadView.render(Math.max(10, width - 2)); return withFrame(inner, width, this.theme, this.threadView.getTitle()); } catch (e) { const detail = e instanceof Error ? (e.stack ?? e.message) : String(e); return withFrame(this.errorPanel(detail, "render"), width, this.theme, "btw"); } } private errorPanel(detail: string, where: string): string[] { const head = this.theme.fg("error", `btw ${where} error (Esc to close):`); const body = detail.split("\n").slice(0, 14).map((l) => this.theme.fg("dim", l.slice(0, 200))); return [head, ...body]; } invalidate(): void { this.threadView.invalidate(); } } function stamp(): string { try { return new Date().toISOString(); } catch { return ""; } } /** * Rounded accent frame around the overlay so it reads as a floating panel, * visually distinct from the main session (which has no border). */ export function withFrame(lines: string[], width: number, th: Theme, title: string): string[] { const innerW = Math.max(10, width - 2); // A long title on a very narrow terminal would push the top border past the // body width; cut it so the frame corners always line up. const shownTitle = visibleWidth(title) > innerW - 3 ? truncateToWidth(title, Math.max(1, innerW - 3)) : title; const top = th.fg("borderAccent", "╭─") + th.fg("accent", ` ${shownTitle} `) + th.fg("borderAccent", "─".repeat(Math.max(0, innerW - visibleWidth(shownTitle) - 3)) + "╮"); const bottom = th.fg("borderAccent", "╰" + "─".repeat(innerW) + "╯"); const side = th.fg("borderAccent", "│"); const fit = (l: string): string => { // Re-measure after truncation: a wide grapheme dropped at the boundary // can leave the cut line short, and the right border must stay aligned. const cut = visibleWidth(l) > innerW ? truncateToWidth(l, innerW) : l; return cut + " ".repeat(Math.max(0, innerW - visibleWidth(cut))); }; return [top, ...lines.map((l) => side + fit(l) + side), bottom]; } /** Open the persistent /btw overlay on the active thread. */ export async function openOverlay( ctx: ExtensionContext, threads: ThreadStore, grounding: Grounding, initialQuestion?: string, onPromote?: ((note: string) => void) | ((note: string) => Promise), runners?: SideRunners, maintenance?: SummaryMaintenance, ): Promise { if (ctx.mode !== "tui") { ctx.ui.notify("/btw requires interactive mode", "error"); return; } if (!ctx.model) { ctx.ui.notify("No model selected", "error"); return; } const q = initialQuestion?.trim(); // On a plain reopen (no question), if nothing is active but threads remain // (for example the active thread was just deleted), land on the most recent // one rather than a blank view. threads are stored oldest-first. A `/btw // ` reopen is left alone: it intentionally starts a fresh thread. if (!q && !threads.getActive()) { const all = threads.listThreads(); if (all.length) threads.setActive(all[all.length - 1].id); } await ctx.ui.custom( (tui, theme, _kb, done) => { const overlay = new BtwOverlay(ctx, tui, theme, threads, grounding, done, onPromote, runners, maintenance); if (q) overlay.submitInitial(q); return overlay; }, { overlay: true, overlayOptions: { width: "80%", anchor: "center" } }, ); } function historyLabel(snapshot: import("./threads.ts").HistorySnapshot, ordinal: number): string { const question = snapshot.thread.entries[0]?.question.trim() || "(empty question)"; const preview = question.replace(/\s+/g, " ").slice(0, 72); const date = snapshot.thread.createdAt || snapshot.scopeCreatedAt || "unknown date"; return `${preview}${question.length > 72 ? "…" : ""} · ${date} · ${snapshot.scopeKind} #${ordinal}`; } /** Browse archived threads without a model; only an explicit Continue creates a writable clone. */ export async function openHistory( ctx: ExtensionContext, threads: ThreadStore, grounding: Grounding, onPromote?: ((note: string) => void) | ((note: string) => Promise), runners?: SideRunners, maintenance?: SummaryMaintenance, query?: string, ): Promise { if (ctx.mode !== "tui") { ctx.ui.notify("/btw --history requires interactive mode", "error"); return; } const token = threads.captureScopeToken(); const needle = query?.toLowerCase(); const snapshots = threads.history().filter((snapshot) => !needle || snapshot.thread.entries.some((entry) => entry.question.toLowerCase().includes(needle) || entry.attempts.some((attempt) => attempt.answer.toLowerCase().includes(needle)))); if (!token || !snapshots.length) { ctx.ui.notify("btw: no archived threads", "info"); return; } const options = snapshots.map((snapshot, index) => historyLabel(snapshot, index + 1)); const chosen = await ctx.ui.select("btw history", options); if (!chosen) return; const index = options.indexOf(chosen); if (index < 0) return; const snapshot = snapshots[index]!; const proceed = await ctx.ui.custom( (tui, theme, _kb, done) => { const view = new BtwHistoryView(tui, theme, snapshot.thread, () => done(true), () => done(false)); return { handleInput: (data: string) => view.handleInput(data), render: (width: number) => withFrame(view.render(Math.max(10, width - 2)), width, theme, view.getTitle()), invalidate: () => {}, }; }, { overlay: true, overlayOptions: { width: "80%", anchor: "center" } }, ); if (!proceed) return; if (!threads.isCurrent(token)) { ctx.ui.notify("btw: session changed; reopen history before continuing", "info"); return; } if (!ctx.model) { ctx.ui.notify("No model selected", "error"); return; } if (!threads.continueThread(token, snapshot)) { ctx.ui.notify("btw: session changed; reopen history before continuing", "info"); return; } await openOverlay(ctx, threads, grounding, undefined, onPromote, runners, maintenance); }