				// Put the selected message in the editor
				this.editor.setText(selectedText);

				// Hide selector and show editor again
				this.hideUserMessageSelector();
				this.ui.requestRender();
			},
			() => {
				// Just hide the selector
				this.hideUserMessageSelector();
				this.ui.requestRender();
			},
		);

		// Replace editor with selector
		this.editorContainer.clear();
		this.editorContainer.addChild(this.userMessageSelector);
		this.ui.setFocus(this.userMessageSelector.getMessageList());
		this.ui.requestRender();
	}

	private hideUserMessageSelector(): void {
		// Replace selector with editor in the container
		this.editorContainer.clear();
		this.editorContainer.addChild(this.editor);
		this.userMessageSelector = null;
		this.ui.setFocus(this.editor);
	}

	private showSessionSelector(): void {
		// Create session selector
		this.sessionSelector = new SessionSelectorComponent(
			this.sessionManager,
			async (sessionPath) => {
				this.hideSessionSelector();
				await this.handleResumeSession(sessionPath);
			},
			() => {
				// Just hide the selector
				this.hideSessionSelector();
				this.ui.requestRender();
			},
		);

		// Replace editor with selector
		this.editorContainer.clear();
		this.editorContainer.addChild(this.sessionSelector);
		this.ui.setFocus(this.sessionSelector.getSessionList());
		this.ui.requestRender();
	}

	private async handleResumeSession(sessionPath: string): Promise<void> {
		// Unsubscribe first to prevent processing events during transition
		this.unsubscribe?.();

		// Abort and wait for completion
		this.agent.abort();
		await this.agent.waitForIdle();

		// Stop loading animation
		if (this.loadingAnimation) {
			this.loadingAnimation.stop();
			this.loadingAnimation = null;
		}
		this.statusContainer.clear();

		// Clear UI state
		this.queuedMessages = [];
		this.pendingMessagesContainer.clear();
		this.streamingComponent = null;
		this.pendingTools.clear();

		// Set the selected session as active
		this.sessionManager.setSessionFile(sessionPath);

		// Reload the session
		const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
		this.agent.replaceMessages(loaded.messages);

		// Restore model if saved in session
		const savedModel = this.sessionManager.loadModel();
		if (savedModel) {
			const availableModels = (await getAvailableModels()).models;
			const match = availableModels.find((m) => m.provider === savedModel.provider && m.id === savedModel.modelId);
			if (match) {
				this.agent.setModel(match);
			}
		}

		// Restore thinking level if saved in session
		const savedThinking = this.sessionManager.loadThinkingLevel();
		if (savedThinking) {
			this.agent.setThinkingLevel(savedThinking as ThinkingLevel);
		}

		// Resubscribe to agent
		this.subscribeToAgent();

		// Clear and re-render the chat
		this.chatContainer.clear();
		this.isFirstUserMessage = true;
		this.renderInitialMessages(this.agent.state);

		// Show confirmation message
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("dim", "Resumed session"), 1, 0));

		this.ui.requestRender();
	}

	private hideSessionSelector(): void {
		// Replace selector with editor in the container
		this.editorContainer.clear();
		this.editorContainer.addChild(this.editor);
		this.sessionSelector = null;
		this.ui.setFocus(this.editor);
	}

	private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
		// For logout mode, filter to only show logged-in providers
		let providersToShow: string[] = [];
		if (mode === "logout") {
			const loggedInProviders = listOAuthProviders();
			if (loggedInProviders.length === 0) {
				this.chatContainer.addChild(new Spacer(1));
				this.chatContainer.addChild(
					new Text(theme.fg("dim", "No OAuth providers logged in. Use /login first."), 1, 0),
				);
				this.ui.requestRender();
				return;
			}
			providersToShow = loggedInProviders;
		}

		// Create OAuth selector
		this.oauthSelector = new OAuthSelectorComponent(
			mode,
			async (providerId: string) => {
				// Hide selector first
				this.hideOAuthSelector();

				if (mode === "login") {
					// Handle login
					this.chatContainer.addChild(new Spacer(1));
					this.chatContainer.addChild(new Text(theme.fg("dim", `Logging in to ${providerId}...`), 1, 0));
					this.ui.requestRender();

					try {
						await login(
							providerId as SupportedOAuthProvider,
							(url: string) => {
								// Show auth URL to user
								this.chatContainer.addChild(new Spacer(1));
								this.chatContainer.addChild(new Text(theme.fg("accent", "Opening browser to:"), 1, 0));
								this.chatContainer.addChild(new Text(theme.fg("accent", url), 1, 0));
								this.chatContainer.addChild(new Spacer(1));
								this.chatContainer.addChild(
									new Text(theme.fg("warning", "Paste the authorization code below:"), 1, 0),
								);
								this.ui.requestRender();

								// Open URL in browser
								const openCmd =
									process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
								exec(`${openCmd} "${url}"`);
							},
							async () => {
								// Prompt for code with a simple Input
								return new Promise<string>((resolve) => {
									const codeInput = new Input();
									codeInput.onSubmit = () => {
										const code = codeInput.getValue();
										// Restore editor
										this.editorContainer.clear();
										this.editorContainer.addChild(this.editor);
										this.ui.setFocus(this.editor);
										resolve(code);
									};

									this.editorContainer.clear();
									this.editorContainer.addChild(codeInput);
									this.ui.setFocus(codeInput);
									this.ui.requestRender();
								});
							},
						);

						// Success - invalidate OAuth cache so footer updates
						invalidateOAuthCache();
						this.chatContainer.addChild(new Spacer(1));
						this.chatContainer.addChild(
							new Text(theme.fg("success", `✓ Successfully logged in to ${providerId}`), 1, 0),
						);
						this.chatContainer.addChild(new Text(theme.fg("dim", `Tokens saved to ${getOAuthPath()}`), 1, 0));
						this.ui.requestRender();
					} catch (error: any) {
						this.showError(`Login failed: ${error.message}`);
					}
				} else {
					// Handle logout
					try {
						await logout(providerId as SupportedOAuthProvider);

						// Invalidate OAuth cache so footer updates
						invalidateOAuthCache();
						this.chatContainer.addChild(new Spacer(1));
						this.chatContainer.addChild(
							new Text(theme.fg("success", `✓ Successfully logged out of ${providerId}`), 1, 0),
						);
						this.chatContainer.addChild(
							new Text(theme.fg("dim", `Credentials removed from ${getOAuthPath()}`), 1, 0),
						);
						this.ui.requestRender();
					} catch (error: any) {
						this.showError(`Logout failed: ${error.message}`);
					}
				}
			},
			() => {
				// Cancel - just hide the selector
				this.hideOAuthSelector();
				this.ui.requestRender();
			},
		);

		// Replace editor with selector
		this.editorContainer.clear();
		this.editorContainer.addChild(this.oauthSelector);
		this.ui.setFocus(this.oauthSelector);
		this.ui.requestRender();
	}

	private hideOAuthSelector(): void {
		// Replace selector with editor in the container
		this.editorContainer.clear();
		this.editorContainer.addChild(this.editor);
		this.oauthSelector = null;
		this.ui.setFocus(this.editor);
	}

	private handleExportCommand(text: string): void {
		// Parse optional filename from command: /export [filename]
		const parts = text.split(/\s+/);
		const outputPath = parts.length > 1 ? parts[1] : undefined;

		try {
			// Export session to HTML
			const filePath = exportSessionToHtml(this.sessionManager, this.agent.state, outputPath);

			// Show success message in chat - matching thinking level style
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", `Session exported to: ${filePath}`), 1, 0));
			this.ui.requestRender();
		} catch (error: any) {
			// Show error message in chat
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(
				new Text(theme.fg("error", `Failed to export session: ${error.message || "Unknown error"}`), 1, 0),
			);
			this.ui.requestRender();
		}
	}

	private handleCopyCommand(): void {
		// Find the last assistant message
		const lastAssistantMessage = this.agent.state.messages
			.slice()
			.reverse()
			.find((m) => m.role === "assistant");

		if (!lastAssistantMessage) {
			this.showError("No agent messages to copy yet.");
			return;
		}

		// Extract raw text content from all text blocks
		let textContent = "";

		for (const content of lastAssistantMessage.content) {
			if (content.type === "text") {
				textContent += content.text;
			}
		}

		if (!textContent.trim()) {
			this.showError("Last agent message contains no text content.");
			return;
		}

		// Copy to clipboard using cross-platform compatible method
		try {
			copyToClipboard(textContent);
		} catch (error) {
			this.showError(error instanceof Error ? error.message : String(error));
			return;
		}

		// Show confirmation message
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("dim", "Copied last agent message to clipboard"), 1, 0));
		this.ui.requestRender();
	}

	private handleSessionCommand(): void {
		// Get session info
		const sessionFile = this.sessionManager.getSessionFile();
		const state = this.agent.state;

		// Count messages
		const userMessages = state.messages.filter((m) => m.role === "user").length;
		const assistantMessages = state.messages.filter((m) => m.role === "assistant").length;
		const toolResults = state.messages.filter((m) => m.role === "toolResult").length;
		const totalMessages = state.messages.length;

		// Count tool calls from assistant messages
		let toolCalls = 0;
		for (const message of state.messages) {
			if (message.role === "assistant") {
				const assistantMsg = message as AssistantMessage;
				toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length;
			}
		}

		// Calculate cumulative usage from all assistant messages (same as footer)
		let totalInput = 0;
		let totalOutput = 0;
		let totalCacheRead = 0;
		let totalCacheWrite = 0;
		let totalCost = 0;

		for (const message of state.messages) {
			if (message.role === "assistant") {
				const assistantMsg = message as AssistantMessage;
				totalInput += assistantMsg.usage.input;
				totalOutput += assistantMsg.usage.output;
				totalCacheRead += assistantMsg.usage.cacheRead;
				totalCacheWrite += assistantMsg.usage.cacheWrite;
				totalCost += assistantMsg.usage.cost.total;
			}
		}

		const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;

		// Build info text
		let info = `${theme.bold("Session Info")}\n\n`;
		info += `${theme.fg("dim", "File:")} ${sessionFile}\n`;
		info += `${theme.fg("dim", "ID:")} ${this.sessionManager.getSessionId()}\n\n`;
		info += `${theme.bold("Messages")}\n`;
		info += `${theme.fg("dim", "User:")} ${userMessages}\n`;
		info += `${theme.fg("dim", "Assistant:")} ${assistantMessages}\n`;
		info += `${theme.fg("dim", "Tool Calls:")} ${toolCalls}\n`;
		info += `${theme.fg("dim", "Tool Results:")} ${toolResults}\n`;
		info += `${theme.fg("dim", "Total:")} ${totalMessages}\n\n`;
		info += `${theme.bold("Tokens")}\n`;
		info += `${theme.fg("dim", "Input:")} ${totalInput.toLocaleString()}\n`;
		info += `${theme.fg("dim", "Output:")} ${totalOutput.toLocaleString()}\n`;
		if (totalCacheRead > 0) {
			info += `${theme.fg("dim", "Cache Read:")} ${totalCacheRead.toLocaleString()}\n`;
		}
		if (totalCacheWrite > 0) {
			info += `${theme.fg("dim", "Cache Write:")} ${totalCacheWrite.toLocaleString()}\n`;
		}
		info += `${theme.fg("dim", "Total:")} ${totalTokens.toLocaleString()}\n`;

		if (totalCost > 0) {
			info += `\n${theme.bold("Cost")}\n`;
			info += `${theme.fg("dim", "Total:")} ${totalCost.toFixed(4)}`;
		}

		// Show info in chat
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(info, 1, 0));
		this.ui.requestRender();
	}

	private handleChangelogCommand(): void {
		const changelogPath = getChangelogPath();
		const allEntries = parseChangelog(changelogPath);

		// Show all entries in reverse order (oldest first, newest last)
		const changelogMarkdown =
			allEntries.length > 0
				? allEntries
						.reverse()
						.map((e) => e.content)
						.join("\n\n")
				: "No changelog entries found.";

		// Display in chat
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new DynamicBorder());
		this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
		this.ui.addChild(new Spacer(1));
		this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, getMarkdownTheme()));
		this.chatContainer.addChild(new DynamicBorder());
		this.ui.requestRender();
	}

	private async handleClearCommand(): Promise<void> {
		// Unsubscribe first to prevent processing abort events
		this.unsubscribe?.();

		// Abort and wait for completion
		this.agent.abort();
		await this.agent.waitForIdle();

		// Stop loading animation
		if (this.loadingAnimation) {
			this.loadingAnimation.stop();
			this.loadingAnimation = null;
		}
		this.statusContainer.clear();

		// Reset agent and session
		this.agent.reset();
		this.sessionManager.reset();

		// Resubscribe to agent
		this.subscribeToAgent();

		// Clear UI state
		this.chatContainer.clear();
		this.pendingMessagesContainer.clear();
		this.queuedMessages = [];
		this.streamingComponent = null;
		this.pendingTools.clear();
		this.isFirstUserMessage = true;

		// Show confirmation
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(
			new Text(theme.fg("accent", "✓ Context cleared") + "\n" + theme.fg("muted", "Started fresh session"), 1, 1),
		);

		this.ui.requestRender();
	}

	private handleDebugCommand(): void {
		// Force a render and capture all lines with their widths
		const width = this.ui.terminal.columns;
		const allLines = this.ui.render(width);

		const debugLogPath = getDebugLogPath();
		const debugData = [
			`Debug output at ${new Date().toISOString()}`,
			`Terminal width: ${width}`,
			`Total lines: ${allLines.length}`,
			"",
			"=== All rendered lines with visible widths ===",
			...allLines.map((line, idx) => {
				const vw = visibleWidth(line);
				const escaped = JSON.stringify(line);
				return `[${idx}] (w=${vw}) ${escaped}`;
			}),
			"",
			"=== Agent messages (JSONL) ===",
			...this.agent.state.messages.map((msg) => JSON.stringify(msg)),
			"",
		].join("\n");

		fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
		fs.writeFileSync(debugLogPath, debugData);

		// Show confirmation
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(
			new Text(theme.fg("accent", "✓ Debug log written") + "\n" + theme.fg("muted", debugLogPath), 1, 1),
		);

		this.ui.requestRender();
	}

	private async handleBashCommand(command: string): Promise<void> {
		// Create component and add to chat
		this.bashComponent = new BashExecutionComponent(command, this.ui);
		this.chatContainer.addChild(this.bashComponent);
		this.ui.requestRender();

		try {
			const result = await this.executeBashCommand(command, (chunk) => {
				if (this.bashComponent) {
					this.bashComponent.appendOutput(chunk);
					this.ui.requestRender();
				}
			});

			if (this.bashComponent) {
				this.bashComponent.setComplete(
					result.exitCode,
					result.cancelled,
					result.truncationResult,
					result.fullOutputPath,
				);

				// Create and save message (even if cancelled, for consistency with LLM aborts)
				const bashMessage: BashExecutionMessage = {
					role: "bashExecution",
					command,
					output: result.truncationResult?.content || this.bashComponent.getOutput(),
					exitCode: result.exitCode,
					cancelled: result.cancelled,
					truncated: result.truncationResult?.truncated || false,
					fullOutputPath: result.fullOutputPath,
					timestamp: Date.now(),
				};

				// Add to agent state
				this.agent.appendMessage(bashMessage);

				// Save to session
				this.sessionManager.saveMessage(bashMessage);
			}
		} catch (error) {
			const errorMessage = error instanceof Error ? error.message : "Unknown error";
			if (this.bashComponent) {
				this.bashComponent.setComplete(null, false);
			}
			this.showError(`Bash command failed: ${errorMessage}`);
		}

		this.bashComponent = null;
		this.ui.requestRender();
	}

	private executeBashCommand(
		command: string,
		onChunk: (chunk: string) => void,
	): Promise<{
		exitCode: number | null;
		cancelled: boolean;
		truncationResult?: TruncationResult;
		fullOutputPath?: string;
	}> {
		return new Promise((resolve, reject) => {
			const { shell, args } = getShellConfig();
			const child = spawn(shell, [...args, command], {
				detached: true,
				stdio: ["ignore", "pipe", "pipe"],
			});

			this.bashProcess = child;

			// Track sanitized output for truncation
			const outputChunks: string[] = [];
			let outputBytes = 0;
			const maxOutputBytes = DEFAULT_MAX_BYTES * 2;

			// Temp file for large output
			let tempFilePath: string | undefined;
			let tempFileStream: WriteStream | undefined;
			let totalBytes = 0;

			const handleData = (data: Buffer) => {
				totalBytes += data.length;

				// Sanitize once at the source: strip ANSI, replace binary garbage, normalize newlines
				const text = sanitizeBinaryOutput(stripAnsi(data.toString())).replace(/\r/g, "");

				// Start writing to temp file if exceeds threshold
				if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
					const id = randomBytes(8).toString("hex");
					tempFilePath = join(tmpdir(), `pi-bash-${id}.log`);
					tempFileStream = createWriteStream(tempFilePath);
					for (const chunk of outputChunks) {
						tempFileStream.write(chunk);
					}
				}

				if (tempFileStream) {
					tempFileStream.write(text);
				}

				// Keep rolling buffer of sanitized text
				outputChunks.push(text);
				outputBytes += text.length;
				while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
					const removed = outputChunks.shift()!;
					outputBytes -= removed.length;
				}

				// Stream to component
				onChunk(text);
			};

			child.stdout?.on("data", handleData);
			child.stderr?.on("data", handleData);

			child.on("close", (code) => {
				if (tempFileStream) {
					tempFileStream.end();
				}

				this.bashProcess = null;

				// Combine buffered chunks for truncation (already sanitized)
				const fullOutput = outputChunks.join("");
				const truncationResult = truncateTail(fullOutput);

				// code === null means killed (cancelled)
				const cancelled = code === null;

				resolve({
					exitCode: code,
					cancelled,
					truncationResult: truncationResult.truncated ? truncationResult : undefined,
					fullOutputPath: tempFilePath,
				});
			});

			child.on("error", (err) => {
				if (tempFileStream) {
					tempFileStream.end();
				}
				this.bashProcess = null;
				reject(err);
			});
		});
	}

	private compactionAbortController: AbortController | null = null;

	/**
	 * Shared logic to execute context compaction.
	 * Handles aborting agent, showing loader, performing compaction, updating session/UI.
	 */
	private async executeCompaction(customInstructions?: string, isAuto = false): Promise<void> {
		// Unsubscribe first to prevent processing events during compaction
		this.unsubscribe?.();

		// Abort and wait for completion
		this.agent.abort();
		await this.agent.waitForIdle();

		// Stop loading animation
		if (this.loadingAnimation) {
			this.loadingAnimation.stop();
			this.loadingAnimation = null;
		}
		this.statusContainer.clear();

		// Create abort controller for compaction
		this.compactionAbortController = new AbortController();

		// Set up escape handler during compaction
		const originalOnEscape = this.editor.onEscape;
		this.editor.onEscape = () => {
			if (this.compactionAbortController) {
				this.compactionAbortController.abort();
			}
		};

		// Show compacting status with loader
		this.chatContainer.addChild(new Spacer(1));
		const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
		const compactingLoader = new Loader(
			this.ui,
			(spinner) => theme.fg("accent", spinner),
			(text) => theme.fg("muted", text),
			label,
		);
		this.statusContainer.addChild(compactingLoader);
		this.ui.requestRender();

		try {
			// Get API key for current model
			const apiKey = await getApiKeyForModel(this.agent.state.model);
			if (!apiKey) {
				throw new Error(`No API key for ${this.agent.state.model.provider}`);
			}

			// Perform compaction with abort signal
			const entries = this.sessionManager.loadEntries();
			const settings = this.settingsManager.getCompactionSettings();
			const compactionEntry = await compact(
				entries,
				this.agent.state.model,
				settings,
				apiKey,
				this.compactionAbortController.signal,
				customInstructions,
			);

			// Check if aborted after compact returned
			if (this.compactionAbortController.signal.aborted) {
				throw new Error("Compaction cancelled");
			}

			// Save compaction to session
			this.sessionManager.saveCompaction(compactionEntry);

			// Reload session
			const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
			this.agent.replaceMessages(loaded.messages);

			// Rebuild UI
			this.chatContainer.clear();
			this.rebuildChatFromMessages();

			// Add compaction component at current position so user can see/expand the summary
			const compactionComponent = new CompactionComponent(compactionEntry.tokensBefore, compactionEntry.summary);
			compactionComponent.setExpanded(this.toolOutputExpanded);
			this.chatContainer.addChild(compactionComponent);

			// Update footer with new state (fixes context % display)
			this.footer.updateState(this.agent.state);
		} catch (error) {
			const message = error instanceof Error ? error.message : String(error);
			if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
				this.showError("Compaction cancelled");
			} else {
				this.showError(`Compaction failed: ${message}`);
			}
		} finally {
			// Clean up
			compactingLoader.stop();
			this.statusContainer.clear();
			this.compactionAbortController = null;
			this.editor.onEscape = originalOnEscape;
		}

		// Resubscribe to agent
		this.subscribeToAgent();
	}

	private async handleCompactCommand(customInstructions?: string): Promise<void> {
		// Check if there are any messages to compact
		const entries = this.sessionManager.loadEntries();
		const messageCount = entries.filter((e) => e.type === "message").length;

		if (messageCount < 2) {
			this.showWarning("Nothing to compact (no messages yet)");
			return;
		}

		await this.executeCompaction(customInstructions, false);
	}

	private handleAutocompactCommand(): void {
		const currentEnabled = this.settingsManager.getCompactionEnabled();
		const newState = !currentEnabled;
		this.settingsManager.setCompactionEnabled(newState);
		this.footer.setAutoCompactEnabled(newState);

		// Show brief notification (same style as thinking level toggle)
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("dim", `Auto-compaction: ${newState ? "on" : "off"}`), 1, 0));
		this.ui.requestRender();
	}

	private updatePendingMessagesDisplay(): void {
		this.pendingMessagesContainer.clear();

		if (this.queuedMessages.length > 0) {
			this.pendingMessagesContainer.addChild(new Spacer(1));

			for (const message of this.queuedMessages) {
				const queuedText = theme.fg("dim", "Queued: " + message);
				this.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
			}
		}
	}

	stop(): void {
		if (this.loadingAnimation) {
			this.loadingAnimation.stop();
			this.loadingAnimation = null;
		}
		this.footer.dispose();
		if (this.isInitialized) {
			this.ui.stop();
			this.isInitialized = false;
		}
	}
}


