import { randomBytes } from "node:crypto";
import * as fs from "node:fs";
import { createWriteStream, type WriteStream } from "node:fs";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { join } from "node:path";
import type { Agent, AgentEvent, AgentState, AppMessage, ThinkingLevel } from "@mariozechner/companion-agent-core";
import type { AssistantMessage, Message, Model } from "@mariozechner/companion-ai";
import type { SlashCommand } from "@mariozechner/companion-tui";
import {
	CombinedAutocompleteProvider,
	Container,
	Input,
	Loader,
	Markdown,
	ProcessTerminal,
	Spacer,
	Text,
	TruncatedText,
	TUI,
	visibleWidth,
} from "@mariozechner/companion-tui";
import { exec, spawn } from "child_process";
import stripAnsi from "strip-ansi";
import { getChangelogPath, parseChangelog } from "../changelog.js";
import { copyToClipboard } from "../clipboard.js";
import { calculateContextTokens, compact, shouldCompact } from "../compaction.js";
import { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from "../config.js";
import { exportSessionToHtml } from "../export-html.js";
import { type BashExecutionMessage, isBashExecutionMessage } from "../messages.js";
import { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from "../model-config.js";
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../oauth/index.js";
import {
	getLatestCompactionEntry,
	loadSessionFromEntries,
	type SessionManager,
	SUMMARY_PREFIX,
	SUMMARY_SUFFIX,
} from "../session-manager.js";
import type { SettingsManager } from "../settings-manager.js";
import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../shell.js";
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js";
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
import { DEFAULT_MAX_BYTES, type TruncationResult, truncateTail } from "../tools/truncate.js";
import { AssistantMessageComponent } from "./assistant-message.js";
import { BashExecutionComponent } from "./bash-execution.js";
import { CompactionComponent } from "./compaction.js";
import { CustomEditor } from "./custom-editor.js";
import { DynamicBorder } from "./dynamic-border.js";
import { FooterComponent } from "./footer.js";
import { ModelSelectorComponent } from "./model-selector.js";
import { OAuthSelectorComponent } from "./oauth-selector.js";
import { QueueModeSelectorComponent } from "./queue-mode-selector.js";
import { SessionSelectorComponent } from "./session-selector.js";
import { ThemeSelectorComponent } from "./theme-selector.js";
import { ThinkingSelectorComponent } from "./thinking-selector.js";
import { ToolExecutionComponent } from "./tool-execution.js";
import { UserMessageComponent } from "./user-message.js";
import { UserMessageSelectorComponent } from "./user-message-selector.js";

/**
 * TUI renderer for the coding agent
 */
export class TuiRenderer {
	private ui: TUI;
	private chatContainer: Container;
	private pendingMessagesContainer: Container;
	private statusContainer: Container;
	private editor: CustomEditor;
	private editorContainer: Container; // Container to swap between editor and selector
	private footer: FooterComponent;
	private agent: Agent;
	private sessionManager: SessionManager;
	private settingsManager: SettingsManager;
	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;
	private collapseChangelog = false;

	// Message queueing
	private queuedMessages: string[] = [];

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

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

	// Thinking level selector
	private thinkingSelector: ThinkingSelectorComponent | null = null;

	// Queue mode selector
	private queueModeSelector: QueueModeSelectorComponent | null = null;

	// Theme selector
	private themeSelector: ThemeSelectorComponent | null = null;

	// Model selector
	private modelSelector: ModelSelectorComponent | null = null;

	// User message selector (for branching)
	private userMessageSelector: UserMessageSelectorComponent | null = null;

	// Session selector (for resume)
	private sessionSelector: SessionSelectorComponent | null = null;

	// OAuth selector
	private oauthSelector: any | null = null;

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

	// Model scope for quick cycling
	private scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }> = [];

	// Tool output expansion state
	private toolOutputExpanded = false;

	// Thinking block visibility state
	private hideThinkingBlock = false;

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

	// File-based slash commands
	private fileCommands: FileSlashCommand[] = [];

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

	// Track running bash command process for cancellation
	private bashProcess: ReturnType<typeof spawn> | null = null;

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

	constructor(
		agent: Agent,
		sessionManager: SessionManager,
		settingsManager: SettingsManager,
		version: string,
		changelogMarkdown: string | null = null,
		collapseChangelog = false,
		scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }> = [],
		fdPath: string | null = null,
	) {
		this.agent = agent;
		this.sessionManager = sessionManager;
		this.settingsManager = settingsManager;
		this.version = version;
		this.changelogMarkdown = changelogMarkdown;
		this.collapseChangelog = collapseChangelog;
		this.scopedModels = scopedModels;
		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(); // Container to hold editor or selector
		this.editorContainer.addChild(this.editor); // Start with editor
		this.footer = new FooterComponent(agent.state);
		this.footer.setAutoCompactEnabled(this.settingsManager.getCompactionEnabled());

		// Define slash commands
		const thinkingCommand: SlashCommand = {
			name: "thinking",
			description: "Select reasoning level (opens selector UI)",
		};

		const modelCommand: SlashCommand = {
			name: "model",
			description: "Select model (opens selector UI)",
		};

		const exportCommand: SlashCommand = {
			name: "export",
			description: "Export session to HTML file",
		};

		const copyCommand: SlashCommand = {
			name: "copy",
			description: "Copy last agent message to clipboard",
		};

		const sessionCommand: SlashCommand = {
			name: "session",
			description: "Show session info and stats",
		};

		const changelogCommand: SlashCommand = {
			name: "changelog",
			description: "Show changelog entries",
		};

		const branchCommand: SlashCommand = {
			name: "branch",
			description: "Create a new branch from a previous message",
		};

		const loginCommand: SlashCommand = {
			name: "login",
			description: "Login with OAuth provider",
		};

		const logoutCommand: SlashCommand = {
			name: "logout",
			description: "Logout from OAuth provider",
		};

		const queueCommand: SlashCommand = {
			name: "queue",
			description: "Select message queue mode (opens selector UI)",
		};

		const themeCommand: SlashCommand = {
			name: "theme",
			description: "Select color theme (opens selector UI)",
		};

		const clearCommand: SlashCommand = {
			name: "clear",
			description: "Clear context and start a fresh session",
		};

		const compactCommand: SlashCommand = {
			name: "compact",
			description: "Manually compact the session context",
		};

		const autocompactCommand: SlashCommand = {
			name: "autocompact",
			description: "Toggle automatic context compaction",
		};

		const resumeCommand: SlashCommand = {
			name: "resume",
			description: "Resume a different session",
		};

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

		// Load file-based slash commands
		this.fileCommands = loadSlashCommands();

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

		// Setup autocomplete for file paths and slash commands
		const autocompleteProvider = new CombinedAutocompleteProvider(
			[
				thinkingCommand,
				modelCommand,
				themeCommand,
				exportCommand,
				copyCommand,
				sessionCommand,
				changelogCommand,
				branchCommand,
				loginCommand,
				logoutCommand,
				queueCommand,
				clearCommand,
				compactCommand,
				autocompactCommand,
				resumeCommand,
				...fileSlashCommands,
			],
			process.cwd(),
			fdPath,
		);
		this.editor.setAutocompleteProvider(autocompleteProvider);
	}

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

		// Add header with logo and instructions
		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.collapseChangelog) {
				// Show condensed version with hint to use /changelog
				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); // Use container that can hold editor or selector
		this.ui.addChild(this.footer);
		this.ui.setFocus(this.editor);

		// Set up custom key handlers on the editor
		this.editor.onEscape = () => {
			// Intercept Escape key when processing
			if (this.loadingAnimation) {
				// Get all queued messages
				const queuedText = this.queuedMessages.join("\n\n");

				// Get current editor text
				const currentText = this.editor.getText();

				// Combine: queued messages + current editor text
				const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");

				// Put back in editor
				this.editor.setText(combinedText);

				// Clear queued messages
				this.queuedMessages = [];
				this.updatePendingMessagesDisplay();

				// Clear agent's queue too
				this.agent.clearMessageQueue();

				// Abort
				this.agent.abort();
			} else if (this.bashProcess) {
				// Kill running bash command
				if (this.bashProcess.pid) {
					killProcessTree(this.bashProcess.pid);
				}
				this.bashProcess = null;
			} else if (this.isBashMode) {
				// Cancel bash mode and clear editor
				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; // Reset to prevent triple-escape
				} 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();
		};

		// Handle editor text changes for bash mode detection
		this.editor.onChange = (text: string) => {
			const wasBashMode = this.isBashMode;
			this.isBashMode = text.trimStart().startsWith("!");
			if (wasBashMode !== this.isBashMode) {
				this.updateEditorBorderColor();
			}
		};

		// Handle editor submission
		this.editor.onSubmit = async (text: string) => {
			text = text.trim();
			if (!text) return;

			// Check for /thinking command
			if (text === "/thinking") {
				// Show thinking level selector
				this.showThinkingSelector();
				this.editor.setText("");
				return;
			}

			// Check for /model command
			if (text === "/model") {
				// Show model selector
				this.showModelSelector();
				this.editor.setText("");
				return;
			}

			// Check for /export command
			if (text.startsWith("/export")) {
				this.handleExportCommand(text);
				this.editor.setText("");
				return;
			}

			// Check for /copy command
			if (text === "/copy") {
				this.handleCopyCommand();
				this.editor.setText("");
				return;
			}

			// Check for /session command
			if (text === "/session") {
				this.handleSessionCommand();
				this.editor.setText("");
				return;
			}

			// Check for /changelog command
			if (text === "/changelog") {
				this.handleChangelogCommand();
				this.editor.setText("");
				return;
			}

			// Check for /branch command
			if (text === "/branch") {
				this.showUserMessageSelector();
				this.editor.setText("");
				return;
			}

			// Check for /login command
			if (text === "/login") {
				this.showOAuthSelector("login");
				this.editor.setText("");
				return;
			}

			// Check for /logout command
			if (text === "/logout") {
				this.showOAuthSelector("logout");
				this.editor.setText("");
				return;
			}

			// Check for /queue command
			if (text === "/queue") {
				this.showQueueModeSelector();
				this.editor.setText("");
				return;
			}

			// Check for /theme command
			if (text === "/theme") {
				this.showThemeSelector();
				this.editor.setText("");
				return;
			}

			// Check for /clear command
			if (text === "/clear") {
				this.handleClearCommand();
				this.editor.setText("");
				return;
			}

			// Check for /compact command
			if (text === "/compact" || text.startsWith("/compact ")) {
				const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
				this.handleCompactCommand(customInstructions);
				this.editor.setText("");
				return;
			}

			// Check for /autocompact command
			if (text === "/autocompact") {
				this.handleAutocompactCommand();
				this.editor.setText("");
				return;
			}

			// Check for /debug command
			if (text === "/debug") {
				this.handleDebugCommand();
				this.editor.setText("");
				return;
			}

			// Check for /resume command
			if (text === "/resume") {
				this.showSessionSelector();
				this.editor.setText("");
				return;
			}

			// Check for bash command (!<command>)
			if (text.startsWith("!")) {
				const command = text.slice(1).trim();
				if (command) {
					// Block if bash already running
					if (this.bashProcess) {
						this.showWarning("A bash command is already running. Press Esc to cancel it first.");
						// Restore text since editor clears on submit
						this.editor.setText(text);
						return;
					}
					// Add to history for up/down arrow navigation
					this.editor.addToHistory(text);
					this.handleBashCommand(command);
					// Reset bash mode since editor is now empty
					this.isBashMode = false;
					this.updateEditorBorderColor();
					return;
				}
			}

			// Check for file-based slash commands
			text = expandSlashCommand(text, this.fileCommands);

			// Normal message submission - validate model and API key first
			const currentModel = this.agent.state.model;
			if (!currentModel) {
				this.showError(
					"No model selected.\n\n" +
						"Set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)\n" +
						`or create ${getModelsPath()}\n\n` +
						"Then use /model to select a model.",
				);
				return;
			}

			// Validate API key (async)
			const apiKey = await getApiKeyForModel(currentModel);
			if (!apiKey) {
				this.showError(
					`No API key found for ${currentModel.provider}.\n\n` +
						`Set the appropriate environment variable or update ${getModelsPath()}`,
				);
				this.editor.setText(text);
				return;
			}

			// Check if agent is currently streaming
			if (this.agent.state.isStreaming) {
				// Queue the message instead of submitting
				this.queuedMessages.push(text);

				// Queue in agent
				await this.agent.queueMessage({
					role: "user",
					content: [{ type: "text", text }],
					timestamp: Date.now(),
				});

				// Update pending messages display
				this.updatePendingMessagesDisplay();

				// Add to history for up/down arrow navigation
				this.editor.addToHistory(text);

				// Clear editor
				this.editor.setText("");
				this.ui.requestRender();
				return;
			}

			// All good, proceed with submission
			if (this.onInputCallback) {
				this.onInputCallback(text);
			}

			// Add to history for up/down arrow navigation
			this.editor.addToHistory(text);
		};

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

		// Subscribe to agent events for UI updates and session saving
		this.subscribeToAgent();

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

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

	private subscribeToAgent(): void {
		this.unsubscribe = this.agent.subscribe(async (event) => {
			// Handle UI updates
			await this.handleEvent(event, this.agent.state);

			// Save messages to session
			if (event.type === "message_end") {
				this.sessionManager.saveMessage(event.message);

				// Check if we should initialize session now (after first user+assistant exchange)
				if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {
					this.sessionManager.startSession(this.agent.state);
				}

				// Check for auto-compaction after assistant messages
				if (event.message.role === "assistant") {
					await this.checkAutoCompaction();
				}
			}
		});
	}

	private async checkAutoCompaction(): Promise<void> {
		const settings = this.settingsManager.getCompactionSettings();
		if (!settings.enabled) return;

		// Get last non-aborted assistant message from agent state
		const messages = this.agent.state.messages;
		let lastAssistant: AssistantMessage | null = null;
		for (let i = messages.length - 1; i >= 0; i--) {
			const msg = messages[i];
			if (msg.role === "assistant") {
				const assistantMsg = msg as AssistantMessage;
				if (assistantMsg.stopReason !== "aborted") {
					lastAssistant = assistantMsg;
					break;
				}
			}
		}
		if (!lastAssistant) return;

		const contextTokens = calculateContextTokens(lastAssistant.usage);
		const contextWindow = this.agent.state.model.contextWindow;

		if (!shouldCompact(contextTokens, contextWindow, settings)) return;

		// Trigger auto-compaction
		await this.executeCompaction(undefined, true);
	}

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

		// Update footer with current stats
		this.footer.updateState(state);

		switch (event.type) {
			case "agent_start":
				// Show loading animation
				// Note: Don't disable submit - we handle queuing in onSubmit callback
				// Stop old loader before clearing
				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") {
					// Check if this is a queued message
					const userMsg = event.message;
					const textBlocks =
						typeof userMsg.content === "string"
							? [{ type: "text", text: userMsg.content }]
							: userMsg.content.filter((c) => c.type === "text");
					const messageText = textBlocks.map((c) => c.text).join("");

					const queuedIndex = this.queuedMessages.indexOf(messageText);
					if (queuedIndex !== -1) {
						// Remove from queued messages
						this.queuedMessages.splice(queuedIndex, 1);
						this.updatePendingMessagesDisplay();
					}

					// Show user message immediately and clear editor
					this.addMessageToChat(event.message);
					this.editor.setText("");
					this.ui.requestRender();
				} else if (event.message.role === "assistant") {
					// Create assistant component for streaming
					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":
				// Update streaming component
				if (this.streamingComponent && event.message.role === "assistant") {
					const assistantMsg = event.message as AssistantMessage;
					this.streamingComponent.updateContent(assistantMsg);

					// Create tool execution components as soon as we see tool calls
					for (const content of assistantMsg.content) {
						if (content.type === "toolCall") {
							// Only create if we haven't created it yet
							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 {
								// Update existing component with latest arguments as they stream
								const component = this.pendingTools.get(content.id);
								if (component) {
									component.updateArgs(content.arguments);
								}
							}
						}
					}

					this.ui.requestRender();
				}
				break;

			case "message_end":
				// Skip user messages (already shown in message_start)
				if (event.message.role === "user") {
					break;
				}
				if (this.streamingComponent && event.message.role === "assistant") {
					const assistantMsg = event.message as AssistantMessage;

					// Update streaming component with final message (includes stopReason)
					this.streamingComponent.updateContent(assistantMsg);

					// If message was aborted or errored, mark all pending tool components as failed
					if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
						const errorMessage =
							assistantMsg.stopReason === "aborted" ? "Operation aborted" : assistantMsg.errorMessage || "Error";
						for (const [toolCallId, component] of this.pendingTools.entries()) {
							component.updateResult({
								content: [{ type: "text", text: errorMessage }],
								isError: true,
							});
						}
						this.pendingTools.clear();
					}

					// Keep the streaming component - it's now the final assistant message
					this.streamingComponent = null;

					// Invalidate footer cache to refresh git branch (in case agent executed git commands)
					this.footer.invalidate();
				}
				this.ui.requestRender();
				break;

			case "tool_execution_start": {
				// Component should already exist from message_update, but create if missing
				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": {
				// Update the existing tool component with the result
				const component = this.pendingTools.get(event.toolCallId);
				if (component) {
					// Convert result to the format expected by updateResult
					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":
				// Stop loading animation
				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();
				// Note: Don't need to re-enable submit - we never disable it
				this.ui.requestRender();
				break;
		}
	}

	private addMessageToChat(message: Message | AppMessage): void {
		// Handle bash execution messages
		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 userMsg = message;
			// Extract text content from content blocks
			const textBlocks =
				typeof userMsg.content === "string"
					? [{ type: "text", text: userMsg.content }]
					: userMsg.content.filter((c) => c.type === "text");
			const textContent = textBlocks.map((c) => 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 assistantMsg = message;

			// Add assistant message component
			const assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);
			this.chatContainer.addChild(assistantComponent);
		}
		// Note: tool calls and results are now handled via tool_execution_start/end events
	}

	renderInitialMessages(state: AgentState): void {
		// Render all existing messages (for --continue mode)
		// Reset first user message flag for initial render
		this.isFirstUserMessage = true;

		// Update footer with loaded state
		this.footer.updateState(state);

		// Update editor border color based on current thinking level
		this.updateEditorBorderColor();

		// Get compaction info if any
		const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());

		// Render messages
		for (let i = 0; i < state.messages.length; i++) {
			const message = state.messages[i];

			// Handle bash execution messages
			if (isBashExecutionMessage(message)) {
				this.addMessageToChat(message);
				continue;
			}

			if (message.role === "user") {
				const userMsg = message;
				const textBlocks =
					typeof userMsg.content === "string"
						? [{ type: "text", text: userMsg.content }]
						: userMsg.content.filter((c) => c.type === "text");
				const textContent = textBlocks.map((c) => c.text).join("");
				if (textContent) {
					// Check if this is a compaction summary message
					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);

				// Create tool execution components for any tool calls
				for (const content of assistantMsg.content) {
					if (content.type === "toolCall") {
						const component = new ToolExecutionComponent(content.name, content.arguments);
						this.chatContainer.addChild(component);

						// If message was aborted/errored, immediately mark tool as failed
						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 {
							// Store in map so we can update with results later
							this.pendingTools.set(content.id, component);
						}
					}
				}
			} else if (message.role === "toolResult") {
				// Update existing tool execution component with results				;
				const component = this.pendingTools.get(message.toolCallId);
				if (component) {
					component.updateResult({
						content: message.content,
						details: message.details,
						isError: message.isError,
					});
					// Remove from pending map since it's complete
					this.pendingTools.delete(message.toolCallId);
				}
			}
		}
		// Clear pending tools after rendering initial messages
		this.pendingTools.clear();

		// Populate editor history with user messages from the session (oldest first so newest is at index 0)
		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) => c.type === "text");
				const textContent = textBlocks.map((c) => c.text).join("");
				// Skip compaction summary messages
				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 {
		// Reset state and re-render messages from agent state
		this.isFirstUserMessage = true;
		this.pendingTools.clear();

		// Get compaction info if any
		const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());

		for (const message of this.agent.state.messages) {
			// Handle bash execution messages
			if (isBashExecutionMessage(message)) {
				this.addMessageToChat(message);
				continue;
			}

			if (message.role === "user") {
				const userMsg = message;
				const textBlocks =
					typeof userMsg.content === "string"
						? [{ type: "text", text: userMsg.content }]
						: userMsg.content.filter((c) => c.type === "text");
				const textContent = textBlocks.map((c) => c.text).join("");
				if (textContent) {
					// Check if this is a compaction summary message
					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;
				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();
	}

	private handleCtrlC(): void {
		// Handle Ctrl+C double-press logic
		const now = Date.now();
		const timeSinceLastCtrlC = now - this.lastSigintTime;

		if (timeSinceLastCtrlC < 500) {
			// Second Ctrl+C within 500ms - exit
			this.stop();
			process.exit(0);
		} else {
			// First Ctrl+C - clear the editor
			this.clearEditor();
			this.lastSigintTime = now;
		}
	}

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

	private cycleThinkingLevel(): void {
		// Only cycle if model supports thinking
		if (!this.agent.state.model?.reasoning) {
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", "Current model does not support thinking"), 1, 0));
			this.ui.requestRender();
			return;
		}

		// xhigh is only available for codex-max models
		const modelId = this.agent.state.model?.id || "";
		const supportsXhigh = modelId.includes("codex-max");
		const levels: ThinkingLevel[] = supportsXhigh
			? ["off", "minimal", "low", "medium", "high", "xhigh"]
			: ["off", "minimal", "low", "medium", "high"];
		const currentLevel = this.agent.state.thinkingLevel || "off";
		const currentIndex = levels.indexOf(currentLevel);
		const nextIndex = (currentIndex + 1) % levels.length;
		const nextLevel = levels[nextIndex];

		// Apply the new thinking level
		this.agent.setThinkingLevel(nextLevel);

		// Save thinking level change to session and settings
		this.sessionManager.saveThinkingLevelChange(nextLevel);
		this.settingsManager.setDefaultThinkingLevel(nextLevel);

		// Update border color
		this.updateEditorBorderColor();

		// Show brief notification
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("dim", `Thinking level: ${nextLevel}`), 1, 0));
		this.ui.requestRender();
	}

	private async cycleModel(): Promise<void> {
		// Use scoped models if available, otherwise all available models
		if (this.scopedModels.length > 0) {
			// Use scoped models with thinking levels
			if (this.scopedModels.length === 1) {
				this.chatContainer.addChild(new Spacer(1));
				this.chatContainer.addChild(new Text(theme.fg("dim", "Only one model in scope"), 1, 0));
				this.ui.requestRender();
				return;
			}

			const currentModel = this.agent.state.model;
			let currentIndex = this.scopedModels.findIndex(
				(sm) => sm.model.id === currentModel?.id && sm.model.provider === currentModel?.provider,
			);

			// If current model not in scope, start from first
			if (currentIndex === -1) {
				currentIndex = 0;
			}

			const nextIndex = (currentIndex + 1) % this.scopedModels.length;
			const nextEntry = this.scopedModels[nextIndex];
			const nextModel = nextEntry.model;
			const nextThinking = nextEntry.thinkingLevel;

			// Validate API key
			const apiKey = await getApiKeyForModel(nextModel);
			if (!apiKey) {
				this.showError(`No API key for ${nextModel.provider}/${nextModel.id}`);
				return;
			}

			// Switch model
			this.agent.setModel(nextModel);

			// Save model change to session and settings
			this.sessionManager.saveModelChange(nextModel.provider, nextModel.id);
			this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);

			// Apply thinking level (silently use "off" if model doesn't support thinking)
			const effectiveThinking = nextModel.reasoning ? nextThinking : "off";
			this.agent.setThinkingLevel(effectiveThinking);
			this.sessionManager.saveThinkingLevelChange(effectiveThinking);
			this.settingsManager.setDefaultThinkingLevel(effectiveThinking);
			this.updateEditorBorderColor();

			// Show notification
			this.chatContainer.addChild(new Spacer(1));
			const thinkingStr = nextModel.reasoning && nextThinking !== "off" ? ` (thinking: ${nextThinking})` : "";
			this.chatContainer.addChild(
				new Text(theme.fg("dim", `Switched to ${nextModel.name || nextModel.id}${thinkingStr}`), 1, 0),
			);
			this.ui.requestRender();
		} else {
			// Fallback to all available models (no thinking level changes)
			const { models: availableModels, error } = await getAvailableModels();
			if (error) {
				this.showError(`Failed to load models: ${error}`);
				return;
			}

			if (availableModels.length === 0) {
				this.showError("No models available to cycle");
				return;
			}

			if (availableModels.length === 1) {
				this.chatContainer.addChild(new Spacer(1));
				this.chatContainer.addChild(new Text(theme.fg("dim", "Only one model available"), 1, 0));
				this.ui.requestRender();
				return;
			}

			const currentModel = this.agent.state.model;
			let currentIndex = availableModels.findIndex(
				(m) => m.id === currentModel?.id && m.provider === currentModel?.provider,
			);

			// If current model not in scope, start from first
			if (currentIndex === -1) {
				currentIndex = 0;
			}

			const nextIndex = (currentIndex + 1) % availableModels.length;
			const nextModel = availableModels[nextIndex];

			// Validate API key
			const apiKey = await getApiKeyForModel(nextModel);
			if (!apiKey) {
				this.showError(`No API key for ${nextModel.provider}/${nextModel.id}`);
				return;
			}

			// Switch model
			this.agent.setModel(nextModel);

			// Save model change to session and settings
			this.sessionManager.saveModelChange(nextModel.provider, nextModel.id);
			this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);

			// Show notification
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", `Switched to ${nextModel.name || nextModel.id}`), 1, 0));
			this.ui.requestRender();
		}
	}

	private toggleToolOutputExpansion(): void {
		this.toolOutputExpanded = !this.toolOutputExpanded;

		// Update all tool execution, compaction, and bash execution components
		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);

		// Update all assistant message components and rebuild their content
		for (const child of this.chatContainer.children) {
			if (child instanceof AssistantMessageComponent) {
				child.setHideThinkingBlock(this.hideThinkingBlock);
			}
		}

		// Rebuild chat to apply visibility change
		this.chatContainer.clear();
		this.rebuildChatFromMessages();

		// Show brief notification
		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();
	}

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

	showError(errorMessage: string): void {
		// Show error message in the chat
		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 {
		// Show warning message in the chat
		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 {
		// Show new version notification in the chat
		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/companion-coding-agent"),
				1,
				0,
			),
		);
		this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
		this.ui.requestRender();
	}

	private showThinkingSelector(): void {
		// Create thinking selector with current level
		this.thinkingSelector = new ThinkingSelectorComponent(
			this.agent.state.thinkingLevel,
			(level) => {
				// Apply the selected thinking level
				this.agent.setThinkingLevel(level);

				// Save thinking level change to session and settings
				this.sessionManager.saveThinkingLevelChange(level);
				this.settingsManager.setDefaultThinkingLevel(level);

				// Update border color
				this.updateEditorBorderColor();

				// Show confirmation message with proper spacing
				this.chatContainer.addChild(new Spacer(1));
				const confirmText = new Text(theme.fg("dim", `Thinking level: ${level}`), 1, 0);
				this.chatContainer.addChild(confirmText);

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

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

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

	private showQueueModeSelector(): void {
		// Create queue mode selector with current mode
		this.queueModeSelector = new QueueModeSelectorComponent(
			this.agent.getQueueMode(),
			(mode) => {
				// Apply the selected queue mode
				this.agent.setQueueMode(mode);

				// Save queue mode to settings
				this.settingsManager.setQueueMode(mode);

				// Show confirmation message with proper spacing
				this.chatContainer.addChild(new Spacer(1));
				const confirmText = new Text(theme.fg("dim", `Queue mode: ${mode}`), 1, 0);
				this.chatContainer.addChild(confirmText);

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

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

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

	private showThemeSelector(): void {
		// Get current theme from settings
		const currentTheme = this.settingsManager.getTheme() || "dark";

		// Create theme selector
		this.themeSelector = new ThemeSelectorComponent(
			currentTheme,
			(themeName) => {
				// Apply the selected theme
				const result = setTheme(themeName);

				// Save theme to settings
				this.settingsManager.setTheme(themeName);

				// Invalidate all components to clear cached rendering
				this.ui.invalidate();

				// Show confirmation or error message
				this.chatContainer.addChild(new Spacer(1));
				if (result.success) {
					const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
					this.chatContainer.addChild(confirmText);
				} else {
					const errorText = new Text(
						theme.fg("error", `Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`),
						1,
						0,
					);
					this.chatContainer.addChild(errorText);
				}

				// Hide selector and show editor again
				this.hideThemeSelector();
				this.ui.requestRender();
			},
			() => {
				// Just hide the selector
				this.hideThemeSelector();
				this.ui.requestRender();
			},
			(themeName) => {
				// Preview theme on selection change
				const result = setTheme(themeName);
				if (result.success) {
					this.ui.invalidate();
					this.ui.requestRender();
				}
				// If failed, theme already fell back to dark, just don't re-render
			},
		);

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

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

	private showModelSelector(): void {
		// Create model selector with current model
		this.modelSelector = new ModelSelectorComponent(
			this.ui,
			this.agent.state.model,
			this.settingsManager,
			(model) => {
				// Apply the selected model
				this.agent.setModel(model);

				// Save model change to session
				this.sessionManager.saveModelChange(model.provider, model.id);

				// Show confirmation message with proper spacing
				this.chatContainer.addChild(new Spacer(1));
				const confirmText = new Text(theme.fg("dim", `Model: ${model.id}`), 1, 0);
				this.chatContainer.addChild(confirmText);

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

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

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

	private showUserMessageSelector(): void {
		// Read from session file directly to see ALL historical user messages
		// (including those before compaction events)
		const entries = this.sessionManager.loadEntries();
		const userMessages: Array<{ index: number; text: string }> = [];

		const getUserMessageText = (content: string | Array<{ type: string; text?: string }>): string => {
			if (typeof content === "string") return content;
			if (Array.isArray(content)) {
				return content
					.filter((c): c is { type: "text"; text: string } => c.type === "text")
					.map((c) => c.text)
					.join("");
			}
			return "";
		};

		for (let i = 0; i < entries.length; i++) {
			const entry = entries[i];
			if (entry.type !== "message") continue;
			if (entry.message.role !== "user") continue;

			const textContent = getUserMessageText(entry.message.content);
			if (textContent) {
				userMessages.push({ index: i, text: textContent });
			}
		}

		// Don't show selector if there are no messages or only one message
		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;
		}

		// Create user message selector
		this.userMessageSelector = new UserMessageSelectorComponent(
			userMessages,
			(entryIndex) => {
				// Get the selected user message text to put in the editor
				const selectedEntry = entries[entryIndex];
				if (selectedEntry.type !== "message") return;
				if (selectedEntry.message.role !== "user") return;

				const selectedText = getUserMessageText(selectedEntry.message.content);

				// Create a branched session by copying entries up to (but not including) the selected entry
				const newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex);

				// Set the new session file as active
				this.sessionManager.setSessionFile(newSessionFile);

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

				// 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", "Branched to new session"), 1, 0));

				// Put the selected message in the editor

[Showing lines 1-1603 of 2377 (50.0KB limit). Use offset=1604 to continue]


Let me get the rest of the tui-renderer file:


				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(), `companion-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 a complete picture. Let me do a deep analysis.

## Deep Analysis: `main.ts` and `tui-renderer.ts`

### Overview of the Problem

The code has evolved organically around three distinct run modes:
1. **Interactive mode** (TUI) - full terminal UI experience
2. **Print/JSON mode** (single-shot) - run once and exit, output text or JSON events
3. **RPC mode** - headless, JSON-in/JSON-out, meant for embedding in other apps

The problem is that these modes share a lot of conceptual logic but it's implemented differently in each, leading to:

### Code Duplication Issues

#### 1. **Agent Event Handling**
- `tui-renderer.ts` has `handleEvent()` that subscribes to agent events, saves messages via `sessionManager.saveMessage()`, and handles session initialization
- `runSingleShotMode()` in `main.ts` has its own subscription loop for JSON output
- `runRpcMode()` in `main.ts` has yet another subscription loop doing the same session saving plus JSON output

All three have nearly identical patterns:
```typescript
agent.subscribe((event) => {
  // output/render event
  if (event.type === "message_end") {
    sessionManager.saveMessage(event.message);
    // maybe init session
  }
});
```

#### 2. **Auto-compaction Logic**
- `tui-renderer.ts` has `checkAutoCompaction()` called after assistant messages
- `runRpcMode()` has duplicate `checkAutoCompaction()` implementation
- `runSingleShotMode()` doesn't do it (maybe intentional?)

#### 3. **Bash Command Execution**
- `tui-renderer.ts` has `executeBashCommand()` with streaming callbacks, temp file handling
- `main.ts` has `executeRpcBashCommand()` which is nearly identical but without streaming

#### 4. **Slash Command Expansion**
- `runInteractiveMode()` calls `expandSlashCommand()` before `agent.prompt()`
- `runSingleShotMode()` calls `expandSlashCommand()` before `agent.prompt()`
- No slash command handling in RPC mode (intentional, but inconsistent)

### Architecture Issues

#### 1. **TuiRenderer is a God Class**
At ~2400 lines, `TuiRenderer` does too much:
- TUI lifecycle management (init, render, event loop)
- Agent event handling and session management
- All slash command implementations (/export, /copy, /session, /changelog, /branch, /login, /logout, /queue, /theme, /clear, /compact, /autocompact, /debug, /resume)
- Bash command execution (!command)
- Model/thinking/theme selection UI
- Message queuing display
- Hotkey handling (Ctrl+C, Ctrl+P, Ctrl+O, Ctrl+T, Shift+Tab, Esc)

#### 2. **Naming**
- `TuiRenderer` doesn't just render, it's the entire interactive mode coordinator
- Should be something like `InteractiveMode` or `TuiController`

#### 3. **Mode-specific code scattered in main.ts**
- `runInteractiveMode()`, `runSingleShotMode()`, `runRpcMode()` are all in `main.ts`
- Model resolution, system prompt building, session loading also in `main.ts`

---

## Refactoring Plan

### Phase 1: Extract Shared Logic into a Core Module

Create `packages/coding-agent/src/core/agent-session.ts`:

```typescript
/**
 * Handles agent lifecycle, session persistence, and event routing.
 * Shared between all modes.
 */
export class AgentSession {
  constructor(
    public agent: Agent,
    public sessionManager: SessionManager,
    public settingsManager: SettingsManager,
  ) {}

  /** Subscribe to agent events, handle session persistence, emit to callback */
  subscribe(onEvent: (event: AgentEvent) => void): () => void;

  /** Check and perform auto-compaction if enabled */
  checkAutoCompaction(): Promise<void>;

  /** Execute manual compaction */
  compact(customInstructions?: string, signal?: AbortSignal): Promise<CompactionEntry>;

  /** Reset agent and session (for /clear) */
  reset(): Promise<void>;

  /** Branch session from entry index */
  branch(entryIndex: number): void;

  /** Switch to different session file */
  switchSession(sessionPath: string): Promise<void>;
}
```

This consolidates:
- Session saving on `message_end`
- Session initialization check
- Auto-compaction logic
- Manual compaction (currently duplicated between `handleCompactCommand` and RPC)

### Phase 2: Extract Bash Execution into Shared Module

Create `packages/coding-agent/src/core/bash-executor.ts`:

```typescript
export interface BashExecutionResult {
  output: string;
  exitCode: number | null;
  cancelled: boolean;
  truncationResult?: TruncationResult;
  fullOutputPath?: string;
}

/**
 * Execute a bash command with optional streaming and cancellation.
 */
export function executeBash(
  command: string,
  options?: {
    onChunk?: (chunk: string) => void;
    signal?: AbortSignal;
  }
): Promise<BashExecutionResult>;
```

This replaces:
- `TuiRenderer.executeBashCommand()`
- `executeRpcBashCommand()` in `main.ts`

### Phase 3: Create Mode-Specific Modules

Rename and split:

```
src/modes/
  interactive-mode.ts  (renamed from tui-renderer.ts)
  print-mode.ts        (extracted from runSingleShotMode)
  rpc-mode.ts          (extracted from runRpcMode)
  index.ts             (exports)
```

Each mode would:
- Take an `AgentSession` instance
- Implement mode-specific I/O
- Use shared `executeBash()` if needed

#### `print-mode.ts` (simple)
```typescript
export async function runPrintMode(
  session: AgentSession,
  mode: "text" | "json",
  messages: string[],
  initialMessage?: string,
  initialAttachments?: Attachment[],
): Promise<void>;
```

#### `rpc-mode.ts`
```typescript
export async function runRpcMode(session: AgentSession): Promise<never>;
```

#### `interactive-mode.ts` (the big one)

Split `TuiRenderer` into:

1. **InteractiveMode** - orchestrator class
   - Owns `TUI`, `AgentSession`
   - Main event loop
   - Hotkey routing

2. **CommandHandler** - slash command implementations
   - Extract all `/` command handlers into a separate class
   - Each command is a method: `handleExport()`, `handleCopy()`, etc.

3. **SelectorManager** - modal selector management
   - Handles showing/hiding model, thinking, theme, session, branch selectors
   - Currently this is a mess of `showXSelector()` / `hideXSelector()` methods

### Phase 4: Simplify main.ts

After extraction, `main.ts` becomes:

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

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

  // Shared setup
  const { agent, sessionManager, settingsManager } = await setupAgent(parsed);
  const session = new AgentSession(agent, sessionManager, settingsManager);

  // Route to mode
  if (parsed.mode === "rpc") {
    await runRpcMode(session);
  } else if (parsed.print || parsed.mode) {
    await runPrintMode(session, parsed.mode || "text", parsed.messages, ...);
  } else {
    await runInteractiveMode(session, { version, scopedModels, fdPath, ... });
  }
}
```

### Phase 5: Extract Setup Logic

Create `packages/coding-agent/src/core/setup.ts`:

```typescript
export interface SetupResult {
  agent: Agent;
  sessionManager: SessionManager;
  settingsManager: SettingsManager;
  initialMessages: Message[];
  modelFallbackMessage: string | null;
}

export async function setupAgent(options: SetupOptions): Promise<SetupResult>;
```

This extracts from `main.ts`:
- Model resolution (CLI args → scope → session → settings → fallback)
- System prompt building
- Session loading (for --continue/--resume)
- Thinking level resolution

---

## Summary of Changes

| Current | Proposed |
|---------|----------|
| `main.ts` (1100+ lines) | `main.ts` (~200 lines) + `core/setup.ts` |
| `tui/tui-renderer.ts` (2400 lines) | `modes/interactive-mode.ts` + `interactive/command-handler.ts` + `interactive/selector-manager.ts` |
| `runSingleShotMode()` in main.ts | `modes/print-mode.ts` |
| `runRpcMode()` in main.ts | `modes/rpc-mode.ts` |
| Duplicated bash execution | `core/bash-executor.ts` |
| Duplicated session/compaction logic | `core/agent-session.ts` |

### Benefits

1. **Clear separation of concerns**: Each mode has its own file
2. **No more duplication**: Session management, bash execution, compaction in one place
3. **Testability**: `AgentSession`, `CommandHandler` can be unit tested independently
4. **Maintainability**: Adding a new mode means adding one file, not touching everything
5. **Naming clarity**: `InteractiveMode` instead of `TuiRenderer`

### Risks

1. **Large refactor**: This is a significant change touching core code paths
2. **TUI coupling**: Some slash commands deeply interact with TUI state (selectors, rendering). May need to keep some coupling.
3. **Testing**: Need to manually test all three modes after refactoring

---

What's your take? Want me to proceed with any specific phase, or adjust the plan?


AgentSession looks like a good idea, can we put more stuff into that? what about thinking levels etc.? if we could get rid of the tui coupling then agent session could be resused for all modes, exposing all the things each mode can use