/** * btw — in-process side tasks for pi with a live full-screen panel. * * `/af-btw ` forks the CURRENT session into an in-process sub-session * (`createAgentSession`) that inherits the full conversation as context, works the * side task in the same cwd, and streams into a full-screen panel with its own * transcript + follow-up composer. A compact result card lands in the main * transcript when the session next goes idle. * * Design constraints (all intentional — see .pi/extensions/btw/README.md): * - Command-only surface. No model-callable tool, no subcommands. `/af-btw ` * starts a task and opens the panel; `/af-btw` (no args) or `Alt+'` reopens it. * - In-process sub-session, NOT a child `pi` process. The fork is a real * `AgentSession` with fixed built-in tools and NO extensions/custom tools (the * sub-session loads no extension runtime → no recursion, mirroring the old * `--no-extensions` child). * - The full-screen panel is the primary surface: it opens immediately, streams * the sub-session live, and accepts follow-ups (mid-run follow-ups steer the * active run; idle follow-ups start a fresh turn). `Esc` returns to the main * session while the task keeps running; completion only toasts. * - Each completed turn writes `.pi/btw-sessions/.result.md` and queues a * COMPACT card (✓/✗ + note + elapsed + first lines + artifact path) for the * main transcript, delivered only when idle and kept OUT of the main agent's * LLM context via an on("context") filter. */ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, MessageRenderer, ContextEvent, Theme, AgentSession, AgentSessionEvent, } from "@mariozechner/pi-coding-agent"; import { getMarkdownTheme, copyToClipboard, createAgentSession, DefaultResourceLoader, SessionManager, getAgentDir, DynamicBorder, } from "@mariozechner/pi-coding-agent"; import { Text, Box, Container, Spacer, Markdown, Input, matchesKey, Key, truncateToWidth, } from "@mariozechner/pi-tui"; import { mkdirSync, writeFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs"; import { join } from "node:path"; import { FULLSCREEN_OVERLAY, bodyRows, fitToHeight } from "../../harnesses/lib/fleet-overlay.ts"; import { createPanelResources } from "../../harnesses/lib/fleet-panel.ts"; import { MAX_RETAINED_TERMINAL_THREADS, appendTimelineDelta as appendTimelineDeltaState, appendTimelineEntry as appendTimelineEntryState, beginTurn, capSelectedMarkdown, clampModalView, finishTurn, formatBtwCardPreview, formatBtwResultMarkdown, isTerminalStatus, planTerminalRetention, reconcileThreadView, resolveThreadId, shiftModalViewForPrunedEntries, steerTurn, turnElapsedMs, } from "./state.js"; const RESULT_TYPE = "btw-result"; const STATUS_KEY = "btw"; // Background artifacts (forked session JSONL + result.md) older than this are pruned // on session_start. They are pure debugging aids — the result also lives in the chat. const CLEANUP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // Coding tools the sub-session runs with (matches pi's default built-in set). const SUBSESSION_TOOLS = ["read", "bash", "edit", "write"]; // Header, border, composer border/label, composer, and hint are fixed chrome. const BTW_CHROME_ROWS = 6; // One entry in a thread's live transcript. Consecutive text/thinking deltas are // coalesced into the trailing entry of the same kind; each tool call and each // operator follow-up is its own entry. interface TimelineEntry { kind: "text" | "tool" | "thinking" | "user"; title: string; content: string; timestamp: number; toolCallId?: string; } // A running (or finished) btw side task. Multiple may run concurrently; the panel // binds to one at a time (`render` is set only while it is the shown thread). interface BtwThread { id: string; note: string; session: AgentSession; status: "running" | "done" | "error"; timeline: TimelineEntry[]; // Lifetime marker for the retained thread; current-turn timing is separate. startedAt: number; turnStartedAt?: number; turnFinishedAt?: number; turns: number; sessionFile: string; resultPath: string; unsubscribe?: () => void; disposed?: boolean; render?: (force?: boolean) => void; } interface BtwDetails { id: string; note: string; ok: boolean; elapsedMs: number; resultPath: string; } interface PendingResult { content: string; details: BtwDetails; } interface CurrentPanelHandle { show: (id: string) => void; currentId: () => string | undefined; timelinePruned: (id: string, prunedCount: number) => void; clamp: () => void; } export default function btwExtension(pi: ExtensionAPI) { // Live threads keyed by btw id; `order` tracks creation order for ←/→ cycling // and "most recent" defaults. const threads = new Map(); const order: string[] = []; // Compact result cards waiting for the session to go idle before they are shown. const pending: PendingResult[] = []; // Most-recent context, refreshed on every hook so deferred delivery, status, and // the panel always have a live handle after the original command returned. let latestCtx: ExtensionContext | undefined; // Panel lifecycle: guard against double-open, remember last-viewed thread, and // expose a handle so a fresh /af-btw can retarget an already-open panel. let panelOpen = false; let lastViewedId: string | undefined; let currentPanel: CurrentPanelHandle | undefined; // Set on session_shutdown so abort()-driven prompt rejections don't push cards. let shuttingDown = false; // Cross-extension signal: once any btw command/shortcut has run this session, the // agent-hub harness footer surfaces the "Alt+' btw" hint next to its "Alt+A view:" // hint. A bare globalThis flag is the pragmatic bridge between two independent // always-on extensions (no shared module). Harmless when agent-hub isn't loaded. function markBtwActivated(): void { (globalThis as { __btwActivated?: boolean }).__btwActivated = true; } function sessionsDir(ctx: ExtensionContext): string { return join(ctx.cwd, ".pi", "btw-sessions"); } function mostRecentId(): string | undefined { return resolveThreadId(undefined, order); } function runningCount(): number { let n = 0; for (const t of threads.values()) if (t.status === "running") n++; return n; } function updateStatus(ctx: ExtensionContext | undefined): void { const n = runningCount(); ctx?.ui.setStatus(STATUS_KEY, n > 0 ? `btw: ${n} running` : undefined); } function threadElapsedMs(t: BtwThread): number { return turnElapsedMs(t, Date.now()); } function fmtDuration(ms: number): string { const s = Math.round(ms / 1000); if (s < 60) return `${s}s`; return `${Math.floor(s / 60)}m ${s % 60}s`; } // Deliver queued cards, but ONLY when idle. During streaming, sendMessage would // steer/follow-up the active turn; the no-options call lands in the "append + // display, no turn" branch only when not streaming. agent_end re-drives this. function flush(): void { const ctx = latestCtx; if (!ctx || !ctx.isIdle() || pending.length === 0) return; const batch = pending.splice(0); for (const r of batch) { pi.sendMessage({ customType: RESULT_TYPE, content: r.content, display: true, details: r.details }); } } function cleanupOldArtifacts(ctx: ExtensionContext): void { const dir = sessionsDir(ctx); if (!existsSync(dir)) return; const cutoff = Date.now() - CLEANUP_MAX_AGE_MS; for (const name of readdirSync(dir)) { const p = join(dir, name); try { if (statSync(p).mtimeMs < cutoff) rmSync(p, { force: true }); } catch { /* best-effort */ } } } function unsubscribeThread(t: BtwThread): void { const unsubscribe = t.unsubscribe; if (!unsubscribe) return; t.unsubscribe = undefined; try { unsubscribe(); } catch { /* best-effort */ } } function ensureSubscribed(t: BtwThread): void { if (t.disposed || t.unsubscribe) return; t.unsubscribe = t.session.subscribe((ev) => handleEvent(t, ev)); } function disposeThread(t: BtwThread): void { if (t.disposed) return; unsubscribeThread(t); t.render = undefined; t.disposed = true; try { t.session.dispose(); } catch { /* best-effort */ } } function reconcileThreadPointers(): void { const currentId = currentPanel?.currentId(); const view = reconcileThreadView({ order, lastViewedId, currentId }); lastViewedId = view.lastViewedId; if (!currentPanel) return; if (view.currentId && view.currentId !== currentId) currentPanel.show(view.currentId); else currentPanel.clamp(); } function enforceTerminalRetention(): void { const statuses = new Map(); for (const id of order) { const t = threads.get(id); if (t) statuses.set(id, t.status); } const plan = planTerminalRetention(order, statuses, MAX_RETAINED_TERMINAL_THREADS); if (plan.evictedIds.length === 0) return; const evicted = new Set(); for (const id of plan.evictedIds) { const t = threads.get(id); if (!t || !isTerminalStatus(t.status)) continue; disposeThread(t); threads.delete(id); evicted.add(id); } if (evicted.size === 0) return; order.length = 0; order.push(...plan.order.filter((id) => threads.has(id) && !evicted.has(id))); reconcileThreadPointers(); } function applyTimelineResult(t: BtwThread, result: { timeline: TimelineEntry[]; prunedCount: number }): void { t.timeline = result.timeline; if (result.prunedCount > 0) currentPanel?.timelinePruned(t.id, result.prunedCount); } function addTimelineEntry(t: BtwThread, entry: Omit & { timestamp?: number }): void { applyTimelineResult(t, appendTimelineEntryState(t.timeline, entry, Date.now())); } // Framing for the side task. Unlike the old child-process version there IS now an // interactive surface (the panel composer), so the "unattended" instruction is // gone; the scoping + summary instructions stay. function framedTask(note: string): string { return [ "You have been resumed into a copy of another pi session — the conversation", "history above is your full context. You are working a side task while the main", "session continues. An operator is watching this side task in a panel and can send", "you follow-up messages, so ask a brief clarifying question only if you are truly", "blocked. Your file changes land in the same working directory as the main session,", "so keep them scoped to exactly what the task asks.", "", `Side task: ${note}`, "", "End your reply with a concise summary of what you did and any result the main", "session needs.", ].join("\n"); } // Coalesce a streaming text/thinking delta: extend the trailing entry when it is // the same kind, otherwise start a new one. The state helper caps entry content // and prunes old transcript entries before the panel sees the update. function appendDelta(t: BtwThread, kind: "text" | "thinking", delta: string): void { applyTimelineResult(t, appendTimelineDeltaState(t.timeline, kind, delta, Date.now())); } function summarizeToolArgs(toolName: string, args: unknown): string { if (args && typeof args === "object") { const a = args as Record; const path = a.file_path ?? a.path; if (typeof path === "string") return path; if (typeof a.command === "string") return a.command; if (typeof a.pattern === "string") return a.pattern; } try { const json = JSON.stringify(args); return json && json !== "{}" ? truncateToWidth(json, 200) : ""; } catch { return ""; } } // Translate sub-session events into transcript entries. function handleEvent(t: BtwThread, ev: AgentSessionEvent): void { if (ev.type === "message_update") { const a = ev.assistantMessageEvent; if (a.type === "text_delta") appendDelta(t, "text", a.delta); else if (a.type === "thinking_delta") appendDelta(t, "thinking", a.delta); else return; } else if (ev.type === "tool_execution_start") { addTimelineEntry(t, { kind: "tool", title: ev.toolName || "tool", content: summarizeToolArgs(ev.toolName, ev.args), toolCallId: ev.toolCallId, }); } else if (ev.type === "tool_execution_end") { for (let i = t.timeline.length - 1; i >= 0; i--) { const e = t.timeline[i]; if (e.kind === "tool" && e.toolCallId === ev.toolCallId) { e.title = `${ev.toolName || "tool"} — ${ev.isError ? "✗ error" : "✓ done"}`; break; } } } else { return; } t.render?.(false); } function writeResult(t: BtwThread, ok: boolean, answer: string, elapsedMs: number): void { try { writeFileSync( t.resultPath, formatBtwResultMarkdown({ note: t.note, ok, elapsedMs, sessionFile: t.sessionFile, answer, }), ); } catch { /* best-effort */ } } function pushCard(t: BtwThread, ok: boolean, answer: string, elapsedMs: number): void { pending.push({ content: formatBtwCardPreview(answer), details: { id: t.id, note: t.note, ok, elapsedMs, resultPath: t.resultPath }, }); } function onTurnDone(t: BtwThread): void { // abort() during shutdown resolves the prompt too — don't emit a card then. if (shuttingDown) return; t.turns += 1; Object.assign(t, finishTurn(t, Date.now(), "done")); unsubscribeThread(t); const elapsedMs = threadElapsedMs(t); const answer = (t.session.getLastAssistantText() || "").trim() || "_(side task finished but produced no text output)_"; writeResult(t, true, answer, elapsedMs); pushCard(t, true, answer, elapsedMs); updateStatus(latestCtx); t.render?.(true); latestCtx?.ui.notify(`btw done (${fmtDuration(elapsedMs)}): ${t.note}`, "info"); enforceTerminalRetention(); flush(); } function onTurnError(t: BtwThread, err: unknown): void { if (shuttingDown) return; Object.assign(t, finishTurn(t, Date.now(), "error")); unsubscribeThread(t); const elapsedMs = threadElapsedMs(t); const message = err instanceof Error ? err.message : String(err); const answer = `**Side task failed.**\n\n\`\`\`\n${message}\n\`\`\``; addTimelineEntry(t, { kind: "text", title: "Error", content: answer }); writeResult(t, false, answer, elapsedMs); pushCard(t, false, answer, elapsedMs); updateStatus(latestCtx); t.render?.(true); latestCtx?.ui.notify(`btw error: ${t.note}`, "error"); enforceTerminalRetention(); flush(); } // Start a fresh turn on an idle thread (initial task or an idle follow-up). The // prompt() promise resolves at the real agent_end, so it is our completion signal. function startTurn(t: BtwThread, text: string): void { Object.assign(t, beginTurn(t, Date.now())); ensureSubscribed(t); updateStatus(latestCtx); t.session.prompt(text).then( () => onTurnDone(t), (err) => onTurnError(t, err), ); } // Submit an operator follow-up. Mid-run it steers the active turn (and folds into // that run's completion, so we do NOT attach a second completion handler); idle it // starts a fresh turn. function submitFollowUp(id: string, text: string): void { const t = threads.get(id); if (!t) return; addTimelineEntry(t, { kind: "user", title: "You", content: text }); if (t.session.isStreaming) { Object.assign(t, steerTurn(t)); t.session.prompt(text, { streamingBehavior: "steer" }).catch((err) => { // Race: the run finished between the check and the queue → run it fresh. if (!t.session.isStreaming) startTurn(t, text); else latestCtx?.ui.notify(`btw: follow-up failed: ${err instanceof Error ? err.message : String(err)}`, "error"); }); updateStatus(latestCtx); } else { startTurn(t, text); } t.render?.(true); } async function launch(ctx: ExtensionCommandContext, note: string): Promise { const mainSession = ctx.sessionManager.getSessionFile(); if (!mainSession || !existsSync(mainSession)) { ctx.ui.notify("btw: no session file to fork yet — send a message first.", "error"); return; } const dir = sessionsDir(ctx); try { mkdirSync(dir, { recursive: true }); } catch { /* recursive mkdir tolerates an existing dir */ } const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; // Fork the main session into our own dir, then wrap it in an in-process // sub-session. A custom resource loader with noExtensions:true mirrors the old // `--no-extensions` child — the sub-session loads no extension runtime, so it // never re-runs this (or any) extension factory. let session: AgentSession; let sessionFile: string; try { const sm = SessionManager.forkFrom(mainSession, ctx.cwd, dir); sessionFile = sm.getSessionFile() ?? join(dir, `${id}.jsonl`); const loader = new DefaultResourceLoader({ cwd: ctx.cwd, agentDir: getAgentDir(), noExtensions: true }); await loader.reload(); const result = await createAgentSession({ cwd: ctx.cwd, sessionManager: sm, model: ctx.model, modelRegistry: ctx.modelRegistry, tools: SUBSESSION_TOOLS, resourceLoader: loader, }); session = result.session; } catch (err) { ctx.ui.notify(`btw: could not start side task — ${err instanceof Error ? err.message : String(err)}`, "error"); return; } const thread: BtwThread = { id, note, session, status: "running", timeline: [], startedAt: Date.now(), turns: 0, sessionFile, resultPath: join(dir, `${id}.result.md`), }; threads.set(id, thread); order.push(id); startTurn(thread, framedTask(note)); updateStatus(ctx); ctx.ui.notify(`btw started: ${note}`, "info"); // Open the panel immediately on this thread, or retarget an open panel. if (panelOpen && currentPanel) currentPanel.show(id); else void openPanel(ctx, id); } // ── Full-screen panel ───────────────────────────── // Full-screen overlay with a live transcript (icons/markdown like agent-hub's // ZoomUI) and a follow-up composer. Navigation keys are intercepted // here; everything else feeds the composer. class BtwPanel { private selectedIndex = 0; private scrollOffset = 0; private followTail = true; private contentHeight = bodyRows(undefined, BTW_CHROME_ROWS); readonly input = new Input(); constructor( public currentId: string, private onFollowUp: (text: string) => void, private notify: (m: string, type?: "info" | "warning" | "error" | "success") => void, private close: () => void, ) { this.input.focused = true; this.input.onSubmit = (val: string) => { const text = val.trim(); if (!text) return; this.input.setValue(""); this.onFollowUp(text); }; this.input.onEscape = () => this.close(); } private get thread(): BtwThread | undefined { return threads.get(this.currentId); } private applyViewState(state: { selectedIndex: number; scrollOffset: number; followTail: boolean }): void { this.selectedIndex = state.selectedIndex; this.scrollOffset = state.scrollOffset; this.followTail = state.followTail; } clamp(): void { this.applyViewState( clampModalView( { selectedIndex: this.selectedIndex, scrollOffset: this.scrollOffset, followTail: this.followTail }, this.thread?.timeline.length ?? 0, this.contentHeight, ), ); } timelinePruned(id: string, prunedCount: number): void { if (id !== this.currentId) return; this.applyViewState( shiftModalViewForPrunedEntries( { selectedIndex: this.selectedIndex, scrollOffset: this.scrollOffset, followTail: this.followTail }, prunedCount, this.thread?.timeline.length ?? 0, this.contentHeight, ), ); } show(id: string): void { const next = resolveThreadId(id, order); if (!next || !threads.has(next)) return; this.currentId = next; this.selectedIndex = 0; this.scrollOffset = 0; this.followTail = true; this.clamp(); } private switchThread(dir: 1 | -1): void { if (order.length < 2) return; const i = order.indexOf(this.currentId); const start = i >= 0 ? i : order.length - 1; const next = order[(start + dir + order.length) % order.length]; this.show(next); } handleInput(data: string, tui: { requestRender: () => void }): void { this.clamp(); const t = this.thread; const n = t ? t.timeline.length : 0; const maxIndex = Math.max(0, n - 1); const multi = order.length >= 2; if (matchesKey(data, Key.up)) { this.followTail = false; this.selectedIndex = Math.max(0, this.selectedIndex - 1); } else if (matchesKey(data, Key.down)) { this.selectedIndex = Math.min(maxIndex, this.selectedIndex + 1); if (n === 0 || this.selectedIndex >= maxIndex) this.followTail = true; } else if (multi && matchesKey(data, Key.left)) { this.switchThread(-1); lastViewedId = this.currentId; } else if (multi && matchesKey(data, Key.right)) { this.switchThread(1); lastViewedId = this.currentId; } else if (matchesKey(data, Key.ctrl("c"))) { void this.copySelected(); } else if (matchesKey(data, Key.escape)) { this.close(); return; } else { // Composer handles printable text, Enter (submit), backspace, etc. this.input.handleInput(data); } this.clamp(); tui.requestRender(); } private async copySelected(): Promise { this.clamp(); const t = this.thread; const item = t?.timeline[this.selectedIndex]; if (!item) return; try { await copyToClipboard(item.content); this.notify("Copied entry", "success"); } catch (err) { this.notify(`Copy failed: ${err instanceof Error ? err.message : String(err)}`, "error"); } } private renderEntry(entry: TimelineEntry, isSelected: boolean, width: number, theme: Theme, mdTheme: ReturnType): string[] { const box = new Box(1, 0, (s: string) => (isSelected ? theme.bg("selectedBg", s) : s)); let icon = "○"; let color = "dim"; if (entry.kind === "text") { icon = "🤖"; color = "accent"; } else if (entry.kind === "tool") { icon = "🛠"; color = "warning"; } else if (entry.kind === "thinking") { icon = "💭"; color = "dim"; } else if (entry.kind === "user") { icon = "🧑"; color = "success"; } box.addChild(new Text(`${theme.fg(color, icon)} ${theme.bold(entry.title)}`, 0, 0)); if (isSelected) { box.addChild(new Markdown(capSelectedMarkdown(entry.content || "(empty)"), 2, 0, mdTheme)); } else { const flat = (entry.content || "").replace(/\s+/g, " ").trim(); box.addChild(new Text(theme.fg("dim", " " + (truncateToWidth(flat, Math.max(0, width - 8)) || "…")), 0, 0)); } return box.render(width); } render(width: number, terminalRows: number | undefined, theme: Theme): string[] { this.contentHeight = bodyRows(terminalRows, BTW_CHROME_ROWS); const lines: string[] = []; const mdTheme = getMarkdownTheme(); const t = this.thread; // ── Header: BTW │ note │ status │ elapsed │ N/M ── const head = new Container(); head.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); if (t) { const st = t.status; const sc = st === "error" ? "error" : st === "running" ? "warning" : "success"; const idx = order.indexOf(this.currentId); const pos = `${idx >= 0 ? idx + 1 : 1}/${order.length}`; const noteShort = truncateToWidth(t.note, Math.max(10, width - 36)); const sep = theme.fg("dim", "│"); head.addChild( new Text( `${theme.fg("accent", theme.bold(" BTW"))} ${sep} ${theme.bold(noteShort)} ${sep} ${theme.fg(sc, st)} ${sep} ${theme.fg("dim", fmtDuration(threadElapsedMs(t)))} ${sep} ${theme.fg("success", pos)}`, 1, 0, ), ); } else { head.addChild(new Text(theme.fg("dim", " btw — no side task"), 1, 0)); } lines.push(...head.render(width)); // ── Body: transcript (selected entry expanded, others previewed) ── const items = t?.timeline ?? []; this.clamp(); const composerLines = this.input.render(width); // Input may wrap; borrow those extra rows from the transcript, never from // the composer/hint chrome. const transcriptHeight = Math.max(0, this.contentHeight - Math.max(0, composerLines.length - 1)); const bodyLines: string[] = []; if (items.length === 0) { bodyLines.push(theme.fg("dim", " Working…")); } else { for (let i = this.scrollOffset; i < items.length; i++) { const card = this.renderEntry(items[i], i === this.selectedIndex, width, theme, mdTheme); if (bodyLines.length > 0 && bodyLines.length + card.length > transcriptHeight) break; bodyLines.push(...card); } } lines.push(...fitToHeight(bodyLines, transcriptHeight)); // ── Footer: composer + key hints ── const footer = new Container(); footer.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); footer.addChild(new Text(theme.fg("dim", " follow-up:"), 1, 0)); lines.push(...footer.render(width)); lines.push(...composerLines); const switchHint = order.length >= 2 ? " · ←/→ switch" : ""; lines.push(theme.fg("dim", ` Enter send · Esc return to main (task keeps running) · ↑/↓ scroll${switchHint} · Ctrl+C copy`)); return fitToHeight(lines, this.contentHeight + BTW_CHROME_ROWS); } } async function openPanel(ctx: ExtensionContext, startId?: string): Promise { if (panelOpen) { if (startId && currentPanel) currentPanel.show(startId); return; } const initial = resolveThreadId(startId, order) ?? mostRecentId(); if (!initial) { ctx.ui.notify("No btw side tasks yet — /af-btw to start one.", "info"); return; } panelOpen = true; lastViewedId = initial; const resources = createPanelResources(); try { await ctx.ui.custom( (tui, theme, _kb, done) => { const panel = new BtwPanel( initial, (text) => submitFollowUp(panel.currentId, text), (m, type) => ctx.ui.notify(m, (type === "success" ? "info" : type) as "info" | "warning" | "error"), () => done(undefined), ); // Throttle live refreshes to ~12fps; force pushes the final frame. let lastRender = 0; const requestRender = (force?: boolean) => { const now = Date.now(); if (force || now - lastRender > 80) { lastRender = now; tui.requestRender(); } }; // Bind the render hook to whichever thread the panel currently shows. const bindRender = () => { for (const th of threads.values()) th.render = undefined; const cur = threads.get(panel.currentId); if (cur) cur.render = requestRender; }; bindRender(); // Let a fresh /af-btw retarget this open panel and keep selection valid when // retention/pruning changes the backing thread list. currentPanel = { show: (id: string) => { panel.show(id); bindRender(); lastViewedId = panel.currentId; tui.requestRender(); }, currentId: () => panel.currentId, timelinePruned: (id: string, prunedCount: number) => { panel.timelinePruned(id, prunedCount); tui.requestRender(); }, clamp: () => { panel.clamp(); tui.requestRender(); }, }; // Keep elapsed/spinner live even between stream events. resources.every(500, () => tui.requestRender()); return { render: (w: number) => panel.render(w, tui.terminal?.rows, theme as Theme), handleInput: (data: string) => { const before = panel.currentId; panel.handleInput(data, tui); if (panel.currentId !== before) { bindRender(); lastViewedId = panel.currentId; } }, invalidate: () => {}, dispose: () => resources.dispose(), }; }, { ...FULLSCREEN_OVERLAY, onHandle: (h) => h.focus() }, ); } finally { resources.dispose(); currentPanel = undefined; for (const th of threads.values()) th.render = undefined; panelOpen = false; } } pi.registerCommand("af-btw", { description: "Start a side task in a live panel (full session context), or reopen the panel. Usage: /af-btw [task]", handler: async (args: string, ctx: ExtensionCommandContext) => { latestCtx = ctx; markBtwActivated(); const note = args.trim(); if (!note) { if (threads.size > 0) { await openPanel(ctx, lastViewedId); } else { ctx.ui.notify("Usage: /af-btw — runs a side task with this session's full context.", "warning"); } return; } await launch(ctx, note); }, }); // Alt+' reopens the panel. Chosen at the operator's request: unlike Alt+B (pi's // editor cursor-word-left) it doesn't collide with a composer motion. Was // Alt+Shift+B → Alt+B → Alt+' — pick another binding here if your terminal eats it. pi.registerShortcut("alt+'", { description: "Open the btw side-task panel", handler: async (ctx) => { latestCtx = ctx; markBtwActivated(); if (threads.size === 0) { ctx.ui.notify("No btw side tasks yet — /af-btw to start one.", "info"); return; } await openPanel(ctx, lastViewedId); }, }); // Render the compact result card: header (✓/✗ + note + elapsed), the first lines // of the final answer, and the artifact path. The full answer lives in result.md. const renderer: MessageRenderer = (message, _options, theme) => { const d = message.details; const body = typeof message.content === "string" ? message.content : message.content .filter((c) => c.type === "text") .map((c) => (c as { text: string }).text) .join("\n"); const container = new Container(); container.addChild(new Spacer(1)); const box = new Box(1, 1, (s: string) => theme.bg("customMessageBg", s)); const icon = d?.ok === false ? theme.fg("error", "✗") : theme.fg("success", "✓"); const elapsed = d ? theme.fg("dim", ` · ${fmtDuration(d.elapsedMs)}`) : ""; box.addChild(new Text(`${icon} ${theme.bold(`btw${d?.note ? `: ${d.note}` : ""}`)}${elapsed}`, 0, 0)); box.addChild(new Spacer(1)); box.addChild(new Markdown(body || "_(no output)_", 0, 0, getMarkdownTheme())); if (d?.resultPath) { box.addChild(new Spacer(1)); box.addChild(new Text(theme.fg("dim", `full result: ${d.resultPath}`), 0, 0)); } container.addChild(box); return container; }; pi.registerMessageRenderer(RESULT_TYPE, renderer); // Keep btw cards visible in the transcript but OUT of the main agent's LLM // context — they are for the human, and must not derail the main reasoning. pi.on("context", (ev: ContextEvent) => { const filtered = ev.messages.filter( (m) => !(m.role === "custom" && (m as { customType?: string }).customType === RESULT_TYPE), ); if (filtered.length !== ev.messages.length) return { messages: filtered }; }); pi.on("session_start", async (_ev, ctx) => { latestCtx = ctx; cleanupOldArtifacts(ctx); updateStatus(ctx); }); pi.on("agent_end", async (_ev, ctx) => { latestCtx = ctx; // The main turn just ended → we are (about to be) idle. Defer one tick so the // streaming flag is fully cleared before flush() checks isIdle(). setTimeout(flush, 0); }); pi.on("session_shutdown", async () => { shuttingDown = true; pending.length = 0; const retained = [...threads.values()]; for (const t of retained) { try { await t.session.abort(); } catch { /* best-effort */ } disposeThread(t); } threads.clear(); order.length = 0; lastViewedId = undefined; currentPanel?.clamp(); updateStatus(latestCtx); }); }