import { stripVTControlCharacters } from "node:util";
import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import type { Theme } from "@mariozechner/pi-coding-agent";
import { PtyTerminalSession } from "./pty-session.js";
import { sessionManager, generateSessionId } from "./session-manager.js";
import type { InteractiveShellConfig } from "./config.js";
import {
	type InteractiveShellResult,
	type HandsFreeUpdate,
	type InteractiveShellOptions,
	type DialogChoice,
	type OverlayState,
	HEADER_LINES,
	FOOTER_LINES_COMPACT,
	formatDuration,
	formatShortcut,
} from "./types.js";
import { captureCompletionOutput, captureTransferOutput, maybeBuildHandoffPreview, maybeWriteHandoffSnapshot } from "./handoff-utils.js";
import { createSessionQueryState, getSessionOutput } from "./session-query.js";

export class InteractiveShellOverlay implements Component, Focusable {
	focused = false;

	private tui: TUI;
	private theme: Theme;
	private done: (result: InteractiveShellResult) => void;
	private session: PtyTerminalSession;
	private options: InteractiveShellOptions;
	private config: InteractiveShellConfig;

	private state: OverlayState = "running";
	private dialogSelection: DialogChoice = "transfer";
	private exitCountdown = 0;
	private countdownInterval: ReturnType<typeof setInterval> | null = null;
	private lastWidth = 0;
	private lastHeight = 0;
	// Hands-free mode
	private userTookOver = false;
	private handsFreeInterval: ReturnType<typeof setInterval> | null = null;
	private handsFreeInitialTimeout: ReturnType<typeof setTimeout> | null = null;
	private startTime: number;
	private sessionId: string | null = null;
	private sessionUnregistered = false;
	// Timeout
	private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
	// Prevent double done() calls
	private finished = false;
	// Budget tracking for hands-free updates
	private totalCharsSent = 0;
	private budgetExhausted = false;
	private currentUpdateInterval: number;
	private currentQuietThreshold: number;
	private updateMode: "on-quiet" | "interval";
	private quietTimer: ReturnType<typeof setTimeout> | null = null;
	private hasUnsentData = false;
	// Non-blocking mode: track status for agent queries
	private completionResult: InteractiveShellResult | undefined;
	private queryState = createSessionQueryState();
	// Completion callbacks for waiters
	private completeCallbacks: Array<() => void> = [];
	// Simple render throttle to reduce flicker
	private renderTimeout: ReturnType<typeof setTimeout> | null = null;

