Commit bb387d2c93ed
Changed files (14)
dots
pi
agent
extensions
git
dots/pi/agent/extensions/git/index.ts
@@ -3,7 +3,6 @@
// =============================================================================
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { Type } from "@sinclair/typebox";
import {
listWorktrees,
createWorktree,
@@ -311,29 +310,38 @@ export default function (pi: ExtensionAPI) {
label: "Git Worktree",
description:
"Manage git worktrees in ~/.local/share/worktrees/<org>/<repo>/<branch>. Uses lazyworktree when available for consistent org/repo/name layout, falls back to raw git. Create isolated working directories for different branches without switching in the main repository. USE WHEN starting feature work that needs isolation OR working on multiple branches simultaneously.",
- parameters: Type.Object({
- action: Type.Union([
- Type.Literal("list"),
- Type.Literal("create"),
- Type.Literal("remove"),
- Type.Literal("exec"),
- Type.Literal("note"),
- ], {
- description: "The worktree action to perform"
- }),
- branch: Type.Optional(Type.String({
- description: "Branch name (required for create/remove) or worktree name (for exec/note)"
- })),
- path: Type.Optional(Type.String({
- description: "Custom path for worktree (defaults to ~/.local/share/worktrees/<org>/<repo>/<branch>)"
- })),
- force: Type.Optional(Type.Boolean({
- description: "Force removal even with uncommitted changes"
- })),
- exec: Type.Optional(Type.String({
- description: "Command to run: post-create hook (with create), command to execute (with exec action), or note content to write (with note action). Omit exec with note action to read the note."
- })),
- }),
+ parameters: {
+ type: "object",
+ required: ["action"],
+ properties: {
+ action: {
+ anyOf: [
+ { const: "list", type: "string" },
+ { const: "create", type: "string" },
+ { const: "remove", type: "string" },
+ { const: "exec", type: "string" },
+ { const: "note", type: "string" },
+ ],
+ description: "The worktree action to perform",
+ },
+ branch: {
+ type: "string",
+ description: "Branch name (required for create/remove) or worktree name (for exec/note)",
+ },
+ path: {
+ type: "string",
+ description: "Custom path for worktree (defaults to ~/.local/share/worktrees/<org>/<repo>/<branch>)",
+ },
+ force: {
+ type: "boolean",
+ description: "Force removal even with uncommitted changes",
+ },
+ exec: {
+ type: "string",
+ description: "Command to run: post-create hook (with create), command to execute (with exec action), or note content to write (with note action). Omit exec with note action to read the note.",
+ },
+ },
+ },
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
const { action, branch, path: customPath, force, exec: execCmd } = params;
dots/pi/agent/extensions/intercepted-commands/conda
@@ -1,30 +0,0 @@
-#!/usr/bin/env bash
-# Conda interception - only block package management commands
-# Allow conda for non-Python tasks if needed
-
-case "$1" in
- install|update|remove|uninstall|create|env)
- echo "Error: conda package management is disabled. Use uv instead:" >&2
- echo "" >&2
- echo " conda install PACKAGE -> uv add PACKAGE" >&2
- echo " conda create -n env -> uv venv" >&2
- echo " conda env create -> uv sync (with pyproject.toml)" >&2
- echo " conda update -> uv lock --upgrade && uv sync" >&2
- echo " conda remove -> uv remove PACKAGE" >&2
- echo "" >&2
- echo "For non-Python conda packages, use nix-shell or devenv instead." >&2
- echo "" >&2
- exit 1
- ;;
- *)
- # Allow other conda commands (info, list, etc.) to pass through
- # Find real conda, skipping this shim
- real_conda=$(PATH=$(echo "$PATH" | tr ':' '\n' | grep -v "intercepted-commands" | tr '\n' ':') which conda 2>/dev/null)
- if [ -n "$real_conda" ]; then
- exec "$real_conda" "$@"
- else
- echo "Error: conda not found in PATH (outside of intercepted-commands)" >&2
- exit 1
- fi
- ;;
-esac
dots/pi/agent/extensions/intercepted-commands/pip
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-echo "Error: pip is disabled. Use uv instead:" >&2
-echo "" >&2
-echo " To install a package for a script: uv run --with PACKAGE python script.py" >&2
-echo " To add a dependency to the project: uv add PACKAGE" >&2
-echo " To install from requirements.txt: uv pip install -r requirements.txt" >&2
-echo "" >&2
-exit 1
dots/pi/agent/extensions/intercepted-commands/pip3
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-echo "Error: pip3 is disabled. Use uv instead:" >&2
-echo "" >&2
-echo " To install a package for a script: uv run --with PACKAGE python script.py" >&2
-echo " To add a dependency to the project: uv add PACKAGE" >&2
-echo " To install from requirements.txt: uv pip install -r requirements.txt" >&2
-echo "" >&2
-exit 1
dots/pi/agent/extensions/intercepted-commands/pipenv
@@ -1,10 +0,0 @@
-#!/usr/bin/env bash
-echo "Error: pipenv is disabled. Use uv instead:" >&2
-echo "" >&2
-echo " pipenv install -> uv add / uv sync" >&2
-echo " pipenv run -> uv run" >&2
-echo " pipenv shell -> source .venv/bin/activate (after uv venv)" >&2
-echo " pipenv lock -> uv lock" >&2
-echo " Pipfile -> pyproject.toml (uv init)" >&2
-echo "" >&2
-exit 1
dots/pi/agent/extensions/intercepted-commands/poetry
@@ -1,12 +0,0 @@
-#!/usr/bin/env bash
-echo "Error: poetry is disabled. Use uv instead:" >&2
-echo "" >&2
-echo " poetry init -> uv init" >&2
-echo " poetry add -> uv add" >&2
-echo " poetry install -> uv sync" >&2
-echo " poetry run -> uv run" >&2
-echo " poetry shell -> source .venv/bin/activate (after uv venv)" >&2
-echo " poetry lock -> uv lock" >&2
-echo " poetry update -> uv lock --upgrade" >&2
-echo "" >&2
-exit 1
dots/pi/agent/extensions/intercepted-commands/python
@@ -1,50 +0,0 @@
-#!/usr/bin/env bash
-
-# Check for disallowed module invocations
-for arg in "$@"; do
- case "$arg" in
- -mpip|-m\ pip|pip)
- echo "Error: 'python -m pip' is disabled. Use uv instead:" >&2
- echo "" >&2
- echo " To install a package for a script: uv run --with PACKAGE python script.py" >&2
- echo " To add a dependency to the project: uv add PACKAGE" >&2
- echo "" >&2
- exit 1
- ;;
- -mvenv|-m\ venv|venv)
- echo "Error: 'python -m venv' is disabled. Use uv instead:" >&2
- echo "" >&2
- echo " To create a virtual environment: uv venv" >&2
- echo "" >&2
- exit 1
- ;;
- esac
-done
-
-# Check for -m flag followed by pip or venv
-prev=""
-for arg in "$@"; do
- if [ "$prev" = "-m" ]; then
- case "$arg" in
- pip)
- echo "Error: 'python -m pip' is disabled. Use uv instead:" >&2
- echo "" >&2
- echo " To install a package for a script: uv run --with PACKAGE python script.py" >&2
- echo " To add a dependency to the project: uv add PACKAGE" >&2
- echo "" >&2
- exit 1
- ;;
- venv)
- echo "Error: 'python -m venv' is disabled. Use uv instead:" >&2
- echo "" >&2
- echo " To create a virtual environment: uv venv" >&2
- echo "" >&2
- exit 1
- ;;
- esac
- fi
- prev="$arg"
-done
-
-# Dispatch to uv run python
-exec uv run python "$@"
dots/pi/agent/extensions/intercepted-commands/python3
@@ -1,50 +0,0 @@
-#!/usr/bin/env bash
-
-# Check for disallowed module invocations
-for arg in "$@"; do
- case "$arg" in
- -mpip|-m\ pip|pip)
- echo "Error: 'python3 -m pip' is disabled. Use uv instead:" >&2
- echo "" >&2
- echo " To install a package for a script: uv run --with PACKAGE python script.py" >&2
- echo " To add a dependency to the project: uv add PACKAGE" >&2
- echo "" >&2
- exit 1
- ;;
- -mvenv|-m\ venv|venv)
- echo "Error: 'python3 -m venv' is disabled. Use uv instead:" >&2
- echo "" >&2
- echo " To create a virtual environment: uv venv" >&2
- echo "" >&2
- exit 1
- ;;
- esac
-done
-
-# Check for -m flag followed by pip or venv
-prev=""
-for arg in "$@"; do
- if [ "$prev" = "-m" ]; then
- case "$arg" in
- pip)
- echo "Error: 'python3 -m pip' is disabled. Use uv instead:" >&2
- echo "" >&2
- echo " To install a package for a script: uv run --with PACKAGE python script.py" >&2
- echo " To add a dependency to the project: uv add PACKAGE" >&2
- echo "" >&2
- exit 1
- ;;
- venv)
- echo "Error: 'python3 -m venv' is disabled. Use uv instead:" >&2
- echo "" >&2
- echo " To create a virtual environment: uv venv" >&2
- echo "" >&2
- exit 1
- ;;
- esac
- fi
- prev="$arg"
-done
-
-# Dispatch to uv run python3
-exec uv run python3 "$@"
dots/pi/agent/extensions/intercepted-commands/virtualenv
@@ -1,9 +0,0 @@
-#!/usr/bin/env bash
-echo "Error: virtualenv is disabled. Use uv instead:" >&2
-echo "" >&2
-echo " virtualenv .venv -> uv venv" >&2
-echo " virtualenv env -> uv venv env" >&2
-echo "" >&2
-echo "Then activate with: source .venv/bin/activate" >&2
-echo "" >&2
-exit 1
dots/pi/agent/extensions/auto-theme.ts
@@ -1,214 +0,0 @@
-/**
- * Auto Theme Extension - Sync pi theme with system color scheme
- *
- * Automatically switches between light/dark theme based on system preference.
- * Theme switches happen live without requiring restart.
- *
- * Features:
- * - Reads system color scheme from dconf on startup
- * - Watches for dbus signals when color scheme changes
- * - Automatically updates pi theme (live)
- * - Commands: /theme-sync, /theme-status
- *
- * System integration:
- * - Reads: /org/gnome/desktop/interface/color-scheme (prefer-dark or prefer-light)
- * - Watches: ca.desrt.dconf Writer Notify signals
- * - Compatible with toggle-color-scheme script
- */
-
-import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
-import { exec } from "node:child_process";
-import { promisify } from "node:util";
-
-const execAsync = promisify(exec);
-
-// State
-let currentContext: ExtensionContext | null = null;
-let watchProcess: any = null;
-let currentTheme: "dark" | "light" | null = null;
-
-// =============================================================================
-// System Color Scheme Detection
-// =============================================================================
-
-async function getSystemColorScheme(): Promise<"dark" | "light" | null> {
- try {
- const { stdout } = await execAsync("dconf read /org/gnome/desktop/interface/color-scheme");
- const scheme = stdout.trim().replace(/'/g, "");
-
- if (scheme.includes("prefer-dark")) {
- return "dark";
- } else if (scheme.includes("prefer-light")) {
- return "light";
- }
-
- return null;
- } catch {
- return null;
- }
-}
-
-async function applyTheme(theme: "dark" | "light"): Promise<void> {
- if (!currentContext) {
- return;
- }
-
- if (currentTheme === theme) {
- return; // Already set
- }
-
- try {
- // Map to actual theme names
- // You have: modus-vivendi (dark) and modus-operandi (light)
- // Pi also has built-in: "dark" and "light"
- // Prefer modus themes if available, fallback to built-in
- const themeName = theme === "dark" ? "modus-vivendi" : "modus-operandi";
- currentContext.ui.setTheme(themeName);
- currentTheme = theme;
- } catch (error) {
- // Fallback to built-in themes if modus themes not found
- try {
- currentContext.ui.setTheme(theme);
- currentTheme = theme;
- } catch {
- // Ignore errors - context might not be ready
- }
- }
-}
-
-async function syncTheme(): Promise<"dark" | "light" | null> {
- const systemScheme = await getSystemColorScheme();
-
- if (systemScheme) {
- await applyTheme(systemScheme);
- return systemScheme;
- }
-
- return null;
-}
-
-// =============================================================================
-// D-Bus Watcher
-// =============================================================================
-
-function startWatching(): void {
- if (watchProcess) {
- return; // Already watching
- }
-
- // Use dbus-monitor to watch for color-scheme changes
- const dbusMonitor = exec(
- "dbus-monitor --session \"type='signal',interface='ca.desrt.dconf.Writer',member='Notify'\"",
- { maxBuffer: 1024 * 1024 }
- );
-
- let buffer = "";
-
- dbusMonitor.stdout?.on("data", (data: Buffer) => {
- buffer += data.toString();
- const lines = buffer.split("\n");
- buffer = lines.pop() || "";
-
- for (const line of lines) {
- // Look for color-scheme in the signal
- if (line.includes("color-scheme")) {
- // Debounce: wait a bit for dconf to settle
- setTimeout(async () => {
- await syncTheme();
- }, 500);
- break;
- }
- }
- });
-
- dbusMonitor.on("error", () => {
- watchProcess = null;
- });
-
- dbusMonitor.on("exit", () => {
- watchProcess = null;
- });
-
- watchProcess = dbusMonitor;
-
- // Unref the child process so it doesn't prevent Node.js from exiting
- dbusMonitor.unref();
- dbusMonitor.stdout?.unref();
- dbusMonitor.stderr?.unref();
-}
-
-function stopWatching(): void {
- if (watchProcess) {
- watchProcess.kill();
- watchProcess = null;
- }
-}
-
-// =============================================================================
-// Extension
-// =============================================================================
-
-export default function (pi: ExtensionAPI) {
- // On session start, sync theme and start watching
- pi.on("session_start", async (_event, ctx) => {
- currentContext = ctx;
-
- const theme = await syncTheme();
- if (!theme) {
- // Couldn't detect system scheme, use default
- currentTheme = "dark"; // Assume dark as fallback
- }
-
- // Start watching for changes
- startWatching();
- });
-
- // Cleanup on shutdown
- pi.on("session_shutdown", () => {
- stopWatching();
- currentContext = null;
- currentTheme = null;
- });
-
- // Command: /theme-sync
- pi.registerCommand("theme-sync", {
- description: "Sync pi theme with system color scheme",
- handler: async (_args, ctx) => {
- currentContext = ctx;
- const theme = await syncTheme();
- if (theme) {
- ctx.ui.notify(`Theme synced to: ${theme}`, "success");
- } else {
- ctx.ui.notify("Could not detect system color scheme", "error");
- }
- },
- });
-
- // Command: /theme-status
- pi.registerCommand("theme-status", {
- description: "Show current theme sync status",
- handler: async (_args, ctx) => {
- const systemScheme = await getSystemColorScheme();
- const watching = watchProcess !== null;
- const themeName = currentTheme === "dark" ? "modus-vivendi" : currentTheme === "light" ? "modus-operandi" : "unknown";
-
- const message = `System: ${systemScheme || "unknown"} | Theme: ${themeName} | Watching: ${watching ? "yes" : "no"}`;
- ctx.ui.notify(message, "info");
- },
- });
-
- // Command: /theme-watch
- pi.registerCommand("theme-watch", {
- description: "Toggle watching for system color scheme changes",
- handler: async (_args, ctx) => {
- if (watchProcess) {
- stopWatching();
- ctx.ui.notify("Stopped watching for color scheme changes", "info");
- } else {
- currentContext = ctx;
- startWatching();
- ctx.ui.notify("Started watching for color scheme changes", "info");
- }
- },
- });
-}
dots/pi/agent/extensions/inline-bash.ts
@@ -1,94 +0,0 @@
-/**
- * Inline Bash Extension - expands inline bash commands in user prompts.
- *
- * Start pi with this extension:
- * pi -e ./examples/extensions/inline-bash.ts
- *
- * Then type prompts with inline bash:
- * What's in !{pwd}?
- * The current branch is !{git branch --show-current} and status: !{git status --short}
- * My node version is !{node --version}
- *
- * The !{command} patterns are executed and replaced with their output before
- * the prompt is sent to the agent.
- *
- * Note: Regular !command syntax (whole-line bash) is preserved and works as before.
- */
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-
-export default function (pi: ExtensionAPI) {
- const PATTERN = /!\{([^}]+)\}/g;
- const TIMEOUT_MS = 30000;
-
- pi.on("input", async (event, ctx) => {
- const text = event.text;
-
- // Don't process if it's a whole-line bash command (starts with !)
- // This preserves the existing !command behavior
- if (text.trimStart().startsWith("!") && !text.trimStart().startsWith("!{")) {
- return { action: "continue" };
- }
-
- // Check if there are any inline bash patterns
- if (!PATTERN.test(text)) {
- return { action: "continue" };
- }
-
- // Reset regex state after test()
- PATTERN.lastIndex = 0;
-
- let result = text;
- const expansions: Array<{ command: string; output: string; error?: string }> = [];
-
- // Find all matches first (to avoid issues with replacing while iterating)
- const matches: Array<{ full: string; command: string }> = [];
- let match = PATTERN.exec(text);
- while (match) {
- matches.push({ full: match[0], command: match[1] });
- match = PATTERN.exec(text);
- }
-
- // Execute each command and collect results
- for (const { full, command } of matches) {
- try {
- const bashResult = await pi.exec("bash", ["-c", command], {
- timeout: TIMEOUT_MS,
- });
-
- const output = bashResult.stdout || bashResult.stderr || "";
- const trimmed = output.trim();
-
- if (bashResult.code !== 0 && bashResult.stderr) {
- expansions.push({
- command,
- output: trimmed,
- error: `exit code ${bashResult.code}`,
- });
- } else {
- expansions.push({ command, output: trimmed });
- }
-
- result = result.replace(full, trimmed);
- } catch (err) {
- const errorMsg = err instanceof Error ? err.message : String(err);
- expansions.push({ command, output: "", error: errorMsg });
- result = result.replace(full, `[error: ${errorMsg}]`);
- }
- }
-
- // Show what was expanded (if UI available)
- if (ctx.hasUI && expansions.length > 0) {
- const summary = expansions
- .map((e) => {
- const status = e.error ? ` (${e.error})` : "";
- const preview = e.output.length > 50 ? `${e.output.slice(0, 50)}...` : e.output;
- return `!{${e.command}}${status} -> "${preview}"`;
- })
- .join("\n");
-
- ctx.ui.notify(`Expanded ${expansions.length} inline command(s):\n${summary}`, "info");
- }
-
- return { action: "transform", text: result, images: event.images };
- });
-}
dots/pi/agent/extensions/kitty-reference.ts
@@ -1,953 +0,0 @@
-/**
- * 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). For send_text: include a literal newline for Enter (e.g. 'ls -la\\n' sends the command and presses Enter). Control characters work too: \\u0003 for Ctrl+C, \\u0004 for Ctrl+D, \\t for Tab.",
- })),
- 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);
- },
- });
-}
dots/pi/agent/extensions/tmux-reference.ts
@@ -1,395 +0,0 @@
-/**
- * Tmux Reference Extension - Reference tmux pane content in your prompts
- *
- * Features:
- * - /tmux command to open tmux pane picker
- * - Shows all panes across all sessions
- * - Captures pane content (scrollback buffer)
- * - Inserts @tmux:session:window.pane reference at cursor
- * - Automatically injects pane content on prompt submit
- *
- * Usage:
- * 1. Type /tmux while editing a prompt
- * 2. Select a tmux pane from the list
- * 3. Press Enter to insert the reference
- * 4. Submit your prompt - pane content will be injected automatically
- */
-
-import { type ExtensionAPI, type ExtensionContext, type Theme } from "@mariozechner/pi-coding-agent";
-import { matchesKey, visibleWidth } from "@mariozechner/pi-tui";
-import { execSync } from "node:child_process";
-
-// Tmux reference pattern: @tmux:session:window.pane
-const TMUX_REF_PATTERN = /@tmux:([^:\s]+):(\d+)\.(\d+)/g;
-
-interface TmuxPane {
- sessionName: string;
- windowIndex: number;
- paneIndex: number;
- paneTitle: string;
- currentCommand: string;
- width: number;
- height: number;
-}
-
-function isTmuxAvailable(): boolean {
- try {
- execSync("tmux list-sessions", { stdio: "pipe" });
- return true;
- } catch {
- return false;
- }
-}
-
-function listTmuxPanes(): TmuxPane[] {
- try {
- const output = execSync(
- 'tmux list-panes -a -F "#{session_name}\t#{window_index}\t#{pane_index}\t#{pane_title}\t#{pane_current_command}\t#{pane_width}\t#{pane_height}"',
- { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
- );
-
- return output
- .trim()
- .split("\n")
- .filter((line) => line.trim())
- .map((line) => {
- const [sessionName, windowIndex, paneIndex, paneTitle, currentCommand, width, height] =
- line.split("\t");
- return {
- sessionName: sessionName!,
- windowIndex: parseInt(windowIndex!, 10),
- paneIndex: parseInt(paneIndex!, 10),
- paneTitle: paneTitle || "",
- currentCommand: currentCommand || "",
- width: parseInt(width!, 10),
- height: parseInt(height!, 10),
- };
- });
- } catch {
- return [];
- }
-}
-
-function capturePaneContent(pane: TmuxPane, lines = 500): string {
- try {
- const target = `${pane.sessionName}:${pane.windowIndex}.${pane.paneIndex}`;
- const output = execSync(`tmux capture-pane -t "${target}" -p -S -${lines}`, {
- encoding: "utf8",
- stdio: ["pipe", "pipe", "pipe"],
- });
- return output.trim();
- } catch (error) {
- return `[Error capturing pane: ${error instanceof Error ? error.message : String(error)}]`;
- }
-}
-
-function formatPaneLabel(pane: TmuxPane): string {
- const target = `${pane.sessionName}:${pane.windowIndex}.${pane.paneIndex}`;
- const title = pane.paneTitle || pane.currentCommand || "untitled";
- return `${target} - ${title}`;
-}
-
-/**
- * Tmux pane picker overlay component with preview
- */
-class TmuxPickerOverlay {
- readonly width = 120;
- private readonly maxVisible = 8;
- private readonly previewLines = 12;
-
- private panes: TmuxPane[] = [];
- private filteredPanes: TmuxPane[] = [];
- private query = "";
- private selectedIndex = 0;
- private scrollOffset = 0;
- private previewCache = new Map<string, string[]>();
-
- constructor(
- private theme: Theme,
- private done: (result: TmuxPane | null) => void,
- ) {
- this.refreshPanes();
- }
-
- private refreshPanes(): void {
- this.panes = listTmuxPanes();
- this.filterPanes();
- }
-
- private filterPanes(): void {
- if (!this.query) {
- this.filteredPanes = this.panes;
- } else {
- const lowerQuery = this.query.toLowerCase();
- this.filteredPanes = this.panes.filter(
- (p) =>
- p.sessionName.toLowerCase().includes(lowerQuery) ||
- p.paneTitle.toLowerCase().includes(lowerQuery) ||
- p.currentCommand.toLowerCase().includes(lowerQuery),
- );
- }
- this.selectedIndex = 0;
- this.scrollOffset = 0;
- }
-
- private getPreview(pane: TmuxPane): string[] {
- const key = `${pane.sessionName}:${pane.windowIndex}.${pane.paneIndex}`;
- if (this.previewCache.has(key)) {
- return this.previewCache.get(key)!;
- }
-
- const content = capturePaneContent(pane, 50);
- const lines = content.split("\n").filter((l) => l.trim());
- const lastLines = lines.slice(-this.previewLines);
- this.previewCache.set(key, lastLines);
- return lastLines;
- }
-
- handleInput(data: string): void {
- if (matchesKey(data, "escape")) {
- this.done(null);
- return;
- }
-
- if (matchesKey(data, "return")) {
- this.done(this.filteredPanes[this.selectedIndex] ?? null);
- return;
- }
-
- if (matchesKey(data, "up")) {
- if (this.selectedIndex > 0) {
- this.selectedIndex--;
- if (this.selectedIndex < this.scrollOffset) {
- this.scrollOffset = this.selectedIndex;
- }
- }
- return;
- }
-
- if (matchesKey(data, "down")) {
- if (this.selectedIndex < this.filteredPanes.length - 1) {
- this.selectedIndex++;
- if (this.selectedIndex >= this.scrollOffset + this.maxVisible) {
- this.scrollOffset = this.selectedIndex - this.maxVisible + 1;
- }
- }
- return;
- }
-
- if (matchesKey(data, "backspace")) {
- if (this.query.length > 0) {
- this.query = this.query.slice(0, -1);
- this.filterPanes();
- }
- return;
- }
-
- // Regular character input
- if (data.length === 1 && data.charCodeAt(0) >= 32) {
- this.query += data;
- this.filterPanes();
- }
- }
-
- 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("Tmux Panes"))}`));
-
- // Search input
- const searchPrompt = th.fg("accent", "❯ ");
- const searchText = this.query || th.fg("dim", "Search panes...");
- lines.push(row(` ${searchPrompt}${searchText}`));
-
- // Divider
- lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
-
- // Pane list
- const visiblePanes = this.filteredPanes.slice(
- this.scrollOffset,
- this.scrollOffset + this.maxVisible,
- );
-
- // Calculate max target width for alignment
- const targetWidth = 28;
-
- for (let i = 0; i < this.maxVisible; i++) {
- if (i < visiblePanes.length) {
- const pane = visiblePanes[i]!;
- const actualIndex = this.scrollOffset + i;
- const isSelected = actualIndex === this.selectedIndex;
-
- const prefix = isSelected ? th.fg("accent", " ▶ ") : " ";
- const targetStr = `${pane.sessionName}:${pane.windowIndex}.${pane.paneIndex}`;
- const fittedTarget = visibleWidth(targetStr) > targetWidth
- ? truncate(targetStr, targetWidth)
- : pad(targetStr, targetWidth);
- const target = th.fg("muted", fittedTarget);
- const separator = th.fg("dim", "│ ");
-
- const title = pane.paneTitle || pane.currentCommand || "untitled";
- const fixedWidth = 3 + targetWidth + 2; // prefix + target + separator
- const maxTitleWidth = Math.max(10, innerW - fixedWidth - 1);
- const truncatedTitle = truncate(title, maxTitleWidth);
- const titleStyled = isSelected ? th.fg("text", truncatedTitle) : th.fg("muted", truncatedTitle);
-
- lines.push(row(`${prefix}${target}${separator}${titleStyled}`));
- } else if (i === 0 && this.filteredPanes.length === 0) {
- lines.push(row(th.fg("dim", " No panes found")));
- } else {
- lines.push(row(""));
- }
- }
-
- // Scroll indicator
- if (this.filteredPanes.length > this.maxVisible) {
- const shown = `${this.scrollOffset + 1}-${Math.min(
- this.scrollOffset + this.maxVisible,
- this.filteredPanes.length,
- )}`;
- const total = this.filteredPanes.length;
- lines.push(row(th.fg("dim", ` (${shown} of ${total})`)));
- } else {
- lines.push(row(""));
- }
-
- // Preview section
- lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
-
- const selectedPane = this.filteredPanes[this.selectedIndex];
- if (selectedPane) {
- const target = `${selectedPane.sessionName}:${selectedPane.windowIndex}.${selectedPane.paneIndex}`;
- lines.push(row(` ${th.fg("accent", "Preview:")} ${th.fg("dim", target)}`));
- lines.push(row(""));
-
- const preview = this.getPreview(selectedPane);
- 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 pane selected")));
- for (let i = 0; i < this.previewLines + 1; i++) {
- lines.push(row(""));
- }
- }
-
- // Footer
- lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
- lines.push(row(th.fg("dim", " ↑↓ navigate [Enter] select [Esc] cancel")));
-
- // Bottom border
- lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
-
- return lines;
- }
-
- invalidate(): void {}
- dispose(): void {}
-}
-
-/**
- * Resolve tmux references in a prompt and capture content
- */
-function resolveTmuxReferences(prompt: string): { resolvedPrompt: string; contexts: string[] } {
- const contexts: string[] = [];
- const panes = listTmuxPanes();
-
- const resolvedPrompt = prompt.replace(TMUX_REF_PATTERN, (match, sessionName, windowIndex, paneIndex) => {
- const pane = panes.find(
- (p) =>
- p.sessionName === sessionName &&
- p.windowIndex === parseInt(windowIndex, 10) &&
- p.paneIndex === parseInt(paneIndex, 10),
- );
-
- if (pane) {
- const content = capturePaneContent(pane);
- const target = `${pane.sessionName}:${pane.windowIndex}.${pane.paneIndex}`;
- const title = pane.paneTitle || pane.currentCommand || "untitled";
- contexts.push(
- `## Tmux Pane: ${target}\n**Title:** ${title}\n**Command:** ${pane.currentCommand}\n\n\`\`\`\n${content}\n\`\`\``,
- );
- return match;
- }
- return match;
- });
-
- return { resolvedPrompt, contexts };
-}
-
-export default function (pi: ExtensionAPI) {
- pi.registerCommand("tmux", {
- description: "Insert tmux pane reference",
- handler: async (_args, ctx) => {
- if (!ctx.hasUI) {
- ctx.ui.notify("Tmux picker requires interactive mode", "error");
- return;
- }
-
- if (!isTmuxAvailable()) {
- ctx.ui.notify("Tmux is not running", "error");
- return;
- }
-
- const result = await ctx.ui.custom<TmuxPane | null>(
- (_tui, theme, _kb, done) => new TmuxPickerOverlay(theme, done),
- { overlay: true },
- );
-
- if (result) {
- const target = `${result.sessionName}:${result.windowIndex}.${result.paneIndex}`;
- const currentText = ctx.ui.getEditorText();
- ctx.ui.setEditorText(currentText + `@tmux:${target} `);
- ctx.ui.notify(`Inserted reference to: ${formatPaneLabel(result).slice(0, 40)}...`, "info");
- }
- },
- });
-
- // Inject context when prompt contains tmux references
- pi.on("before_agent_start", async (event, _ctx) => {
- const { contexts } = resolveTmuxReferences(event.prompt);
-
- if (contexts.length === 0) {
- return;
- }
-
- const contextMessage = contexts.join("\n\n---\n\n");
-
- return {
- message: {
- customType: "tmux-reference",
- content: contextMessage,
- display: true,
- },
- };
- });
-}
dots/pi/agent/extensions/worktree-context.ts
@@ -1,139 +0,0 @@
-/**
- * Worktree Context Extension
- *
- * When running inside a lazyworktree worktree, injects the worktree note
- * into the system prompt via before_agent_start. This gives the LLM context
- * about what the worktree is for (PR summary, ticket notes, etc.).
- *
- * Detection: checks if cwd is under ~/.local/share/worktrees/
- * Notes source: lazyworktree's worktree-notes.json or splitted markdown files
- *
- * Commands:
- * /worktree-note — Show the current worktree note (if any)
- */
-
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { existsSync, readFileSync } from "node:fs";
-import { basename, relative } from "node:path";
-import { homedir } from "node:os";
-import { join } from "node:path";
-
-const WORKTREE_BASE = join(homedir(), ".local/share/worktrees");
-const NOTES_JSON = join(homedir(), ".local/share/lazyworktree/worktree-notes.json");
-
-interface WorktreeInfo {
- org: string;
- repo: string;
- branch: string;
- relativePath: string;
-}
-
-function detectWorktree(cwd: string): WorktreeInfo | null {
- if (!cwd.startsWith(WORKTREE_BASE)) return null;
-
- const rel = relative(WORKTREE_BASE, cwd);
- const parts = rel.split("/");
- // Expected: org/repo/branch[/...]
- if (parts.length < 3) return null;
-
- return {
- org: parts[0],
- repo: parts[1],
- branch: parts[2],
- relativePath: parts.slice(0, 3).join("/"),
- };
-}
-
-function loadNoteFromJson(relativePath: string): string | null {
- if (!existsSync(NOTES_JSON)) return null;
-
- try {
- const data = JSON.parse(readFileSync(NOTES_JSON, "utf8"));
- // Notes are keyed by relative path from worktree_dir
- const note = data[relativePath];
- if (note && typeof note === "object" && note.content) {
- return note.content;
- }
- if (typeof note === "string" && note.trim()) {
- return note;
- }
- } catch {
- // Ignore parse errors
- }
- return null;
-}
-
-function loadSplittedNote(info: WorktreeInfo): string | null {
- // Try common splitted note locations
- const candidates = [
- join(homedir(), ".local/share/lazyworktree/notes", info.org, info.repo, `${info.branch}.md`),
- join(homedir(), ".local/share/lazyworktree/notes", `${info.org}/${info.repo}`, `${info.branch}.md`),
- ];
-
- for (const path of candidates) {
- if (existsSync(path)) {
- try {
- const content = readFileSync(path, "utf8").trim();
- // Strip YAML frontmatter if present
- if (content.startsWith("---")) {
- const endIdx = content.indexOf("---", 3);
- if (endIdx > 0) {
- return content.slice(endIdx + 3).trim();
- }
- }
- return content;
- } catch {
- continue;
- }
- }
- }
- return null;
-}
-
-export default function (pi: ExtensionAPI) {
- let cachedInfo: WorktreeInfo | null = null;
- let cachedNote: string | null = null;
-
- const refreshCache = (cwd: string) => {
- cachedInfo = detectWorktree(cwd);
- cachedNote = cachedInfo
- ? loadSplittedNote(cachedInfo) ?? loadNoteFromJson(cachedInfo.relativePath)
- : null;
- };
-
- pi.on("session_start", async (_event, ctx) => refreshCache(ctx.cwd));
- pi.on("session_switch", async (_event, ctx) => refreshCache(ctx.cwd));
- pi.on("session_directory", async (event) => refreshCache(event.cwd));
-
- pi.on("before_agent_start", async (event, _ctx) => {
- if (!cachedInfo || !cachedNote) return;
-
- const header = `\n\n## Worktree Context\nYou are working in a git worktree: ${cachedInfo.org}/${cachedInfo.repo} (branch: ${cachedInfo.branch})\n\nWorktree notes:\n${cachedNote}`;
-
- return {
- systemPrompt: event.systemPrompt + header,
- };
- });
-
- pi.registerCommand("worktree-note", {
- description: "Show the current worktree note (if any)",
- handler: async (_args, ctx) => {
- const info = detectWorktree(ctx.cwd);
- if (!info) {
- ctx.ui.notify("Not in a worktree", "info");
- return;
- }
-
- const note = loadSplittedNote(info) ?? loadNoteFromJson(info.relativePath);
- if (!note) {
- ctx.ui.notify(`Worktree ${info.org}/${info.repo}/${info.branch}: no note found`, "info");
- return;
- }
-
- ctx.ui.notify(
- `📝 ${info.org}/${info.repo}/${info.branch}\n${note}`,
- "info",
- );
- },
- });
-}