// btw — a `/btw` side-chat popover backed by an isolated in-memory AgentSession seeded with the // main conversation context, with optional summary injection back into the main chat. // // Vendored from `mitsuhiko/agent-stuff` `extensions/btw.ts` (MIT). Adapted for perk: // - `registerBtw(pi, gating)` wrapper (the perk registerX pattern) over the original default export; // - the local `notify` helper routes through the headless-safe `report()` surfaces seam // (`perk: btw — ` grammar, D7); // - the side session's toolset mirrors perk's read-only gate (`sideSessionTools(gating.isActive())`) // and the session cache key carries the gate state so a gate flip recreates the session — perk's // structural read-only guarantee is never bypassed by the isolated side session; // - §5 themed-glyph conformance (`❌`→`✗`, running `⚙`→`▸`) via the extracted core; // - the extended `stripDynamicSystemPromptFooter` regex (also strips `Current date:`); // - the side session is seeded THROUGH its own `SessionManager` (`seedSessionManager`): Pi // ≥ 0.87 rebuilds `context.messages` from the manager's projection on every request, so // assigning `agent.state.messages` would be silently discarded; parent `system` messages are // never seeded (they would become the side session's provider-visible tool loadout); // - the summary is a tool-free one-shot over `ModelRegistry.streamSimple` (request-time auth, // live `--api-key`/extension providers), not a temporary AgentSession; // - every overlay display sink renders through the surfaces module's display sanitizer: the // Markdown answer (final, streaming partial, plain-text fallback) is control-stripped, and the // single-row question, error, tool-call and status rows are folded to one control-free line; // the input draft is scrubbed of control bytes on entry. Persisted entries, in-memory thread // state and the raw tool identifiers `tool_execution_end` matches on stay byte-exact. // // Charter note: `/btw`'s UI is a `ctx.ui.custom` overlay — the ONE sanctioned exception to the §6 D6 // decline (docs/design/tui-charter.md). It is human-invoked only (no model tool, not a stage/door), // `ctx.hasUI`-gated, and never machine-reachable (cold/headless/RPC), so it cannot threaten the // machine-executability the decline protects. `ctx.ui.custom` stays declined for all workflow surfaces. import type { ThinkingLevel as AiThinkingLevel, AssistantMessage } from "@earendil-works/pi-ai"; import { type AgentSession, type AgentSessionEvent, createAgentSession, createExtensionRuntime, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getMarkdownTheme, type KeybindingsManager, type ModelRuntime, type ResourceLoader, SessionManager, type SessionProjection, } from "@earendil-works/pi-coding-agent"; import { Container, type Focusable, Input, Markdown, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; // Structural slices of pi-tui's `TUI` / `OverlayHandle` — used to dodge the dual-copy class clash // (pi-coding-agent bundles its own pi-tui copy; importing the `TUI` class type directly clashes on // its private fields). The overlay only needs these methods. type TuiLike = { requestRender(): void }; type OverlayHandleLike = { setHidden(hidden: boolean): void; hide(): void; focus(): void; isFocused(): boolean; }; import { AGENT_SCRATCH_CONTEXT_TYPE, type AgentScratchProvisioner, createAgentScratchProvisioner, } from "../../substrate/agentScratch.ts"; import type { ToolGating } from "../../substrate/toolGating.ts"; import { report } from "../../surfaces/report.ts"; import { btwThreadEntryRenderer, btwThreadResetEntryRenderer, registerTranscriptRenderer, sanitizeOneLine, stripTerminalControls, } from "../../surfaces/surfaces.ts"; import { extractEventAssistantText, extractText, formatThread, formatToolArgs, lastAssistantMessage, renderErrorLine, renderToolCallLines, sideSessionTools, stripDynamicSystemPromptFooter, type ToolCallInfo, } from "./core.ts"; const BTW_ENTRY_TYPE = "btw-thread-entry"; const BTW_RESET_TYPE = "btw-thread-reset"; const BTW_SYSTEM_PROMPT = [ "You are BTW, a side-channel assistant embedded in the user's coding agent.", "You have access to the main conversation context — use it to give informed answers.", "Help with focused questions, planning, and quick explorations.", "Be direct and practical.", ].join(" "); export const BTW_SUMMARY_PROMPT = "Summarize this side conversation for handoff into the main conversation. Keep key decisions, findings, risks, and next actions. Output only the summary."; /** Pi's projected runtime message union (the seed vocabulary; `AgentMessage` is not a root export). */ export type ProjectedMessage = SessionProjection["messages"][number]; type SessionThinkingLevel = "off" | AiThinkingLevel; export type BtwDetails = { question: string; answer: string; timestamp: number; provider: string; model: string; thinkingLevel: SessionThinkingLevel; usage?: AssistantMessage["usage"]; }; type BtwResetDetails = { timestamp: number; }; type OverlayRuntime = { handle?: OverlayHandleLike; refresh?: () => void; close?: () => void; finish?: () => void; setDraft?: (value: string) => void; closed?: boolean; }; type SideSessionRuntime = { session: AgentSession; modelKey: string; agentScratchContent: string | null; unsubscribe: () => void; }; function createBtwResourceLoader( ctx: ExtensionContext, appendSystemPrompt: string[] = [BTW_SYSTEM_PROMPT], ): ResourceLoader { const extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() }; const systemPrompt = stripDynamicSystemPromptFooter(ctx.getSystemPrompt()); return { getExtensions: () => extensionsResult, getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }), getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => systemPrompt, getAppendSystemPrompt: () => appendSystemPrompt, extendResources: () => {}, reload: async () => {}, } as unknown as ResourceLoader; } /** * The live session's canonical model/auth runtime, recovered from the extension-facing compat * facade. pi 0.84 moved session model dispatch onto `ModelRuntime` and `createAgentSession` no * longer accepts a `modelRegistry` — but extensions still receive only the `ModelRegistry` * facade, which wraps the live runtime in a (compile-time-)private `runtime` field. Probe it * structurally (feature-detect `stream`) so btw's side/summary sessions share the ACTIVE * session's credential state — runtime API-key overrides (`--api-key`) and extension-registered * providers exist only on the live runtime, so a freshly-created default runtime can fail or * silently dispatch with a different credential. On any other host shape the probe yields * `undefined` and `createAgentSession` falls back to its own default runtime. * Pinned against the real facade in btw.test.ts (the probe breaks loudly if pi renames the field). */ export function liveModelRuntime( ctx: Pick, ): ModelRuntime | undefined { const probed = (ctx.modelRegistry as unknown as { runtime?: unknown }).runtime; if (probed && typeof (probed as { stream?: unknown }).stream === "function") { return probed as ModelRuntime; } return undefined; } /** * Persist a projected-message seed into a (fresh) side `SessionManager` as canonical entries, so * the side AgentSession's own projection reproduces it: Pi rebuilds `context.messages` from the * manager on every request, so this is the ONLY seeding path that survives a prompt. Exhaustive * over the projection roles — `appendMessage`'s contract excludes compaction/branch summaries * (they get their own entry kinds, which project back as the same roles; `appendCompaction` at an * empty manager's root projects exactly `[summary]`), and `system` messages are skipped outright: * a parent system message would become the side session's provider-visible tool loadout (defense * in depth behind `buildSeedMessages`'s selection). */ export function seedSessionManager( manager: SessionManager, seed: readonly ProjectedMessage[], ): void { for (const message of seed) { switch (message.role) { case "compactionSummary": manager.appendCompaction(message.summary, null, message.tokensBefore); break; case "branchSummary": manager.branchWithSummary(manager.getLeafId(), message.summary); break; case "system": break; case "user": case "assistant": case "toolResult": case "bashExecution": case "custom": manager.appendMessage(message); break; default: { const unreachable: never = message; throw new Error(`unknown projected message role: ${JSON.stringify(unreachable)}`); } } } } /** * Construct btw's isolated in-memory AgentSession (the tool-capable side chat) on the live * session's model runtime (`liveModelRuntime`), seeded through its own `SessionManager` * (`seedSessionManager`) — `createAgentSession` initializes the agent's messages from that * manager and every request re-projects from it, so no `refreshContext()` is needed. btw passes * `model` and `thinkingLevel` explicitly, so the SDK never "restores" them from the seeded * manager. Throws when no model is selected — callers gate on `ctx.model` first. */ export async function createBtwAgentSession( ctx: ExtensionContext, opts: { thinkingLevel: SessionThinkingLevel; tools: string[]; appendSystemPrompt?: string[]; seed?: readonly ProjectedMessage[]; }, ): Promise { const model = ctx.model; if (!model) throw new Error("No active model selected."); const sessionManager = SessionManager.inMemory(); seedSessionManager(sessionManager, opts.seed ?? []); const { session } = await createAgentSession({ sessionManager, model, modelRuntime: liveModelRuntime(ctx), thinkingLevel: opts.thinkingLevel, tools: opts.tools, resourceLoader: createBtwResourceLoader(ctx, opts.appendSystemPrompt), }); return session; } /** * The side session's seed: the parent's live projection (`buildSessionProjection().messages` — * compaction selection and context edits applied by Pi) minus the run-owned scratch customs and * EVERY `system` message (Pi ≥ 0.86 persists structured system-prompt sections as `system` * messages carrying `toolsAdded`/`toolsRemoved`; seeded, they would become side authority), then * the btw thread as user/assistant pairs. */ export function buildSeedMessages(ctx: ExtensionContext, thread: BtwDetails[]): ProjectedMessage[] { const seed: ProjectedMessage[] = []; try { const contextMessages = ctx.sessionManager.buildSessionProjection().messages; seed.push( ...contextMessages.filter( (message) => message.role !== "system" && (message as { customType?: string }).customType !== AGENT_SCRATCH_CONTEXT_TYPE, ), ); } catch { // Ignore context seed failures and continue with an empty side thread. } for (const item of thread) { seed.push( { role: "user", content: [{ type: "text", text: item.question }], timestamp: item.timestamp, }, { role: "assistant", content: [{ type: "text", text: item.answer }], provider: item.provider, model: item.model, api: ctx.model?.api ?? "openai-responses", usage: item.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", timestamp: item.timestamp, } as AssistantMessage, ); } return seed; } /** * Summarize a btw thread with ONE tool-free model request through the public registry stream * (`ModelRegistry.streamSimple` → the live `ModelRuntime` with request-time authentication, so a * live `--api-key` override and extension-registered providers are honored). The system prompt * is `BTW_SUMMARY_PROMPT` alone; `reasoning` is omitted (= thinking off). Request-time auth/setup * failures surface as an `error`-stopped message and are thrown with their text. */ export async function summarizeBtwThread( ctx: Pick, items: readonly BtwDetails[], opts: { signal?: AbortSignal } = {}, ): Promise { const model = ctx.model; if (!model) { throw new Error("No active model selected."); } const stream = ctx.modelRegistry.streamSimple( model, { systemPrompt: BTW_SUMMARY_PROMPT, messages: [{ role: "user", content: formatThread(items), timestamp: Date.now() }], }, { ...(opts.signal ? { signal: opts.signal } : {}) }, ); const response = await stream.result(); if (response.stopReason === "aborted") { throw new Error("Summary request was aborted."); } if (response.stopReason === "error") { throw new Error(response.errorMessage || "Summary request failed."); } return extractText(response.content) || "(No summary generated)"; } const PASTE_START = "\u001b[200~"; const PASTE_END = "\u001b[201~"; /** * Clean a complete bracketed paste BEFORE the Input inserts it, so its cursor advances by the * cleaned length (scrubbing the value afterwards would leave the cursor at the raw offset). Pi's * terminal delivers a paste whole, `ESC[200~ESC[201~` in one chunk. The framing mirrors * `Input.handleInput`: the first start marker is removed and everything before the first end * marker is the pasted text. Data without a complete paste passes through untouched. */ function scrubBracketedPaste(data: string): string { if (!data.includes(PASTE_START)) return data; const body = data.replace(PASTE_START, ""); const end = body.indexOf(PASTE_END); if (end === -1) return data; return `${PASTE_START}${stripTerminalControls(body.slice(0, end))}${body.slice(end)}`; } class BtwOverlay extends Container implements Focusable { private readonly input: Input; private readonly tui: TuiLike; private readonly theme: ExtensionContext["ui"]["theme"]; private readonly keybindings: KeybindingsManager; private readonly getTranscript: ( width: number, theme: ExtensionContext["ui"]["theme"], ) => string[]; private readonly getStatus: () => string; private readonly onSubmitCallback: (value: string) => void; private readonly onDismissCallback: () => void; private _focused = false; get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; this.input.focused = value; } constructor( tui: TuiLike, theme: ExtensionContext["ui"]["theme"], keybindings: KeybindingsManager, getTranscript: (width: number, theme: ExtensionContext["ui"]["theme"]) => string[], getStatus: () => string, onSubmit: (value: string) => void, onDismiss: () => void, ) { super(); this.tui = tui; this.theme = theme; this.keybindings = keybindings; this.getTranscript = getTranscript; this.getStatus = getStatus; this.onSubmitCallback = onSubmit; this.onDismissCallback = onDismiss; this.input = new Input(); this.input.onSubmit = (value) => { this.onSubmitCallback(value); }; this.input.onEscape = () => { this.onDismissCallback(); }; } handleInput(data: string): void { if (this.keybindings.matches(data, "tui.select.cancel")) { this.onDismissCallback(); return; } // A paste can carry ESC/BEL/C1 bytes the Input keeps and echoes inside the frame. Clean the // pasted text itself (never the whole of `data`: bracketed-paste markers and arrow keys are // themselves CSI sequences), so the cursor stays right after the paste. this.input.handleInput(scrubBracketedPaste(data)); // Backstop for control bytes that arrive any other way (a paste split across chunks): the // draft stays control-free, though Input.setValue only clamps the cursor there. const value = this.input.getValue(); const clean = stripTerminalControls(value); if (clean !== value) this.input.setValue(clean); } setDraft(value: string): void { this.input.setValue(value); this.tui.requestRender(); } getDraft(): string { return this.input.getValue(); } private frameLine(content: string, innerWidth: number): string { const truncated = truncateToWidth(content, innerWidth, ""); const padding = Math.max(0, innerWidth - visibleWidth(truncated)); return `${this.theme.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("borderMuted", "│")}`; } private borderLine(innerWidth: number, edge: "top" | "bottom"): string { const left = edge === "top" ? "┌" : "└"; const right = edge === "top" ? "┐" : "┘"; return this.theme.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`); } override render(width: number): string[] { const dialogWidth = Math.max(56, Math.min(width, Math.floor(width * 0.9))); const innerWidth = Math.max(40, dialogWidth - 2); const terminalRows = process.stdout.rows ?? 30; const dialogHeight = Math.max(16, Math.min(30, Math.floor(terminalRows * 0.75))); const chromeHeight = 7; const transcriptHeight = Math.max(6, dialogHeight - chromeHeight); // Markdown renders to innerWidth already — no manual wrapping needed const transcript = this.getTranscript(innerWidth, this.theme); const visibleTranscript = transcript.slice(-transcriptHeight); const transcriptPadding = Math.max(0, transcriptHeight - visibleTranscript.length); const status = this.getStatus(); const previousFocused = this.input.focused; this.input.focused = false; const inputLine = this.input.render(innerWidth)[0] ?? ""; this.input.focused = previousFocused; const lines = [ this.borderLine(innerWidth, "top"), this.frameLine(this.theme.fg("accent", this.theme.bold(" BTW side chat ")), innerWidth), this.frameLine(this.theme.fg("dim", "Separate side conversation. Esc closes."), innerWidth), this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`), ]; for (const line of visibleTranscript) { lines.push(this.frameLine(line, innerWidth)); } for (let i = 0; i < transcriptPadding; i++) { lines.push(this.frameLine("", innerWidth)); } lines.push(this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`)); lines.push(this.frameLine(this.theme.fg("warning", sanitizeOneLine(status)), innerWidth)); lines.push( `${this.theme.fg("borderMuted", "│")}${inputLine}${this.theme.fg("borderMuted", "│")}`, ); lines.push(this.frameLine(this.theme.fg("dim", "Enter submit · Esc close"), innerWidth)); lines.push(this.borderLine(innerWidth, "bottom")); return lines; } } export function registerBtw( pi: ExtensionAPI, gating: ToolGating, agentScratch: AgentScratchProvisioner = createAgentScratchProvisioner(), ): void { // Transcript markers for the btw thread entries (audit §2.3): renderer bodies in surfaces.ts, // registration = wiring, feature-detect inside the seam (pre-0.80.4 hosts stay inert). registerTranscriptRenderer(pi, BTW_ENTRY_TYPE, btwThreadEntryRenderer); registerTranscriptRenderer(pi, BTW_RESET_TYPE, btwThreadResetEntryRenderer); let thread: BtwDetails[] = []; let pendingQuestion: string | null = null; let pendingAnswer = ""; let pendingError: string | null = null; let pendingToolCalls: ToolCallInfo[] = []; let sideBusy = false; let overlayStatus = "Ready"; let overlayDraft = ""; let overlayRuntime: OverlayRuntime | null = null; let activeSideSession: SideSessionRuntime | null = null; let overlayRefreshTimer: ReturnType | null = null; const mdTheme = getMarkdownTheme(); // perk gate-mirror: the cache key carries the read-only/read-write gate state, so a gate flip // (read-only ↔ read-write) recreates the side session with the correct `sideSessionTools` set // — never reusing a stale toolset that could bypass perk's read-only guarantee. function getModelKey(ctx: ExtensionContext): string { const model = ctx.model; return model ? `${model.provider}/${model.id}#${gating.isActive() ? "ro" : "rw"}` : "none"; } // perk: route the local notify through the headless-safe report() seam (D7 grammar). function notify( ctx: ExtensionContext | ExtensionCommandContext, message: string, level: "info" | "warning" | "error", ): void { report(ctx, "btw", level, message); } function renderMarkdownLines(text: string, width: number): string[] { // The one entry for every multi-line answer sink (final, streaming partial, fallback): strip // controls but keep line structure; the answer state itself stays raw. const safe = stripTerminalControls(text); if (!safe) return []; try { const md = new Markdown(safe, 0, 0, mdTheme); return md.render(width); } catch { // Fall back to plain text wrapping if Markdown rendering fails return safe.split("\n").flatMap((line) => { if (!line) return [""]; const wrapped: string[] = []; for (let i = 0; i < line.length; i += width) { wrapped.push(line.slice(i, i + width)); } return wrapped.length > 0 ? wrapped : [""]; }); } } function getTranscriptLines(width: number, theme: ExtensionContext["ui"]["theme"]): string[] { try { return getTranscriptLinesInner(width, theme); } catch (error) { return [ theme.fg( "error", `Render error: ${error instanceof Error ? error.message : String(error)}`, ), ]; } } function getTranscriptLinesInner( width: number, theme: ExtensionContext["ui"]["theme"], ): string[] { if (thread.length === 0 && !pendingQuestion && !pendingAnswer && !pendingError) { return [theme.fg("dim", "No BTW messages yet. Type a question below.")]; } const lines: string[] = []; for (const item of thread.slice(-6)) { // User message const userText = sanitizeOneLine(item.question); lines.push( theme.fg("accent", theme.bold("You: ")) + truncateToWidth(userText, width - 5, "…"), ); lines.push(""); // Assistant message rendered as markdown const mdLines = renderMarkdownLines(item.answer, width); lines.push(...mdLines); lines.push(""); } if (pendingQuestion) { const userText = sanitizeOneLine(pendingQuestion); lines.push( theme.fg("accent", theme.bold("You: ")) + truncateToWidth(userText, width - 5, "…"), ); // Show tool calls inline (§5-conformed glyphs via the extracted core) if (pendingToolCalls.length > 0) { // Fold a display copy: the stored entries keep the RAW name `tool_execution_end` matches. const displayCalls = pendingToolCalls.map((tc) => ({ ...tc, toolName: sanitizeOneLine(tc.toolName), args: sanitizeOneLine(tc.args), })); lines.push(...renderToolCallLines(displayCalls, theme, width)); } if (pendingError) { lines.push(renderErrorLine(theme, sanitizeOneLine(pendingError))); } else if (pendingAnswer) { lines.push(""); const mdLines = renderMarkdownLines(pendingAnswer, width); lines.push(...mdLines); } else if (pendingToolCalls.length === 0) { lines.push(theme.fg("dim", "…")); } } // Trim trailing empty line while (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } return lines; } function syncOverlay(): void { overlayRuntime?.refresh?.(); } function scheduleOverlayRefresh(): void { if (overlayRefreshTimer) { return; } overlayRefreshTimer = setTimeout(() => { overlayRefreshTimer = null; syncOverlay(); }, 16); } function setOverlayStatus(status: string, throttled = false): void { overlayStatus = status; if (throttled) { scheduleOverlayRefresh(); } else { syncOverlay(); } } function dismissOverlay(): void { overlayRuntime?.close?.(); overlayRuntime = null; if (overlayRefreshTimer) { clearTimeout(overlayRefreshTimer); overlayRefreshTimer = null; } } function setOverlayDraft(value: string): void { overlayDraft = value; overlayRuntime?.setDraft?.(value); } async function disposeSideSession(): Promise { const current = activeSideSession; activeSideSession = null; if (!current) { return; } try { current.unsubscribe(); } catch { // Ignore unsubscribe errors during cleanup. } try { await current.session.abort(); } catch { // Ignore abort errors during cleanup. } current.session.dispose(); if (overlayRefreshTimer) { clearTimeout(overlayRefreshTimer); overlayRefreshTimer = null; } } async function resetThread( _ctx: ExtensionContext | ExtensionCommandContext, persist = true, ): Promise { thread = []; pendingQuestion = null; pendingAnswer = ""; pendingError = null; pendingToolCalls = []; sideBusy = false; setOverlayDraft(""); setOverlayStatus("Ready"); await disposeSideSession(); if (persist) { const details: BtwResetDetails = { timestamp: Date.now() }; pi.appendEntry(BTW_RESET_TYPE, details); } syncOverlay(); } async function restoreThread(ctx: ExtensionContext): Promise { await disposeSideSession(); thread = []; pendingQuestion = null; pendingAnswer = ""; pendingError = null; pendingToolCalls = []; sideBusy = false; overlayStatus = "Ready"; overlayDraft = ""; const branch = ctx.sessionManager.getBranch(); let lastResetIndex = -1; for (let i = 0; i < branch.length; i++) { const entry = branch[i]; if (entry && entry.type === "custom" && entry.customType === BTW_RESET_TYPE) { lastResetIndex = i; } } for (const entry of branch.slice(lastResetIndex + 1)) { if (entry.type !== "custom" || entry.customType !== BTW_ENTRY_TYPE) { continue; } const details = entry.data as BtwDetails | undefined; if (!details?.question || !details.answer) { continue; } thread.push(details); } syncOverlay(); } async function createSideSession( ctx: ExtensionCommandContext, agentScratchContent: string | null, ): Promise { if (!ctx.model) { return null; } // perk gate-mirror: read-only ⇒ ["read"] only (a foreign session's bash can't be sandboxed // by perk's isReadOnlyBashCommand); read-write ⇒ the full set. The session rides the LIVE // runtime (`createBtwAgentSession` → `liveModelRuntime`) so auth dispatch matches the main // session exactly. Scratch posture comes from this same gate rather than reverse-engineering // it from a tool array. const session = await createBtwAgentSession(ctx, { thinkingLevel: pi.getThinkingLevel() as SessionThinkingLevel, tools: sideSessionTools(gating.isActive()), appendSystemPrompt: agentScratchContent === null ? undefined : [BTW_SYSTEM_PROMPT, agentScratchContent], seed: buildSeedMessages(ctx, thread), }); const unsubscribe = session.subscribe((event: AgentSessionEvent) => { if (!sideBusy || !pendingQuestion) { return; } switch (event.type) { case "message_start": case "message_update": case "message_end": { const streamed = extractEventAssistantText(event.message); if (streamed) { pendingAnswer = streamed; pendingError = null; } setOverlayStatus( event.type === "message_end" ? "Finalizing side response..." : "Streaming side response...", true, ); return; } case "tool_execution_start": { const toolName = (event as { toolName?: string }).toolName ?? "unknown"; try { pendingToolCalls.push({ toolCallId: (event as { toolCallId?: string }).toolCallId ?? "", toolName, args: formatToolArgs(toolName, (event as { args?: unknown }).args), status: "running", }); } catch { // Ignore tool tracking failures } setOverlayStatus(`Running tool: ${toolName}...`, true); return; } case "tool_execution_end": { const endToolName = (event as { toolName?: string }).toolName ?? "unknown"; const tc = pendingToolCalls.find( (t) => t.toolName === endToolName && t.status === "running", ); if (tc) { tc.status = (event as { isError?: boolean }).isError ? "error" : "done"; } setOverlayStatus("Streaming side response...", true); return; } case "turn_end": { setOverlayStatus("Finalizing side response...", true); return; } default: return; } }); return { session, modelKey: getModelKey(ctx), agentScratchContent, unsubscribe, }; } async function ensureSideSession( ctx: ExtensionCommandContext, ): Promise { if (!ctx.model) { return null; } // This runs before every side-model prompt. A successful resolve repairs deletion // idempotently; a transition between unavailable and available scratch recreates the cached // session so its immutable resource-loader prompt matches the current turn. const agentScratchContent = gating.isActive() ? null : (agentScratch.resolve(ctx)?.content ?? null); const expectedModelKey = getModelKey(ctx); if ( activeSideSession && activeSideSession.modelKey === expectedModelKey && activeSideSession.agentScratchContent === agentScratchContent ) { return activeSideSession; } await disposeSideSession(); activeSideSession = await createSideSession(ctx, agentScratchContent); return activeSideSession; } async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise { if (!ctx.hasUI) { return; } if (overlayRuntime?.handle) { overlayRuntime.handle.setHidden(false); overlayRuntime.handle.focus(); overlayRuntime.refresh?.(); return; } const runtime: OverlayRuntime = {}; const closeRuntime = () => { if (runtime.closed) { return; } runtime.closed = true; runtime.handle?.hide(); if (overlayRuntime === runtime) { overlayRuntime = null; } runtime.finish?.(); }; runtime.close = closeRuntime; overlayRuntime = runtime; void ctx.ui .custom( async (tui, theme, keybindings, done) => { runtime.finish = () => done(); const overlay = new BtwOverlay( tui, theme, keybindings, (width, t) => getTranscriptLines(width, t), () => overlayStatus, (value) => { void submitFromOverlay(ctx, value); }, () => { void closeOverlayFlow(ctx); }, ); overlay.focused = true; overlay.setDraft(overlayDraft); runtime.setDraft = (value) => overlay.setDraft(value); runtime.refresh = () => { overlay.focused = runtime.handle?.isFocused() ?? false; tui.requestRender(); }; runtime.close = () => { overlayDraft = overlay.getDraft(); closeRuntime(); }; if (runtime.closed) { done(); } return overlay; }, { overlay: true, overlayOptions: { width: "80%", minWidth: 72, maxHeight: "78%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 }, }, onHandle: (handle) => { runtime.handle = handle; handle.focus(); if (runtime.closed) { closeRuntime(); } }, }, ) .catch((error) => { if (overlayRuntime === runtime) { overlayRuntime = null; } notify(ctx, error instanceof Error ? error.message : String(error), "error"); }); } // The summary one-shot's abort is owned here: created per call, aborted on session shutdown. let summaryAbort: AbortController | null = null; async function injectSummaryIntoMain( ctx: ExtensionContext | ExtensionCommandContext, ): Promise { if (thread.length === 0) { notify(ctx, "No BTW thread to summarize.", "warning"); return; } setOverlayStatus("Summarizing BTW thread for injection..."); summaryAbort = new AbortController(); try { const summary = await summarizeBtwThread(ctx, thread, { signal: summaryAbort.signal }); const message = `Summary of my BTW side conversation:\n\n${summary}`; if (ctx.isIdle()) { pi.sendUserMessage(message); } else { pi.sendUserMessage(message, { deliverAs: "followUp" }); } await resetThread(ctx); notify(ctx, "Injected BTW summary into main chat.", "info"); } catch (error) { notify(ctx, error instanceof Error ? error.message : String(error), "error"); } finally { summaryAbort = null; } } async function closeOverlayFlow(ctx: ExtensionContext | ExtensionCommandContext): Promise { dismissOverlay(); if (!ctx.hasUI) { return; } if (thread.length === 0) { return; } const choice = await ctx.ui.select("Close BTW:", [ "Keep side thread", "Inject summary into main chat", ]); if (choice === "Inject summary into main chat") { await injectSummaryIntoMain(ctx); } } async function runBtwPrompt(ctx: ExtensionCommandContext, question: string): Promise { const model = ctx.model; if (!model) { setOverlayStatus("No active model selected."); notify(ctx, "No active model selected.", "error"); return; } const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); if (auth.ok === false) { const message = auth.error; setOverlayStatus(message); notify(ctx, message, "error"); return; } if (sideBusy) { notify(ctx, "BTW is still processing the previous message.", "warning"); return; } const side = await ensureSideSession(ctx); if (!side) { notify(ctx, "Unable to create BTW side session.", "error"); return; } sideBusy = true; pendingQuestion = question; pendingAnswer = ""; pendingError = null; pendingToolCalls = []; setOverlayStatus("Streaming side response..."); syncOverlay(); try { await side.session.prompt(question, { source: "extension" }); const response = lastAssistantMessage(side.session.state.messages) as AssistantMessage | null; if (!response) { throw new Error("BTW request finished without a response."); } if (response.stopReason === "aborted") { throw new Error("BTW request aborted."); } if (response.stopReason === "error") { throw new Error(response.errorMessage || "BTW request failed."); } const answer = extractText(response.content) || "(No text response)"; pendingAnswer = answer; const details: BtwDetails = { question, answer, timestamp: Date.now(), provider: model.provider, model: model.id, thinkingLevel: pi.getThinkingLevel() as SessionThinkingLevel, usage: response.usage, }; thread.push(details); pi.appendEntry(BTW_ENTRY_TYPE, details); pendingQuestion = null; pendingAnswer = ""; pendingToolCalls = []; setOverlayStatus("Ready for the next side question."); } catch (error) { const message = error instanceof Error ? error.message : String(error); pendingError = message; setOverlayStatus("BTW request failed."); notify(ctx, message, "error"); } finally { sideBusy = false; syncOverlay(); } } async function submitFromOverlay( ctx: ExtensionContext | ExtensionCommandContext, rawValue: string, ): Promise { const question = rawValue.trim(); if (!question) { setOverlayStatus("Enter a question first."); return; } setOverlayDraft(""); if (!("waitForIdle" in ctx)) { setOverlayStatus("BTW submit requires command context. Re-open with /btw."); return; } await runBtwPrompt(ctx as ExtensionCommandContext, question); } pi.registerCommand("btw", { description: "Open a simple BTW side-chat popover. `/btw ` asks immediately, `/btw` opens the side thread.", handler: async (args, ctx) => { const question = args.trim(); if (!question) { if (thread.length > 0 && ctx.hasUI) { const choice = await ctx.ui.select("BTW side chat:", [ "Continue previous conversation", "Start fresh", ]); if (choice === "Continue previous conversation") { // Dispose session so it's recreated with fresh main context on next submit await disposeSideSession(); setOverlayStatus("Continuing BTW thread."); await ensureOverlay(ctx); } else if (choice === "Start fresh") { await resetThread(ctx, true); setOverlayStatus("Ready"); await ensureOverlay(ctx); } // null = user cancelled (Esc), do nothing } else { await resetThread(ctx, true); setOverlayStatus("Ready"); await ensureOverlay(ctx); } return; } await ensureOverlay(ctx); await runBtwPrompt(ctx, question); }, }); pi.on("session_start", async (_event, ctx) => { await restoreThread(ctx); }); pi.on("session_tree", async (_event, ctx) => { await restoreThread(ctx); }); pi.on("session_shutdown", async () => { summaryAbort?.abort(); await disposeSideSession(); dismissOverlay(); }); }