	constructor(
		tui: TUI,
		theme: Theme,
		options: InteractiveShellOptions,
		config: InteractiveShellConfig,
		done: (result: InteractiveShellResult) => void,
	) {
		this.tui = tui;
		this.theme = theme;
		this.options = options;
		this.config = config;
		this.done = done;
		this.startTime = options.startedAt ?? Date.now();

		const overlayWidth = Math.floor((tui.terminal.columns * this.config.overlayWidthPercent) / 100);
		const overlayHeight = Math.floor((tui.terminal.rows * this.config.overlayHeightPercent) / 100);
		const cols = Math.max(20, overlayWidth - 4);
		const rows = Math.max(3, overlayHeight - (HEADER_LINES + FOOTER_LINES_COMPACT + 2));

		const ptyEvents = {
			onData: (data: string) => {
				this.debouncedRender();
				if (this.state === "hands-free" && (this.updateMode === "on-quiet" || this.options.autoExitOnQuiet)) {
					const visible = stripVTControlCharacters(data);
					if (visible.trim().length > 0) {
						if (this.updateMode === "on-quiet") {
							this.hasUnsentData = true;
						}
						this.resetQuietTimer();
					}
				}
			},
			onExit: () => {
				if (this.finished) return;
				this.stopTimeout();

				if (this.state === "hands-free" && this.sessionId) {
					if (this.hasUnsentData || this.updateMode === "interval") {
						this.emitHandsFreeUpdate();
						this.hasUnsentData = false;
					}
					if (this.options.onHandsFreeUpdate) {
						this.options.onHandsFreeUpdate({
							status: "exited",
							sessionId: this.sessionId,
							runtime: Date.now() - this.startTime,
							tail: [],
							tailTruncated: false,
							totalCharsSent: this.totalCharsSent,
							budgetExhausted: this.budgetExhausted,
						});
					}
					this.finishWithExit();
					return;
				}

				this.stopHandsFreeUpdates();
				this.state = "exited";
				this.exitCountdown = this.config.exitAutoCloseDelay;
				this.startExitCountdown();
				this.tui.requestRender();
			},
		};

		if (options.existingSession) {
			this.session = options.existingSession;
			this.session.setEventHandlers(ptyEvents);
			this.session.resize(cols, rows);
		} else {
			this.session = new PtyTerminalSession(
				{
					command: options.command,
					cwd: options.cwd,
					cols,
					rows,
					scrollback: this.config.scrollbackLines,
					ansiReemit: this.config.ansiReemit,
				},
				ptyEvents,
			);
		}

		// Initialize hands-free mode settings
		this.updateMode = options.handsFreeUpdateMode ?? config.handsFreeUpdateMode;
		this.currentUpdateInterval = options.handsFreeUpdateInterval ?? config.handsFreeUpdateInterval;
		this.currentQuietThreshold = options.handsFreeQuietThreshold ?? config.handsFreeQuietThreshold;

		if (options.mode === "hands-free" || options.mode === "dispatch") {
			this.state = "hands-free";
			this.sessionId = options.sessionId ?? generateSessionId(options.name);
			sessionManager.registerActive({
				id: this.sessionId,
				command: options.command,
				reason: options.reason,
				write: (data) => this.session.write(data),
				kill: () => this.killSession(),
				background: () => this.backgroundSession(),
				getOutput: (options) => this.getOutputSinceLastCheck(options),
				getStatus: () => this.getSessionStatus(),
				getRuntime: () => this.getRuntime(),
				getResult: () => this.getCompletionResult(),
				setUpdateInterval: (intervalMs) => this.setUpdateInterval(intervalMs),
				setQuietThreshold: (thresholdMs) => this.setQuietThreshold(thresholdMs),
				onComplete: (callback) => this.registerCompleteCallback(callback),
			});
			this.startHandsFreeUpdates();
		}

		if (options.timeout && options.timeout > 0) {
			this.timeoutTimer = setTimeout(() => {
				this.finishWithTimeout();
			}, options.timeout);
		}

		if (options.existingSession && options.existingSession.exited) {
			queueMicrotask(() => {
				if (this.finished) return;
				this.stopTimeout();
				if (this.state === "hands-free" && this.sessionId) {
					if (this.options.onHandsFreeUpdate) {
						this.options.onHandsFreeUpdate({
							status: "exited",
							sessionId: this.sessionId,
							runtime: Date.now() - this.startTime,
							tail: [],
							tailTruncated: false,
							totalCharsSent: this.totalCharsSent,
							budgetExhausted: this.budgetExhausted,
						});
					}
					this.finishWithExit();
				} else {
					this.stopHandsFreeUpdates();
					this.state = "exited";
					this.exitCountdown = this.config.exitAutoCloseDelay;
					this.startExitCountdown();
					this.tui.requestRender();
				}
			});
		}
	}

	// Public methods for non-blocking mode (agent queries)

	/** Get rendered terminal output (last N lines, truncated if too large) */
	getOutputSinceLastCheck(options: { skipRateLimit?: boolean; lines?: number; maxChars?: number; offset?: number; drain?: boolean; incremental?: boolean } | boolean = false): { output: string; truncated: boolean; totalBytes: number; totalLines?: number; hasMore?: boolean; rateLimited?: boolean; waitSeconds?: number } {
		return getSessionOutput(this.session, this.config, this.queryState, options, this.completionResult?.completionOutput);
	}

	/** Get current session status */
	getSessionStatus(): "running" | "user-takeover" | "exited" | "killed" | "backgrounded" {
		if (this.completionResult) {
			if (this.completionResult.cancelled) return "killed";
			if (this.completionResult.backgrounded) return "backgrounded";
			if (this.userTookOver) return "user-takeover";
			return "exited";
		}
		if (this.userTookOver) return "user-takeover";
		if (this.state === "exited") return "exited";
		return "running";
	}

	/** Get runtime in milliseconds */
	getRuntime(): number {
		return Date.now() - this.startTime;
	}

	/** Get completion result (if session has ended) */
	getCompletionResult(): InteractiveShellResult | undefined {
		return this.completionResult;
	}

	/** Register a callback to be called when session completes */
	registerCompleteCallback(callback: () => void): void {
		// If already completed, call immediately
		if (this.completionResult) {
			callback();
			return;
		}
		this.completeCallbacks.push(callback);
	}

	/** Trigger all completion callbacks */
	private triggerCompleteCallbacks(): void {
		for (const callback of this.completeCallbacks) {
			try {
				callback();
			} catch (error) {
				console.error("interactive-shell: completion callback error:", error);
			}
		}
		this.completeCallbacks = [];
	}

	/** Debounced render - waits for data to settle before rendering */
	private debouncedRender(): void {
		if (this.renderTimeout) {
			clearTimeout(this.renderTimeout);
		}
		// Wait 16ms for more data before rendering
		this.renderTimeout = setTimeout(() => {
			this.renderTimeout = null;
			this.tui.requestRender();
		}, 16);
	}

	/** Kill the session programmatically */
	killSession(): void {
		if (!this.finished) {
			this.finishWithKill();
		}
	}

