/** * /btw — ask a side question that inherits the main conversation but never * enters it. * * Behavior (Claude Code compatible): * - `/btw ` answers in a floating overlay while the main agent keeps * running. The side request sees the main session context but has no tools. * - Side questions are continuous within a session: earlier exchanges are * replayed into the next request. The thread is persisted in the session file * as `btw-thread` custom entries, so it survives reloads, restarts and * `/resume` of the same session, and follows the active `/tree` branch. * - Overlay keys: ↑/↓ scroll, ⇧←/→ browse the side thread, `e` expand or fold a * long question, `c` copy the selected answer, `f` promote the selected * exchange into the main conversation, `x` delete the selected exchange, * Esc/Enter close. * - `/btw` without a question reopens the overlay on the existing thread. * * Nothing reaches the main conversation unless `f` is pressed. */ import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai"; import { convertToLlm, copyToClipboard, getMarkdownTheme, sessionEntryToContextMessages, type ExtensionAPI, type ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; import { Container, Markdown, Text } from "@earendil-works/pi-tui"; import { BTW_SYSTEM_PROMPT, buildSideRequest } from "./context.ts"; import { BtwOverlay, OVERLAY_MARGIN, OVERLAY_MAX_HEIGHT_PERCENT, OVERLAY_MIN_WIDTH, OVERLAY_WIDTH_PERCENT, type BtwOverlayResult, type BtwQueryOutcome, type BtwThreadSink } from "./overlay.ts"; import { appendExchange, BTW_ENTRY_TYPE, BTW_MESSAGE_TYPE, findThread, markPromoted, removeExchange, replayWindow, type BtwExchange, } from "./thread.ts"; export default function btwExtension(pi: ExtensionAPI): void { /** Side thread of the active session, oldest first. */ let thread: BtwExchange[] = []; /** Guards against two stacked overlays; a second one would steal focus. */ let overlayOpen = false; /** Open overlay, so a session swap can stop it before the runtime goes stale. */ let activeOverlay: BtwOverlay | null = null; pi.on("session_start", (_event, ctx) => { thread = findThread(ctx.sessionManager.getBranch()); // Cleanup is shared with session_shutdown because the host does not promise // the shutdown event before a rebind; dispose() is idempotent. activeOverlay?.dispose(); overlayOpen = false; activeOverlay = null; }); // `/reload` and session replacements (`/new`, `/resume`, `/fork`) hide // overlays without resolving `ctx.ui.custom()`, so the host never calls // `dispose()`: the spinner timer would keep running and a late answer would // write to an already invalidated extension runtime. pi emits // `session_shutdown` before that teardown, which is the only place to stop // the overlay and abort its request from the extension side. pi.on("session_shutdown", () => { activeOverlay?.dispose(); activeOverlay = null; overlayOpen = false; }); // `/tree` navigation swaps the active branch without restarting the session // (no session_start fires), so the thread must be rebuilt from the branch // that is active after the switch. pi.on("session_tree", (_event, ctx) => { thread = findThread(ctx.sessionManager.getBranch()); }); // Renders exchanges promoted with `f` inside the main transcript. pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, options, theme) => { const container = new Container(); container.addChild( new Text(theme.fg("accent", "❯ btw") + theme.fg("dim", " · promoted into the main conversation"), options.outputPad, 0), ); container.addChild(new Markdown(contentToText(message.content), options.outputPad, 0, getMarkdownTheme())); return container; }); pi.registerCommand("btw", { // pi 0.85.1 maps only name/description/getArgumentCompletions for extension // commands, so the `[question]` placeholder for the autocomplete list has to // live in the description; built-ins and prompt templates use their own // `argumentHint` slot instead. description: "[question] Ask a side question without adding it to the main conversation", handler: async (rawArgs, ctx) => { const question = rawArgs.trim(); if (ctx.mode !== "tui") { ctx.ui.notify("/btw requires interactive mode", "error"); return; } if (overlayOpen) { ctx.ui.notify("A btw overlay is already open", "warning"); return; } if (question === "" && thread.length === 0) { ctx.ui.notify("Usage: /btw ", "warning"); return; } const model = ctx.model; if (question !== "" && model === undefined) { ctx.ui.notify("No model selected. Pick one with /model and retry.", "error"); return; } // Snapshot the replay window now: the in-flight request must not see // exchanges that finish after this command started. const replayed = replayWindow(thread); const sink: BtwThreadSink = { add: (exchange) => { thread = appendExchange(thread, exchange); pi.appendEntry(BTW_ENTRY_TYPE, { kind: "exchange", exchange }); }, remove: (id) => { thread = removeExchange(thread, id); pi.appendEntry(BTW_ENTRY_TYPE, { kind: "delete", id }); }, }; overlayOpen = true; let result: BtwOverlayResult; try { result = await ctx.ui.custom( (tui, theme, _keybindings, done) => { const overlay = new BtwOverlay({ tui, theme, thread, question: question === "" ? null : question, // The model label and replay count describe the request that is // about to run; the overlay stores them on the exchange it creates. modelLabel: model === undefined ? "no model" : `${model.provider}/${model.id}`, replayedCount: replayed.length, ask: question !== "" && model !== undefined ? (signal) => askSideQuestion(ctx, model, question, replayed, signal) : undefined, sink, copy: copyToClipboard, done, }); activeOverlay = overlay; return overlay; }, { overlay: true, overlayOptions: { anchor: "center", width: `${OVERLAY_WIDTH_PERCENT}%`, minWidth: OVERLAY_MIN_WIDTH, maxHeight: `${OVERLAY_MAX_HEIGHT_PERCENT}%`, margin: OVERLAY_MARGIN, }, }, ); } finally { overlayOpen = false; activeOverlay = null; } if (result.action === "fork") { promoteExchange(ctx, result.exchange); } }, }); /** * Promote one side exchange into the main conversation. * * The exchange is appended at the session leaf as a custom message: it joins * the main agent's context on the next request, and while the main agent is * still streaming pi defers it to the end of the current turn instead of * interrupting it. Appending never rewrites earlier history, so every cached * prefix of the main conversation stays valid. */ function promoteExchange(ctx: ExtensionCommandContext, exchange: BtwExchange): void { pi.appendEntry(BTW_ENTRY_TYPE, { kind: "promoted", id: exchange.id }); thread = markPromoted(thread, exchange.id); pi.sendMessage({ customType: BTW_MESSAGE_TYPE, content: formatPromotedExchange(exchange), display: true, details: { id: exchange.id, model: exchange.model }, }); ctx.ui.notify("btw exchange added to the main conversation", "info"); } } /** * Run one no-tools side request against the main session context. * * All provider failures are converted into a UI outcome; nothing is retried or * silently swallowed, so the overlay always shows exactly what happened. */ async function askSideQuestion( ctx: ExtensionCommandContext, model: Model, question: string, replayed: readonly BtwExchange[], signal: AbortSignal, ): Promise { // Mirror pi's own context pipeline: compaction-aware entries projected to // agent messages, then transformed to provider messages. try { const context = convertToLlm( ctx.sessionManager.buildContextEntries().flatMap((entry) => sessionEntryToContextMessages(entry)), ); const messages = buildSideRequest({ context, replayed, question, model: { api: model.api, provider: model.provider, id: model.id }, now: Date.now(), }); const response = await ctx.modelRegistry.complete( model, { systemPrompt: BTW_SYSTEM_PROMPT, messages }, { signal, headers: sideRequestHeaders(model, ctx.sessionManager.getSessionId()) }, ); if (response.stopReason === "aborted") return { kind: "cancelled" }; if (response.stopReason === "error") { return { kind: "error", message: response.errorMessage?.trim() || `${model.provider}/${model.id} returned an error` }; } const answer = response.content .filter((block): block is TextContent => block.type === "text") .map((block) => block.text) .join("\n") .trim(); if (answer === "") { return { kind: "error", message: `${model.provider}/${model.id} returned no text (stop reason: ${response.stopReason})` }; } return { kind: "answer", answer }; } catch (error) { if (signal.aborted) return { kind: "cancelled" }; return { kind: "error", message: error instanceof Error ? error.message : String(error) }; } } /** OpenCode's API host, used to detect models that need session headers. */ const OPENCODE_HOST = "opencode.ai"; /** * Extra headers a side request must carry. * * Pi adds provider-attribution headers inside the agent's stream wrapper * (`mergeProviderAttributionHeaders` in pi's `core/provider-attribution`), * which a direct `ModelRegistry.complete()` call bypasses. OpenCode rejects * such requests with "MissingSessionID", so replicate the session headers * that matter for routing there. Attribution headers for other providers * (OpenRouter et al.) are optional and stay out of side requests. * * Only those session headers are replicated: the rest of pi's stream pipeline * (the `sessionId` option that drives provider cache keys, request timeouts and * retries, transport selection, thinking budgets, payload/response hooks and * the context-window clamp on maxTokens) does not apply to a side request. */ function sideRequestHeaders(model: Model, sessionId: string): Record | undefined { if (sessionId === "" || !isOpencodeModel(model)) return undefined; return { "x-opencode-session": sessionId, "x-opencode-client": "pi" }; } /** * Pi's own session-header detection: opencode provider ids or an opencode.ai * base URL. * * A base URL that does not parse means "not opencode" rather than a failed * request: providers such as `azure-openai-responses` ship an empty baseUrl * and resolve their endpoint from environment variables, so their side * requests must still be sent without session headers. */ function isOpencodeModel(model: Model): boolean { if (model.provider === "opencode" || model.provider === "opencode-go") return true; try { return new URL(model.baseUrl).hostname === OPENCODE_HOST; } catch { return false; } } /** * Text sent to the main agent when an exchange is promoted. * * It doubles as the transcript rendering, so the provenance stays visible: the * answer came from conversation context only and the main agent should verify * it before acting on it. */ /** * Text of the custom message that carries a promoted exchange. * * Exported because tools/make-doc-images.mjs renders the README screenshot * through this exact formatter, so the documented output cannot drift from the * text the main agent receives. */ export function formatPromotedExchange(exchange: BtwExchange): string { const quotedQuestion = exchange.question .split("\n") .map((line) => `> ${line}`) .join("\n"); return [ "_[btw] The user promoted this side exchange into the main conversation. The answer was produced without tools from conversation context only; verify it before relying on it._", "", quotedQuestion, "", exchange.answer, ].join("\n"); } function contentToText(content: string | readonly (TextContent | ImageContent)[]): string { if (typeof content === "string") return content; return content .map((block) => (block.type === "text" ? block.text : "[image]")) .join("\n"); }