Now I have both files fully. Let me create a clean InteractiveMode that uses AgentSession properly. The key insight is:

**What moves to AgentSession (already there):**
- `subscribeToAgent()` → `session.subscribe()`
- `checkAutoCompaction()` → handled internally
- `cycleThinkingLevel()` → `session.cycleThinkingLevel()`
- `cycleModel()` → `session.cycleModel()`
- `executeBashCommand()` → `session.executeBash()`
- `executeCompaction()` → `session.compact()`
- `handleResumeSession()` → `session.switchSession()`
- `handleClearCommand()` reset logic → `session.reset()`
- `queuedMessages` → `session.getQueuedMessages()`, `session.clearQueue()`
- `scopedModels` → `session.scopedModels`
- `fileCommands` → `session.fileCommands`

**What stays in InteractiveMode (UI-specific):**
- TUI setup, components, rendering
- Editor, key handlers
- All selector UIs
- `handleEvent()` for UI updates
- `addMessageToChat()`, `renderInitialMessages()`, `rebuildChatFromMessages()`
- All show/hide selectors
- showError/showWarning/showNewVersionNotification
- updateEditorBorderColor, toggleToolOutputExpansion, toggleThinkingBlockVisibility

Let me write the new file:

write

/**
 * Interactive mode for the coding agent.
 * Handles TUI rendering and user interaction, delegating business logic to AgentSession.
 */