	private startExitCountdown(): void {
		this.stopCountdown();
		this.countdownInterval = setInterval(() => {
			this.exitCountdown--;
			if (this.exitCountdown <= 0) {
				this.finishWithExit();
			} else {
				this.tui.requestRender();
			}
		}, 1000);
	}

	private stopCountdown(): void {
		if (this.countdownInterval) {
			clearInterval(this.countdownInterval);
			this.countdownInterval = null;
		}
	}

	private startHandsFreeUpdates(): void {
		if (this.options.onHandsFreeUpdate) {
			// Send initial update after a short delay (let process start)
			this.handsFreeInitialTimeout = setTimeout(() => {
				this.handsFreeInitialTimeout = null;
				if (this.state === "hands-free") {
					this.emitHandsFreeUpdate();
				}
			}, 2000);

			this.handsFreeInterval = setInterval(() => {
				if (this.state === "hands-free") {
					if (this.updateMode === "on-quiet") {
						if (this.hasUnsentData) {
							this.emitHandsFreeUpdate();
							this.hasUnsentData = false;
							if (this.options.autoExitOnQuiet) {
								this.resetQuietTimer();
							} else {
								this.stopQuietTimer();
							}
						}
					} else {
						this.emitHandsFreeUpdate();
					}
				}
			}, this.currentUpdateInterval);
		}

		if (this.options.autoExitOnQuiet) {
			this.resetQuietTimer();
		}
	}

	private resetQuietTimer(): void {
		this.stopQuietTimer();
		this.quietTimer = setTimeout(() => {
			this.quietTimer = null;
			if (this.state === "hands-free") {
				// Auto-exit on quiet: kill session when output stops (agent likely finished task)
				if (this.options.autoExitOnQuiet) {
					const gracePeriod = this.options.autoExitGracePeriod ?? this.config.autoExitGracePeriod;
					if (Date.now() - this.startTime < gracePeriod) {
						if (this.hasUnsentData) {
							this.emitHandsFreeUpdate();
							this.hasUnsentData = false;
						}
						this.resetQuietTimer();
						return;
					}
					// Emit final update with any pending output
					if (this.hasUnsentData) {
						this.emitHandsFreeUpdate();
						this.hasUnsentData = false;
					}
					// Send completion notification and auto-close
					// Use "killed" status since we're forcibly terminating (matches finishWithKill's cancelled=true)
					if (this.options.onHandsFreeUpdate && this.sessionId) {
						this.options.onHandsFreeUpdate({
							status: "killed",
							sessionId: this.sessionId,
							runtime: Date.now() - this.startTime,
							tail: [],
							tailTruncated: false,
							totalCharsSent: this.totalCharsSent,
							budgetExhausted: this.budgetExhausted,
						});
					}
					this.finishWithKill();
					return;
				}
				// Normal behavior: just emit update
				if (this.hasUnsentData) {
					this.emitHandsFreeUpdate();
					this.hasUnsentData = false;
				}
			}
		}, this.currentQuietThreshold);
	}

	private stopQuietTimer(): void {
		if (this.quietTimer) {
			clearTimeout(this.quietTimer);
			this.quietTimer = null;
		}
	}

	/** Update the hands-free update interval dynamically */
	setUpdateInterval(intervalMs: number): void {
		const clamped = Math.max(5000, Math.min(300000, intervalMs));
		if (clamped === this.currentUpdateInterval) return;
		this.currentUpdateInterval = clamped;

		if (this.handsFreeInterval) {
			clearInterval(this.handsFreeInterval);
			this.handsFreeInterval = setInterval(() => {
				if (this.state === "hands-free") {
					if (this.updateMode === "on-quiet") {
						if (this.hasUnsentData) {
							this.emitHandsFreeUpdate();
							this.hasUnsentData = false;
							if (this.options.autoExitOnQuiet) {
								this.resetQuietTimer();
							} else {
								this.stopQuietTimer();
							}
						}
					} else {
						this.emitHandsFreeUpdate();
					}
				}
			}, this.currentUpdateInterval);
		}
	}

	/** Update the quiet threshold dynamically */
	setQuietThreshold(thresholdMs: number): void {
		const clamped = Math.max(1000, Math.min(30000, thresholdMs));
		if (clamped === this.currentQuietThreshold) return;
		this.currentQuietThreshold = clamped;

		if (this.quietTimer) {
			this.resetQuietTimer();
		}
	}

	private stopHandsFreeUpdates(): void {
		if (this.handsFreeInitialTimeout) {
			clearTimeout(this.handsFreeInitialTimeout);
			this.handsFreeInitialTimeout = null;
		}
		if (this.handsFreeInterval) {
			clearInterval(this.handsFreeInterval);
			this.handsFreeInterval = null;
		}
		this.stopQuietTimer();
	}

