Commit 175ec946dba6
Changed files (1)
dots
pi
agent
extensions
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);
+ },
+ });
+}