Commit 175ec946dba6

Vincent Demeester <vincent@sbr.pm>
2026-02-10 17:20:21
feat: add kitty terminal control pi extension
Added extension for kitty terminal with two capabilities: Interactive picker (/kitty, Ctrl+;) with action menu: - Reference: insert @kitty:ID to inject window content - Focus: switch to a window/tab - Send text: type into another window - Close: close a window with confirmation LLM tool (kitty_control) with 9 actions: - list, get_text, focus, send_text, launch, close_window, close_tab, set_title, scroll Uses kitty remote control API (kitty @) which requires allow_remote_control in kitty.conf.
1 parent 22d9142
Changed files (1)
dots
pi
agent
dots/pi/agent/extensions/kitty-reference.ts
@@ -0,0 +1,953 @@
+/**
+ * Kitty Reference & Control Extension
+ *
+ * Two capabilities:
+ * 1. Reference: Pick kitty windows and inject their content as prompt context
+ * 2. Control: LLM-callable tool to list, focus, read, send text, launch, and close windows
+ *
+ * Features:
+ * - /kitty command and Ctrl+; shortcut to open window picker
+ * - Picker shows all windows with preview, action menu (reference, focus, send text, close)
+ * - @kitty:ID references in prompts auto-inject window content
+ * - kitty_control tool for LLM to interact with the terminal
+ *
+ * Requires: allow_remote_control and listen_on in kitty.conf
+ *
+ * Inspired by tmux-reference.ts from laulauland/dotfiles
+ */
+
+import { type ExtensionAPI, type ExtensionContext, type Theme } from "@mariozechner/pi-coding-agent";
+import { DynamicBorder } from "@mariozechner/pi-coding-agent";
+import { matchesKey, visibleWidth, type SelectItem, SelectList, Container, Text, Spacer } from "@mariozechner/pi-tui";
+import { Type } from "@sinclair/typebox";
+import { StringEnum } from "@mariozechner/pi-ai";
+import { execSync } from "node:child_process";
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Types
+// ═══════════════════════════════════════════════════════════════════════════
+
+const KITTY_REF_PATTERN = /@kitty:(\d+)(?::(\w+))?/g;
+
+type TextExtent = "screen" | "all" | "last_cmd_output" | "last_non_empty_output";
+
+const EXTENT_LABELS: Record<TextExtent, string> = {
+	screen: "Screen",
+	all: "Screen + Scrollback",
+	last_cmd_output: "Last command output",
+	last_non_empty_output: "Last non-empty output",
+};
+
+interface KittyWindow {
+	id: number;
+	tabId: number;
+	tabTitle: string;
+	title: string;
+	cwd: string;
+	isSelf: boolean;
+	isFocused: boolean;
+	cmdline: string[];
+	foregroundProcess: string;
+}
+
+interface KittyTab {
+	id: number;
+	title: string;
+	isActive: boolean;
+	windowCount: number;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Kitty Remote Control Helpers
+// ═══════════════════════════════════════════════════════════════════════════
+
+function kittyExec(args: string): string {
+	return execSync(`kitty @ ${args}`, {
+		encoding: "utf8",
+		stdio: ["pipe", "pipe", "pipe"],
+		timeout: 10000,
+	}).trim();
+}
+
+function isKittyAvailable(): boolean {
+	try {
+		kittyExec("ls");
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+function listKittyWindows(): KittyWindow[] {
+	try {
+		const output = kittyExec("ls");
+		const data = JSON.parse(output);
+		const windows: KittyWindow[] = [];
+
+		for (const osWindow of data) {
+			for (const tab of osWindow.tabs) {
+				for (const win of tab.windows) {
+					const fg = win.foreground_processes?.[0] ?? {};
+					windows.push({
+						id: win.id,
+						tabId: tab.id,
+						tabTitle: tab.title || `Tab ${tab.id}`,
+						title: win.title || "",
+						cwd: win.cwd || "",
+						isSelf: win.is_self || false,
+						isFocused: win.is_focused || false,
+						cmdline: fg.cmdline || [],
+						foregroundProcess: fg.cmdline?.[0]?.split("/").pop() || "?",
+					});
+				}
+			}
+		}
+
+		return windows;
+	} catch {
+		return [];
+	}
+}
+
+function listKittyTabs(): KittyTab[] {
+	try {
+		const output = kittyExec("ls");
+		const data = JSON.parse(output);
+		const tabs: KittyTab[] = [];
+
+		for (const osWindow of data) {
+			for (const tab of osWindow.tabs) {
+				tabs.push({
+					id: tab.id,
+					title: tab.title || `Tab ${tab.id}`,
+					isActive: tab.is_active || false,
+					windowCount: tab.windows?.length || 0,
+				});
+			}
+		}
+
+		return tabs;
+	} catch {
+		return [];
+	}
+}
+
+function captureWindowContent(windowId: number, extent: TextExtent = "screen"): string {
+	try {
+		return kittyExec(`get-text --match id:${windowId} --extent ${extent}`);
+	} catch {
+		if (extent !== "screen") {
+			try {
+				return kittyExec(`get-text --match id:${windowId} --extent screen`);
+			} catch { /* fall through */ }
+		}
+		return `[Error capturing window ${windowId}]`;
+	}
+}
+
+function focusWindow(windowId: number): boolean {
+	try {
+		kittyExec(`focus-window --match id:${windowId}`);
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+function focusTab(tabId: number): boolean {
+	try {
+		kittyExec(`focus-tab --match id:${tabId}`);
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+function sendText(windowId: number, text: string): boolean {
+	try {
+		// Use stdin to avoid shell escaping issues
+		execSync(`kitty @ send-text --match id:${windowId} --stdin`, {
+			input: text,
+			stdio: ["pipe", "pipe", "pipe"],
+			timeout: 5000,
+		});
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+function launchWindow(opts: { cmd?: string[]; cwd?: string; title?: string; type?: string }): number | null {
+	try {
+		const args: string[] = ["launch"];
+		if (opts.type) args.push(`--type=${opts.type}`);
+		if (opts.cwd) args.push(`--cwd=${opts.cwd}`);
+		if (opts.title) args.push(`--title=${opts.title}`);
+		if (opts.cmd && opts.cmd.length > 0) {
+			args.push("--");
+			args.push(...opts.cmd);
+		}
+		const result = kittyExec(args.join(" "));
+		const id = parseInt(result, 10);
+		return isNaN(id) ? null : id;
+	} catch {
+		return null;
+	}
+}
+
+function closeWindow(windowId: number): boolean {
+	try {
+		kittyExec(`close-window --match id:${windowId}`);
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+function closeTab(tabId: number): boolean {
+	try {
+		kittyExec(`close-tab --match id:${tabId}`);
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+function setWindowTitle(windowId: number, title: string): boolean {
+	try {
+		kittyExec(`set-window-title --match id:${windowId} --temporary ${JSON.stringify(title)}`);
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+function setTabTitle(tabId: number, title: string): boolean {
+	try {
+		kittyExec(`set-tab-title --match id:${tabId} ${JSON.stringify(title)}`);
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+function scrollWindow(windowId: number, amount: string): boolean {
+	try {
+		kittyExec(`scroll-window --match id:${windowId} ${amount}`);
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+function shortCwd(cwd: string): string {
+	return cwd.replace(/^\/home\/[^/]+/, "~");
+}
+
+function formatWindowLabel(win: KittyWindow): string {
+	return `${win.foregroundProcess} (${shortCwd(win.cwd)})`;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Picker Overlay
+// ═══════════════════════════════════════════════════════════════════════════
+
+type PickerResult =
+	| { action: "reference"; window: KittyWindow; extent: TextExtent }
+	| { action: "focus"; window: KittyWindow }
+	| { action: "send-text"; window: KittyWindow }
+	| { action: "close"; window: KittyWindow }
+	| null;
+
+class KittyPickerOverlay {
+	readonly width = 120;
+	private readonly maxVisible = 8;
+	private readonly previewLines = 12;
+
+	private windows: KittyWindow[] = [];
+	private filteredWindows: KittyWindow[] = [];
+	private query = "";
+	private selectedIndex = 0;
+	private scrollOffset = 0;
+	private previewCache = new Map<number, string[]>();
+	private selectedExtent: TextExtent = "last_non_empty_output";
+	private showingActions = false;
+	private actionIndex = 0;
+
+	private actions: { value: string; label: string }[] = [
+		{ value: "reference", label: "Add to prompt (@kitty:ID)" },
+		{ value: "focus", label: "Focus window" },
+		{ value: "send-text", label: "Send text to window" },
+		{ value: "close", label: "Close window" },
+	];
+
+	constructor(
+		private theme: Theme,
+		private done: (result: PickerResult) => void,
+	) {
+		this.refreshWindows();
+	}
+
+	private refreshWindows(): void {
+		this.windows = listKittyWindows().filter((w) => !w.isSelf);
+		this.filterWindows();
+	}
+
+	private filterWindows(): void {
+		if (!this.query) {
+			this.filteredWindows = this.windows;
+		} else {
+			const lowerQuery = this.query.toLowerCase();
+			this.filteredWindows = this.windows.filter(
+				(w) =>
+					w.title.toLowerCase().includes(lowerQuery) ||
+					w.foregroundProcess.toLowerCase().includes(lowerQuery) ||
+					w.cwd.toLowerCase().includes(lowerQuery) ||
+					w.tabTitle.toLowerCase().includes(lowerQuery) ||
+					w.cmdline.join(" ").toLowerCase().includes(lowerQuery) ||
+					String(w.id).includes(lowerQuery),
+			);
+		}
+		this.selectedIndex = 0;
+		this.scrollOffset = 0;
+	}
+
+	private getPreview(win: KittyWindow): string[] {
+		if (this.previewCache.has(win.id)) {
+			return this.previewCache.get(win.id)!;
+		}
+
+		const content = captureWindowContent(win.id, "screen");
+		const lines = content.split("\n").filter((l) => l.trim());
+		const lastLines = lines.slice(-this.previewLines);
+		this.previewCache.set(win.id, lastLines);
+		return lastLines;
+	}
+
+	private cycleExtent(direction: 1 | -1): void {
+		const extents: TextExtent[] = ["screen", "all", "last_cmd_output", "last_non_empty_output"];
+		const currentIdx = extents.indexOf(this.selectedExtent);
+		const nextIdx = (currentIdx + direction + extents.length) % extents.length;
+		this.selectedExtent = extents[nextIdx];
+	}
+
+	handleInput(data: string): void {
+		if (this.showingActions) {
+			this.handleActionInput(data);
+			return;
+		}
+
+		if (matchesKey(data, "escape")) {
+			this.done(null);
+			return;
+		}
+
+		if (matchesKey(data, "return")) {
+			const win = this.filteredWindows[this.selectedIndex];
+			if (win) {
+				this.showingActions = true;
+				this.actionIndex = 0;
+			}
+			return;
+		}
+
+		if (matchesKey(data, "up")) {
+			if (this.selectedIndex > 0) {
+				this.selectedIndex--;
+				if (this.selectedIndex < this.scrollOffset) {
+					this.scrollOffset = this.selectedIndex;
+				}
+				this.previewCache.clear();
+			}
+			return;
+		}
+
+		if (matchesKey(data, "down")) {
+			if (this.selectedIndex < this.filteredWindows.length - 1) {
+				this.selectedIndex++;
+				if (this.selectedIndex >= this.scrollOffset + this.maxVisible) {
+					this.scrollOffset = this.selectedIndex - this.maxVisible + 1;
+				}
+				this.previewCache.clear();
+			}
+			return;
+		}
+
+		if (matchesKey(data, "tab")) {
+			this.cycleExtent(1);
+			return;
+		}
+		if (matchesKey(data, "shift+tab")) {
+			this.cycleExtent(-1);
+			return;
+		}
+
+		if (matchesKey(data, "backspace")) {
+			if (this.query.length > 0) {
+				this.query = this.query.slice(0, -1);
+				this.filterWindows();
+			}
+			return;
+		}
+
+		if (data.length === 1 && data.charCodeAt(0) >= 32) {
+			this.query += data;
+			this.filterWindows();
+		}
+	}
+
+	private handleActionInput(data: string): void {
+		if (matchesKey(data, "escape")) {
+			this.showingActions = false;
+			return;
+		}
+
+		if (matchesKey(data, "up")) {
+			this.actionIndex = (this.actionIndex - 1 + this.actions.length) % this.actions.length;
+			return;
+		}
+
+		if (matchesKey(data, "down")) {
+			this.actionIndex = (this.actionIndex + 1) % this.actions.length;
+			return;
+		}
+
+		if (matchesKey(data, "return")) {
+			const win = this.filteredWindows[this.selectedIndex];
+			if (!win) return;
+
+			const action = this.actions[this.actionIndex].value;
+			switch (action) {
+				case "reference":
+					this.done({ action: "reference", window: win, extent: this.selectedExtent });
+					break;
+				case "focus":
+					this.done({ action: "focus", window: win });
+					break;
+				case "send-text":
+					this.done({ action: "send-text", window: win });
+					break;
+				case "close":
+					this.done({ action: "close", window: win });
+					break;
+			}
+		}
+	}
+
+	render(_width: number): string[] {
+		const w = this.width;
+		const th = this.theme;
+		const innerW = w - 2;
+		const lines: string[] = [];
+
+		const pad = (s: string, len: number) => {
+			const vis = visibleWidth(s);
+			return s + " ".repeat(Math.max(0, len - vis));
+		};
+
+		const truncate = (s: string, maxW: number) => {
+			if (visibleWidth(s) <= maxW) return s;
+			let result = "";
+			let width = 0;
+			for (const char of s) {
+				const charWidth = visibleWidth(char);
+				if (width + charWidth > maxW - 1) break;
+				result += char;
+				width += charWidth;
+			}
+			return result + "…";
+		};
+
+		const row = (content: string) => th.fg("border", "│") + pad(content, innerW) + th.fg("border", "│");
+
+		// Top border
+		lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
+
+		// Title
+		lines.push(row(` ${th.fg("accent", th.bold("Kitty Windows"))}`));
+
+		// Search input
+		const searchPrompt = th.fg("accent", "❯ ");
+		const searchText = this.query || th.fg("dim", "Search windows...");
+		lines.push(row(` ${searchPrompt}${searchText}`));
+
+		// Extent selector
+		const extentLabel = EXTENT_LABELS[this.selectedExtent];
+		lines.push(row(` ${th.fg("muted", "Capture:")} ${th.fg("accent", extentLabel)} ${th.fg("dim", "(tab to cycle)")}`));
+
+		// Divider
+		lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
+
+		// Window list
+		const visibleWindows = this.filteredWindows.slice(
+			this.scrollOffset,
+			this.scrollOffset + this.maxVisible,
+		);
+
+		const idWidth = 6;
+		const procWidth = 14;
+		const cwdWidth = 30;
+
+		for (let i = 0; i < this.maxVisible; i++) {
+			if (i < visibleWindows.length) {
+				const win = visibleWindows[i]!;
+				const actualIndex = this.scrollOffset + i;
+				const isSelected = actualIndex === this.selectedIndex;
+
+				const prefix = isSelected ? th.fg("accent", " ▶ ") : "   ";
+				const idStr = pad(`#${win.id}`, idWidth);
+				const proc = pad(truncate(win.foregroundProcess, procWidth), procWidth);
+				const cwd = pad(truncate(shortCwd(win.cwd), cwdWidth), cwdWidth);
+				const separator = th.fg("dim", "│ ");
+
+				const tabInfo = truncate(win.tabTitle, innerW - idWidth - procWidth - cwdWidth - 12);
+
+				const idStyled = th.fg("dim", idStr);
+				const procStyled = isSelected ? th.fg("text", proc) : th.fg("accent", proc);
+				const cwdStyled = th.fg("muted", cwd);
+				const tabStyled = th.fg("dim", tabInfo);
+
+				lines.push(row(`${prefix}${idStyled}${separator}${procStyled}${separator}${cwdStyled}${separator}${tabStyled}`));
+			} else if (i === 0 && this.filteredWindows.length === 0) {
+				lines.push(row(th.fg("dim", "   No windows found")));
+			} else {
+				lines.push(row(""));
+			}
+		}
+
+		// Scroll indicator
+		if (this.filteredWindows.length > this.maxVisible) {
+			const shown = `${this.scrollOffset + 1}-${Math.min(
+				this.scrollOffset + this.maxVisible,
+				this.filteredWindows.length,
+			)}`;
+			lines.push(row(th.fg("dim", ` (${shown} of ${this.filteredWindows.length})`)));
+		} else {
+			lines.push(row(""));
+		}
+
+		// Preview or action menu
+		lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
+
+		if (this.showingActions) {
+			const selectedWin = this.filteredWindows[this.selectedIndex];
+			if (selectedWin) {
+				lines.push(row(` ${th.fg("accent", th.bold("Action for:"))} ${th.fg("muted", `#${selectedWin.id} ${selectedWin.foregroundProcess}`)}`));
+				lines.push(row(""));
+				for (let i = 0; i < this.actions.length; i++) {
+					const isSelected = i === this.actionIndex;
+					const prefix = isSelected ? th.fg("accent", " ▶ ") : "   ";
+					const label = isSelected ? th.fg("accent", this.actions[i].label) : th.fg("muted", this.actions[i].label);
+					lines.push(row(`${prefix}${label}`));
+				}
+				// Pad remaining lines
+				for (let i = 0; i < this.previewLines - this.actions.length; i++) {
+					lines.push(row(""));
+				}
+			}
+		} else {
+			const selectedWin = this.filteredWindows[this.selectedIndex];
+			if (selectedWin) {
+				const label = `#${selectedWin.id} ${selectedWin.foregroundProcess}`;
+				const cmd = selectedWin.cmdline.join(" ");
+				lines.push(row(` ${th.fg("accent", "Preview:")} ${th.fg("muted", label)} ${th.fg("dim", `[${truncate(cmd, 50)}]`)}`));
+				lines.push(row(""));
+
+				const preview = this.getPreview(selectedWin);
+				for (let i = 0; i < this.previewLines; i++) {
+					const previewLine = preview[i] ?? "";
+					const truncatedPreview = truncate(previewLine, innerW - 2);
+					lines.push(row(` ${th.fg("dim", truncatedPreview)}`));
+				}
+			} else {
+				lines.push(row(th.fg("dim", " No window selected")));
+				for (let i = 0; i < this.previewLines + 1; i++) {
+					lines.push(row(""));
+				}
+			}
+		}
+
+		// Footer
+		lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
+		if (this.showingActions) {
+			lines.push(row(th.fg("dim", " ↑↓ navigate  Enter confirm  Esc back")));
+		} else {
+			lines.push(row(th.fg("dim", " ↑↓ navigate  Tab capture mode  Enter actions  Esc cancel")));
+		}
+
+		// Bottom border
+		lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
+
+		return lines;
+	}
+
+	invalidate(): void {}
+	dispose(): void {}
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Reference Resolution
+// ═══════════════════════════════════════════════════════════════════════════
+
+function resolveKittyReferences(prompt: string): { resolvedPrompt: string; contexts: string[] } {
+	const contexts: string[] = [];
+	const windows = listKittyWindows();
+
+	const resolvedPrompt = prompt.replace(KITTY_REF_PATTERN, (match, windowId, extent) => {
+		const id = parseInt(windowId, 10);
+		const win = windows.find((w) => w.id === id);
+
+		if (win) {
+			const textExtent = (extent as TextExtent) || "last_non_empty_output";
+			const content = captureWindowContent(id, textExtent);
+			const cmd = win.cmdline.join(" ");
+			contexts.push(
+				`## Kitty Window #${win.id}\n**Process:** ${win.foregroundProcess}\n**Command:** \`${cmd}\`\n**CWD:** \`${shortCwd(win.cwd)}\`\n**Capture:** ${EXTENT_LABELS[textExtent] || textExtent}\n\n\`\`\`\n${content}\n\`\`\``,
+			);
+			return match;
+		}
+		return match;
+	});
+
+	return { resolvedPrompt, contexts };
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Extension Export
+// ═══════════════════════════════════════════════════════════════════════════
+
+export default function (pi: ExtensionAPI) {
+	// ─── Interactive Picker ─────────────────────────────────────────────
+
+	async function openKittyPicker(ctx: ExtensionContext): Promise<void> {
+		if (!ctx.hasUI) {
+			ctx.ui.notify("Kitty picker requires interactive mode", "error");
+			return;
+		}
+
+		if (!isKittyAvailable()) {
+			ctx.ui.notify("Kitty remote control not available. Check allow_remote_control in kitty.conf", "error");
+			return;
+		}
+
+		const result = await ctx.ui.custom<PickerResult>(
+			(_tui, theme, _kb, done) => new KittyPickerOverlay(theme, done),
+			{ overlay: true },
+		);
+
+		if (!result) return;
+
+		switch (result.action) {
+			case "reference": {
+				const extentSuffix = result.extent !== "last_non_empty_output" ? `:${result.extent}` : "";
+				const ref = `@kitty:${result.window.id}${extentSuffix}`;
+				const currentText = ctx.ui.getEditorText();
+				ctx.ui.setEditorText(currentText + ref + " ");
+				ctx.ui.notify(`Inserted reference to: ${formatWindowLabel(result.window)}`, "info");
+				break;
+			}
+			case "focus": {
+				focusTab(result.window.tabId);
+				focusWindow(result.window.id);
+				ctx.ui.notify(`Focused: ${formatWindowLabel(result.window)}`, "info");
+				break;
+			}
+			case "send-text": {
+				const text = await ctx.ui.input("Send text to window:", "");
+				if (text) {
+					const ok = sendText(result.window.id, text + "\n");
+					if (ok) {
+						ctx.ui.notify(`Sent text to: ${formatWindowLabel(result.window)}`, "info");
+					} else {
+						ctx.ui.notify("Failed to send text", "error");
+					}
+				}
+				break;
+			}
+			case "close": {
+				const ok = await ctx.ui.confirm(
+					"Close window?",
+					`Close #${result.window.id} ${formatWindowLabel(result.window)}?`,
+				);
+				if (ok) {
+					closeWindow(result.window.id);
+					ctx.ui.notify(`Closed: ${formatWindowLabel(result.window)}`, "info");
+				}
+				break;
+			}
+		}
+	}
+
+	pi.registerCommand("kitty", {
+		description: "Pick kitty window to reference or control",
+		handler: async (_args, ctx) => openKittyPicker(ctx),
+	});
+
+	pi.registerShortcut("ctrl+;", {
+		description: "Pick kitty window to reference or control",
+		handler: async (ctx) => openKittyPicker(ctx),
+	});
+
+	// ─── Reference Injection ────────────────────────────────────────────
+
+	pi.on("before_agent_start", async (event, _ctx) => {
+		const { contexts } = resolveKittyReferences(event.prompt);
+
+		if (contexts.length === 0) return;
+
+		return {
+			message: {
+				customType: "kitty-reference",
+				content: contexts.join("\n\n---\n\n"),
+				display: true,
+			},
+		};
+	});
+
+	// ─── LLM Tool ───────────────────────────────────────────────────────
+
+	pi.registerTool({
+		name: "kitty_control",
+		label: "Kitty Control",
+		description: `Control the kitty terminal emulator. Can list windows/tabs, read window content, focus windows, send text/commands to windows, launch new windows/tabs, close windows/tabs, set titles, and scroll.
+
+Use this to:
+- See what's running in other terminal windows
+- Read output from commands running in other panes
+- Send commands to other terminal windows
+- Open new terminals for parallel work
+- Manage terminal layout`,
+		parameters: Type.Object({
+			action: StringEnum([
+				"list",
+				"get_text",
+				"focus",
+				"send_text",
+				"launch",
+				"close_window",
+				"close_tab",
+				"set_title",
+				"scroll",
+			] as const, {
+				description: "Action to perform",
+			}),
+			window_id: Type.Optional(Type.Number({
+				description: "Target window ID (from list action). Required for: get_text, focus, send_text, close_window, set_title, scroll",
+			})),
+			tab_id: Type.Optional(Type.Number({
+				description: "Target tab ID. For: close_tab, set_title (tab), focus (focuses tab first)",
+			})),
+			text: Type.Optional(Type.String({
+				description: "Text to send (send_text) or title to set (set_title)",
+			})),
+			extent: Type.Optional(StringEnum([
+				"screen",
+				"all",
+				"last_cmd_output",
+				"last_non_empty_output",
+			] as const, {
+				description: "What text to capture (get_text). Default: last_non_empty_output. last_cmd_output and last_non_empty_output require shell integration.",
+			})),
+			cmd: Type.Optional(Type.Array(Type.String(), {
+				description: "Command to run in new window (launch). Empty = default shell",
+			})),
+			cwd: Type.Optional(Type.String({
+				description: "Working directory for new window (launch)",
+			})),
+			launch_type: Type.Optional(StringEnum([
+				"window",
+				"tab",
+				"os-window",
+			] as const, {
+				description: "Type of window to create (launch). Default: tab",
+			})),
+			scroll_amount: Type.Optional(Type.String({
+				description: "Scroll amount (scroll). Examples: '10' (10 lines down), '2p-' (2 pages up), '1r-' (previous prompt), 'start', 'end'",
+			})),
+		}),
+
+		async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
+			if (!isKittyAvailable()) {
+				return {
+					content: [{ type: "text", text: "Kitty remote control not available. Ensure allow_remote_control is set in kitty.conf" }],
+					isError: true,
+				};
+			}
+
+			switch (params.action) {
+				case "list": {
+					const windows = listKittyWindows();
+					const tabs = listKittyTabs();
+
+					let text = `## Kitty Windows\n\n`;
+					text += `**${tabs.length} tabs, ${windows.length} windows**\n\n`;
+
+					const groupedByTab = new Map<number, KittyWindow[]>();
+					for (const win of windows) {
+						const tabWins = groupedByTab.get(win.tabId) || [];
+						tabWins.push(win);
+						groupedByTab.set(win.tabId, tabWins);
+					}
+
+					for (const tab of tabs) {
+						const tabWins = groupedByTab.get(tab.id) || [];
+						text += `### Tab ${tab.id}: ${tab.title}${tab.isActive ? " (active)" : ""}\n`;
+						for (const win of tabWins) {
+							const flags = [
+								win.isSelf ? "self" : "",
+								win.isFocused ? "focused" : "",
+							].filter(Boolean).join(", ");
+							const flagStr = flags ? ` [${flags}]` : "";
+							text += `- **#${win.id}** ${win.foregroundProcess} — \`${shortCwd(win.cwd)}\`${flagStr}\n`;
+							text += `  cmd: \`${win.cmdline.join(" ")}\`\n`;
+						}
+						text += "\n";
+					}
+
+					return {
+						content: [{ type: "text", text }],
+						details: { windows, tabs },
+					};
+				}
+
+				case "get_text": {
+					if (!params.window_id) {
+						return { content: [{ type: "text", text: "window_id is required for get_text" }], isError: true };
+					}
+					const extent = (params.extent as TextExtent) || "last_non_empty_output";
+					const content = captureWindowContent(params.window_id, extent);
+					return {
+						content: [{ type: "text", text: `## Window #${params.window_id} (${EXTENT_LABELS[extent]})\n\n\`\`\`\n${content}\n\`\`\`` }],
+						details: { windowId: params.window_id, extent },
+					};
+				}
+
+				case "focus": {
+					if (params.tab_id) focusTab(params.tab_id);
+					if (params.window_id) {
+						const ok = focusWindow(params.window_id);
+						if (!ok) return { content: [{ type: "text", text: `Failed to focus window #${params.window_id}` }], isError: true };
+					}
+					const target = params.window_id ? `window #${params.window_id}` : `tab #${params.tab_id}`;
+					return { content: [{ type: "text", text: `Focused ${target}` }] };
+				}
+
+				case "send_text": {
+					if (!params.window_id || !params.text) {
+						return { content: [{ type: "text", text: "window_id and text are required for send_text" }], isError: true };
+					}
+					const ok = sendText(params.window_id, params.text);
+					if (!ok) return { content: [{ type: "text", text: `Failed to send text to window #${params.window_id}` }], isError: true };
+					return {
+						content: [{ type: "text", text: `Sent ${params.text.length} chars to window #${params.window_id}` }],
+						details: { windowId: params.window_id, textLength: params.text.length },
+					};
+				}
+
+				case "launch": {
+					const type = params.launch_type || "tab";
+					const id = launchWindow({
+						cmd: params.cmd,
+						cwd: params.cwd,
+						title: params.text,
+						type,
+					});
+					if (id === null) return { content: [{ type: "text", text: "Failed to launch window" }], isError: true };
+					return {
+						content: [{ type: "text", text: `Launched new ${type}: window #${id}` }],
+						details: { windowId: id, type },
+					};
+				}
+
+				case "close_window": {
+					if (!params.window_id) return { content: [{ type: "text", text: "window_id is required" }], isError: true };
+					const ok = closeWindow(params.window_id);
+					if (!ok) return { content: [{ type: "text", text: `Failed to close window #${params.window_id}` }], isError: true };
+					return { content: [{ type: "text", text: `Closed window #${params.window_id}` }] };
+				}
+
+				case "close_tab": {
+					if (!params.tab_id) return { content: [{ type: "text", text: "tab_id is required" }], isError: true };
+					const ok = closeTab(params.tab_id);
+					if (!ok) return { content: [{ type: "text", text: `Failed to close tab #${params.tab_id}` }], isError: true };
+					return { content: [{ type: "text", text: `Closed tab #${params.tab_id}` }] };
+				}
+
+				case "set_title": {
+					if (!params.text) return { content: [{ type: "text", text: "text (title) is required" }], isError: true };
+					if (params.tab_id) {
+						setTabTitle(params.tab_id, params.text);
+						return { content: [{ type: "text", text: `Set tab #${params.tab_id} title to: ${params.text}` }] };
+					}
+					if (params.window_id) {
+						setWindowTitle(params.window_id, params.text);
+						return { content: [{ type: "text", text: `Set window #${params.window_id} title to: ${params.text}` }] };
+					}
+					return { content: [{ type: "text", text: "window_id or tab_id required" }], isError: true };
+				}
+
+				case "scroll": {
+					if (!params.window_id || !params.scroll_amount) {
+						return { content: [{ type: "text", text: "window_id and scroll_amount are required" }], isError: true };
+					}
+					const ok = scrollWindow(params.window_id, params.scroll_amount);
+					if (!ok) return { content: [{ type: "text", text: `Failed to scroll window #${params.window_id}` }], isError: true };
+					return { content: [{ type: "text", text: `Scrolled window #${params.window_id}: ${params.scroll_amount}` }] };
+				}
+
+				default:
+					return { content: [{ type: "text", text: `Unknown action: ${params.action}` }], isError: true };
+			}
+		},
+
+		renderCall(args, theme) {
+			let text = theme.fg("toolTitle", theme.bold("kitty_control "));
+			text += theme.fg("accent", args.action);
+			if (args.window_id) text += theme.fg("dim", ` #${args.window_id}`);
+			if (args.tab_id) text += theme.fg("dim", ` tab:${args.tab_id}`);
+			if (args.action === "send_text" && args.text) {
+				const preview = args.text.length > 40 ? args.text.slice(0, 40) + "…" : args.text;
+				text += " " + theme.fg("muted", `"${preview}"`);
+			}
+			if (args.action === "launch") {
+				const type = args.launch_type || "tab";
+				text += " " + theme.fg("muted", type);
+				if (args.cmd) text += " " + theme.fg("dim", args.cmd.join(" "));
+			}
+			return new Text(text, 0, 0);
+		},
+
+		renderResult(result, { expanded }, theme) {
+			const textContent = result.content?.[0];
+			const text = textContent?.type === "text" ? textContent.text : "(no output)";
+
+			if (result.isError) {
+				return new Text(theme.fg("error", "✗ " + text), 0, 0);
+			}
+
+			// For list action, show compact summary
+			const { details } = result;
+			if (details?.windows && details?.tabs) {
+				const icon = theme.fg("success", "✓");
+				if (!expanded) {
+					let summary = `${icon} ${details.tabs.length} tabs, ${details.windows.length} windows`;
+					for (const win of details.windows.slice(0, 3)) {
+						summary += `\n  ${theme.fg("dim", `#${win.id}`)} ${theme.fg("accent", win.foregroundProcess)} ${theme.fg("muted", shortCwd(win.cwd))}`;
+					}
+					if (details.windows.length > 3) {
+						summary += `\n  ${theme.fg("muted", `... +${details.windows.length - 3} more`)}`;
+					}
+					return new Text(summary, 0, 0);
+				}
+			}
+
+			// Default: show full text
+			const icon = theme.fg("success", "✓");
+			const firstLine = text.split("\n")[0].replace(/^#+\s*/, "");
+			if (!expanded) {
+				return new Text(`${icon} ${firstLine}`, 0, 0);
+			}
+			return new Text(`${icon} ${text}`, 0, 0);
+		},
+	});
+}