import * as fs from "node:fs";
import * as path from "node:path";
import type { AgentEvent, AgentState, AppMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
import type { SlashCommand } from "@mariozechner/pi-tui";
import {
	CombinedAutocompleteProvider,
	Container,
	Input,
	Loader,
	Markdown,
	ProcessTerminal,
	Spacer,
	Text,
	TruncatedText,
	TUI,
	visibleWidth,
} from "@mariozechner/pi-tui";
import { exec } from "child_process";
import { getChangelogPath, parseChangelog } from "../../changelog.js";
import { copyToClipboard } from "../../clipboard.js";
import { APP_NAME, getDebugLogPath, getOAuthPath } from "../../config.js";
import { type AgentSession } from "../../core/agent-session.js";
import { type BashExecutionMessage, isBashExecutionMessage } from "../../messages.js";
import { invalidateOAuthCache } from "../../model-config.js";
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../../oauth/index.js";
import { getLatestCompactionEntry, SUMMARY_PREFIX, SUMMARY_SUFFIX } from "../../session-manager.js";
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../../theme/theme.js";
import { type TruncationResult } from "../../tools/truncate.js";
import { AssistantMessageComponent } from "../../tui/assistant-message.js";
import { BashExecutionComponent } from "../../tui/bash-execution.js";
import { CompactionComponent } from "../../tui/compaction.js";
import { CustomEditor } from "../../tui/custom-editor.js";
import { DynamicBorder } from "../../tui/dynamic-border.js";
import { FooterComponent } from "../../tui/footer.js";
import { ModelSelectorComponent } from "../../tui/model-selector.js";
import { OAuthSelectorComponent } from "../../tui/oauth-selector.js";
import { QueueModeSelectorComponent } from "../../tui/queue-mode-selector.js";
import { SessionSelectorComponent } from "../../tui/session-selector.js";
import { ThemeSelectorComponent } from "../../tui/theme-selector.js";
import { ThinkingSelectorComponent } from "../../tui/thinking-selector.js";
import { ToolExecutionComponent } from "../../tui/tool-execution.js";
import { UserMessageComponent } from "../../tui/user-message.js";
import { UserMessageSelectorComponent } from "../../tui/user-message-selector.js";

export class InteractiveMode {
	private session: AgentSession;
	private ui: TUI;
	private chatContainer: Container;
	private pendingMessagesContainer: Container;
	private statusContainer: Container;
	private editor: CustomEditor;
	private editorContainer: Container;
	private footer: FooterComponent;
	private version: string;
	private isInitialized = false;
	private onInputCallback?: (text: string) => void;
	private loadingAnimation: Loader | null = null;

	private lastSigintTime = 0;
	private lastEscapeTime = 0;
	private changelogMarkdown: string | null = null;

	// Streaming message tracking
	private streamingComponent: AssistantMessageComponent | null = null;

	// Tool execution tracking: toolCallId -> component
	private pendingTools = new Map<string, ToolExecutionComponent>();

	// Selector components
	private thinkingSelector: ThinkingSelectorComponent | null = null;
	private queueModeSelector: QueueModeSelectorComponent | null = null;
	private themeSelector: ThemeSelectorComponent | null = null;
	private modelSelector: ModelSelectorComponent | null = null;
	private userMessageSelector: UserMessageSelectorComponent | null = null;
	private sessionSelector: SessionSelectorComponent | null = null;
	private oauthSelector: OAuthSelectorComponent | null = null;

	// Track if this is the first user message (to skip spacer)
	private isFirstUserMessage = true;

	// Tool output expansion state
	private toolOutputExpanded = false;

	// Thinking block visibility state
	private hideThinkingBlock = false;

	// Agent subscription unsubscribe function
	private unsubscribe?: () => void;

	// Track if editor is in bash mode (text starts with !)
	private isBashMode = false;

	// Track current bash execution component
	private bashComponent: BashExecutionComponent | null = null;

	// Convenience accessors
	private get agent() {
		return this.session.agent;
	}
	private get sessionManager() {
		return this.session.sessionManager;
	}
	private get settingsManager() {
		return this.session.settingsManager;
	}

	constructor(
		session: AgentSession,
		version: string,
		changelogMarkdown: string | null = null,
		fdPath: string | null = null,
	) {
		this.session = session;
		this.version = version;
		this.changelogMarkdown = changelogMarkdown;
		this.ui = new TUI(new ProcessTerminal());
		this.chatContainer = new Container();
		this.pendingMessagesContainer = new Container();
		this.statusContainer = new Container();
		this.editor = new CustomEditor(getEditorTheme());
		this.editorContainer = new Container();
		this.editorContainer.addChild(this.editor);
		this.footer = new FooterComponent(session.state);
		this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);

		// Define slash commands for autocomplete
		const slashCommands: SlashCommand[] = [
			{ name: "thinking", description: "Select reasoning level (opens selector UI)" },
			{ name: "model", description: "Select model (opens selector UI)" },
			{ name: "export", description: "Export session to HTML file" },
			{ name: "copy", description: "Copy last agent message to clipboard" },
			{ name: "session", description: "Show session info and stats" },
			{ name: "changelog", description: "Show changelog entries" },
			{ name: "branch", description: "Create a new branch from a previous message" },
			{ name: "login", description: "Login with OAuth provider" },
			{ name: "logout", description: "Logout from OAuth provider" },
			{ name: "queue", description: "Select message queue mode (opens selector UI)" },
			{ name: "theme", description: "Select color theme (opens selector UI)" },
			{ name: "clear", description: "Clear context and start a fresh session" },
			{ name: "compact", description: "Manually compact the session context" },
			{ name: "autocompact", description: "Toggle automatic context compaction" },
			{ name: "resume", description: "Resume a different session" },
		];

		// Load hide thinking block setting
		this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();

		// Convert file commands to SlashCommand format
		const fileSlashCommands: SlashCommand[] = this.session.fileCommands.map((cmd) => ({
			name: cmd.name,
			description: cmd.description,
		}));

		// Setup autocomplete
		const autocompleteProvider = new CombinedAutocompleteProvider(
			[...slashCommands, ...fileSlashCommands],
			process.cwd(),
			fdPath,
		);
		this.editor.setAutocompleteProvider(autocompleteProvider);
	}

	async init(): Promise<void> {
		if (this.isInitialized) return;

		// Add header
		const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
		const instructions =
			theme.fg("dim", "esc") +
			theme.fg("muted", " to interrupt") +
			"\n" +
			theme.fg("dim", "ctrl+c") +
			theme.fg("muted", " to clear") +
			"\n" +
			theme.fg("dim", "ctrl+c twice") +
			theme.fg("muted", " to exit") +
			"\n" +
			theme.fg("dim", "ctrl+k") +
			theme.fg("muted", " to delete line") +
			"\n" +
			theme.fg("dim", "shift+tab") +
			theme.fg("muted", " to cycle thinking") +
			"\n" +
			theme.fg("dim", "ctrl+p") +
			theme.fg("muted", " to cycle models") +
			"\n" +
			theme.fg("dim", "ctrl+o") +
			theme.fg("muted", " to expand tools") +
			"\n" +
			theme.fg("dim", "ctrl+t") +
			theme.fg("muted", " to toggle thinking") +
			"\n" +
			theme.fg("dim", "/") +
			theme.fg("muted", " for commands") +
			"\n" +
			theme.fg("dim", "!") +
			theme.fg("muted", " to run bash") +
			"\n" +
			theme.fg("dim", "drop files") +
			theme.fg("muted", " to attach");
		const header = new Text(logo + "\n" + instructions, 1, 0);

		// Setup UI layout
		this.ui.addChild(new Spacer(1));
		this.ui.addChild(header);
		this.ui.addChild(new Spacer(1));

		// Add changelog if provided
		if (this.changelogMarkdown) {
			this.ui.addChild(new DynamicBorder());
			if (this.settingsManager.getCollapseChangelog()) {
				const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
				const latestVersion = versionMatch ? versionMatch[1] : this.version;
				const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
				this.ui.addChild(new Text(condensedText, 1, 0));
			} else {
				this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
				this.ui.addChild(new Spacer(1));
				this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
				this.ui.addChild(new Spacer(1));
			}
			this.ui.addChild(new DynamicBorder());
		}

		this.ui.addChild(this.chatContainer);
		this.ui.addChild(this.pendingMessagesContainer);
		this.ui.addChild(this.statusContainer);
		this.ui.addChild(new Spacer(1));
		this.ui.addChild(this.editorContainer);
		this.ui.addChild(this.footer);
		this.ui.setFocus(this.editor);

		this.setupKeyHandlers();
		this.setupEditorSubmitHandler();

		// Start the UI
		this.ui.start();
		this.isInitialized = true;

		// Subscribe to agent events
		this.subscribeToAgent();

		// Set up theme file watcher
		onThemeChange(() => {
			this.ui.invalidate();
			this.updateEditorBorderColor();
			this.ui.requestRender();
		});

		// Set up git branch watcher
		this.footer.watchBranch(() => {
			this.ui.requestRender();
		});
	}

	private setupKeyHandlers(): void {
		this.editor.onEscape = () => {
			if (this.loadingAnimation) {
				// Abort and restore queued messages to editor
				const queuedMessages = this.session.clearQueue();
				const queuedText = queuedMessages.join("\n\n");
				const currentText = this.editor.getText();
				const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");
				this.editor.setText(combinedText);
				this.updatePendingMessagesDisplay();
				this.agent.abort();
			} else if (this.session.isBashRunning) {
				this.session.abortBash();
			} else if (this.isBashMode) {
				this.editor.setText("");
				this.isBashMode = false;
				this.updateEditorBorderColor();
			} else if (!this.editor.getText().trim()) {
				// Double-escape with empty editor triggers /branch
				const now = Date.now();
				if (now - this.lastEscapeTime < 500) {
					this.showUserMessageSelector();
					this.lastEscapeTime = 0;
				} else {
					this.lastEscapeTime = now;
				}
			}
		};

		this.editor.onCtrlC = () => this.handleCtrlC();
		this.editor.onShiftTab = () => this.cycleThinkingLevel();
		this.editor.onCtrlP = () => this.cycleModel();
		this.editor.onCtrlO = () => this.toggleToolOutputExpansion();
		this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility();

		this.editor.onChange = (text: string) => {
			const wasBashMode = this.isBashMode;
			this.isBashMode = text.trimStart().startsWith("!");
			if (wasBashMode !== this.isBashMode) {
				this.updateEditorBorderColor();
			}
		};
	}

	private setupEditorSubmitHandler(): void {
		this.editor.onSubmit = async (text: string) => {
			text = text.trim();
			if (!text) return;

			// Handle slash commands
			if (text === "/thinking") {
				this.showThinkingSelector();
				this.editor.setText("");
				return;
			}
			if (text === "/model") {
				this.showModelSelector();
				this.editor.setText("");
				return;
			}
			if (text.startsWith("/export")) {
				this.handleExportCommand(text);
				this.editor.setText("");
				return;
			}
			if (text === "/copy") {
				this.handleCopyCommand();
				this.editor.setText("");
				return;
			}
			if (text === "/session") {
				this.handleSessionCommand();
				this.editor.setText("");
				return;
			}
			if (text === "/changelog") {
				this.handleChangelogCommand();
				this.editor.setText("");
				return;
			}
			if (text === "/branch") {
				this.showUserMessageSelector();
				this.editor.setText("");
				return;
			}
			if (text === "/login") {
				this.showOAuthSelector("login");
				this.editor.setText("");
				return;
			}
			if (text === "/logout") {
				this.showOAuthSelector("logout");
				this.editor.setText("");
				return;
			}
			if (text === "/queue") {
				this.showQueueModeSelector();
				this.editor.setText("");
				return;
			}
			if (text === "/theme") {
				this.showThemeSelector();
				this.editor.setText("");
				return;
			}
			if (text === "/clear") {
				await this.handleClearCommand();
				this.editor.setText("");
				return;
			}
			if (text === "/compact" || text.startsWith("/compact ")) {
				const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
				await this.handleCompactCommand(customInstructions);
				this.editor.setText("");
				return;
			}
			if (text === "/autocompact") {
				this.handleAutocompactCommand();
				this.editor.setText("");
				return;
			}
			if (text === "/debug") {
				this.handleDebugCommand();
				this.editor.setText("");
				return;
			}
			if (text === "/resume") {
				this.showSessionSelector();
				this.editor.setText("");
				return;
			}

			// Handle bash command
			if (text.startsWith("!")) {
				const command = text.slice(1).trim();
				if (command) {
					if (this.session.isBashRunning) {
						this.showWarning("A bash command is already running. Press Esc to cancel it first.");
						this.editor.setText(text);
						return;
					}
					this.editor.addToHistory(text);
					await this.handleBashCommand(command);
					this.isBashMode = false;
					this.updateEditorBorderColor();
					return;
				}
			}

			// Queue message if agent is streaming
			if (this.session.isStreaming) {
				await this.session.queueMessage(text);
				this.updatePendingMessagesDisplay();
				this.editor.addToHistory(text);
				this.editor.setText("");
				this.ui.requestRender();
				return;
			}

			// Normal message submission
			if (this.onInputCallback) {
				this.onInputCallback(text);
			}
			this.editor.addToHistory(text);
		};
	}

	private subscribeToAgent(): void {
		this.unsubscribe = this.session.subscribe(async (event) => {
			await this.handleEvent(event, this.session.state);
		});
	}

	private async handleEvent(event: AgentEvent, state: AgentState): Promise<void> {
		if (!this.isInitialized) {
			await this.init();
		}

		this.footer.updateState(state);

		switch (event.type) {
			case "agent_start":
				if (this.loadingAnimation) {
					this.loadingAnimation.stop();
				}
				this.statusContainer.clear();
				this.loadingAnimation = new Loader(
					this.ui,
					(spinner) => theme.fg("accent", spinner),
					(text) => theme.fg("muted", text),
					"Working... (esc to interrupt)",
				);
				this.statusContainer.addChild(this.loadingAnimation);
				this.ui.requestRender();
				break;

			case "message_start":
				if (event.message.role === "user") {
					this.addMessageToChat(event.message);
					this.editor.setText("");
					this.updatePendingMessagesDisplay();
					this.ui.requestRender();
				} else if (event.message.role === "assistant") {
					this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock);
					this.chatContainer.addChild(this.streamingComponent);
					this.streamingComponent.updateContent(event.message as AssistantMessage);
					this.ui.requestRender();
				}
				break;

			case "message_update":
				if (this.streamingComponent && event.message.role === "assistant") {
					const assistantMsg = event.message as AssistantMessage;
					this.streamingComponent.updateContent(assistantMsg);

					for (const content of assistantMsg.content) {
						if (content.type === "toolCall") {
							if (!this.pendingTools.has(content.id)) {
								this.chatContainer.addChild(new Text("", 0, 0));
								const component = new ToolExecutionComponent(content.name, content.arguments);
								this.chatContainer.addChild(component);
								this.pendingTools.set(content.id, component);
							} else {
								const component = this.pendingTools.get(content.id);
								if (component) {
									component.updateArgs(content.arguments);
								}
							}
						}
					}
					this.ui.requestRender();
				}
				break;

			case "message_end":
				if (event.message.role === "user") break;
				if (this.streamingComponent && event.message.role === "assistant") {
					const assistantMsg = event.message as AssistantMessage;
					this.streamingComponent.updateContent(assistantMsg);

					if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
						const errorMessage =
							assistantMsg.stopReason === "aborted" ? "Operation aborted" : assistantMsg.errorMessage || "Error";
						for (const [, component] of this.pendingTools.entries()) {
							component.updateResult({
								content: [{ type: "text", text: errorMessage }],
								isError: true,
							});
						}
						this.pendingTools.clear();
					}
					this.streamingComponent = null;
					this.footer.invalidate();
				}
				this.ui.requestRender();
				break;

			case "tool_execution_start": {
				if (!this.pendingTools.has(event.toolCallId)) {
					const component = new ToolExecutionComponent(event.toolName, event.args);
					this.chatContainer.addChild(component);
					this.pendingTools.set(event.toolCallId, component);
					this.ui.requestRender();
				}
				break;
			}

			case "tool_execution_end": {
				const component = this.pendingTools.get(event.toolCallId);
				if (component) {
					const resultData =
						typeof event.result === "string"
							? { content: [{ type: "text" as const, text: event.result }], details: undefined, isError: event.isError }
							: { content: event.result.content, details: event.result.details, isError: event.isError };
					component.updateResult(resultData);
					this.pendingTools.delete(event.toolCallId);
					this.ui.requestRender();
				}
				break;
			}

			case "agent_end":
				if (this.loadingAnimation) {
					this.loadingAnimation.stop();
					this.loadingAnimation = null;
					this.statusContainer.clear();
				}
				if (this.streamingComponent) {
					this.chatContainer.removeChild(this.streamingComponent);
					this.streamingComponent = null;
				}
				this.pendingTools.clear();
				this.ui.requestRender();
				break;
		}
	}

	private addMessageToChat(message: Message | AppMessage): void {
		if (isBashExecutionMessage(message)) {
			const bashMsg = message as BashExecutionMessage;
			const component = new BashExecutionComponent(bashMsg.command, this.ui);
			if (bashMsg.output) {
				component.appendOutput(bashMsg.output);
			}
			component.setComplete(
				bashMsg.exitCode,
				bashMsg.cancelled,
				bashMsg.truncated ? ({ truncated: true } as TruncationResult) : undefined,
				bashMsg.fullOutputPath,
			);
			this.chatContainer.addChild(component);
			return;
		}

		if (message.role === "user") {
			const textBlocks =
				typeof message.content === "string"
					? [{ type: "text", text: message.content }]
					: message.content.filter((c: { type: string }) => c.type === "text");
			const textContent = textBlocks.map((c: { text: string }) => c.text).join("");
			if (textContent) {
				const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
				this.chatContainer.addChild(userComponent);
				this.isFirstUserMessage = false;
			}
		} else if (message.role === "assistant") {
			const assistantComponent = new AssistantMessageComponent(message as AssistantMessage, this.hideThinkingBlock);
			this.chatContainer.addChild(assistantComponent);
		}
	}

	renderInitialMessages(state: AgentState): void {
		this.isFirstUserMessage = true;
		this.footer.updateState(state);
		this.updateEditorBorderColor();

		const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());

		for (const message of state.messages) {
			if (isBashExecutionMessage(message)) {
				this.addMessageToChat(message);
				continue;
			}

			if (message.role === "user") {
				const textBlocks =
					typeof message.content === "string"
						? [{ type: "text", text: message.content }]
						: message.content.filter((c: { type: string }) => c.type === "text");
				const textContent = textBlocks.map((c: { text: string }) => c.text).join("");
				if (textContent) {
					if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {
						const summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);
						const component = new CompactionComponent(compactionEntry.tokensBefore, summary);
						component.setExpanded(this.toolOutputExpanded);
						this.chatContainer.addChild(component);
					} else {
						const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
						this.chatContainer.addChild(userComponent);
						this.isFirstUserMessage = false;
					}
				}
			} else if (message.role === "assistant") {
				const assistantMsg = message as AssistantMessage;
				const assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);
				this.chatContainer.addChild(assistantComponent);

				for (const content of assistantMsg.content) {
					if (content.type === "toolCall") {
						const component = new ToolExecutionComponent(content.name, content.arguments);
						this.chatContainer.addChild(component);

						if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
							const errorMessage =
								assistantMsg.stopReason === "aborted" ? "Operation aborted" : assistantMsg.errorMessage || "Error";
							component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
						} else {
							this.pendingTools.set(content.id, component);
						}
					}
				}
			} else if (message.role === "toolResult") {
				const component = this.pendingTools.get(message.toolCallId);
				if (component) {
					component.updateResult({
						content: message.content,
						details: message.details,
						isError: message.isError,
					});
					this.pendingTools.delete(message.toolCallId);
				}
			}
		}
		this.pendingTools.clear();

		// Populate editor history
		for (const message of state.messages) {
			if (message.role === "user") {
				const textBlocks =
					typeof message.content === "string"
						? [{ type: "text", text: message.content }]
						: message.content.filter((c: { type: string }) => c.type === "text");
				const textContent = textBlocks.map((c: { text: string }) => c.text).join("");
				if (textContent && !textContent.startsWith(SUMMARY_PREFIX)) {
					this.editor.addToHistory(textContent);
				}
			}
		}

		this.ui.requestRender();
	}

	async getUserInput(): Promise<string> {
		return new Promise((resolve) => {
			this.onInputCallback = (text: string) => {
				this.onInputCallback = undefined;
				resolve(text);
			};
		});
	}

	private rebuildChatFromMessages(): void {
		this.isFirstUserMessage = true;
		this.pendingTools.clear();

		const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());

		for (const message of this.session.messages) {
			if (isBashExecutionMessage(message)) {
				this.addMessageToChat(message);
				continue;
			}

			if (message.role === "user") {
				const textBlocks =
					typeof message.content === "string"
						? [{ type: "text", text: message.content }]
						: message.content.filter((c: { type: string }) => c.type === "text");
				const textContent = textBlocks.map((c: { text: string }) => c.text).join("");
				if (textContent) {
					if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {
						const summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);
						const component = new CompactionComponent(compactionEntry.tokensBefore, summary);
						component.setExpanded(this.toolOutputExpanded);
						this.chatContainer.addChild(component);
					} else {
						const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
						this.chatContainer.addChild(userComponent);
						this.isFirstUserMessage = false;
					}
				}
			} else if (message.role === "assistant") {
				const assistantMsg = message as AssistantMessage;
				const assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);
				this.chatContainer.addChild(assistantComponent);

				for (const content of assistantMsg.content) {
					if (content.type === "toolCall") {
						const component = new ToolExecutionComponent(content.name, content.arguments);
						this.chatContainer.addChild(component);
						this.pendingTools.set(content.id, component);
					}
				}
			} else if (message.role === "toolResult") {
				const component = this.pendingTools.get(message.toolCallId);
				if (component) {
					component.updateResult({
						content: message.content,
						details: message.details,
						isError: message.isError,
					});
					this.pendingTools.delete(message.toolCallId);
				}
			}
		}

		this.pendingTools.clear();
		this.ui.requestRender();
	}

	// =========================================================================
	// Key handlers
	// =========================================================================

	private handleCtrlC(): void {
		const now = Date.now();
		if (now - this.lastSigintTime < 500) {
			this.stop();
			process.exit(0);
		} else {
			this.clearEditor();
			this.lastSigintTime = now;
		}
	}

	private updateEditorBorderColor(): void {
		if (this.isBashMode) {
			this.editor.borderColor = theme.getBashModeBorderColor();
		} else {
			const level = this.session.thinkingLevel || "off";
			this.editor.borderColor = theme.getThinkingBorderColor(level);
		}
		this.ui.requestRender();
	}

	private cycleThinkingLevel(): void {
		const newLevel = this.session.cycleThinkingLevel();
		if (newLevel === null) {
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", "Current model does not support thinking"), 1, 0));
		} else {
			this.updateEditorBorderColor();
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", `Thinking level: ${newLevel}`), 1, 0));
		}
		this.ui.requestRender();
	}

	private async cycleModel(): Promise<void> {
		try {
			const result = await this.session.cycleModel();
			if (result === null) {
				this.chatContainer.addChild(new Spacer(1));
				const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
				this.chatContainer.addChild(new Text(theme.fg("dim", msg), 1, 0));
			} else {
				this.updateEditorBorderColor();
				this.chatContainer.addChild(new Spacer(1));
				const thinkingStr =
					result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
				this.chatContainer.addChild(
					new Text(theme.fg("dim", `Switched to ${result.model.name || result.model.id}${thinkingStr}`), 1, 0),
				);
			}
		} catch (error) {
			this.showError(error instanceof Error ? error.message : String(error));
		}
		this.ui.requestRender();
	}

	private toggleToolOutputExpansion(): void {
		this.toolOutputExpanded = !this.toolOutputExpanded;
		for (const child of this.chatContainer.children) {
			if (child instanceof ToolExecutionComponent) {
				child.setExpanded(this.toolOutputExpanded);
			} else if (child instanceof CompactionComponent) {
				child.setExpanded(this.toolOutputExpanded);
			} else if (child instanceof BashExecutionComponent) {
				child.setExpanded(this.toolOutputExpanded);
			}
		}
		this.ui.requestRender();
	}

	private toggleThinkingBlockVisibility(): void {
		this.hideThinkingBlock = !this.hideThinkingBlock;
		this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);

		for (const child of this.chatContainer.children) {
			if (child instanceof AssistantMessageComponent) {
				child.setHideThinkingBlock(this.hideThinkingBlock);
			}
		}

		this.chatContainer.clear();
		this.rebuildChatFromMessages();

		const status = this.hideThinkingBlock ? "hidden" : "visible";
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("dim", `Thinking blocks: ${status}`), 1, 0));
		this.ui.requestRender();
	}

	// =========================================================================
	// UI helpers
	// =========================================================================

	clearEditor(): void {
		this.editor.setText("");
		this.ui.requestRender();
	}

	showError(errorMessage: string): void {
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
		this.ui.requestRender();
	}

	showWarning(warningMessage: string): void {
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
		this.ui.requestRender();
	}

	showNewVersionNotification(newVersion: string): void {
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
		this.chatContainer.addChild(
			new Text(
				theme.bold(theme.fg("warning", "Update Available")) +
					"\n" +
					theme.fg("muted", `New version ${newVersion} is available. Run: `) +
					theme.fg("accent", "npm install -g @mariozechner/pi-coding-agent"),
				1,
				0,
			),
		);
		this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
		this.ui.requestRender();
	}

	private updatePendingMessagesDisplay(): void {
		this.pendingMessagesContainer.clear();
		const queuedMessages = this.session.getQueuedMessages();
		if (queuedMessages.length > 0) {
			this.pendingMessagesContainer.addChild(new Spacer(1));
			for (const message of queuedMessages) {
				const queuedText = theme.fg("dim", "Queued: " + message);
				this.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
			}
		}
	}

	// =========================================================================
	// Selectors
	// =========================================================================

	private showThinkingSelector(): void {
		this.thinkingSelector = new ThinkingSelectorComponent(
			this.session.thinkingLevel,
			(level) => {
				this.session.setThinkingLevel(level);
				this.updateEditorBorderColor();
				this.chatContainer.addChild(new Spacer(1));
				this.chatContainer.addChild(new Text(theme.fg("dim", `Thinking level: ${level}`), 1, 0));
				this.hideThinkingSelector();
				this.ui.requestRender();
			},
			() => {
				this.hideThinkingSelector();
				this.ui.requestRender();
			},
		);
		this.editorContainer.clear();
		this.editorContainer.addChild(this.thinkingSelector);
		this.ui.setFocus(this.thinkingSelector.getSelectList());
		this.ui.requestRender();
	}

	private hideThinkingSelector(): void {
		this.editorContainer.clear();
		this.editorContainer.addChild(this.editor);
		this.thinkingSelector = null;
		this.ui.setFocus(this.editor);
	}

	private showQueueModeSelector(): void {
		this.queueModeSelector = new QueueModeSelectorComponent(
			this.session.queueMode,
			(mode) => {
				this.session.setQueueMode(mode);
				this.chatContainer.addChild(new Spacer(1));
				this.chatContainer.addChild(new Text(theme.fg("dim", `Queue mode: ${mode}`), 1, 0));
				this.hideQueueModeSelector();
				this.ui.requestRender();
			},
			() => {
				this.hideQueueModeSelector();
				this.ui.requestRender();
			},
		);
		this.editorContainer.clear();
		this.editorContainer.addChild(this.queueModeSelector);
		this.ui.setFocus(this.queueModeSelector.getSelectList());
		this.ui.requestRender();
	}

	private hideQueueModeSelector(): void {
		this.editorContainer.clear();
		this.editorContainer.addChild(this.editor);
		this.queueModeSelector = null;
		this.ui.setFocus(this.editor);
	}

	private showThemeSelector(): void {
		const currentTheme = this.settingsManager.getTheme() || "dark";
		this.themeSelector = new ThemeSelectorComponent(
			currentTheme,
			(themeName) => {
				const result = setTheme(themeName);
				this.settingsManager.setTheme(themeName);
				this.ui.invalidate();
				this.chatContainer.addChild(new Spacer(1));
				if (result.success) {
					this.chatContainer.addChild(new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0));
				} else {
					this.chatContainer.addChild(
						new Text(
							theme.fg("error", `Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`),
							1,
							0,
						),
					);
				}
				this.hideThemeSelector();
				this.ui.requestRender();
			},
			() => {
				this.hideThemeSelector();
				this.ui.requestRender();
			},
			(themeName) => {
				const result = setTheme(themeName);
				if (result.success) {
					this.ui.invalidate();
					this.ui.requestRender();
				}
			},
		);
		this.editorContainer.clear();
		this.editorContainer.addChild(this.themeSelector);
		this.ui.setFocus(this.themeSelector.getSelectList());
		this.ui.requestRender();
	}

	private hideThemeSelector(): void {
		this.editorContainer.clear();
		this.editorContainer.addChild(this.editor);
		this.themeSelector = null;
		this.ui.setFocus(this.editor);
	}

	private showModelSelector(): void {
		this.modelSelector = new ModelSelectorComponent(
			this.ui,
			this.session.model,
			this.settingsManager,
			(model) => {
				this.agent.setModel(model);
				this.sessionManager.saveModelChange(model.provider, model.id);
				this.chatContainer.addChild(new Spacer(1));
				this.chatContainer.addChild(new Text(theme.fg("dim", `Model: ${model.id}`), 1, 0));
				this.hideModelSelector();
				this.ui.requestRender();
			},
			() => {
				this.hideModelSelector();
				this.ui.requestRender();
			},
		);
		this.editorContainer.clear();
		this.editorContainer.addChild(this.modelSelector);
		this.ui.setFocus(this.modelSelector);
		this.ui.requestRender();
	}

	private hideModelSelector(): void {
		this.editorContainer.clear();
		this.editorContainer.addChild(this.editor);
		this.modelSelector = null;
		this.ui.setFocus(this.editor);
	}

	private showUserMessageSelector(): void {
		const userMessages = this.session.getUserMessagesForBranching();

		if (userMessages.length <= 1) {
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", "No messages to branch from"), 1, 0));
			this.ui.requestRender();
			return;
		}

		this.userMessageSelector = new UserMessageSelectorComponent(
			userMessages.map((m) => ({ index: m.entryIndex, text: m.text })),
			(entryIndex) => {
				const selectedText = this.session.branch(entryIndex);
				this.chatContainer.clear();
				this.isFirstUserMessage = true;
				this.renderInitialMessages(this.session.state);
				this.chatContainer.addChild(new Spacer(1));
				this.chatContainer.addChild(new Text(theme.fg("dim", "Branched to new session"), 1, 0));
				this.editor.setText(selectedText);
				this.hideUserMessageSelector();
				this.ui.requestRender();
			},
			() => {
				this.hideUserMessageSelector();
				this.ui.requestRender();
			},
		);
		this.editorContainer.clear();
		this.editorContainer.addChild(this.userMessageSelector);
		this.ui.setFocus(this.userMessageSelector.getMessageList());
		this.ui.requestRender();
	}

	private hideUserMessageSelector(): void {
		this.editorContainer.clear();
		this.editorContainer.addChild(this.editor);
		this.userMessageSelector = null;
		this.ui.setFocus(this.editor);
	}

	private showSessionSelector(): void {
		this.sessionSelector = new SessionSelectorComponent(
			this.sessionManager,
			async (sessionPath) => {
				this.hideSessionSelector();
				await this.handleResumeSession(sessionPath);
			},
			() => {
				this.hideSessionSelector();
				this.ui.requestRender();
			},
		);
		this.editorContainer.clear();
		this.editorContainer.addChild(this.sessionSelector);
		this.ui.setFocus(this.sessionSelector.getSessionList());
		this.ui.requestRender();
	}

	private async handleResumeSession(sessionPath: string): Promise<void> {
		// Stop loading animation
		if (this.loadingAnimation) {
			this.loadingAnimation.stop();
			this.loadingAnimation = null;
		}
		this.statusContainer.clear();

		// Clear UI state
		this.pendingMessagesContainer.clear();
		this.streamingComponent = null;
		this.pendingTools.clear();

		// Switch session via AgentSession
		await this.session.switchSession(sessionPath);

		// Clear and re-render the chat
		this.chatContainer.clear();
		this.isFirstUserMessage = true;
		this.renderInitialMessages(this.session.state);

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("dim", "Resumed session"), 1, 0));
		this.ui.requestRender();
	}

	private hideSessionSelector(): void {
		this.editorContainer.clear();
		this.editorContainer.addChild(this.editor);
		this.sessionSelector = null;
		this.ui.setFocus(this.editor);
	}

	private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
		if (mode === "logout") {
			const loggedInProviders = listOAuthProviders();
			if (loggedInProviders.length === 0) {
				this.chatContainer.addChild(new Spacer(1));
				this.chatContainer.addChild(new Text(theme.fg("dim", "No OAuth providers logged in. Use /login first."), 1, 0));
				this.ui.requestRender();
				return;
			}
		}

		this.oauthSelector = new OAuthSelectorComponent(
			mode,
			async (providerId: string) => {
				this.hideOAuthSelector();

				if (mode === "login") {
					this.chatContainer.addChild(new Spacer(1));
					this.chatContainer.addChild(new Text(theme.fg("dim", `Logging in to ${providerId}...`), 1, 0));
					this.ui.requestRender();

					try {
						await login(
							providerId as SupportedOAuthProvider,
							(url: string) => {
								this.chatContainer.addChild(new Spacer(1));
								this.chatContainer.addChild(new Text(theme.fg("accent", "Opening browser to:"), 1, 0));
								this.chatContainer.addChild(new Text(theme.fg("accent", url), 1, 0));
								this.chatContainer.addChild(new Spacer(1));
								this.chatContainer.addChild(new Text(theme.fg("warning", "Paste the authorization code below:"), 1, 0));
								this.ui.requestRender();

								const openCmd =
									process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
								exec(`${openCmd} "${url}"`);
							},
							async () => {
								return new Promise<string>((resolve) => {
									const codeInput = new Input();
									codeInput.onSubmit = () => {
										const code = codeInput.getValue();
										this.editorContainer.clear();
										this.editorContainer.addChild(this.editor);
										this.ui.setFocus(this.editor);
										resolve(code);
									};
									this.editorContainer.clear();
									this.editorContainer.addChild(codeInput);
									this.ui.setFocus(codeInput);
									this.ui.requestRender();
								});
							},
						);

						invalidateOAuthCache();
						this.chatContainer.addChild(new Spacer(1));
						this.chatContainer.addChild(new Text(theme.fg("success", `✓ Successfully logged in to ${providerId}`), 1, 0));
						this.chatContainer.addChild(new Text(theme.fg("dim", `Tokens saved to ${getOAuthPath()}`), 1, 0));
						this.ui.requestRender();
					} catch (error: unknown) {
						this.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
					}
				} else {
					try {
						await logout(providerId as SupportedOAuthProvider);
						invalidateOAuthCache();
						this.chatContainer.addChild(new Spacer(1));
						this.chatContainer.addChild(new Text(theme.fg("success", `✓ Successfully logged out of ${providerId}`), 1, 0));
						this.chatContainer.addChild(new Text(theme.fg("dim", `Credentials removed from ${getOAuthPath()}`), 1, 0));
						this.ui.requestRender();
					} catch (error: unknown) {
						this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
					}
				}
			},
			() => {
				this.hideOAuthSelector();
				this.ui.requestRender();
			},
		);

		this.editorContainer.clear();
		this.editorContainer.addChild(this.oauthSelector);
		this.ui.setFocus(this.oauthSelector);
		this.ui.requestRender();
	}

	private hideOAuthSelector(): void {
		this.editorContainer.clear();
		this.editorContainer.addChild(this.editor);
		this.oauthSelector = null;
		this.ui.setFocus(this.editor);
	}

	// =========================================================================
	// Command handlers
	// =========================================================================

	private handleExportCommand(text: string): void {
		const parts = text.split(/\s+/);
		const outputPath = parts.length > 1 ? parts[1] : undefined;

		try {
			const filePath = this.session.exportToHtml(outputPath);
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", `Session exported to: ${filePath}`), 1, 0));
			this.ui.requestRender();
		} catch (error: unknown) {
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(
				new Text(
					theme.fg("error", `Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`),
					1,
					0,
				),
			);
			this.ui.requestRender();
		}
	}

	private handleCopyCommand(): void {
		const text = this.session.getLastAssistantText();
		if (!text) {
			this.showError("No agent messages to copy yet.");
			return;
		}

		try {
			copyToClipboard(text);
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", "Copied last agent message to clipboard"), 1, 0));
			this.ui.requestRender();
		} catch (error) {
			this.showError(error instanceof Error ? error.message : String(error));
		}
	}

	private handleSessionCommand(): void {
		const stats = this.session.getSessionStats();

		let info = `${theme.bold("Session Info")}\n\n`;
		info += `${theme.fg("dim", "File:")} ${stats.sessionFile}\n`;
		info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`;
		info += `${theme.bold("Messages")}\n`;
		info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`;
		info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`;
		info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`;
		info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`;
		info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`;
		info += `${theme.bold("Tokens")}\n`;
		info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`;
		info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`;
		if (stats.tokens.cacheRead > 0) {
			info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`;
		}
		if (stats.tokens.cacheWrite > 0) {
			info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`;
		}
		info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`;

		if (stats.cost > 0) {
			info += `\n${theme.bold("Cost")}\n`;
			info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`;
		}

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(info, 1, 0));
		this.ui.requestRender();
	}

	private handleChangelogCommand(): void {
		const changelogPath = getChangelogPath();
		const allEntries = parseChangelog(changelogPath);

		const changelogMarkdown =
			allEntries.length > 0
				? allEntries
						.reverse()
						.map((e) => e.content)
						.join("\n\n")
				: "No changelog entries found.";

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new DynamicBorder());
		this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
		this.ui.addChild(new Spacer(1));
		this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, getMarkdownTheme()));
		this.chatContainer.addChild(new DynamicBorder());
		this.ui.requestRender();
	}

	private async handleClearCommand(): Promise<void> {
		// Stop loading animation
		if (this.loadingAnimation) {
			this.loadingAnimation.stop();
			this.loadingAnimation = null;
		}
		this.statusContainer.clear();

		// Reset via session
		await this.session.reset();

		// Clear UI state
		this.chatContainer.clear();
		this.pendingMessagesContainer.clear();
		this.streamingComponent = null;
		this.pendingTools.clear();
		this.isFirstUserMessage = true;

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(
			new Text(theme.fg("accent", "✓ Context cleared") + "\n" + theme.fg("muted", "Started fresh session"), 1, 1),
		);
		this.ui.requestRender();
	}

	private handleDebugCommand(): void {
		const width = this.ui.terminal.columns;
		const allLines = this.ui.render(width);

		const debugLogPath = getDebugLogPath();
		const debugData = [
			`Debug output at ${new Date().toISOString()}`,
			`Terminal width: ${width}`,
			`Total lines: ${allLines.length}`,
			"",
			"=== All rendered lines with visible widths ===",
			...allLines.map((line, idx) => {
				const vw = visibleWidth(line);
				const escaped = JSON.stringify(line);
				return `[${idx}] (w=${vw}) ${escaped}`;
			}),
			"",
			"=== Agent messages (JSONL) ===",
			...this.session.messages.map((msg) => JSON.stringify(msg)),
			"",
		].join("\n");

		fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
		fs.writeFileSync(debugLogPath, debugData);

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(
			new Text(theme.fg("accent", "✓ Debug log written") + "\n" + theme.fg("muted", debugLogPath), 1, 1),
		);
		this.ui.requestRender();
	}

	private async handleBashCommand(command: string): Promise<void> {
		this.bashComponent = new BashExecutionComponent(command, this.ui);
		this.chatContainer.addChild(this.bashComponent);
		this.ui.requestRender();

		try {
			const result = await this.session.executeBash(command, (chunk) => {
				if (this.bashComponent) {
					this.bashComponent.appendOutput(chunk);
					this.ui.requestRender();
				}
			});

			if (this.bashComponent) {
				this.bashComponent.setComplete(
					result.exitCode,
					result.cancelled,
					result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined,
					result.fullOutputPath,
				);
			}
		} catch (error) {
			if (this.bashComponent) {
				this.bashComponent.setComplete(null, false);
			}
			this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
		}

		this.bashComponent = null;
		this.ui.requestRender();
	}

	private async handleCompactCommand(customInstructions?: string): Promise<void> {
		const entries = this.sessionManager.loadEntries();
		const messageCount = entries.filter((e) => e.type === "message").length;

		if (messageCount < 2) {
			this.showWarning("Nothing to compact (no messages yet)");
			return;
		}

		await this.executeCompaction(customInstructions, false);
	}

	private handleAutocompactCommand(): void {
		const newState = !this.session.autoCompactionEnabled;
		this.session.setAutoCompactionEnabled(newState);
		this.footer.setAutoCompactEnabled(newState);

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("dim", `Auto-compaction: ${newState ? "on" : "off"}`), 1, 0));
		this.ui.requestRender();
	}

	private async executeCompaction(customInstructions?: string, isAuto = false): Promise<void> {
		// Stop loading animation
		if (this.loadingAnimation) {
			this.loadingAnimation.stop();
			this.loadingAnimation = null;
		}
		this.statusContainer.clear();

		// Set up escape handler during compaction
		const originalOnEscape = this.editor.onEscape;
		this.editor.onEscape = () => {
			this.session.abortCompaction();
		};

		// Show compacting status
		this.chatContainer.addChild(new Spacer(1));
		const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
		const compactingLoader = new Loader(
			this.ui,
			(spinner) => theme.fg("accent", spinner),
			(text) => theme.fg("muted", text),
			label,
		);
		this.statusContainer.addChild(compactingLoader);
		this.ui.requestRender();

		try {
			const result = await this.session.compact(customInstructions);

			// Rebuild UI
			this.chatContainer.clear();
			this.rebuildChatFromMessages();

			// Add compaction component
			const compactionComponent = new CompactionComponent(result.tokensBefore, result.summary);
			compactionComponent.setExpanded(this.toolOutputExpanded);
			this.chatContainer.addChild(compactionComponent);

			this.footer.updateState(this.session.state);
		} catch (error) {
			const message = error instanceof Error ? error.message : String(error);
			if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
				this.showError("Compaction cancelled");
			} else {
				this.showError(`Compaction failed: ${message}`);
			}
		} finally {
			compactingLoader.stop();
			this.statusContainer.clear();
			this.editor.onEscape = originalOnEscape;
		}
	}

	stop(): void {
		if (this.loadingAnimation) {
			this.loadingAnimation.stop();
			this.loadingAnimation = null;
		}
		this.footer.dispose();
		if (this.unsubscribe) {
			this.unsubscribe();
		}
		if (this.isInitialized) {
			this.ui.stop();
			this.isInitialized = false;
		}
	}
}