	private stopTimeout(): void {
		if (this.timeoutTimer) {
			clearTimeout(this.timeoutTimer);
			this.timeoutTimer = null;
		}
	}

	private unregisterActiveSession(releaseId = false): void {
		if (this.sessionId && !this.sessionUnregistered) {
			sessionManager.unregisterActive(this.sessionId, releaseId);
			this.sessionUnregistered = true;
		}
	}

	private emitHandsFreeUpdate(): void {
		if (!this.options.onHandsFreeUpdate || !this.sessionId) return;

		const maxChars = this.options.handsFreeUpdateMaxChars ?? this.config.handsFreeUpdateMaxChars;
		const maxTotalChars = this.options.handsFreeMaxTotalChars ?? this.config.handsFreeMaxTotalChars;

		let tail: string[] = [];
		let truncated = false;

		// Only include content if budget not exhausted
		if (!this.budgetExhausted) {
			// Get incremental output since last update
			let newOutput = this.session.getRawStream({ sinceLast: true, stripAnsi: true });

			// Truncate if exceeds per-update limit
			if (newOutput.length > maxChars) {
				newOutput = newOutput.slice(-maxChars);
				truncated = true;
			}

			// Check total budget
			if (this.totalCharsSent + newOutput.length > maxTotalChars) {
				// Truncate to fit remaining budget
				const remaining = maxTotalChars - this.totalCharsSent;
				if (remaining > 0) {
					newOutput = newOutput.slice(-remaining);
					truncated = true;
				} else {
					newOutput = "";
				}
				this.budgetExhausted = true;
			}

			if (newOutput.length > 0) {
				this.totalCharsSent += newOutput.length;
				// Split into lines for the tail array
				tail = newOutput.split("\n");
			}
		}

		this.options.onHandsFreeUpdate({
			status: "running",
			sessionId: this.sessionId,
			runtime: Date.now() - this.startTime,
			tail,
			tailTruncated: truncated,
			totalCharsSent: this.totalCharsSent,
			budgetExhausted: this.budgetExhausted,
		});
	}

	private triggerUserTakeover(): void {
		if (this.state !== "hands-free" || !this.sessionId) return;

		// Flush any pending output before stopping updates
		// In interval mode, hasUnsentData is not tracked, so always flush
		if (this.hasUnsentData || this.updateMode === "interval") {
			this.emitHandsFreeUpdate();
			this.hasUnsentData = false;
		}

		this.stopHandsFreeUpdates();
		this.state = "running";
		this.userTookOver = true;

		if (this.options.onHandsFreeUpdate) {
			this.options.onHandsFreeUpdate({
				status: "user-takeover",
				sessionId: this.sessionId,
				runtime: Date.now() - this.startTime,
				tail: [],
				tailTruncated: false,
				userTookOver: true,
				totalCharsSent: this.totalCharsSent,
				budgetExhausted: this.budgetExhausted,
			});
		}
		// In streaming mode (blocking tool call), unregister now since the agent
		// gets the result via tool return. Otherwise keep registered for queries.
		if (this.options.streamingMode) {
			this.unregisterActiveSession(true);
		}

		this.tui.requestRender();
	}

	private returnToHandsFree(): void {
		if (!this.userTookOver || !this.sessionId || this.session.exited) return;

		this.state = "hands-free";
		this.userTookOver = false;

		// Re-register if streaming mode previously released the session
		if (this.sessionUnregistered) {
			sessionManager.registerActive({
				id: this.sessionId,
				command: this.options.command,
				reason: this.options.reason,
				write: (data) => this.session.write(data),
				kill: () => this.killSession(),
				background: () => this.backgroundSession(),
				getOutput: (options) => this.getOutputSinceLastCheck(options),
				getStatus: () => this.getSessionStatus(),
				getRuntime: () => this.getRuntime(),
				getResult: () => this.getCompletionResult(),
				setUpdateInterval: (intervalMs) => this.setUpdateInterval(intervalMs),
				setQuietThreshold: (thresholdMs) => this.setQuietThreshold(thresholdMs),
				onComplete: (callback) => this.registerCompleteCallback(callback),
			});
			this.sessionUnregistered = false;
		}

		if (this.options.onHandsFreeUpdate) {
			this.options.onHandsFreeUpdate({
				status: "agent-resumed",
				sessionId: this.sessionId,
				runtime: Date.now() - this.startTime,
				tail: [],
				tailTruncated: false,
				totalCharsSent: this.totalCharsSent,
				budgetExhausted: this.budgetExhausted,
			});
		}

		this.startHandsFreeUpdates();
		this.tui.requestRender();
	}

