import { stripVTControlCharacters } from "node:util"; import { buildSessionContext, copyToClipboard, createAgentSession, createExtensionRuntime, SessionManager, type AgentSession, type AgentSessionEvent, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ResourceLoader, } from "@earendil-works/pi-coding-agent"; import { type AssistantMessage, type Message, type ThinkingLevel as AiThinkingLevel, type UserMessage } from "@earendil-works/pi-ai"; import { Box, Container, Input, Key, Markdown, Text, isViewportTUI, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Focusable, type KeybindingsManager, type MarkdownTheme, type OverlayHandle, type TUI, } from "@earendil-works/pi-tui"; const BTW_MESSAGE_TYPE = "btw-note"; const BTW_ENTRY_TYPE = "btw-thread-entry"; const BTW_RESET_TYPE = "btw-thread-reset"; const BTW_MODEL_OVERRIDE_TYPE = "btw-model-override"; const BTW_THINKING_OVERRIDE_TYPE = "btw-thinking-override"; const BTW_FOCUS_SHORTCUTS = [Key.ctrl("/"), Key.ctrlAlt("w")] as const; function matchesBtwFocusShortcut(data: string): boolean { return BTW_FOCUS_SHORTCUTS.some((shortcut) => matchesKey(data, shortcut)); } const BTW_SYSTEM_PROMPT = [ "You are having an aside conversation with the user, separate from their main working session.", "If main session messages are provided, they are for context only — that work is being handled by another agent.", "If no main session messages are provided, treat this as a fully contextless tangent thread and rely only on the user's words plus your general instructions.", "Focus on answering the user's side questions, helping them think through ideas, or planning next steps.", "Do not act as if you need to continue unfinished work from the main session unless the user explicitly asks you to prepare something for injection back to it.", ].join(" "); const BTW_SUMMARIZE_SYSTEM_PROMPT = "Summarize the side conversation concisely. Preserve key decisions, plans, insights, risks, and action items. Output only the summary."; const BTW_CONTINUE_THREAD_USER_TEXT = "[The following is a separate side conversation. Continue this thread.]"; const BTW_CONTINUE_THREAD_ASSISTANT_TEXT = "Understood, continuing our side conversation."; type SessionThinkingLevel = "off" | AiThinkingLevel; type BtwThreadMode = "contextual" | "tangent"; type SessionModel = NonNullable; type CreateSessionOptions = Exclude[0], undefined>; type ModelRegistryWithRuntime = { readonly runtime?: unknown }; /** * Loose model reference parsed from `/btw:model ` and persisted to * session entries. Resolved to a full SessionModel via ctx.modelRegistry.find(...). */ type BtwModelRef = Pick; type BtwDetails = { question: string; thinking: string; answer: string; provider: string; model: string; api: string; thinkingLevel: SessionThinkingLevel; timestamp: number; usage?: AssistantMessage["usage"]; }; type ParsedBtwArgs = { question: string; save: boolean; }; type SaveState = "not-saved" | "saved" | "queued"; type BtwResetDetails = { timestamp: number; mode?: BtwThreadMode; }; type BtwModelOverrideDetails = | ({ timestamp: number; action: "set" } & Pick) | { timestamp: number; action: "clear" }; type BtwThinkingOverrideDetails = | { timestamp: number; action: "set"; thinkingLevel: SessionThinkingLevel } | { timestamp: number; action: "clear" }; type ResolvedBtwModel = { model: SessionModel | null; source: "override" | "main" | "none"; configuredOverride: SessionModel | null; fallbackReason?: string; }; type ResolvedBtwSettings = { model: SessionModel | null; modelSource: "override" | "main" | "none"; configuredModelOverride: SessionModel | null; thinkingLevel: SessionThinkingLevel; thinkingSource: "override" | "main"; fallbackReason?: string; }; type BtwTranscriptEntry = | { id: number; turnId: number; type: "turn-boundary"; phase: "start" | "end" } | { id: number; turnId: number; type: "user-message"; text: string } | { id: number; turnId: number; type: "thinking"; text: string; streaming: boolean } | { id: number; turnId: number; type: "assistant-text"; text: string; streaming: boolean } | { id: number; turnId: number; type: "tool-call"; toolCallId: string; toolName: string; args: string } | { id: number; turnId: number; type: "tool-result"; toolCallId: string; toolName: string; content: string; truncated: boolean; isError: boolean; streaming: boolean; }; type BtwTranscript = BtwTranscriptEntry[]; type BtwMouseEvent = { button: number; x: number; y: number; action: "press" | "release"; motion: boolean; wheelDelta: number | null; }; type BtwTranscriptSelectionPoint = { line: number; column: number; }; type BtwTranscriptState = { entries: BtwTranscript; revision: number; nextEntryId: number; nextTurnId: number; currentTurnId: number | null; lastTurnId: number | null; toolCalls: Map; }; type BtwSessionRuntime = { session: AgentSession; mode: BtwThreadMode; subscriptions: Set<() => void>; sideThreadStartIndex: number; }; type OverlayRuntime = { handle?: OverlayHandle; refresh?: () => void; close?: () => void; finish?: () => void; setDraft?: (value: string) => void; streamRefreshTimer?: ReturnType; streamRefreshPending?: boolean; closed?: boolean; }; function isVisibleBtwMessage(message: { role: string; customType?: string }): boolean { return message.role === "custom" && message.customType === BTW_MESSAGE_TYPE; } function isCustomEntry(entry: unknown, customType: string): entry is { type: "custom"; customType: string; data?: unknown } { return !!entry && typeof entry === "object" && (entry as { type?: string }).type === "custom" && (entry as { customType?: string }).customType === customType; } function stripDynamicSystemPromptFooter(systemPrompt: string): string { return systemPrompt .replace(/\nCurrent date and time:[^\n]*(?:\nCurrent working directory:[^\n]*)?$/u, "") .replace(/\nCurrent working directory:[^\n]*$/u, "") .trim(); } function createBtwResourceLoader( ctx: ExtensionCommandContext, appendSystemPrompt: string[] = [BTW_SYSTEM_PROMPT], ): ResourceLoader { const extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() }; const systemPrompt = stripDynamicSystemPromptFooter(ctx.getSystemPrompt()); const resourceLoader = { getExtensions: () => extensionsResult, getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }), getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => systemPrompt, getSystemPromptSource: () => undefined, getAppendSystemPrompt: () => appendSystemPrompt, getAppendSystemPromptSources: () => [], extendResources: () => {}, reload: async () => {}, }; return resourceLoader as ResourceLoader; } function getSessionModelOptions(ctx: ExtensionCommandContext): { modelRegistry: ExtensionCommandContext["modelRegistry"]; modelRuntime?: unknown; } { const modelRegistry = ctx.modelRegistry; // Pi 0.83+ keeps the canonical ModelRuntime behind the ModelRegistry compatibility facade. const modelRuntime = (modelRegistry as unknown as ModelRegistryWithRuntime).runtime; return modelRuntime ? { modelRegistry, modelRuntime } : { modelRegistry }; } function extractText(parts: AssistantMessage["content"], type: "text" | "thinking"): string { const chunks: string[] = []; for (const part of parts) { if (type === "text" && part.type === "text") { chunks.push(part.text); } else if (type === "thinking" && part.type === "thinking") { chunks.push(part.thinking); } } return chunks.join("\n").trim(); } function extractAnswer(message: AssistantMessage): string { return extractText(message.content, "text") || "(No text response)"; } function extractThinking(message: AssistantMessage): string { return extractText(message.content, "thinking"); } function parseBtwArgs(args: string): ParsedBtwArgs { const save = /(?:^|\s)(?:--save|-s)(?=\s|$)/.test(args); const question = args.replace(/(?:^|\s)(?:--save|-s)(?=\s|$)/g, " ").trim(); return { question, save }; } function parseBtwModelArgs(args: string): | { action: "show" } | { action: "clear" } | { action: "set"; model: BtwModelRef } | { action: "invalid"; message: string } { const trimmed = args.trim(); if (!trimmed) { return { action: "show" }; } if (trimmed === "clear") { return { action: "clear" }; } const parts = trimmed.split(/\s+/); if (parts.length !== 3) { return { action: "invalid", message: "Usage: /btw:model | clear" }; } const [provider, id, api] = parts; return { action: "set", model: { provider, id, api } as BtwModelRef }; } function parseBtwThinkingArgs(args: string): | { action: "show" } | { action: "clear" } | { action: "set"; thinkingLevel: SessionThinkingLevel } { const trimmed = args.trim(); if (!trimmed) { return { action: "show" }; } if (trimmed === "clear") { return { action: "clear" }; } return { action: "set", thinkingLevel: trimmed as SessionThinkingLevel }; } function formatModelRef(model: Pick): string { return `${model.provider}/${model.id} (${model.api})`; } function buildBtwSeedState( ctx: ExtensionCommandContext, thread: BtwDetails[], mode: BtwThreadMode, sessionModel: SessionModel | null, ): { messages: Message[]; sideThreadStartIndex: number } { const messages: Message[] = []; if (mode === "contextual") { try { messages.push( ...(buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages as Message[]).filter( (message) => !isVisibleBtwMessage(message), ), ); } catch { messages.push( ...ctx.sessionManager.getEntries().flatMap((entry) => { if (!entry || typeof entry !== "object") { return []; } const message = entry as unknown as Partial & { role?: string; customType?: string; content?: unknown }; if (typeof message.role !== "string" || !Array.isArray(message.content)) { return []; } return isVisibleBtwMessage({ role: message.role, customType: message.customType }) ? [] : [message as Message]; }), ); } } const sideThreadStartIndex = messages.length; if (thread.length > 0) { messages.push( { role: "user", content: [{ type: "text", text: BTW_CONTINUE_THREAD_USER_TEXT }], timestamp: Date.now(), }, { role: "assistant", content: [{ type: "text", text: BTW_CONTINUE_THREAD_ASSISTANT_TEXT }], provider: sessionModel?.provider ?? "unknown", model: sessionModel?.id ?? "unknown", api: sessionModel?.api ?? "openai-responses", 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: Date.now(), }, ); for (const entry of thread) { messages.push( { role: "user", content: [{ type: "text", text: entry.question }], timestamp: entry.timestamp, }, { role: "assistant", content: [{ type: "text", text: entry.answer }], provider: entry.provider, model: entry.model, api: entry.api || sessionModel?.api || ctx.model?.api || "openai-responses", usage: entry.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: entry.timestamp, }, ); } } return { messages, sideThreadStartIndex, }; } function formatToolPreview(value: unknown): string { if (value === undefined) { return ""; } if (typeof value === "string") { return value; } if (value && typeof value === "object") { const path = (value as { path?: unknown }).path; if (typeof path === "string") { return path; } } try { const preview = JSON.stringify(value); if (!preview || preview === "{}") { return ""; } return preview.length > 120 ? `${preview.slice(0, 117)}...` : preview; } catch { return ""; } } function createEmptyTranscriptState(): BtwTranscriptState { return { entries: [], revision: 0, nextEntryId: 1, nextTurnId: 1, currentTurnId: null, lastTurnId: null, toolCalls: new Map(), }; } function appendTranscriptEntry( state: BtwTranscriptState, entry: Omit, ): T { const nextEntry = { ...entry, id: state.nextEntryId++ } as T; state.entries.push(nextEntry); state.revision++; return nextEntry; } function ensureTranscriptTurn(state: BtwTranscriptState): number { if (state.currentTurnId !== null) { return state.currentTurnId; } const turnId = state.nextTurnId++; state.currentTurnId = turnId; state.lastTurnId = turnId; appendTranscriptEntry(state, { type: "turn-boundary", turnId, phase: "start" } as Omit, "id">); return turnId; } function finishTranscriptTurn(state: BtwTranscriptState, turnId?: number | null): void { const resolvedTurnId = turnId ?? state.currentTurnId; if (resolvedTurnId === null || resolvedTurnId === undefined) { return; } const hasEndBoundary = state.entries.some( (entry) => entry.turnId === resolvedTurnId && entry.type === "turn-boundary" && entry.phase === "end", ); if (!hasEndBoundary) { appendTranscriptEntry(state, { type: "turn-boundary", turnId: resolvedTurnId, phase: "end" } as Omit, "id">); } let changed = false; for (const entry of state.entries) { if (entry.turnId !== resolvedTurnId) { continue; } if ( (entry.type === "thinking" || entry.type === "assistant-text" || entry.type === "tool-result") && entry.streaming ) { entry.streaming = false; changed = true; } } if (state.lastTurnId !== resolvedTurnId) { state.lastTurnId = resolvedTurnId; changed = true; } if (state.currentTurnId === resolvedTurnId) { state.currentTurnId = null; changed = true; } if (changed) { state.revision++; } } function removeTranscriptTurn(state: BtwTranscriptState, turnId: number | null): void { if (turnId === null) { return; } state.entries = state.entries.filter((entry) => entry.turnId !== turnId); for (const [toolCallId, toolCall] of state.toolCalls.entries()) { if (toolCall.turnId === turnId) { state.toolCalls.delete(toolCallId); } } if (state.currentTurnId === turnId) { state.currentTurnId = null; } if (state.lastTurnId === turnId) { state.lastTurnId = null; } state.revision++; } function findLatestTranscriptEntry( state: BtwTranscriptState, turnId: number, type: TType, ): Extract | undefined { for (let i = state.entries.length - 1; i >= 0; i--) { const entry = state.entries[i]; if (entry.turnId === turnId && entry.type === type) { return entry as Extract; } } return undefined; } function ensureTranscriptTurnForUserMessage(state: BtwTranscriptState): number { if (state.currentTurnId !== null) { const currentAssistant = findLatestTranscriptEntry(state, state.currentTurnId, "assistant-text"); if (currentAssistant && !currentAssistant.streaming) { finishTranscriptTurn(state, state.currentTurnId); } } return ensureTranscriptTurn(state); } function extractMessageText(message: { content?: string | AssistantMessage["content"] | UserMessage["content"] }): string { if (typeof message.content === "string") { return message.content; } if (!Array.isArray(message.content)) { return ""; } return message.content .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string") .map((part) => part.text) .join("\n") .trim(); } function upsertUserMessageEntry(state: BtwTranscriptState, turnId: number, text: string): void { if (!text) { return; } const existing = findLatestTranscriptEntry(state, turnId, "user-message"); if (existing) { if (existing.text !== text) { existing.text = text; state.revision++; } return; } appendTranscriptEntry(state, { type: "user-message", turnId, text } as Omit, "id">); } function upsertTranscriptTextEntry( state: BtwTranscriptState, turnId: number, type: "thinking" | "assistant-text", text: string, streaming: boolean, ): void { if (!text) { return; } const existing = findLatestTranscriptEntry(state, turnId, type); if (existing) { if (existing.text !== text || existing.streaming !== streaming) { existing.text = text; existing.streaming = streaming; state.revision++; } return; } appendTranscriptEntry(state, { type, turnId, text, streaming } as Omit, "id">); } function summarizeToolResult(value: unknown, maxLength = 400): { content: string; truncated: boolean } { let content = ""; if (value && typeof value === "object") { const toolValue = value as { content?: Array<{ type?: string; text?: string }>; error?: unknown; message?: unknown; }; if (Array.isArray(toolValue.content)) { content = toolValue.content .filter((part) => part.type === "text" && typeof part.text === "string") .map((part) => part.text ?? "") .join("\n") .trim(); } if (!content && typeof toolValue.error === "string") { content = toolValue.error; } if (!content && typeof toolValue.message === "string") { content = toolValue.message; } } if (!content) { if (typeof value === "string") { content = value; } else if (value !== undefined) { try { content = JSON.stringify(value, null, 2); } catch { content = String(value); } } } if (!content) { content = "(no tool output)"; } const truncated = content.length > maxLength; return { content: truncated ? `${content.slice(0, maxLength - 3)}...` : content, truncated, }; } function ensureToolCallEntry( state: BtwTranscriptState, turnId: number, toolCallId: string, toolName: string, args: string, ): { turnId: number; callEntryId: number; resultEntryId?: number } { const existing = state.toolCalls.get(toolCallId); if (existing) { return existing; } const callEntry = appendTranscriptEntry(state, { type: "tool-call", turnId, toolCallId, toolName, args, } as Omit, "id">); const record = { turnId, callEntryId: callEntry.id }; state.toolCalls.set(toolCallId, record); return record; } function upsertToolResultEntry( state: BtwTranscriptState, turnId: number, toolCallId: string, toolName: string, content: string, truncated: boolean, isError: boolean, streaming: boolean, ): void { const toolCall = ensureToolCallEntry(state, turnId, toolCallId, toolName, ""); const existing = toolCall.resultEntryId !== undefined ? state.entries.find((entry) => entry.id === toolCall.resultEntryId && entry.type === "tool-result") : undefined; if (existing && existing.type === "tool-result") { if ( existing.content !== content || existing.truncated !== truncated || existing.isError !== isError || existing.streaming !== streaming ) { existing.content = content; existing.truncated = truncated; existing.isError = isError; existing.streaming = streaming; state.revision++; } return; } const resultEntry = appendTranscriptEntry(state, { type: "tool-result", turnId, toolCallId, toolName, content, truncated, isError, streaming, } as Omit, "id">); toolCall.resultEntryId = resultEntry.id; } function applyAssistantMessageToTranscript( state: BtwTranscriptState, turnId: number, message: AssistantMessage, streaming: boolean, ): void { const assistantMessage = message; const thinking = extractThinking(assistantMessage); const answer = extractMessageText(assistantMessage); if (thinking) { upsertTranscriptTextEntry(state, turnId, "thinking", thinking, streaming); } if (answer) { upsertTranscriptTextEntry(state, turnId, "assistant-text", answer, streaming); } } function applyTranscriptEvent(state: BtwTranscriptState, event: AgentSessionEvent): void { switch (event.type) { case "turn_start": { ensureTranscriptTurn(state); return; } case "message_start": { if (event.message.role === "user") { const turnId = ensureTranscriptTurnForUserMessage(state); upsertUserMessageEntry(state, turnId, extractMessageText(event.message)); return; } if (event.message.role === "assistant") { const turnId = ensureTranscriptTurn(state); applyAssistantMessageToTranscript(state, turnId, event.message, true); } return; } case "message_update": { if (event.message.role !== "assistant") { return; } const turnId = ensureTranscriptTurn(state); applyAssistantMessageToTranscript(state, turnId, event.message, true); return; } case "message_end": { if (event.message.role === "user") { const turnId = ensureTranscriptTurnForUserMessage(state); upsertUserMessageEntry(state, turnId, extractMessageText(event.message)); return; } if (event.message.role === "assistant") { const turnId = ensureTranscriptTurn(state); applyAssistantMessageToTranscript(state, turnId, event.message, false); } return; } case "tool_execution_start": { const turnId = ensureTranscriptTurn(state); ensureToolCallEntry(state, turnId, event.toolCallId, event.toolName, formatToolPreview(event.args)); return; } case "tool_execution_update": { const turnId = state.toolCalls.get(event.toolCallId)?.turnId ?? ensureTranscriptTurn(state); const result = summarizeToolResult(event.partialResult); upsertToolResultEntry( state, turnId, event.toolCallId, event.toolName, result.content, result.truncated, false, true, ); return; } case "tool_execution_end": { const turnId = state.toolCalls.get(event.toolCallId)?.turnId ?? ensureTranscriptTurn(state); const result = summarizeToolResult(event.result); upsertToolResultEntry( state, turnId, event.toolCallId, event.toolName, result.content, result.truncated, event.isError, false, ); return; } case "turn_end": { finishTranscriptTurn(state); return; } default: return; } } function appendPersistedTranscriptTurn(state: BtwTranscriptState, details: BtwDetails): void { const turnId = ensureTranscriptTurn(state); upsertUserMessageEntry(state, turnId, details.question); if (details.thinking) { upsertTranscriptTextEntry(state, turnId, "thinking", details.thinking, false); } upsertTranscriptTextEntry(state, turnId, "assistant-text", details.answer, false); finishTranscriptTurn(state, turnId); } function setTranscriptFailure(state: BtwTranscriptState, message: string): void { const turnId = state.currentTurnId ?? state.lastTurnId ?? ensureTranscriptTurn(state); upsertTranscriptTextEntry(state, turnId, "assistant-text", `❌ ${message}`, false); finishTranscriptTurn(state, turnId); } function hasStreamingTranscriptEntry(entries: BtwTranscript): boolean { return entries.some( (entry) => (entry.type === "thinking" || entry.type === "assistant-text" || entry.type === "tool-result") && entry.streaming, ); } function getCompletedExchangeCount(entries: BtwTranscript): number { return entries.filter((entry) => entry.type === "assistant-text" && !entry.streaming).length; } /** * Build a MarkdownTheme from the extension-provided theme so markdown rendering in the * BTW overlay/saved notes follows the active theme without depending on the global * theme state (which may not be initialized in non-interactive contexts). */ function buildMarkdownTheme(theme: ExtensionContext["ui"]["theme"]): MarkdownTheme { return { heading: (text) => theme.fg("mdHeading", text), link: (text) => theme.fg("mdLink", text), linkUrl: (text) => theme.fg("mdLinkUrl", text), code: (text) => theme.fg("mdCode", text), codeBlock: (text) => theme.fg("mdCodeBlock", text), codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text), quote: (text) => theme.fg("mdQuote", text), quoteBorder: (text) => theme.fg("mdQuoteBorder", text), hr: (text) => theme.fg("mdHr", text), listBullet: (text) => theme.fg("mdListBullet", text), bold: (text) => theme.bold(text), italic: (text) => theme.italic(text), strikethrough: (text) => theme.strikethrough(text), underline: (text) => theme.underline(text), // Keep code blocks in the overlay simple: no syntax highlighting, just the code-block color. highlightCode: (code) => code.split("\n").map((line) => theme.fg("mdCodeBlock", line)), }; } type BtwMarkdownRenderCacheEntry = { type: "thinking" | "assistant-text"; text: string; contentWidth: number; lines: string[]; }; type BtwWrappedLineCacheEntry = { source: string; innerWidth: number; lines: string[]; plainLines: string[]; }; function buildOverlayTranscript( entries: BtwTranscript, theme: ExtensionContext["ui"]["theme"], markdownTheme: MarkdownTheme, contentWidth: number, markdownCache: Map, ): string[] { if (entries.length === 0) { return [theme.fg("dim", "No BTW thread yet. Ask a side question to start one.")]; } const lines: string[] = []; const userBadge = buildTranscriptBadge(theme, "You", "userMessageBg", "accent"); const thinkingBadge = buildTranscriptBadge(theme, "Thinking", "toolPendingBg", "warning"); const toolBadge = buildTranscriptBadge(theme, "Tool", "toolPendingBg", "warning"); const assistantBadge = buildTranscriptBadge(theme, "Assistant", "customMessageBg", "success"); const separator = theme.fg("borderMuted", "────────────────────────────────────────"); const blockIndent = BTW_BLOCK_INDENT; const resultIndent = blockIndent; const pushBlankLine = () => { if (lines.length > 0 && lines[lines.length - 1] !== "") { lines.push(""); } }; const pushInlineBlock = ( header: string, text: string, options: { blankBefore?: boolean; style?: (value: string) => string } = {}, ) => { const bodyLines = text.split("\n"); const style = options.style ?? ((value: string) => value); if (options.blankBefore !== false) { pushBlankLine(); } const firstLine = bodyLines.shift() ?? ""; lines.push(`${header}${firstLine ? ` ${style(firstLine)}` : ""}`); for (const line of bodyLines) { lines.push(`${blockIndent}${style(line)}`); } }; const pushStackedBlock = ( header: string, text: string, options: { blankBefore?: boolean; indent?: string; style?: (value: string) => string } = {}, ) => { const bodyLines = text.split("\n"); const indent = options.indent ?? blockIndent; const style = options.style ?? ((value: string) => value); if (options.blankBefore !== false) { pushBlankLine(); } lines.push(header); for (const line of bodyLines) { lines.push(`${indent}${style(line)}`); } }; for (const entry of entries) { if (entry.type === "turn-boundary") { if (entry.phase === "start" && lines.length > 0) { pushBlankLine(); lines.push(separator); } continue; } if (entry.type === "user-message") { pushInlineBlock(userBadge, entry.text, { blankBefore: false }); continue; } if (entry.type === "thinking") { const thinkingHeader = entry.streaming ? `${thinkingBadge} ${theme.fg("warning", "▍")}` : thinkingBadge; // Keep one bounded cache record per transcript entry. Completed history is reused // across streaming frames instead of being reparsed for every token update. let cached = markdownCache.get(entry.id); if ( !cached || cached.type !== entry.type || cached.text !== entry.text || cached.contentWidth !== contentWidth ) { cached = { type: entry.type, text: entry.text, contentWidth, lines: new Markdown(entry.text, 0, 0, markdownTheme, { color: (text: string) => theme.fg("warning", text), italic: true, }) .render(Math.max(1, contentWidth)) .map((line) => line.replace(/\s+$/u, "")), }; markdownCache.set(entry.id, cached); } pushBlankLine(); lines.push(thinkingHeader); for (const line of cached.lines) { lines.push(line ? `${blockIndent}${line}` : ""); } continue; } if (entry.type === "tool-call") { const toolLabel = theme.fg("warning", theme.bold(entry.toolName)); const argsLabel = entry.args ? theme.fg("dim", ` · ${entry.args}`) : ""; pushInlineBlock(toolBadge, `${toolLabel}${argsLabel}`); continue; } if (entry.type === "tool-result") { const resultHeaderLabel = entry.isError ? theme.fg("error", "↳ error") : entry.streaming ? theme.fg("warning", "↳ streaming result") : theme.fg("dim", "↳ result"); const truncationLabel = entry.truncated ? theme.fg("dim", " (truncated)") : ""; pushStackedBlock(`${resultHeaderLabel}${truncationLabel}`, entry.content, { blankBefore: false, indent: resultIndent, style: (line) => (entry.isError ? theme.fg("error", line) : theme.fg("dim", line)), }); continue; } if (entry.type === "assistant-text") { const assistantHeader = entry.streaming ? `${assistantBadge} ${theme.fg("warning", "▍")}` : assistantBadge; let cached = markdownCache.get(entry.id); if ( !cached || cached.type !== entry.type || cached.text !== entry.text || cached.contentWidth !== contentWidth ) { cached = { type: entry.type, text: entry.text, contentWidth, lines: new Markdown(entry.text, 0, 0, markdownTheme) .render(Math.max(1, contentWidth)) .map((line) => line.replace(/\s+$/u, "")), }; markdownCache.set(entry.id, cached); } pushBlankLine(); lines.push(assistantHeader); for (const line of cached.lines) { lines.push(line ? `${blockIndent}${line}` : ""); } } } return lines; } function getLastAssistantMessage(session: AgentSession): AssistantMessage | null { for (let i = session.state.messages.length - 1; i >= 0; i--) { const message = session.state.messages[i]; if (message.role === "assistant") { return message as AssistantMessage; } } return null; } type BtwHandoffExchange = { user: string; assistant: string; }; function buildBtwMessageContent(question: string, answer: string): string { return `Q: ${question}\n\nA: ${answer}`; } function formatThread(thread: BtwHandoffExchange[]): string { return thread.map((entry) => `User: ${entry.user.trim()}\nAssistant: ${entry.assistant.trim()}`).join("\n\n---\n\n"); } function isThreadContinuationMarker(messages: Message[], index: number): boolean { const userMessage = messages[index]; const assistantMessage = messages[index + 1]; return ( userMessage?.role === "user" && extractMessageText(userMessage) === BTW_CONTINUE_THREAD_USER_TEXT && assistantMessage?.role === "assistant" && extractMessageText(assistantMessage) === BTW_CONTINUE_THREAD_ASSISTANT_TEXT ); } function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffExchange[] { const handoffMessages = sessionRuntime.session.state.messages.slice(sessionRuntime.sideThreadStartIndex); const threadMessages = isThreadContinuationMarker(handoffMessages as Message[], 0) ? handoffMessages.slice(2) : handoffMessages; const exchanges: BtwHandoffExchange[] = []; let currentUser = ""; let currentAssistant = ""; const pushCurrent = () => { if (!currentUser && !currentAssistant) { return; } exchanges.push({ user: currentUser.trim() || "(No user prompt)", assistant: currentAssistant.trim() || "(No assistant response)", }); currentUser = ""; currentAssistant = ""; }; for (const message of threadMessages) { if (message.role !== "user" && message.role !== "assistant") { continue; } const text = extractMessageText(message).trim(); if (!text) { continue; } if (message.role === "user") { pushCurrent(); currentUser = text; continue; } currentAssistant = currentAssistant ? `${currentAssistant}\n\n${text}` : text; } pushCurrent(); return exchanges; } function saveVisibleBtwNote( pi: ExtensionAPI, details: BtwDetails, saveRequested: boolean, wasBusy: boolean, ): SaveState { if (!saveRequested) { return "not-saved"; } const message = { customType: BTW_MESSAGE_TYPE, content: buildBtwMessageContent(details.question, details.answer), display: true, details, }; if (wasBusy) { pi.sendMessage(message, { deliverAs: "followUp" }); return "queued"; } pi.sendMessage(message); return "saved"; } function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void { if (ctx.hasUI) { ctx.ui.notify(message, level); } } /** Fixed overlay rows outside the transcript viewport (must match render() structure). */ const BTW_OVERLAY_CHROME_LINES = 9; /** Indent applied to transcript block bodies (assistant/thinking/tool-result continuations). */ const BTW_BLOCK_INDENT = " "; /** Double-Escape window for entering history reuse mode (matches Claude Code's Esc Esc). */ const BTW_ESCAPE_DOUBLE_WINDOW_MS = 500; /** Delay before a single Escape (with no input, LLM idle) dismisses the overlay. */ const BTW_ESCAPE_EXIT_DELAY_MS = 300; /** Coalesce high-frequency streaming deltas before doing expensive transcript work. */ const BTW_STREAM_REFRESH_INTERVAL_MS = 32; const BTW_TRANSCRIPT_START_ROW = 4; const BTW_OVERLAY_TOP_MARGIN = 0; const BTW_GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" }); function extractTerminalControlSequence(value: string, index: number): string | null { if (value[index] !== "\x1b") { return null; } const remainder = value.slice(index); return ( remainder.match(/^\x1b\[[0-?]*[ -/]*[@-~]/)?.[0] ?? remainder.match(/^\x1b\][^\x07]*(?:\x07|\x1b\\)/)?.[0] ?? null ); } function countGraphemes(value: string): number { let count = 0; for (const _segment of BTW_GRAPHEME_SEGMENTER.segment(value)) { count++; } return count; } function sliceByTerminalColumns(value: string, startColumn: number, length: number): string { if (length <= 0) { return ""; } const endColumn = startColumn + length; let result = ""; let currentColumn = 0; let index = 0; let pendingControlSequences = ""; while (index < value.length && currentColumn < endColumn) { const controlSequence = extractTerminalControlSequence(value, index); if (controlSequence) { if (currentColumn >= startColumn) { result += controlSequence; } else { pendingControlSequences += controlSequence; } index += controlSequence.length; continue; } let textEnd = index; while (textEnd < value.length && !extractTerminalControlSequence(value, textEnd)) { textEnd++; } for (const { segment } of BTW_GRAPHEME_SEGMENTER.segment(value.slice(index, textEnd))) { const segmentWidth = visibleWidth(segment); const inRange = currentColumn >= startColumn && currentColumn < endColumn; if (inRange && currentColumn + segmentWidth <= endColumn) { if (pendingControlSequences) { result += pendingControlSequences; pendingControlSequences = ""; } result += segment; } currentColumn += segmentWidth; if (currentColumn >= endColumn) { break; } } index = textEnd; } return result; } function getOverlayTitle(mode: BtwThreadMode): string { return mode === "tangent" ? "BTW tangent" : "BTW"; } function buildTranscriptBadge( theme: ExtensionContext["ui"]["theme"], label: string, background: "userMessageBg" | "toolPendingBg" | "customMessageBg", foreground: "accent" | "warning" | "success", ): string { return theme.bg(background, theme.fg(foreground, theme.bold(` ${label} `))); } class BtwOverlayComponent extends Container implements Focusable { private readonly input: Input; private readonly statusText: Text; private readonly modeText: Text; private readonly summaryText: Text; private readonly hintsText: Text; private readonly readTranscriptState: () => BtwTranscriptState; private readonly getStatus: () => string | null; private readonly getMode: () => BtwThreadMode; private readonly onSubmitCallback: (value: string) => void; private readonly onDismissCallback: () => void; private readonly onUnfocusCallback: () => void; private readonly onAbortRequestCallback: () => void; private readonly getIsStreaming: () => boolean; private readonly onRewindCallback: () => void; private readonly tui: TUI; private readonly theme: ExtensionContext["ui"]["theme"]; private readonly markdownTheme: MarkdownTheme; private readonly markdownRenderCache = new Map(); private wrappedLineCache: BtwWrappedLineCacheEntry[] = []; private transcriptLines: string[] = []; private transcriptScrollOffset = 0; private transcriptViewportHeight = 8; private followTranscript = true; private renderedTranscriptLines: string[] = []; private renderedTranscriptPlainLines: string[] = []; private renderedTranscriptInnerWidth = 0; private renderedDialogWidth = 0; private formattedTranscriptState: BtwTranscriptState | null = null; private formattedTranscriptRevision = -1; private formattedTranscriptContentWidth = -1; private wrappedTranscriptRevision = -1; private wrappedTranscriptInnerWidth = -1; private selectionAnchor: BtwTranscriptSelectionPoint | null = null; private selectionFocus: BtwTranscriptSelectionPoint | null = null; private selectionMoved = false; private selectionNotice: string | null = null; private selectionCopyGeneration = 0; private mouseTrackingEnabled = false; private readonly ownsMouseTracking: boolean; private _focused = false; private modeTextValue = ""; private summaryTextValue = ""; private statusTextValue = ""; private hintsTextValue = ""; private lastEscapeAt = 0; private escapeExitTimer: ReturnType | null = null; get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; this.input.focused = value; this.setMouseTrackingEnabled(value); } constructor( tui: TUI, theme: ExtensionContext["ui"]["theme"], keybindings: KeybindingsManager, readTranscriptState: () => BtwTranscriptState, getStatus: () => string | null, getMode: () => BtwThreadMode, getIsStreaming: () => boolean, onSubmit: (value: string) => void, onDismiss: () => void, onUnfocus: () => void, onAbortRequest: () => void, onRewind: () => void, ) { super(); this.tui = tui; this.ownsMouseTracking = !isViewportTUI(tui); this.theme = theme; this.markdownTheme = buildMarkdownTheme(theme); this.readTranscriptState = readTranscriptState; this.getStatus = getStatus; this.getMode = getMode; this.getIsStreaming = getIsStreaming; this.onSubmitCallback = onSubmit; this.onDismissCallback = onDismiss; this.onUnfocusCallback = onUnfocus; this.onAbortRequestCallback = onAbortRequest; this.onRewindCallback = onRewind; this.modeText = new Text("", 1, 0); this.summaryText = new Text("", 1, 0); this.statusText = new Text("", 1, 0); this.input = new Input(); this.input.onSubmit = (value) => { this.followTranscript = true; this.onSubmitCallback(value); }; this.input.onEscape = () => { // Fallback path (custom keybindings that do not deliver Escape to handleInput): // treat it like a single Escape with no input and an idle LLM. this.cancelEscapeExit(); this.onDismissCallback(); }; this.hintsText = new Text("", 1, 0); const originalHandleInput = this.input.handleInput.bind(this.input); this.input.handleInput = (data: string) => { if (matchesKey(data, Key.escape)) { if (this.input.getValue().length > 0) { // Input present: clear it and reset the double-escape state. this.cancelEscapeExit(); this.lastEscapeAt = 0; this.input.setValue(""); this.tui.requestRender(); return; } const now = Date.now(); if (now - this.lastEscapeAt <= BTW_ESCAPE_DOUBLE_WINDOW_MS) { // Double-Escape with an idle LLM: rewind the last exchange. this.lastEscapeAt = 0; this.cancelEscapeExit(); this.onRewindCallback(); return; } this.lastEscapeAt = now; if (this.getIsStreaming()) { // LLM running: abort the request, keep the panel open. this.cancelEscapeExit(); this.onAbortRequestCallback(); return; } // LLM idle, no input: dismiss after a short delay so a second Escape can // still rewind the last exchange. this.scheduleEscapeExit(); return; } if (keybindings.matches(data, "app.clear")) { // Ctrl+C: clear a non-empty composer; abort the running request; or dismiss. if (this.input.getValue().length > 0) { this.cancelEscapeExit(); this.lastEscapeAt = 0; this.input.setValue(""); this.tui.requestRender(); return; } if (this.getIsStreaming()) { this.cancelEscapeExit(); this.onAbortRequestCallback(); return; } this.cancelEscapeExit(); this.onDismissCallback(); return; } if (keybindings.matches(data, "tui.select.cancel")) { // Custom keybindings without app.clear: Ctrl+C falls through here. if (this.getIsStreaming()) { this.cancelEscapeExit(); this.onAbortRequestCallback(); return; } this.cancelEscapeExit(); this.onDismissCallback(); return; } this.cancelEscapeExit(); this.lastEscapeAt = 0; originalHandleInput(data); }; this.refresh(); } private scheduleEscapeExit(): void { this.cancelEscapeExit(); this.escapeExitTimer = setTimeout(() => { this.escapeExitTimer = null; this.lastEscapeAt = 0; this.onDismissCallback(); }, BTW_ESCAPE_EXIT_DELAY_MS); } private cancelEscapeExit(): void { if (this.escapeExitTimer !== null) { clearTimeout(this.escapeExitTimer); this.escapeExitTimer = null; } } private frameLine(content: string, innerWidth: number): string { const truncated = truncateToWidth(content, innerWidth, ""); const padding = Math.max(0, innerWidth - visibleWidth(truncated)); return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`; } private ruleLine(innerWidth: number): string { return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`); } private borderLine(innerWidth: number, edge: "top" | "bottom"): string { const left = edge === "top" ? "┌" : "└"; const right = edge === "top" ? "┐" : "┘"; return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`); } private ensureFormattedTranscript(contentWidth: number): void { const state = this.readTranscriptState(); if ( this.formattedTranscriptState === state && this.formattedTranscriptRevision === state.revision && this.formattedTranscriptContentWidth === contentWidth ) { return; } this.transcriptLines = buildOverlayTranscript( state.entries, this.theme, this.markdownTheme, contentWidth, this.markdownRenderCache, ); const liveIds = new Set(state.entries.map((entry) => entry.id)); for (const id of this.markdownRenderCache.keys()) { if (!liveIds.has(id)) { this.markdownRenderCache.delete(id); } } this.formattedTranscriptState = state; this.formattedTranscriptRevision = state.revision; this.formattedTranscriptContentWidth = contentWidth; this.wrappedTranscriptRevision = -1; } private ensureWrappedTranscript(innerWidth: number): void { if ( this.wrappedTranscriptRevision === this.formattedTranscriptRevision && this.wrappedTranscriptInnerWidth === innerWidth ) { return; } const nextLineCache: BtwWrappedLineCacheEntry[] = []; const wrapped: string[] = []; const plain: string[] = []; for (let i = 0; i < this.transcriptLines.length; i++) { const source = this.transcriptLines[i]; let cached = this.wrappedLineCache[i]; if (!cached || cached.source !== source || cached.innerWidth !== innerWidth) { const lines = source ? wrapTextWithAnsi(source, Math.max(1, innerWidth)) : [""]; cached = { source, innerWidth, lines, plainLines: lines.map((line) => stripVTControlCharacters(line)), }; } nextLineCache.push(cached); wrapped.push(...cached.lines); plain.push(...cached.plainLines); } this.wrappedLineCache = nextLineCache; this.renderedTranscriptLines = wrapped; this.renderedTranscriptPlainLines = plain; this.wrappedTranscriptRevision = this.formattedTranscriptRevision; this.wrappedTranscriptInnerWidth = innerWidth; } private getDialogHeight(): number { return Math.max(BTW_OVERLAY_CHROME_LINES, this.tui.terminal?.rows ?? process.stdout.rows ?? 30); } private scrollTranscript(delta: number): void { if (delta < 0) { this.followTranscript = false; } this.transcriptScrollOffset = Math.max(0, this.transcriptScrollOffset + delta); this.tui.requestRender(); } dispose(): void { this.cancelEscapeExit(); this.selectionCopyGeneration++; this.setMouseTrackingEnabled(false); this.markdownRenderCache.clear(); this.wrappedLineCache = []; } override invalidate(): void { super.invalidate(); this.markdownRenderCache.clear(); this.wrappedLineCache = []; this.formattedTranscriptState = null; this.formattedTranscriptRevision = -1; this.formattedTranscriptContentWidth = -1; this.wrappedTranscriptRevision = -1; this.wrappedTranscriptInnerWidth = -1; } private setMouseTrackingEnabled(enabled: boolean): void { if (this.mouseTrackingEnabled === enabled) { return; } this.mouseTrackingEnabled = enabled; // Viewport/fullscreen TUI owns the terminal-wide mouse mode already. Avoid // disabling its global selection/wheel tracking when BTW loses focus. if (!this.ownsMouseTracking) { return; } // Button-event tracking preserves wheel input and also reports left-button drag motion. this.tui.terminal?.write?.(enabled ? "\x1b[?1002h\x1b[?1006h" : "\x1b[?1002l\x1b[?1006l"); } private parseMouseEvent(data: string): BtwMouseEvent | null { const match = data.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/); if (!match) { return null; } const button = Number(match[1]); const wheelDelta = (button & 64) === 64 ? ((button & 1) === 0 ? -3 : 3) : null; return { button, x: Number(match[2]), y: Number(match[3]), action: match[4] === "m" ? "release" : "press", motion: (button & 32) === 32, wheelDelta, }; } private getOverlayOrigin(): { row: number; column: number } { return { row: BTW_OVERLAY_TOP_MARGIN, column: 0, }; } private getTranscriptSelectionPoint( event: BtwMouseEvent, clampToViewport: boolean, ): BtwTranscriptSelectionPoint | null { if (this.renderedTranscriptLines.length === 0 || this.renderedDialogWidth === 0) { return null; } const origin = this.getOverlayOrigin(); const localRow = event.y - 1 - origin.row; const localColumn = event.x - 1 - origin.column; const transcriptRow = localRow - BTW_TRANSCRIPT_START_ROW; const contentColumn = localColumn - 1; const firstVisibleLine = this.transcriptScrollOffset; const lastVisibleLine = Math.min( this.renderedTranscriptLines.length - 1, firstVisibleLine + this.transcriptViewportHeight - 1, ); if (lastVisibleLine < firstVisibleLine) { return null; } if ( !clampToViewport && (transcriptRow < 0 || transcriptRow >= this.transcriptViewportHeight || contentColumn < 0 || contentColumn >= this.renderedTranscriptInnerWidth) ) { return null; } const clampedTranscriptRow = Math.max(0, Math.min(transcriptRow, this.transcriptViewportHeight - 1)); const line = Math.max(firstVisibleLine, Math.min(firstVisibleLine + clampedTranscriptRow, lastVisibleLine)); const lineWidth = visibleWidth(this.renderedTranscriptPlainLines[line] ?? ""); const clampedContentColumn = Math.max(0, Math.min(contentColumn, this.renderedTranscriptInnerWidth - 1)); return { line, column: Math.min(clampedContentColumn, lineWidth), }; } private compareSelectionPoints(a: BtwTranscriptSelectionPoint, b: BtwTranscriptSelectionPoint): number { return a.line === b.line ? a.column - b.column : a.line - b.line; } private getOrderedSelection(): | { start: BtwTranscriptSelectionPoint; end: BtwTranscriptSelectionPoint } | null { if (!this.selectionMoved || !this.selectionAnchor || !this.selectionFocus) { return null; } return this.compareSelectionPoints(this.selectionAnchor, this.selectionFocus) <= 0 ? { start: this.selectionAnchor, end: this.selectionFocus } : { start: this.selectionFocus, end: this.selectionAnchor }; } private getGraphemeColumnRange(text: string, targetColumn: number): { start: number; end: number } { let column = 0; for (const { segment } of BTW_GRAPHEME_SEGMENTER.segment(text)) { const segmentWidth = visibleWidth(segment); if (segmentWidth === 0) { continue; } const end = column + segmentWidth; if (targetColumn < end) { return { start: column, end }; } column = end; } return { start: column, end: column }; } private getSelectionColumnsForLine( lineIndex: number, line: string, ): { start: number; end: number } | null { const selection = this.getOrderedSelection(); if (!selection || lineIndex < selection.start.line || lineIndex > selection.end.line) { return null; } const lineWidth = visibleWidth(line); const start = lineIndex === selection.start.line ? this.getGraphemeColumnRange(line, Math.min(selection.start.column, lineWidth)).start : 0; const end = lineIndex === selection.end.line ? this.getGraphemeColumnRange(line, Math.min(selection.end.column, lineWidth)).end : lineWidth; return end > start ? { start, end } : null; } private highlightTranscriptSelection(line: string, lineIndex: number): string { const lineWidth = visibleWidth(line); const plainLine = this.renderedTranscriptPlainLines[lineIndex] ?? ""; const columns = this.getSelectionColumnsForLine(lineIndex, plainLine); if (!columns) { return line; } const before = sliceByTerminalColumns(line, 0, columns.start); const selected = sliceByTerminalColumns(plainLine, columns.start, columns.end - columns.start); const after = sliceByTerminalColumns(line, columns.end, Math.max(0, lineWidth - columns.end)); const highlighted = this.theme.bg("selectedBg", this.theme.fg("text", selected)); return `${before}${highlighted}${after}`; } private getSelectedTranscriptText(): string { const selection = this.getOrderedSelection(); if (!selection) { return ""; } const selectedLines: string[] = []; for (let lineIndex = selection.start.line; lineIndex <= selection.end.line; lineIndex++) { const line = this.renderedTranscriptPlainLines[lineIndex] ?? ""; const columns = this.getSelectionColumnsForLine(lineIndex, line); selectedLines.push( columns ? sliceByTerminalColumns(line, columns.start, columns.end - columns.start) : "", ); } return selectedLines.join("\n"); } private clearTranscriptSelection(clearNotice = true): void { this.selectionAnchor = null; this.selectionFocus = null; this.selectionMoved = false; if (clearNotice) { const hadNotice = this.selectionNotice !== null; this.selectionNotice = null; this.selectionCopyGeneration++; if (hadNotice) { this.updateHints(false); } } } private buildHintsText(): string { const controls = "↑↓/PgUp/PgDn scroll · Esc/Ctrl+C clear/exit · Esc+Esc rewind · Ctrl+/ switch main/btw"; return this.selectionNotice ? `${this.selectionNotice} · ${controls}` : controls; } private updateHints(requestRender = true): void { this.hintsTextValue = this.buildHintsText(); this.hintsText.setText(this.hintsTextValue); if (requestRender) { this.tui.requestRender(); } } private copyTranscriptSelection(): void { const text = this.getSelectedTranscriptText(); if (!text) { this.clearTranscriptSelection(); return; } const generation = ++this.selectionCopyGeneration; this.selectionNotice = "Copying selection..."; this.updateHints(); void copyToClipboard(text) .then(() => { if (generation !== this.selectionCopyGeneration) { return; } const characterCount = countGraphemes(text); this.selectionNotice = `Copied ${characterCount} character${characterCount === 1 ? "" : "s"}`; this.updateHints(); }) .catch(() => { if (generation !== this.selectionCopyGeneration) { return; } this.selectionNotice = "Could not copy selection"; this.updateHints(); }); } private handleMouseEvent(event: BtwMouseEvent): void { if (event.wheelDelta !== null) { this.scrollTranscript(event.wheelDelta); return; } const baseButton = event.button & 3; if (event.action === "press" && !event.motion && baseButton === 0) { const point = this.getTranscriptSelectionPoint(event, false); if (!point) { return; } this.clearTranscriptSelection(); this.followTranscript = false; this.selectionAnchor = point; this.selectionFocus = point; this.tui.requestRender(); return; } if (event.action === "press" && event.motion && baseButton === 0 && this.selectionAnchor) { const point = this.getTranscriptSelectionPoint(event, true); if (!point) { return; } this.selectionFocus = point; this.selectionMoved = this.compareSelectionPoints(this.selectionAnchor, point) !== 0; this.tui.requestRender(); return; } if (event.action === "release" && this.selectionAnchor) { const point = this.getTranscriptSelectionPoint(event, true); if (point) { this.selectionFocus = point; this.selectionMoved = this.selectionMoved || this.compareSelectionPoints(this.selectionAnchor, point) !== 0; } if (this.selectionMoved) { this.copyTranscriptSelection(); } else { this.clearTranscriptSelection(); } this.tui.requestRender(); } } handleInput(data: string): void { this.cancelEscapeExit(); if (matchesBtwFocusShortcut(data)) { this.onUnfocusCallback(); return; } const mouseEvent = this.parseMouseEvent(data); if (mouseEvent) { this.handleMouseEvent(mouseEvent); return; } if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.up)) { const step = matchesKey(data, Key.pageUp) ? Math.max(1, this.transcriptViewportHeight - 1) : 1; this.scrollTranscript(-step); return; } if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.down)) { const step = matchesKey(data, Key.pageDown) ? Math.max(1, this.transcriptViewportHeight - 1) : 1; this.scrollTranscript(step); return; } this.input.handleInput(data); } private inputFrameLine(dialogWidth: number): string { const targetWidth = Math.max(1, dialogWidth - 2); // Preserve Input's zero-width CURSOR_MARKER so the TUI can position the // terminal's hardware cursor, which anchors IME candidate windows. const renderedInputLine = this.input.render(targetWidth)[0] ?? ""; const inputLine = truncateToWidth(renderedInputLine, targetWidth, ""); const padding = Math.max(0, targetWidth - visibleWidth(inputLine)); return `${this.theme.fg("border", "│")}${inputLine}${" ".repeat(padding)}${this.theme.fg("border", "│")}`; } private fitRenderedLine(line: string, width: number): string { return visibleWidth(line) > width ? truncateToWidth(line, width, "") : line; } override render(width: number): string[] { const dialogWidth = Math.max(1, width); const innerWidth = Math.max(1, dialogWidth - 2); const contentWidth = Math.max(1, innerWidth - BTW_BLOCK_INDENT.length); this.ensureFormattedTranscript(contentWidth); if (this.renderedTranscriptInnerWidth !== 0 && this.renderedTranscriptInnerWidth !== innerWidth) { this.clearTranscriptSelection(); } this.ensureWrappedTranscript(innerWidth); const transcriptLines = this.renderedTranscriptLines; this.renderedDialogWidth = dialogWidth; this.renderedTranscriptInnerWidth = innerWidth; const dialogHeight = this.getDialogHeight(); const chromeHeight = BTW_OVERLAY_CHROME_LINES; const transcriptHeight = Math.max(0, dialogHeight - chromeHeight); this.transcriptViewportHeight = transcriptHeight; const maxScroll = Math.max(0, transcriptLines.length - transcriptHeight); if (this.followTranscript) { this.transcriptScrollOffset = maxScroll; } else { this.transcriptScrollOffset = Math.max(0, Math.min(this.transcriptScrollOffset, maxScroll)); if (this.transcriptScrollOffset >= maxScroll) { this.followTranscript = true; } } const visibleTranscript = transcriptLines.slice( this.transcriptScrollOffset, this.transcriptScrollOffset + transcriptHeight, ); const transcriptPadCount = Math.max(0, transcriptHeight - visibleTranscript.length); const hiddenAbove = this.transcriptScrollOffset; const hiddenBelow = Math.max(0, maxScroll - this.transcriptScrollOffset); const summary = hiddenAbove || hiddenBelow ? `${this.summaryTextValue.trim()} · ↑${hiddenAbove} ↓${hiddenBelow}` : this.summaryTextValue.trim(); const statusLine = this.theme.fg("warning", this.statusTextValue.trim()); const lines = [this.borderLine(innerWidth, "top")]; lines.push(this.frameLine(this.theme.fg("accent", this.theme.bold(this.modeTextValue.trim())), innerWidth)); lines.push(this.frameLine(this.theme.fg("dim", summary), innerWidth)); lines.push(this.ruleLine(innerWidth)); for (let i = 0; i < visibleTranscript.length; i++) { const lineIndex = this.transcriptScrollOffset + i; lines.push(this.frameLine(this.highlightTranscriptSelection(visibleTranscript[i], lineIndex), innerWidth)); } for (let i = 0; i < transcriptPadCount; i++) { lines.push(this.frameLine("", innerWidth)); } lines.push(this.ruleLine(innerWidth)); lines.push(this.frameLine(statusLine, innerWidth)); lines.push(this.inputFrameLine(dialogWidth)); lines.push(this.frameLine(this.theme.fg("dim", this.hintsTextValue.trim()), innerWidth)); lines.push(this.borderLine(innerWidth, "bottom")); return lines.map((line) => this.fitRenderedLine(line, width)); } setDraft(value: string, requestRender = true): void { this.input.setValue(value); if (requestRender) { this.tui.requestRender(); } } getDraft(): string { return this.input.getValue(); } getTranscriptEntries(): BtwTranscript { return this.readTranscriptState().entries.map((entry) => ({ ...entry })); } getTranscriptLines(): string[] { const innerWidth = Math.max(1, this.renderedTranscriptInnerWidth || (this.tui.terminal?.columns ?? 80) - 2); const contentWidth = Math.max(1, innerWidth - BTW_BLOCK_INDENT.length); this.ensureFormattedTranscript(contentWidth); return [...this.transcriptLines]; } refresh(): void { this.modeTextValue = `${getOverlayTitle(this.getMode())} · hidden thread preserved`; this.modeText.setText(this.modeTextValue); const entries = this.readTranscriptState().entries; if (entries.length === 0) { this.clearTranscriptSelection(); } const exchanges = getCompletedExchangeCount(entries); const active = hasStreamingTranscriptEntry(entries) ? " · streaming" : " · idle"; this.summaryTextValue = `${exchanges} exchange${exchanges === 1 ? "" : "s"}${active}`; this.summaryText.setText(this.summaryTextValue); const status = this.getStatus() ?? "Ready. Enter submits; Escape dismisses without clearing."; this.statusTextValue = status; this.statusText.setText(this.statusTextValue); this.hintsTextValue = this.buildHintsText(); this.hintsText.setText(this.hintsTextValue); this.tui.requestRender(); } } export default function (pi: ExtensionAPI) { let pendingThread: BtwDetails[] = []; let pendingMode: BtwThreadMode = "contextual"; let btwModelOverride: SessionModel | null = null; let btwThinkingOverride: SessionThinkingLevel | null = null; let transcriptState = createEmptyTranscriptState(); let overlayStatus: string | null = null; let overlayDraft = ""; let overlayRuntime: OverlayRuntime | null = null; let activeBtwSession: BtwSessionRuntime | null = null; function cancelScheduledUiSync(runtime: OverlayRuntime | null = overlayRuntime): void { if (runtime?.streamRefreshTimer) { clearTimeout(runtime.streamRefreshTimer); runtime.streamRefreshTimer = undefined; } if (runtime) { runtime.streamRefreshPending = false; } } function syncUi(_ctx?: ExtensionContext | ExtensionCommandContext): void { const runtime = overlayRuntime; if (!runtime || runtime.closed) { return; } cancelScheduledUiSync(runtime); if (runtime.handle?.isHidden()) { runtime.streamRefreshPending = true; return; } runtime.streamRefreshPending = false; runtime.refresh?.(); } function scheduleUiSync(_ctx?: ExtensionContext | ExtensionCommandContext): void { const runtime = overlayRuntime; if (!runtime || runtime.closed) { return; } runtime.streamRefreshPending = true; if (runtime.handle?.isHidden() || runtime.streamRefreshTimer) { return; } runtime.streamRefreshTimer = setTimeout(() => { runtime.streamRefreshTimer = undefined; if (runtime.closed || overlayRuntime !== runtime || runtime.handle?.isHidden()) { return; } runtime.streamRefreshPending = false; runtime.refresh?.(); }, BTW_STREAM_REFRESH_INTERVAL_MS); } function setOverlayStatus( status: string | null, ctx?: ExtensionContext | ExtensionCommandContext, streaming = false, ): void { overlayStatus = status; if (streaming) { scheduleUiSync(ctx); } else { syncUi(ctx); } } function setOverlayDraft(value: string): void { overlayDraft = value; overlayRuntime?.setDraft?.(value); } function dismissOverlay(): void { cancelScheduledUiSync(); overlayRuntime?.close?.(); overlayRuntime = null; } /** * Alt+/ (or Ctrl+Alt+W): toggle the overlay's visibility. Hiding releases focus back * to the main editor so the panel no longer covers the main session; pressing the * shortcut again brings the panel back with focus restored. */ function toggleOverlayFocus(): void { const runtime = overlayRuntime; const handle = runtime?.handle; if (!runtime || !handle) { return; } if (handle.isHidden()) { handle.setHidden(false); // The BTW overlay is nonCapturing, so showing it does not auto-focus. handle.focus(); syncUi(); } else { cancelScheduledUiSync(runtime); handle.setHidden(true); handle.unfocus(); runtime.streamRefreshPending = true; runtime.refresh?.(); } } function focusOverlay(): void { const handle = overlayRuntime?.handle; if (!handle) { return; } handle.setHidden(false); handle.focus(); syncUi(); } function removeBtwSessionSubscription(sessionRuntime: BtwSessionRuntime, unsubscribe: () => void): void { if (!sessionRuntime.subscriptions.delete(unsubscribe)) { return; } try { unsubscribe(); } catch { // Ignore unsubscribe errors during BTW session replacement/shutdown. } } function clearBtwSessionSubscriptions(sessionRuntime: BtwSessionRuntime): void { for (const unsubscribe of [...sessionRuntime.subscriptions]) { removeBtwSessionSubscription(sessionRuntime, unsubscribe); } } function handleBtwSessionEvent( sessionRuntime: BtwSessionRuntime, event: AgentSessionEvent, ctx?: ExtensionContext | ExtensionCommandContext, ): void { if (activeBtwSession?.session !== sessionRuntime.session || !overlayRuntime) { return; } const previousRevision = transcriptState.revision; applyTranscriptEvent(transcriptState, event); const transcriptChanged = transcriptState.revision !== previousRevision; if (event.type === "tool_execution_start") { setOverlayStatus(`⏳ running tool: ${event.toolName}`, ctx); return; } if (event.type === "tool_execution_update") { if (transcriptChanged) { scheduleUiSync(ctx); } return; } if (event.type === "tool_execution_end") { setOverlayStatus(sessionRuntime.session.isStreaming ? `⏳ running tool: ${event.toolName}` : "⏳ streaming...", ctx); return; } if (event.type === "turn_end") { setOverlayStatus("⏳ streaming...", ctx); return; } if (event.type === "message_update") { if (transcriptChanged) { scheduleUiSync(ctx); } return; } if (event.type === "message_start" || event.type === "message_end" || event.type === "turn_start") { if (transcriptChanged || event.type !== "message_start") { syncUi(ctx); } } } function subscribeOverlayToActiveBtwSession(ctx?: ExtensionContext | ExtensionCommandContext): void { const sessionRuntime = activeBtwSession; if (!sessionRuntime || sessionRuntime.subscriptions.size > 0) { return; } const unsubscribe = sessionRuntime.session.subscribe((event: AgentSessionEvent) => { handleBtwSessionEvent(sessionRuntime, event, ctx); }); sessionRuntime.subscriptions.add(unsubscribe); } async function disposeBtwSession(): Promise { const current = activeBtwSession; activeBtwSession = null; if (!current) { return; } clearBtwSessionSubscriptions(current); try { await current.session.abort(); } catch { // Ignore abort errors during BTW session replacement/shutdown. } current.session.dispose(); } async function dismissOverlaySession(): Promise { dismissOverlay(); await disposeBtwSession(); } /** * Abort the in-flight BTW request (Escape/Ctrl+C while the LLM is running and the * composer is empty). The overlay stays open; runBtw reports the abort state. */ async function abortActiveBtwRequest(ctx: ExtensionCommandContext | ExtensionContext): Promise { const session = activeBtwSession?.session; if (!session?.isStreaming) { return; } setOverlayStatus("Aborting the running request...", ctx); try { await session.abort(); } catch { // runBtw reports the abort state through its normal failure path. } } async function resolveBtwModel( ctx: ExtensionCommandContext, notifyOnFallback = false, ): Promise { if (btwModelOverride) { const auth = await ctx.modelRegistry.getApiKeyAndHeaders(btwModelOverride); if (auth.ok) { return { model: btwModelOverride, source: "override", configuredOverride: btwModelOverride, }; } const fallbackReason = ctx.model ? `Configured BTW model ${formatModelRef(btwModelOverride)} has no credentials. Falling back to main model ${formatModelRef( ctx.model, )}.` : `Configured BTW model ${formatModelRef(btwModelOverride)} has no credentials, and no main model is active.`; if (notifyOnFallback) { notify(ctx, fallbackReason, "warning"); } if (ctx.model) { return { model: ctx.model, source: "main", configuredOverride: btwModelOverride, fallbackReason, }; } return { model: null, source: "none", configuredOverride: btwModelOverride, fallbackReason, }; } if (ctx.model) { return { model: ctx.model, source: "main", configuredOverride: null, }; } return { model: null, source: "none", configuredOverride: null, }; } async function resolveBtwSettings( ctx: ExtensionCommandContext, notifyOnFallback = false, ): Promise { const resolvedModel = await resolveBtwModel(ctx, notifyOnFallback); const thinkingLevel = btwThinkingOverride ?? (pi.getThinkingLevel() as SessionThinkingLevel); return { model: resolvedModel.model, modelSource: resolvedModel.source, configuredModelOverride: resolvedModel.configuredOverride, thinkingLevel, thinkingSource: btwThinkingOverride ? "override" : "main", fallbackReason: resolvedModel.fallbackReason, }; } function describeResolvedModel(settings: ResolvedBtwSettings): string { if (!settings.model) { if (settings.configuredModelOverride && settings.fallbackReason) { return `BTW model unavailable. ${settings.fallbackReason}`; } return "BTW model unavailable. No active model selected."; } const source = settings.modelSource === "override" ? "override" : settings.configuredModelOverride ? "inherited fallback" : "inherits main thread"; return `BTW model: ${formatModelRef(settings.model)} (${source}).${ settings.fallbackReason ? ` ${settings.fallbackReason}` : "" }`; } function describeResolvedThinking(settings: ResolvedBtwSettings): string { const source = settings.thinkingSource === "override" ? "override" : "inherits main thread"; return `BTW thinking: ${settings.thinkingLevel} (${source}).`; } async function setBtwModelOverride(ctx: ExtensionCommandContext, nextModel: SessionModel | null): Promise { btwModelOverride = nextModel; const details: BtwModelOverrideDetails = nextModel ? { action: "set", timestamp: Date.now(), provider: nextModel.provider, id: nextModel.id, api: nextModel.api } : { action: "clear", timestamp: Date.now() }; pi.appendEntry(BTW_MODEL_OVERRIDE_TYPE, details); await disposeBtwSession(); const settings = await resolveBtwSettings(ctx); const message = nextModel ? `BTW model override set to ${formatModelRef(nextModel)}.` : "BTW model override cleared. BTW now inherits the main thread model."; setOverlayStatus(message, ctx); notify(ctx, `${message} ${describeResolvedModel(settings)}`, "info"); } async function setBtwThinkingOverride( ctx: ExtensionCommandContext, nextThinkingLevel: SessionThinkingLevel | null, ): Promise { btwThinkingOverride = nextThinkingLevel; const details: BtwThinkingOverrideDetails = nextThinkingLevel ? { action: "set", timestamp: Date.now(), thinkingLevel: nextThinkingLevel } : { action: "clear", timestamp: Date.now() }; pi.appendEntry(BTW_THINKING_OVERRIDE_TYPE, details); await disposeBtwSession(); const settings = await resolveBtwSettings(ctx); const message = nextThinkingLevel ? `BTW thinking override set to ${nextThinkingLevel}.` : "BTW thinking override cleared. BTW now inherits the main thread thinking level."; setOverlayStatus(message, ctx); notify(ctx, `${message} ${describeResolvedThinking(settings)}`, "info"); } async function createBtwSubSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise { const settings = await resolveBtwSettings(ctx, true); if (!settings.model) { throw new Error(settings.fallbackReason || "No active model selected."); } const sessionOptions = { cwd: ctx.cwd, sessionManager: SessionManager.inMemory(ctx.cwd), model: settings.model, ...getSessionModelOptions(ctx), thinkingLevel: settings.thinkingLevel, // Match pi's default coding-agent toolset (read/bash/edit/write). tools: ["read", "bash", "edit", "write"], resourceLoader: createBtwResourceLoader(ctx), } as CreateSessionOptions; const { session } = await createAgentSession(sessionOptions); const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode, settings.model); if (seedMessages.length > 0) { session.agent.state.messages = seedMessages as typeof session.state.messages; } return { session, mode, subscriptions: new Set(), sideThreadStartIndex }; } async function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise { const settings = await resolveBtwSettings(ctx); if (!settings.model) { return null; } if (activeBtwSession?.mode === mode) { return activeBtwSession; } await disposeBtwSession(); activeBtwSession = await createBtwSubSession(ctx, mode); return activeBtwSession; } async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise { if (!ctx.hasUI || ctx.mode !== "tui") { return; } if (overlayRuntime?.handle) { subscribeOverlayToActiveBtwSession(ctx); focusOverlay(); return; } const runtime: OverlayRuntime = {}; const closeRuntime = () => { if (runtime.closed) { return; } runtime.closed = true; cancelScheduledUiSync(runtime); if (activeBtwSession) { clearBtwSessionSubscriptions(activeBtwSession); } // `done()` owns overlay removal through Pi's custom-UI lifecycle. Calling the // handle's precise hide first would make `done()` pop a different stacked overlay. 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 BtwOverlayComponent( tui, theme, keybindings, () => transcriptState, () => overlayStatus, () => pendingMode, () => activeBtwSession?.session.isStreaming ?? false, (value) => { void submitFromOverlay(ctx, value); }, () => { void dismissOverlaySession(); }, () => { // Alt+/ received while the overlay has focus: hide the panel so the // main session is visible again (focus returns to the main editor). const handle = overlayRuntime?.handle; if (handle) { cancelScheduledUiSync(); handle.setHidden(true); handle.unfocus(); if (overlayRuntime) { overlayRuntime.streamRefreshPending = true; overlayRuntime.refresh?.(); } } }, () => { void abortActiveBtwRequest(ctx); }, () => { void rewindLastExchange(ctx); }, ); overlay.focused = runtime.handle?.isFocused() ?? true; overlay.setDraft(overlayDraft); runtime.setDraft = (value) => { overlay.setDraft(value, !runtime.handle?.isHidden()); }; runtime.refresh = () => { if (runtime.closed) { return; } overlay.focused = runtime.handle?.isFocused() ?? false; if (runtime.handle?.isHidden()) { runtime.streamRefreshPending = true; return; } overlay.refresh(); }; runtime.close = () => { overlayDraft = overlay.getDraft(); overlay.dispose(); closeRuntime(); }; subscribeOverlayToActiveBtwSession(ctx); if (runtime.closed) { done(); } return overlay; }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", margin: 0, nonCapturing: true, }, onHandle: (handle) => { runtime.handle = handle; handle.focus(); if (runtime.closed) { closeRuntime(); } }, }, ) .catch((error) => { cancelScheduledUiSync(runtime); if (overlayRuntime === runtime) { overlayRuntime = null; } notify(ctx, error instanceof Error ? error.message : String(error), "error"); }); } async function dispatchBtwCommand(name: string, args: string, ctx: ExtensionCommandContext): Promise { const trimmedArgs = args.trim(); if (name === "btw") { const { question, save } = parseBtwArgs(trimmedArgs); if (!question) { await ensureBtwSession(ctx, pendingMode); await ensureOverlay(ctx); return true; } if (pendingMode !== "contextual") { await resetThread(ctx, true, "contextual"); } await runBtw(ctx, question, save, "contextual"); return true; } if (name === "btw:tangent") { const { question, save } = parseBtwArgs(trimmedArgs); if (pendingMode !== "tangent") { await resetThread(ctx, true, "tangent"); } if (!question) { await ensureBtwSession(ctx, "tangent"); await ensureOverlay(ctx); return true; } await runBtw(ctx, question, save, "tangent"); return true; } if (name === "btw:new") { await resetThread(ctx, true, "contextual"); const { question, save } = parseBtwArgs(trimmedArgs); if (question) { await runBtw(ctx, question, save, "contextual"); } else { await ensureBtwSession(ctx, "contextual"); setOverlayStatus("Started a fresh BTW thread.", ctx); await ensureOverlay(ctx); notify(ctx, "Started a fresh BTW thread.", "info"); } return true; } if (name === "btw:clear") { await resetThread(ctx); dismissOverlay(); notify(ctx, "Cleared BTW thread.", "info"); return true; } if (name === "btw:model") { const parsed = parseBtwModelArgs(trimmedArgs); if (parsed.action === "invalid") { setOverlayStatus(parsed.message, ctx); notify(ctx, parsed.message, "error"); return true; } if (parsed.action === "show") { const settings = await resolveBtwSettings(ctx); const message = describeResolvedModel(settings); setOverlayStatus(message, ctx); notify(ctx, message, settings.model ? "info" : "warning"); return true; } if (parsed.action === "clear") { await setBtwModelOverride(ctx, null); return true; } const ref = parsed.model; const resolved = ctx.modelRegistry.find(ref.provider, ref.id); if (!resolved) { const message = `Unknown model ${ref.provider}/${ref.id}. Use /login or /models to add it before setting it as the BTW override.`; setOverlayStatus(message, ctx); notify(ctx, message, "error"); return true; } await setBtwModelOverride(ctx, resolved); return true; } if (name === "btw:thinking") { const parsed = parseBtwThinkingArgs(trimmedArgs); if (parsed.action === "show") { const settings = await resolveBtwSettings(ctx); const message = describeResolvedThinking(settings); setOverlayStatus(message, ctx); notify(ctx, message, "info"); return true; } await setBtwThinkingOverride(ctx, parsed.action === "clear" ? null : parsed.thinkingLevel); return true; } if (name === "btw:inject") { if (pendingThread.length === 0) { notify(ctx, "No BTW thread to inject.", "warning"); return true; } setOverlayStatus("⏳ injecting into the main session...", ctx); await ensureOverlay(ctx); try { const { thread } = await getBtwHandoffThread(ctx); const instructions = trimmedArgs; const content = instructions ? `Here is a side conversation I had. ${instructions}\n\n${formatThread(thread)}` : `Here is a side conversation I had for additional context:\n\n${formatThread(thread)}`; sendThreadToMain(ctx, content); const count = thread.length; await resetThread(ctx); dismissOverlay(); notify(ctx, `Injected BTW thread (${count} exchange${count === 1 ? "" : "s"}).`, "info"); } catch (error) { setOverlayStatus("Inject failed. Thread preserved for retry or summarize.", ctx); notify(ctx, error instanceof Error ? error.message : String(error), "error"); } return true; } if (name === "btw:summarize") { if (pendingThread.length === 0) { notify(ctx, "No BTW thread to summarize.", "warning"); return true; } setOverlayStatus("⏳ summarizing...", ctx); await ensureOverlay(ctx); try { const { thread } = await getBtwHandoffThread(ctx); const summary = await summarizeThread(ctx, thread); const instructions = trimmedArgs; const content = instructions ? `Here is a summary of a side conversation I had. ${instructions}\n\n${summary}` : `Here is a summary of a side conversation I had:\n\n${summary}`; sendThreadToMain(ctx, content); const count = thread.length; await resetThread(ctx); dismissOverlay(); notify(ctx, `Injected BTW summary (${count} exchange${count === 1 ? "" : "s"}).`, "info"); } catch (error) { setOverlayStatus("Summarize failed. Thread preserved for retry or injection.", ctx); notify(ctx, error instanceof Error ? error.message : String(error), "error"); } return true; } return false; } function parseOverlayBtwCommand(value: string): { name: string; args: string } | null { const trimmed = value.trim(); const match = trimmed.match(/^\/(btw:(?:new|tangent|clear|inject|summarize|model|thinking))(?:\s+(.*))?$/); if (!match) { return null; } return { name: match[1], args: match[2]?.trim() ?? "", }; } async function submitFromOverlay(ctx: ExtensionCommandContext | ExtensionContext, value: string): Promise { const question = value.trim(); if (!question) { setOverlayStatus("Enter a BTW prompt before submitting.", ctx); return; } if (!("getSystemPrompt" in ctx)) { setOverlayStatus("BTW overlay submit requires a command context. Reopen BTW from a command.", ctx); return; } const cmdCtx = ctx as ExtensionCommandContext; const btwCommand = parseOverlayBtwCommand(question); if (btwCommand) { setOverlayDraft(""); await dispatchBtwCommand(btwCommand.name, btwCommand.args, cmdCtx); return; } setOverlayDraft(""); setOverlayStatus("⏳ streaming...", ctx); await runBtw(cmdCtx, question, false, pendingMode); } /** * Double-Escape: cancel the in-flight BTW request, or rewind (remove) the last * completed exchange. Each double-Escape rewinds exactly one message; repeat to * keep unwinding. Rewinds are persisted so they survive reloads. */ async function rewindLastExchange(ctx: ExtensionCommandContext | ExtensionContext): Promise { if (activeBtwSession?.session.isStreaming) { setOverlayStatus("Aborting the running request...", ctx); try { await activeBtwSession.session.abort(); } catch { // runBtw reports the abort state through its normal failure path. } return; } if (pendingThread.length === 0) { setOverlayStatus("Nothing to rewind.", ctx); return; } pendingThread.pop(); removeTranscriptTurn(transcriptState, transcriptState.lastTurnId); await disposeBtwSession(); // Persist the rewind so it survives reloads: a fresh reset marker followed by // the remaining (already-shortened) thread, which restoreThread replays. pi.appendEntry(BTW_RESET_TYPE, { timestamp: Date.now(), mode: pendingMode }); for (const details of pendingThread) { pi.appendEntry(BTW_ENTRY_TYPE, details); } setOverlayStatus("Rewound the last message. Thread preserved.", ctx); } async function resetThread( ctx: ExtensionContext | ExtensionCommandContext, persist = true, mode: BtwThreadMode = "contextual", ): Promise { await disposeBtwSession(); pendingThread = []; pendingMode = mode; transcriptState = createEmptyTranscriptState(); setOverlayDraft(""); setOverlayStatus(null, ctx); if (persist) { const details: BtwResetDetails = { timestamp: Date.now(), mode }; pi.appendEntry(BTW_RESET_TYPE, details); } } async function restoreThread(ctx: ExtensionContext): Promise { await disposeBtwSession(); pendingThread = []; pendingMode = "contextual"; btwModelOverride = null; btwThinkingOverride = null; transcriptState = createEmptyTranscriptState(); overlayDraft = ""; overlayStatus = null; const branch = ctx.sessionManager.getBranch(); let lastResetIndex = -1; for (let i = 0; i < branch.length; i++) { if (isCustomEntry(branch[i], BTW_MODEL_OVERRIDE_TYPE)) { const details = (branch[i] as unknown as { data?: BtwModelOverrideDetails }).data; if (details?.action === "set") { const resolved = ctx.modelRegistry.find(details.provider, details.id); if (resolved) { btwModelOverride = resolved; } else { // Configured override is no longer in the registry; drop it on restore. btwModelOverride = null; } } else if (details?.action === "clear") { btwModelOverride = null; } } if (isCustomEntry(branch[i], BTW_THINKING_OVERRIDE_TYPE)) { const details = (branch[i] as unknown as { data?: BtwThinkingOverrideDetails }).data; btwThinkingOverride = details?.action === "set" ? details.thinkingLevel : details?.action === "clear" ? null : btwThinkingOverride; } if (isCustomEntry(branch[i], BTW_RESET_TYPE)) { lastResetIndex = i; const details = (branch[i] as unknown as { data?: BtwResetDetails }).data; pendingMode = details?.mode ?? "contextual"; } } for (const entry of branch.slice(lastResetIndex + 1)) { if (!isCustomEntry(entry, BTW_ENTRY_TYPE)) { continue; } const details = (entry as unknown as { data?: BtwDetails }).data; if (!details?.question || !details.answer) { continue; } const normalizedDetails: BtwDetails = { ...details, api: details.api || ctx.model?.api || "openai-responses", }; pendingThread.push(normalizedDetails); appendPersistedTranscriptTurn(transcriptState, normalizedDetails); } syncUi(ctx); } async function runBtw( ctx: ExtensionCommandContext, question: string, saveRequested: boolean, mode: BtwThreadMode, ): Promise { const settings = await resolveBtwSettings(ctx); const model = settings.model; if (!model) { const message = settings.fallbackReason || "No active model selected."; setOverlayStatus(message, ctx); notify(ctx, message, "error"); return; } const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); if (!auth.ok) { const message = auth.error || `No credentials available for ${model.provider}/${model.id}.`; setOverlayStatus(message, ctx); notify(ctx, message, "error"); await ensureOverlay(ctx); return; } const sessionRuntime = await ensureBtwSession(ctx, mode); if (!sessionRuntime) { setOverlayStatus("No active model selected.", ctx); notify(ctx, "No active model selected.", "error"); return; } const session = sessionRuntime.session; const wasBusy = !ctx.isIdle(); pendingMode = mode; const thinkingLevel = settings.thinkingLevel; setOverlayStatus("⏳ streaming...", ctx, true); await ensureOverlay(ctx); try { await session.prompt(question, { source: "extension" }); const response = getLastAssistantMessage(session); if (!response) { throw new Error("BTW request finished without a response."); } if (response.stopReason === "aborted") { removeTranscriptTurn(transcriptState, transcriptState.lastTurnId ?? transcriptState.currentTurnId); setOverlayStatus("Request aborted.", ctx); return; } if (response.stopReason === "error") { throw new Error(response.errorMessage || "BTW request failed."); } const completedTurnId = transcriptState.lastTurnId ?? transcriptState.currentTurnId; const streamedThinking = completedTurnId !== null ? findLatestTranscriptEntry(transcriptState, completedTurnId, "thinking")?.text : ""; const answer = extractAnswer(response); const thinking = extractThinking(response) || streamedThinking || ""; const details: BtwDetails = { question, thinking, answer, provider: model.provider, model: model.id, api: model.api, thinkingLevel, timestamp: Date.now(), usage: response.usage, }; pendingThread.push(details); pi.appendEntry(BTW_ENTRY_TYPE, details); const saveState = saveVisibleBtwNote(pi, details, saveRequested, wasBusy); if (saveState === "saved") { notify(ctx, "Saved BTW note to the session.", "info"); setOverlayStatus("Saved BTW note to the session.", ctx); } else if (saveState === "queued") { notify(ctx, "BTW note queued to save after the current turn finishes.", "info"); setOverlayStatus("BTW note queued to save after the current turn finishes.", ctx); } else { setOverlayStatus("Ready for a follow-up. Hidden BTW thread updated.", ctx); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); setTranscriptFailure(transcriptState, errorMessage); setOverlayStatus("Request failed. Thread preserved for retry or follow-up.", ctx); notify(ctx, errorMessage, "error"); await disposeBtwSession(); } finally { syncUi(ctx); } } function getPendingThreadForHandoff(): BtwHandoffExchange[] { return pendingThread.map((entry) => ({ user: entry.question, assistant: entry.answer })); } async function getBtwHandoffThread( ctx: ExtensionCommandContext, ): Promise<{ sessionRuntime: BtwSessionRuntime | null; thread: BtwHandoffExchange[] }> { const sessionRuntime = activeBtwSession ?? (await ensureBtwSession(ctx, pendingMode)); const thread = sessionRuntime ? extractBtwHandoffThread(sessionRuntime) : []; const resolvedThread = thread.length > 0 ? thread : getPendingThreadForHandoff(); if (resolvedThread.length === 0) { throw new Error("No BTW thread available for handoff."); } return { sessionRuntime, thread: resolvedThread }; } async function summarizeThread(ctx: ExtensionCommandContext, thread: BtwHandoffExchange[]): Promise { const settings = await resolveBtwSettings(ctx, true); const model = settings.model; if (!model) { throw new Error(settings.fallbackReason || "No active model selected."); } const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); if (!auth.ok) { throw new Error(auth.error || `No credentials available for ${model.provider}/${model.id}.`); } const sessionOptions = { cwd: ctx.cwd, sessionManager: SessionManager.inMemory(ctx.cwd), model, ...getSessionModelOptions(ctx), thinkingLevel: "off", tools: [], resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARIZE_SYSTEM_PROMPT]), } as CreateSessionOptions; const { session } = await createAgentSession(sessionOptions); try { await session.prompt(formatThread(thread), { source: "extension" }); const response = getLastAssistantMessage(session); if (!response) { throw new Error("BTW summarize finished without a response."); } if (response.stopReason === "error") { throw new Error(response.errorMessage || "Failed to summarize BTW thread."); } if (response.stopReason === "aborted") { throw new Error("BTW summarize aborted."); } return extractAnswer(response); } finally { try { await session.abort(); } catch { // Ignore abort errors during summarize session shutdown. } session.dispose(); } } function sendThreadToMain(ctx: ExtensionCommandContext, content: string): void { if (ctx.isIdle()) { pi.sendUserMessage(content); } else { pi.sendUserMessage(content, { deliverAs: "followUp" }); } } pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, { expanded }, theme) => { const details = message.details as BtwDetails | undefined; const content = typeof message.content === "string" ? message.content : "[non-text btw message]"; const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text)); box.addChild(new Text(theme.fg("accent", theme.bold("[BTW]")), 0, 0)); // Render the saved note body as markdown so headings/code/lists are formatted, // matching how the main agent renders assistant messages. box.addChild( new Markdown(content, 0, 0, buildMarkdownTheme(theme), { color: (text: string) => theme.fg("customMessageText", text), }), ); if (expanded && details) { box.addChild( new Text( theme.fg( "dim", `model: ${details.provider}/${details.model} (${details.api ?? "openai-responses"}) · thinking: ${details.thinkingLevel}`, ), 0, 0, ), ); if (details.usage) { box.addChild( new Text( theme.fg( "dim", `tokens: in ${details.usage.input} · out ${details.usage.output} · total ${details.usage.totalTokens}`, ), 0, 0, ), ); } } return box; }); pi.on("context", async (event) => { return { messages: event.messages.filter((message) => !isVisibleBtwMessage(message)), }; }); 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 () => { // Release focus, timers, clipboard callbacks, and terminal mouse state before // waiting for a provider/tool abort that may take time to settle. dismissOverlay(); await disposeBtwSession(); }); for (const shortcut of BTW_FOCUS_SHORTCUTS) { pi.registerShortcut(shortcut, { description: "Toggle BTW overlay focus while leaving it open.", handler: async (_ctx) => { toggleOverlayFocus(); }, }); } pi.registerCommand("btw", { description: "Continue a side conversation in a focused BTW modal. Add --save to also persist a visible note.", handler: async (args, ctx) => { await dispatchBtwCommand("btw", args, ctx); }, }); pi.registerCommand("btw:tangent", { description: "Start or continue a contextless BTW tangent in the focused BTW modal.", handler: async (args, ctx) => { await dispatchBtwCommand("btw:tangent", args, ctx); }, }); pi.registerCommand("btw:new", { description: "Start a fresh BTW thread with main-session context. Optionally ask the first question immediately.", handler: async (args, ctx) => { await dispatchBtwCommand("btw:new", args, ctx); }, }); pi.registerCommand("btw:clear", { description: "Dismiss the BTW modal/widget and clear the current thread.", handler: async (args, ctx) => { await dispatchBtwCommand("btw:clear", args, ctx); }, }); pi.registerCommand("btw:inject", { description: "Inject the full BTW thread into the main agent as a user message.", handler: async (args, ctx) => { await dispatchBtwCommand("btw:inject", args, ctx); }, }); pi.registerCommand("btw:summarize", { description: "Summarize the BTW thread, then inject the summary into the main agent.", handler: async (args, ctx) => { await dispatchBtwCommand("btw:summarize", args, ctx); }, }); pi.registerCommand("btw:model", { description: "Show, set, or clear the BTW-only model override.", handler: async (args, ctx) => { await dispatchBtwCommand("btw:model", args, ctx); }, }); pi.registerCommand("btw:thinking", { description: "Show, set, or clear the BTW-only thinking override.", handler: async (args, ctx) => { await dispatchBtwCommand("btw:thinking", args, ctx); }, }); }