Successfully wrote 49321 bytes to /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts


Now let me check for type errors:

bash

cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1 | head -60


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 238 files in 131ms. Fixed 1 file.

> @mariozechner/pi-agent-core@0.14.2 check
> tsgo --noEmit

../coding-agent/src/modes/interactive/interactive-mode.ts(585,39): error TS2345: Argument of type '(c: { text: string; }) => string' is not assignable to parameter of type '((value: { type: string; text: string; }, index: number, array: { type: string; text: string; }[]) => string) & ((value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string)'.
  Type '(c: { text: string; }) => string' is not assignable to type '(value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string'.
    Types of parameters 'c' and 'value' are incompatible.
      Type 'ImageContent | TextContent' is not assignable to type '{ text: string; }'.
        Property 'text' is missing in type 'ImageContent' but required in type '{ text: string; }'.
../coding-agent/src/modes/interactive/interactive-mode.ts(615,40): error TS2345: Argument of type '(c: { text: string; }) => string' is not assignable to parameter of type '((value: { type: string; text: string; }, index: number, array: { type: string; text: string; }[]) => string) & ((value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string)'.
  Type '(c: { text: string; }) => string' is not assignable to type '(value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string'.
    Types of parameters 'c' and 'value' are incompatible.
      Type 'ImageContent | TextContent' is not assignable to type '{ text: string; }'.
        Property 'text' is missing in type 'ImageContent' but required in type '{ text: string; }'.