	private getDialogOptions(): Array<{ key: DialogChoice; label: string }> {
		const options: Array<{ key: DialogChoice; label: string }> = [];
		if (this.userTookOver && !this.session.exited) {
			options.push({ key: "return-to-agent", label: "Return control to agent" });
		}
		options.push(
			{ key: "transfer", label: "Transfer output to agent" },
			{ key: "background", label: "Run in background" },
			{ key: "kill", label: "Kill process" },
			{ key: "cancel", label: "Cancel (return to session)" },
		);
		return options;
	}

	/** Capture output for dispatch completion notifications */
	private captureCompletionOutput(): InteractiveShellResult["completionOutput"] {
		return captureCompletionOutput(this.session, this.config);
	}

	/** Capture output for transfer action (Ctrl+T or dialog) */
	private captureTransferOutput(): InteractiveShellResult["transferred"] {
		return captureTransferOutput(this.session, this.config);
	}

	private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoffPreview"] | undefined {
		return maybeBuildHandoffPreview(this.session, when, this.config, this.options);
	}

	private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill" | "timeout" | "transfer"): InteractiveShellResult["handoff"] | undefined {
		return maybeWriteHandoffSnapshot(this.session, when, this.config, {
			command: this.options.command,
			cwd: this.options.cwd,
		}, this.options);
	}

	private finishWithExit(): void {
		if (this.finished) return;
		this.finished = true;
		this.stopCountdown();
		this.stopTimeout();
		this.stopHandsFreeUpdates();

		const handoffPreview = this.maybeBuildHandoffPreview("exit");
		const handoff = this.maybeWriteHandoffSnapshot("exit");
		const completionOutput = this.captureCompletionOutput();
		this.session.dispose();
		const result: InteractiveShellResult = {
			exitCode: this.session.exitCode,
			signal: this.session.signal,
			backgrounded: false,
			cancelled: false,
			sessionId: this.sessionId ?? undefined,
			userTookOver: this.userTookOver,
			completionOutput,
			handoffPreview,
			handoff,
		};
		this.completionResult = result;
		this.triggerCompleteCallbacks();

		// In streaming mode (blocking tool call), unregister now since the agent
		// gets the result via tool return. Otherwise keep registered for queries.
		if (this.options.streamingMode) {
			this.unregisterActiveSession(true);
		}

		this.done(result);
	}

	backgroundSession(): void {
		this.finishWithBackground();
	}

	private finishWithBackground(): void {
		if (this.finished) return;
		this.finished = true;
		this.stopCountdown();
		this.stopTimeout();
		this.stopHandsFreeUpdates();

		const handoffPreview = this.maybeBuildHandoffPreview("detach");
		const handoff = this.maybeWriteHandoffSnapshot("detach");
		const addOptions = this.sessionId
			? { id: this.sessionId, noAutoCleanup: this.options.mode === "dispatch", startedAt: new Date(this.startTime) }
			: undefined;
		const id = sessionManager.add(this.options.command, this.session, this.options.name, this.options.reason, addOptions);
		const result: InteractiveShellResult = {
			exitCode: null,
			backgrounded: true,
			backgroundId: id,
			cancelled: false,
			sessionId: this.sessionId ?? undefined,
			userTookOver: this.userTookOver,
			handoffPreview,
			handoff,
		};
		this.completionResult = result;
		this.triggerCompleteCallbacks();

		// In streaming mode (blocking tool call), unregister now since the agent
		// gets the result via tool return. releaseId=false because background owns the ID.
		if (this.options.streamingMode) {
			this.unregisterActiveSession(false);
		}

		this.done(result);
	}

	private finishWithKill(): void {
		if (this.finished) return;
		this.finished = true;
		this.stopCountdown();
		this.stopTimeout();
		this.stopHandsFreeUpdates();

		const handoffPreview = this.maybeBuildHandoffPreview("kill");
		const handoff = this.maybeWriteHandoffSnapshot("kill");
		const completionOutput = this.captureCompletionOutput();
		this.session.kill();
		this.session.dispose();
		const result: InteractiveShellResult = {
			exitCode: null,
			backgrounded: false,
			cancelled: true,
			sessionId: this.sessionId ?? undefined,
			userTookOver: this.userTookOver,
			completionOutput,
			handoffPreview,
			handoff,
		};
		this.completionResult = result;
		this.triggerCompleteCallbacks();

		// In streaming mode (blocking tool call), unregister now since the agent
		// gets the result via tool return. Otherwise keep registered for queries.
		if (this.options.streamingMode) {
			this.unregisterActiveSession(true);
		}

		this.done(result);
	}

	private finishWithTransfer(): void {
		if (this.finished) return;
		this.finished = true;
		this.stopCountdown();
		this.stopTimeout();
		this.stopHandsFreeUpdates();

		// Capture output BEFORE killing the session
		const transferred = this.captureTransferOutput();
		const completionOutput = this.captureCompletionOutput();
		const handoffPreview = this.maybeBuildHandoffPreview("transfer");
		const handoff = this.maybeWriteHandoffSnapshot("transfer");

		this.session.kill();
		this.session.dispose();
		const result: InteractiveShellResult = {
			exitCode: this.session.exitCode,
			signal: this.session.signal,
			backgrounded: false,
			cancelled: false,
			sessionId: this.sessionId ?? undefined,
			userTookOver: this.userTookOver,
			transferred,
			completionOutput,
			handoffPreview,
			handoff,
		};
		this.completionResult = result;
		this.triggerCompleteCallbacks();

		// In streaming mode (blocking tool call), unregister now since the agent
		// gets the result via tool return. Otherwise keep registered for queries.
		if (this.options.streamingMode) {
			this.unregisterActiveSession(true);
		}

		this.done(result);
	}

	private finishWithTimeout(): void {
		if (this.finished) return;
		this.finished = true;
		this.stopCountdown();
		this.stopTimeout();

		// Send final update with any unsent data, then "exited" notification (for timeout)
		if (this.state === "hands-free" && this.options.onHandsFreeUpdate && this.sessionId) {
			// Flush any pending output before sending exited notification
			if (this.hasUnsentData || this.updateMode === "interval") {
				this.emitHandsFreeUpdate();
				this.hasUnsentData = false;
			}
			// Now send exited notification (timedOut is indicated in final tool result)
			this.options.onHandsFreeUpdate({
				status: "exited",
				sessionId: this.sessionId,
				runtime: Date.now() - this.startTime,
				tail: [],
				tailTruncated: false,
				totalCharsSent: this.totalCharsSent,
				budgetExhausted: this.budgetExhausted,
			});
		}

		this.stopHandsFreeUpdates();
		const handoffPreview = this.maybeBuildHandoffPreview("timeout");
		const handoff = this.maybeWriteHandoffSnapshot("timeout");
		const completionOutput = this.captureCompletionOutput();
		this.session.kill();
		this.session.dispose();
		const result: InteractiveShellResult = {
			exitCode: null,
			backgrounded: false,
			cancelled: false,
			timedOut: true,
			sessionId: this.sessionId ?? undefined,
			userTookOver: this.userTookOver,
			completionOutput,
			handoffPreview,
			handoff,
		};
		this.completionResult = result;
		this.triggerCompleteCallbacks();

		// In streaming mode (blocking tool call), unregister now since the agent
		// gets the result via tool return. Otherwise keep registered for queries.
		if (this.options.streamingMode) {
			this.unregisterActiveSession(true);
		}

		this.done(result);
	}

	handleInput(data: string): void {
		if (this.state === "detach-dialog") {
			this.handleDialogInput(data);
			return;
		}

		if (matchesKey(data, this.config.focusShortcut)) {
			this.options.onUnfocus?.();
			return;
		}

		// Ctrl+G: Return to agent monitoring (only active during takeover)
		if (this.userTookOver && this.state === "running" && matchesKey(data, "ctrl+g")) {
			this.returnToHandsFree();
			return;
		}

		// Ctrl+T: Quick transfer - capture output and close (works in all states including "exited")
		if (matchesKey(data, "ctrl+t")) {
			// If in hands-free mode, trigger takeover first (notifies agent)
			if (this.state === "hands-free") {
				this.triggerUserTakeover();
			}
			this.finishWithTransfer();
			return;
		}

		// Ctrl+B: Quick background - dismiss overlay, keep process running
		if (matchesKey(data, "ctrl+b") && !this.session.exited) {
			if (this.state === "hands-free") {
				this.triggerUserTakeover();
			}
			this.finishWithBackground();
			return;
		}

		if (this.state === "exited") {
			if (data.length > 0) {
				this.finishWithExit();
			}
			return;
		}

		// Ctrl+Q opens detach dialog (works in both hands-free and running)
		if (matchesKey(data, "ctrl+q")) {
			// If in hands-free mode, trigger takeover first (notifies agent)
			if (this.state === "hands-free") {
				this.triggerUserTakeover();
			}
			this.state = "detach-dialog";
			this.dialogSelection = (this.userTookOver && !this.session.exited) ? "return-to-agent" : "transfer";
			this.tui.requestRender();
			return;
		}

		// Scroll does NOT trigger takeover
		if (matchesKey(data, "shift+up")) {
			this.session.scrollUp(Math.max(1, this.session.rows - 2));
			this.tui.requestRender();
			return;
		}
		if (matchesKey(data, "shift+down")) {
			this.session.scrollDown(Math.max(1, this.session.rows - 2));
			this.tui.requestRender();
			return;
		}

		// Any other input in hands-free mode triggers user takeover
		if (this.state === "hands-free") {
			this.triggerUserTakeover();
			// Fall through to send the input to subprocess
		}

		this.session.write(data);
	}

	private handleDialogInput(data: string): void {
		if (matchesKey(data, "escape")) {
			this.state = "running";
			this.tui.requestRender();
			return;
		}

		if (matchesKey(data, "up") || matchesKey(data, "down")) {
			const options = this.getDialogOptions();
			const keys = options.map(o => o.key);
			const currentIdx = keys.indexOf(this.dialogSelection);
			const direction = matchesKey(data, "up") ? -1 : 1;
			const newIdx = (currentIdx + direction + keys.length) % keys.length;
			this.dialogSelection = keys[newIdx]!;
			this.tui.requestRender();
			return;
		}

		if (matchesKey(data, "enter")) {
			switch (this.dialogSelection) {
				case "return-to-agent":
					this.returnToHandsFree();
					break;
				case "transfer":
					this.finishWithTransfer();
					break;
				case "kill":
					this.finishWithKill();
					break;
				case "background":
					this.finishWithBackground();
					break;
				case "cancel":
					this.state = "running";
					this.tui.requestRender();
					break;
			}
		}
	}

	render(width: number): string[] {
		width = Math.max(4, width);
		const th = this.theme;
		const borderColor = this.focused ? "borderAccent" : "borderMuted";
		const borderGlyphs = this.focused
			? { topLeft: "╔", topRight: "╗", bottomLeft: "╚", bottomRight: "╝", horizontal: "═", vertical: "║", separatorLeft: "╠", separatorRight: "╣" }
			: { topLeft: "╭", topRight: "╮", bottomLeft: "╰", bottomRight: "╯", horizontal: "─", vertical: "│", separatorLeft: "├", separatorRight: "┤" };
		const border = (s: string) => th.fg(borderColor, s);
		const accent = (s: string) => th.fg("accent", s);
		const dim = (s: string) => th.fg("dim", s);
		const warning = (s: string) => th.fg("warning", s);

		const innerWidth = width - 4;
		const pad = (s: string, w: number) => {
			const vis = visibleWidth(s);
			return s + " ".repeat(Math.max(0, w - vis));
		};
		const row = (content: string) => border(`${borderGlyphs.vertical} `) + pad(truncateToWidth(content, innerWidth, ""), innerWidth) + border(` ${borderGlyphs.vertical}`);
		const emptyRow = () => row("");

		const lines: string[] = [];

		// Sanitize command: collapse newlines and whitespace to single spaces for display
		const sanitizedCommand = this.options.command.replace(/\s+/g, " ").trim();
		const focusBadgeLabel = this.focused ? " SHELL FOCUSED " : " EDITOR FOCUSED ";
		const compactFocusBadgeLabel = this.focused ? " SHELL " : " EDITOR ";
		const makeFocusBadge = (label: string) => th.bg("selectedBg", th.bold(th.fg(this.focused ? "accent" : "muted", label)));
		const pid = dim(`PID: ${this.session.pid}`);
		let titleMeta = `${makeFocusBadge(focusBadgeLabel)} ${pid}`;
		if (visibleWidth(titleMeta) > innerWidth - 4) {
			titleMeta = `${makeFocusBadge(compactFocusBadgeLabel)} ${pid}`;
		}
		if (visibleWidth(titleMeta) > innerWidth - 2) {
			titleMeta = makeFocusBadge(compactFocusBadgeLabel);
		}
		titleMeta = truncateToWidth(titleMeta, innerWidth, "");
		const title = truncateToWidth(sanitizedCommand, Math.max(0, innerWidth - visibleWidth(titleMeta) - 1), "...");
		lines.push(border(borderGlyphs.topLeft + borderGlyphs.horizontal.repeat(width - 2) + borderGlyphs.topRight));
		lines.push(
			row(
				accent(title) +
					" ".repeat(Math.max(0, innerWidth - visibleWidth(title) - visibleWidth(titleMeta))) +
					titleMeta,
			),
		);
		let hint: string;
		// Sanitize reason: collapse newlines and whitespace to single spaces for display
		const sanitizedReason = this.options.reason?.replace(/\s+/g, " ").trim();
		if (this.state === "hands-free") {
			const elapsed = formatDuration(Date.now() - this.startTime);
			hint = `🤖 Hands-free (${elapsed}) • Type anything to take over`;
		} else if (this.userTookOver) {
			hint = sanitizedReason
				? `You took over • Ctrl+G return to agent • ${sanitizedReason}`
				: "You took over • Ctrl+G return to agent";
		} else {
			hint = sanitizedReason
				? `Ctrl+B background • ${sanitizedReason}`
				: "Ctrl+B background";
		}
		lines.push(row(dim(truncateToWidth(hint, innerWidth, "..."))));
		lines.push(border(borderGlyphs.separatorLeft + borderGlyphs.horizontal.repeat(width - 2) + borderGlyphs.separatorRight));

		const dialogOptions = this.state === "detach-dialog" ? this.getDialogOptions() : [];
		const overlayHeight = Math.floor((this.tui.terminal.rows * this.config.overlayHeightPercent) / 100);
		const footerHeight = this.state === "detach-dialog" ? dialogOptions.length + 2 : FOOTER_LINES_COMPACT;
		const chrome = HEADER_LINES + footerHeight + 2;
		const termRows = Math.max(0, overlayHeight - chrome);

		if (termRows > 0) {
			if (innerWidth !== this.lastWidth || termRows !== this.lastHeight) {
				this.session.resize(innerWidth, termRows);
				this.lastWidth = innerWidth;
				this.lastHeight = termRows;
				// After resize, ensure we're at the bottom to prevent flash to top
				this.session.scrollToBottom();
			}

			const viewportLines = this.session.getViewportLines({ ansi: this.config.ansiReemit });
			for (const line of viewportLines) {
				lines.push(row(truncateToWidth(line, innerWidth, "")));
			}
		}

		if (this.session.isScrolledUp()) {
			const hintText = "── ↑ scrolled (Shift+Down) ──";
			const padLen = Math.max(0, Math.floor((width - 2 - visibleWidth(hintText)) / 2));
			lines.push(
				border(borderGlyphs.separatorLeft) +
					dim(
						" ".repeat(padLen) +
							hintText +
							" ".repeat(width - 2 - padLen - visibleWidth(hintText)),
					) +
					border(borderGlyphs.separatorRight),
			);
		} else {
			lines.push(border(borderGlyphs.separatorLeft + borderGlyphs.horizontal.repeat(width - 2) + borderGlyphs.separatorRight));
		}

		const footerLines: string[] = [];
		const focusHint = `${formatShortcut(this.config.focusShortcut)} ${this.focused ? "unfocus" : "focus shell"}`;

		if (this.state === "detach-dialog") {
			footerLines.push(row(accent("Session actions:")));
			for (const opt of dialogOptions) {
				const sel = this.dialogSelection === opt.key;
				footerLines.push(row((sel ? accent("▶ ") : "  ") + (sel ? accent(opt.label) : opt.label)));
			}
			footerLines.push(row(dim("↑↓ select • Enter confirm • Esc cancel")));
		} else if (this.state === "exited") {
			const exitMsg =
				this.session.exitCode === 0
					? th.fg("success", "✓ Exited successfully")
					: warning(`✗ Exited with code ${this.session.exitCode}`);
			footerLines.push(row(exitMsg));
			footerLines.push(row(dim(`Closing in ${this.exitCountdown}s... (any key to close) • ${focusHint}`)));
		} else if (this.state === "hands-free") {
			if (this.focused) {
				footerLines.push(row(dim(`🤖 Agent controlling • Type to take over • Ctrl+T transfer • Ctrl+B background • ${focusHint}`)));
			} else {
				footerLines.push(row(dim(`🤖 Agent controlling • ${focusHint}`)));
			}
		} else if (!this.focused) {
			footerLines.push(row(dim(focusHint)));
		} else if (this.userTookOver) {
			footerLines.push(row(dim(`Ctrl+G agent • Ctrl+T transfer • Ctrl+B background • Ctrl+Q menu • ${focusHint}`)));
		} else {
			footerLines.push(row(dim(`Ctrl+T transfer • Ctrl+B background • Ctrl+Q menu • Shift+Up/Down scroll • ${focusHint}`)));
		}

		while (footerLines.length < footerHeight) {
			footerLines.push(emptyRow());
		}
		lines.push(...footerLines);

		lines.push(border(borderGlyphs.bottomLeft + borderGlyphs.horizontal.repeat(width - 2) + borderGlyphs.bottomRight));

		return lines;
	}

	invalidate(): void {
		this.lastWidth = 0;
		this.lastHeight = 0;
	}

	dispose(): void {
		this.stopCountdown();
		this.stopTimeout();
		this.stopHandsFreeUpdates();
		if (this.renderTimeout) {
			clearTimeout(this.renderTimeout);
			this.renderTimeout = null;
		}
		// Safety cleanup in case dispose() is called without going through finishWith*
		// If session hasn't completed yet, kill it to prevent orphaned processes
		if (!this.completionResult) {
			this.session.kill();
			this.session.dispose();
			this.unregisterActiveSession(true);
		} else if (this.options.streamingMode) {
			// Streaming mode already delivered result via tool return, safe to clean up
			this.unregisterActiveSession(true);
		}
		// Non-blocking mode with completion: keep registered so agent can query
	}
}