../coding-agent/src/modes/interactive/interactive-mode.ts(670,40): error TS2345: Argument of type '(c: { text: string; }) => string' is not assignable to parameter of type '((value: { type: string; text: string; }, index: number, array: { type: string; text: string; }[]) => string) & ((value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string)'.
  Type '(c: { text: string; }) => string' is not assignable to type '(value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string'.
    Types of parameters 'c' and 'value' are incompatible.
      Type 'ImageContent | TextContent' is not assignable to type '{ text: string; }'.
        Property 'text' is missing in type 'ImageContent' but required in type '{ text: string; }'.
../coding-agent/src/modes/interactive/interactive-mode.ts(706,40): error TS2345: Argument of type '(c: { text: string; }) => string' is not assignable to parameter of type '((value: { type: string; text: string; }, index: number, array: { type: string; text: string; }[]) => string) & ((value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string)'.
  Type '(c: { text: string; }) => string' is not assignable to type '(value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string'.
    Types of parameters 'c' and 'value' are incompatible.
      Type 'ImageContent | TextContent' is not assignable to type '{ text: string; }'.
        Property 'text' is missing in type 'ImageContent' but required in type '{ text: string; }'.
npm error Lifecycle script `check` failed with error:
npm error code 2
npm error path /Users/badlogic/workspaces/pi-mono/packages/agent
npm error workspace @mariozechner/pi-agent-core@0.14.2
npm error location /Users/badlogic/workspaces/pi-mono/packages/agent
npm error command failed
npm error command sh -c tsgo --noEmit

> @mariozechner/pi-ai@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 36 files in 41ms. No fixes applied.
../coding-agent/src/modes/interactive/interactive-mode.ts(585,39): error TS2345: Argument of type '(c: { text: string; }) => string' is not assignable to parameter of type '((value: { type: string; text: string; }, index: number, array: { type: string; text: string; }[]) => string) & ((value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string)'.
  Type '(c: { text: string; }) => string' is not assignable to type '(value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string'.
    Types of parameters 'c' and 'value' are incompatible.
      Type 'ImageContent | TextContent' is not assignable to type '{ text: string; }'.
        Property 'text' is missing in type 'ImageContent' but required in type '{ text: string; }'.
../coding-agent/src/modes/interactive/interactive-mode.ts(615,40): error TS2345: Argument of type '(c: { text: string; }) => string' is not assignable to parameter of type '((value: { type: string; text: string; }, index: number, array: { type: string; text: string; }[]) => string) & ((value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string)'.
  Type '(c: { text: string; }) => string' is not assignable to type '(value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string'.
    Types of parameters 'c' and 'value' are incompatible.
      Type 'ImageContent | TextContent' is not assignable to type '{ text: string; }'.
        Property 'text' is missing in type 'ImageContent' but required in type '{ text: string; }'.
../coding-agent/src/modes/interactive/interactive-mode.ts(670,40): error TS2345: Argument of type '(c: { text: string; }) => string' is not assignable to parameter of type '((value: { type: string; text: string; }, index: number, array: { type: string; text: string; }[]) => string) & ((value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string)'.
  Type '(c: { text: string; }) => string' is not assignable to type '(value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string'.
    Types of parameters 'c' and 'value' are incompatible.
      Type 'ImageContent | TextContent' is not assignable to type '{ text: string; }'.
        Property 'text' is missing in type 'ImageContent' but required in type '{ text: string; }'.
../coding-agent/src/modes/interactive/interactive-mode.ts(706,40): error TS2345: Argument of type '(c: { text: string; }) => string' is not assignable to parameter of type '((value: { type: string; text: string; }, index: number, array: { type: string; text: string; }[]) => string) & ((value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string)'.
  Type '(c: { text: string; }) => string' is not assignable to type '(value: ImageContent | TextContent, index: number, array: (ImageContent | TextContent)[]) => string'.
    Types of parameters 'c' and 'value' are incompatible.


Just a few type errors related to the text content extraction. Let me fix them:

cd /Users/badlogic/workspaces/pi-mono && grep -n "textBlocks.map" packages/coding-agent/src/modes/interactive/interactive-mode.ts


585:			const textContent = textBlocks.map((c: { text: string }) => c.text).join("");
615:				const textContent = textBlocks.map((c: { text: string }) => c.text).join("");
670:				const textContent = textBlocks.map((c: { text: string }) => c.text).join("");
706:				const textContent = textBlocks.map((c: { text: string }) => c.text).join("");


The issue is the type annotation. Let me look at how the original tui-renderer.ts handles this:

cd /Users/badlogic/workspaces/pi-mono && grep -A2 "textBlocks.map" packages/coding-agent/src/tui/tui-renderer.ts | head -10


					const messageText = textBlocks.map((c) => c.text).join("");

					const queuedIndex = this.queuedMessages.indexOf(messageText);
--
			const textContent = textBlocks.map((c) => c.text).join("");
			if (textContent) {
				const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
--
				const textContent = textBlocks.map((c) => c.text).join("");
				if (textContent) {


I see - the original doesn't have explicit type annotations. Let me fix the type issues by properly narrowing to TextContent:

cd /Users/badlogic/workspaces/pi-mono && sed -i '' 's/const textContent = textBlocks.map((c: { text: string }) => c.text).join("");/const textContent = textBlocks.map((c) => (c as { text: string }).text).join("");/g' packages/coding-agent/src/modes/interactive/interactive-mode.ts


(no output)


cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1 | head -30


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 238 files in 125ms. No fixes applied.

> @mariozechner/pi-agent-core@0.14.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 36 files in 47ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.14.2 check
> tsgo --noEmit

> @mariozechner/pi-mom@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 12 files in 14ms. No fixes applied.

> @mariozechner/pi@0.14.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.14.2 check


Now let me update the modes/index.ts to export InteractiveMode:

edit

export { InteractiveMode } from "./interactive/interactive-mode.js";
export { runPrintMode } from "./print-mode.js";
export { runRpcMode } from "./rpc-mode.js";


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/index.ts. Changed 91 characters to 160 characters.

 1 /**
 2  * Run modes for the coding agent.
 3  */
 4
+5 export { InteractiveMode } from "./interactive/interactive-mode.js";
 5 export { runPrintMode } from "./print-mode.js";
 6 export { runRpcMode } from "./rpc-mode.js";
 7 // InteractiveMode will be added in WP15


bash

cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1 | tail -20


Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.14.2 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 22 files in 23ms. No fixes applied.

> @mariozechner/pi-web-ui@0.14.2 check
> tsc --noEmit && cd example && tsc --noEmit

> pi-web-ui-example@1.2.2 check
> tsc --noEmit


All checks pass. Let me update the refactor.md checklist and commit:

edit

- [ ] Create `src/modes/interactive/` directory
- [ ] Copy tui-renderer.ts to interactive-mode.ts
- [ ] Rename class to `InteractiveMode`
- [ ] Change constructor to accept AgentSession
- [ ] Update all agent access to go through session
- [ ] Remove `subscribeToAgent()` method (use session.subscribe)
- [ ] Remove `checkAutoCompaction()` method (handled by session)
- [ ] Update `cycleThinkingLevel()` to use session method
- [ ] Update `cycleModel()` to use session method
- [ ] Update bash execution to use session.executeBash()
- [ ] Update compaction to use session.compact()
- [ ] Update reset logic to use session.reset()
- [ ] Update session switching to use session.switchSession()
- [ ] Update branch logic to use session.branch()
- [ ] Remove all direct sessionManager access
- [ ] Update imports to point to `../../tui/` for components (keep old components in place for now)
- [ ] Update modes/index.ts to export InteractiveMode
- [ ] Verify with `npm run check`
- [ ] Manual test interactive mode via cli-new.ts

- [x] Create `src/modes/interactive/` directory
- [x] Copy tui-renderer.ts to interactive-mode.ts
- [x] Rename class to `InteractiveMode`
- [x] Change constructor to accept AgentSession
- [x] Update all agent access to go through session
- [x] Remove `subscribeToAgent()` method (use session.subscribe)
- [x] Remove `checkAutoCompaction()` method (handled by session)
- [x] Update `cycleThinkingLevel()` to use session method
- [x] Update `cycleModel()` to use session method
- [x] Update bash execution to use session.executeBash()
- [x] Update compaction to use session.compact()
- [x] Update reset logic to use session.reset()
- [x] Update session switching to use session.switchSession()
- [x] Update branch logic to use session.branch()
- [x] Remove all direct sessionManager access (use convenience getters for remaining access)
- [x] Update imports to point to `../../tui/` for components (keep old components in place for now)
- [x] Update modes/index.ts to export InteractiveMode
- [x] Verify with `npm run check`
- [ ] Manual test interactive mode via cli-new.ts


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/refactor.md. Changed 1025 characters to 1072 characters.

      ...
 1460 4. Manual test: All hotkeys work
 1461 5. Manual test: Bash execution works
 1462 6. Manual test: Model/thinking cycling works
 1463
-1464 - [ ] Create `src/modes/interactive/` directory
-1465 - [ ] Copy tui-renderer.ts to interactive-mode.ts
-1466 - [ ] Rename class to `InteractiveMode`
-1467 - [ ] Change constructor to accept AgentSession
-1468 - [ ] Update all agent access to go through session
-1469 - [ ] Remove `subscribeToAgent()` method (use session.subscribe)
-1470 - [ ] Remove `checkAutoCompaction()` method (handled by session)
-1471 - [ ] Update `cycleThinkingLevel()` to use session method
-1472 - [ ] Update `cycleModel()` to use session method
-1473 - [ ] Update bash execution to use session.executeBash()
-1474 - [ ] Update compaction to use session.compact()
-1475 - [ ] Update reset logic to use session.reset()
-1476 - [ ] Update session switching to use session.switchSession()
-1477 - [ ] Update branch logic to use session.branch()
-1478 - [ ] Remove all direct sessionManager access
-1479 - [ ] Update imports to point to `../../tui/` for components (keep old components in place for now)
-1480 - [ ] Update modes/index.ts to export InteractiveMode
-1481 - [ ] Verify with `npm run check`
+1464 - [x] Create `src/modes/interactive/` directory
+1465 - [x] Copy tui-renderer.ts to interactive-mode.ts
+1466 - [x] Rename class to `InteractiveMode`
+1467 - [x] Change constructor to accept AgentSession
+1468 - [x] Update all agent access to go through session
+1469 - [x] Remove `subscribeToAgent()` method (use session.subscribe)
+1470 - [x] Remove `checkAutoCompaction()` method (handled by session)
+1471 - [x] Update `cycleThinkingLevel()` to use session method
+1472 - [x] Update `cycleModel()` to use session method
+1473 - [x] Update bash execution to use session.executeBash()
+1474 - [x] Update compaction to use session.compact()
+1475 - [x] Update reset logic to use session.reset()
+1476 - [x] Update session switching to use session.switchSession()
+1477 - [x] Update branch logic to use session.branch()
+1478 - [x] Remove all direct sessionManager access (use convenience getters for remaining access)
+1479 - [x] Update imports to point to `../../tui/` for components (keep old components in place for now)
+1480 - [x] Update modes/index.ts to export InteractiveMode
+1481 - [x] Verify with `npm run check`
 1482 - [ ] Manual test interactive mode via cli-new.ts
 1483
 1484 ---
 1485
      ...


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "WP15: Create InteractiveMode using AgentSession"


Running formatting, linting, and type checking...

> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 238 files in 123ms. No fixes applied.

> @mariozechner/pi-agent-core@0.14.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 36 files in 38ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.14.2 check
> tsgo --noEmit

> @mariozechner/pi-mom@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 12 files in 14ms. No fixes applied.

> @mariozechner/pi@0.14.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.14.2 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 22 files in 23ms. No fixes applied.

> @mariozechner/pi-web-ui@0.14.2 check
> tsc --noEmit && cd example && tsc --noEmit

> pi-web-ui-example@1.2.2 check
> tsc --noEmit

✅ All pre-commit checks passed!
[refactor 0020de8] WP15: Create InteractiveMode using AgentSession
 3 files changed, 1527 insertions(+), 18 deletions(-)
 create mode 100644 packages/coding-agent/src/modes/interactive/interactive-mode.ts


WP15 complete. Now WP16: Update main-new.ts to use InteractiveMode. Let me read the current runInteractiveMode:

cd /Users/badlogic/workspaces/pi-mono && grep -n "async function runInteractiveMode" packages/coding-agent/src/main-new.ts


729:async function runInteractiveMode(


		ui.start();
	});
}

async function runInteractiveMode(
	agent: Agent,
	sessionManager: SessionManager,
	settingsManager: SettingsManager,
	version: string,
	changelogMarkdown: string | null = null,
	collapseChangelog = false,
	modelFallbackMessage: string | null = null,
	versionCheckPromise: Promise<string | null>,
	scopedModels: Array<{ model: Model<Api>; thinkingLevel: ThinkingLevel }> = [],
	initialMessages: string[] = [],
	initialMessage?: string,
	initialAttachments?: Attachment[],
	fdPath: string | null = null,
): Promise<void> {
	const renderer = new TuiRenderer(
		agent,
		sessionManager,
		settingsManager,
		version,
		changelogMarkdown,
		collapseChangelog,
		scopedModels,
		fdPath,
	);

	// Initialize TUI (subscribes to agent events internally)
	await renderer.init();

	// Handle version check result when it completes (don't block)
	versionCheckPromise.then((newVersion) => {
		if (newVersion) {
			renderer.showNewVersionNotification(newVersion);
		}
	});

	// Render any existing messages (from --continue mode)
	renderer.renderInitialMessages(agent.state);

	// Show model fallback warning at the end of the chat if applicable
	if (modelFallbackMessage) {
		renderer.showWarning(modelFallbackMessage);
	}

	// Load file-based slash commands for expansion
	const fileCommands = loadSlashCommands();

	// Process initial message with attachments if provided (from @file args)
	if (initialMessage) {
		try {
			await agent.prompt(expandSlashCommand(initialMessage, fileCommands), initialAttachments);
		} catch (error: unknown) {
			const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
			renderer.showError(errorMessage);
		}
	}

	// Process remaining initial messages if provided (from CLI args)
	for (const message of initialMessages) {
		try {
			await agent.prompt(expandSlashCommand(message, fileCommands));
		} catch (error: unknown) {
			const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
			renderer.showError(errorMessage);
		}
	}

	// Interactive loop
	while (true) {
		const userInput = await renderer.getUserInput();

		// Process the message - agent.prompt will add user message and trigger state updates
		try {
			await agent.prompt(userInput);
		} catch (error: unknown) {
			// Display error in the TUI by adding an error message to the chat
			const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
			renderer.showError(errorMessage);
		}
	}
}

export async function main(args: string[]) {
	const parsed = parseArgs(args);

	if (parsed.help) {
		printHelp();
		return;
	}

	// Handle --export flag: convert session file to HTML and exit
	if (parsed.export) {
		try {
			// Use first message as output path if provided
			const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;
			const result = exportFromFile(parsed.export, outputPath);

[431 more lines in file. Use offset=825 to continue]


Now let me see how it's called:

cd /Users/badlogic/workspaces/pi-mono && grep -n "runInteractiveMode(" packages/coding-agent/src/main-new.ts | tail -5


729:async function runInteractiveMode(
1228:		await runInteractiveMode(


			console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
		}

		// Ensure fd tool is available for file autocomplete
		const fdPath = await ensureTool("fd");

		// Interactive mode - use TUI (may have initial messages from CLI args)
		const collapseChangelog = settingsManager.getCollapseChangelog();
		await runInteractiveMode(
			agent,
			sessionManager,
			settingsManager,
			VERSION,
			changelogMarkdown,
			collapseChangelog,
			modelFallbackMessage,
			versionCheckPromise,
			scopedModels,
			parsed.messages,
			initialMessage,
			initialAttachments,
			fdPath,
		);
	} else {
		// Non-interactive mode (--print flag or --mode flag)
		const session = new AgentSession({
			agent,
			sessionManager,
			settingsManager,
			scopedModels,
			fileCommands,
		});
		await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments);
	}
}