Commit 023108b79f92
Changed files (16)
dots
pi
agent
agents
extensions
shell-completions
threads
dots/pi/agent/agents/oracle.md
@@ -0,0 +1,47 @@
+---
+name: oracle
+description: Deep analysis, debugging, and architecture decisions
+tools: read, grep, find, ls, bash
+model: claude-sonnet-4-5
+---
+
+You are a deep analysis specialist. You investigate complex problems, debug tricky issues, and provide architecture guidance.
+
+Bash is for read-only commands only: `git log`, `git blame`, `git show`, test runs, type checking. Do NOT modify files.
+
+Strategy varies by task:
+
+**Debugging:**
+1. Understand the symptom from context provided
+2. Trace the code path using read/grep
+3. Identify root cause with evidence (specific lines, data flow)
+4. Propose a fix with rationale
+
+**Architecture:**
+1. Map the current structure (modules, dependencies, data flow)
+2. Identify constraints and trade-offs
+3. Propose options with pros/cons
+4. Recommend one with clear justification
+
+**Investigation:**
+1. Gather all relevant code and context
+2. Cross-reference behavior, tests, docs
+3. Synthesize findings into a clear picture
+
+Output format:
+
+## Question / Problem
+Restate what you're analyzing.
+
+## Findings
+Detailed evidence with file paths and line numbers:
+- `file.ts:42` - observation
+- `file.ts:100` - related observation
+
+## Analysis
+Connect the dots. Explain the why, not just the what.
+
+## Recommendation
+Clear, actionable conclusion. If multiple options exist, rank them with trade-offs.
+
+Be thorough but concise. Cite specific code, not vague references.
dots/pi/agent/extensions/shell-completions/scripts/bash-complete.bash
@@ -0,0 +1,47 @@
+#!/bin/bash
+# Gets completions using bash's native completion system
+# Usage: bash-complete.bash "command line" "/path/to/cwd"
+
+__cmdline="$1"
+__cwd="$2"
+
+cd "$__cwd" 2>/dev/null || exit 1
+
+# Extract command name
+__cmd=${__cmdline%% *}
+
+# Source bash-completion framework if available
+for f in /usr/share/bash-completion/bash_completion /etc/bash_completion /opt/homebrew/etc/bash_completion /opt/homebrew/share/bash-completion/bash_completion; do
+ [[ -f "$f" ]] && { source "$f" 2>/dev/null; break; }
+done
+
+# Also try to source command-specific completions directly (macOS/Homebrew)
+for dir in /opt/homebrew/etc/bash_completion.d /usr/share/bash-completion/completions /etc/bash_completion.d; do
+ for f in "$dir/$__cmd" "$dir/$__cmd.bash" "$dir/${__cmd}-completion.bash"; do
+ [[ -f "$f" ]] && source "$f" 2>/dev/null
+ done
+done
+
+# Set up completion environment
+COMP_LINE="$__cmdline"
+COMP_POINT=${#COMP_LINE}
+eval set -- "$COMP_LINE"
+COMP_WORDS=("$@")
+
+# Add empty word if line ends with space (completing new word)
+[[ "${COMP_LINE: -1}" = ' ' ]] && COMP_WORDS+=('')
+
+COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 ))
+
+# Load completion for the command if available
+declare -F _completion_loader &>/dev/null && _completion_loader "$__cmd" 2>/dev/null
+
+# Get the completion function
+completion=$(complete -p "$__cmd" 2>/dev/null | awk '{print $(NF-1)}')
+
+if [[ -n "$completion" ]] && declare -F "$completion" &>/dev/null; then
+ # Call the completion function
+ "$completion" 2>/dev/null
+ # Output unique results
+ printf '%s\n' "${COMPREPLY[@]}" | sort -u | head -30
+fi
dots/pi/agent/extensions/shell-completions/scripts/zsh-capture.zsh
@@ -0,0 +1,148 @@
+#!/bin/zsh
+# Simple zsh completion capture using _complete_help
+# Usage: zsh-capture.zsh "command line" "/path/to/cwd"
+
+emulate -L zsh
+setopt no_beep
+
+local cmdline="$1"
+local cwd="$2"
+
+cd "$cwd" 2>/dev/null || exit 1
+
+# Initialize completion system (use user's zcompdump)
+autoload -Uz compinit
+compinit -C 2>/dev/null
+
+# Parse command line
+local -a words
+words=("${(@Q)${(z)cmdline}}")
+
+# If line ends with space, we're completing a new word
+if [[ "$cmdline" == *" " ]]; then
+ words+=("")
+fi
+
+local cmd="${words[1]}"
+local current="${words[-1]}"
+
+# Helper to output completions
+output() {
+ local val="$1" desc="$2"
+ if [[ -n "$desc" ]]; then
+ print -r -- "${val}"$'\t'"${desc}"
+ else
+ print -r -- "${val}"
+ fi
+}
+
+# Git completions
+if [[ "$cmd" == "git" ]]; then
+ if (( ${#words} == 2 )); then
+ # Git subcommands
+ git --list-cmds=main,others 2>/dev/null | while read -r subcmd; do
+ [[ -z "$current" || "$subcmd" == "$current"* ]] && output "$subcmd"
+ done
+ else
+ local subcmd="${words[2]}"
+ case "$subcmd" in
+ checkout|switch|merge|rebase|branch|log)
+ # Branches
+ git for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null | while read -r b; do
+ [[ -z "$current" || "$b" == "$current"* ]] && output "$b" "branch"
+ done
+ git for-each-ref --format='%(refname:short)' refs/remotes 2>/dev/null | while read -r b; do
+ [[ "$b" == */HEAD ]] && continue
+ local short="${b#*/}"
+ [[ -z "$current" || "$short" == "$current"* ]] && output "$short" "remote"
+ done
+ ;;
+ add|diff|restore|reset)
+ # Modified files
+ git diff --name-only 2>/dev/null | while read -r f; do
+ [[ -z "$current" || "$f" == "$current"* ]] && output "$f" "modified"
+ done
+ git diff --cached --name-only 2>/dev/null | while read -r f; do
+ [[ -z "$current" || "$f" == "$current"* ]] && output "$f" "staged"
+ done
+ ;;
+ push|pull|fetch)
+ if (( ${#words} == 3 )); then
+ git remote 2>/dev/null | while read -r r; do
+ [[ -z "$current" || "$r" == "$current"* ]] && output "$r" "remote"
+ done
+ fi
+ ;;
+ stash)
+ for sub in apply drop list pop show push; do
+ [[ -z "$current" || "$sub" == "$current"* ]] && output "$sub"
+ done
+ ;;
+ esac
+ fi
+ exit 0
+fi
+
+# SSH/SCP completions - hosts
+if [[ "$cmd" == "ssh" || "$cmd" == "scp" || "$cmd" == "sftp" ]]; then
+ {
+ [[ -f ~/.ssh/config ]] && awk '/^Host / && !/\*/{for(i=2;i<=NF;i++)print $i}' ~/.ssh/config
+ [[ -f ~/.ssh/known_hosts ]] && awk -F'[, ]' '{print $1}' ~/.ssh/known_hosts
+ } 2>/dev/null | sort -u | while read -r h; do
+ [[ -z "$current" || "$h" == "$current"* ]] && output "$h" "host"
+ done
+ exit 0
+fi
+
+# Make completions
+if [[ "$cmd" == "make" ]]; then
+ local mf
+ for f in GNUmakefile Makefile makefile; do
+ [[ -f "$f" ]] && mf="$f" && break
+ done
+ if [[ -n "$mf" ]]; then
+ awk -F: '/^[a-zA-Z_][a-zA-Z0-9_-]*:/ && !/^\./{print $1}' "$mf" 2>/dev/null | while read -r t; do
+ [[ -z "$current" || "$t" == "$current"* ]] && output "$t" "target"
+ done
+ fi
+ exit 0
+fi
+
+# NPM/Yarn/PNPM completions
+if [[ "$cmd" == "npm" || "$cmd" == "yarn" || "$cmd" == "pnpm" ]]; then
+ if (( ${#words} == 2 )); then
+ for sub in install add remove run build test start dev publish; do
+ [[ -z "$current" || "$sub" == "$current"* ]] && output "$sub"
+ done
+ elif [[ "${words[2]}" == "run" && -f package.json ]]; then
+ jq -r '.scripts // {} | keys[]' package.json 2>/dev/null | while read -r s; do
+ [[ -z "$current" || "$s" == "$current"* ]] && output "$s" "script"
+ done
+ fi
+ exit 0
+fi
+
+# Docker completions
+if [[ "$cmd" == "docker" ]]; then
+ if (( ${#words} == 2 )); then
+ for sub in build compose exec images logs ps pull push rm rmi run start stop; do
+ [[ -z "$current" || "$sub" == "$current"* ]] && output "$sub"
+ done
+ fi
+ exit 0
+fi
+
+# Fallback: file completion
+if [[ -n "$current" ]]; then
+ local -a matches
+ matches=( ${current}*(N) )
+ for f in "${matches[@]:0:20}"; do
+ [[ -d "$f" ]] && output "${f}/" "directory" || output "$f" "file"
+ done
+else
+ local -a matches
+ matches=( *(N) )
+ for f in "${matches[@]:0:20}"; do
+ [[ -d "$f" ]] && output "${f}/" "directory" || output "$f" "file"
+ done
+fi
dots/pi/agent/extensions/shell-completions/bash.ts
@@ -0,0 +1,125 @@
+/**
+ * Bash shell completion provider.
+ *
+ * Uses bash's native completion system by running a script that sets up
+ * COMP_* environment variables and calls the registered completion function.
+ *
+ * Philosophy: Only provide completions if the user has bash-completion available.
+ */
+
+import type { AutocompleteItem } from "@mariozechner/pi-tui";
+import { spawnSync } from "node:child_process";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { fileURLToPath } from "node:url";
+import type { CompletionResult, ShellCompletionProvider } from "./types.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const COMPLETE_SCRIPT = path.join(__dirname, "scripts", "bash-complete.bash");
+
+/**
+ * Check if bash-completion is available.
+ * We check for the presence of completion scripts in standard locations.
+ */
+let completionCheckCache: boolean | null = null;
+
+function userHasBashCompletions(bashPath: string): boolean {
+ if (completionCheckCache !== null) {
+ return completionCheckCache;
+ }
+
+ try {
+ // Check if bash-completion framework or git completion exists
+ const result = spawnSync(
+ bashPath,
+ [
+ "-c",
+ `
+ for f in /usr/share/bash-completion/bash_completion /etc/bash_completion /opt/homebrew/etc/bash_completion /opt/homebrew/share/bash-completion/bash_completion; do
+ [[ -f "$f" ]] && { echo yes; exit 0; }
+ done
+ # Also check for individual completion files
+ for f in /opt/homebrew/etc/bash_completion.d/git* /usr/share/bash-completion/completions/git; do
+ [[ -f "$f" ]] && { echo yes; exit 0; }
+ done
+ echo no
+ `,
+ ],
+ {
+ encoding: "utf-8",
+ timeout: 500,
+ }
+ );
+
+ completionCheckCache = result.stdout?.trim() === "yes";
+ return completionCheckCache;
+ } catch {
+ completionCheckCache = false;
+ return false;
+ }
+}
+
+/**
+ * Get completions using bash's native completion system.
+ */
+export function getBashCompletions(
+ commandLine: string,
+ cwd: string,
+ bashPath: string
+): CompletionResult | null {
+ // Check if bash completions are available
+ if (!userHasBashCompletions(bashPath)) {
+ return null;
+ }
+
+ // Check if completion script exists
+ if (!fs.existsSync(COMPLETE_SCRIPT)) {
+ return null;
+ }
+
+ // Extract prefix
+ const trimmed = commandLine.trimStart();
+ let prefix = "";
+ if (!trimmed.endsWith(" ")) {
+ const words = trimmed.split(/\s+/);
+ prefix = words[words.length - 1] || "";
+ }
+
+ try {
+ const result = spawnSync(bashPath, [COMPLETE_SCRIPT, commandLine, cwd], {
+ encoding: "utf-8",
+ timeout: 500,
+ maxBuffer: 1024 * 100,
+ cwd,
+ });
+
+ if (result.error || !result.stdout) {
+ return null;
+ }
+
+ const items: AutocompleteItem[] = result.stdout
+ .trim()
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => {
+ // Remove trailing space that bash completion adds
+ const value = line.trimEnd();
+ return { value, label: value };
+ });
+
+ if (items.length === 0) {
+ return null;
+ }
+
+ return {
+ items: items.slice(0, 30),
+ prefix,
+ };
+ } catch {
+ return null;
+ }
+}
+
+export const bashCompletionProvider: ShellCompletionProvider = {
+ getCompletions: getBashCompletions,
+};
dots/pi/agent/extensions/shell-completions/fish.ts
@@ -0,0 +1,84 @@
+/**
+ * Fish shell completion provider.
+ *
+ * Uses fish's native `complete -C` command which provides excellent completions
+ * for most tools automatically.
+ *
+ * Fish always has completions available (it's a core feature), so this never
+ * returns null for "user hasn't configured completions".
+ */
+
+import type { AutocompleteItem } from "@mariozechner/pi-tui";
+import { spawnSync } from "node:child_process";
+import type { CompletionResult, ShellCompletionProvider } from "./types.js";
+
+/**
+ * Get completions using fish's native `complete -C` command.
+ * Fish completions are excellent and cover most tools automatically.
+ */
+export function getFishCompletions(
+ commandLine: string,
+ cwd: string,
+ fishPath: string
+): CompletionResult | null {
+ // Extract prefix
+ const trimmed = commandLine.trimStart();
+ let prefix = "";
+ if (!trimmed.endsWith(" ")) {
+ const words = trimmed.split(/\s+/);
+ prefix = words[words.length - 1] || "";
+ }
+
+ try {
+ // Fish's complete -C gives us completions directly
+ const result = spawnSync(
+ fishPath,
+ ["-c", `complete -C ${JSON.stringify(commandLine)}`],
+ {
+ encoding: "utf-8",
+ timeout: 500,
+ maxBuffer: 1024 * 100,
+ cwd,
+ }
+ );
+
+ if (result.error || !result.stdout) {
+ return null;
+ }
+
+ // Fish output format: "completion\tdescription" (tab-separated)
+ const lines = result.stdout.trim().split("\n").filter(Boolean);
+ const items: AutocompleteItem[] = [];
+
+ for (const line of lines) {
+ const tabIndex = line.indexOf("\t");
+ if (tabIndex >= 0) {
+ const value = line.slice(0, tabIndex).trim();
+ const description = line.slice(tabIndex + 1).trim();
+ if (value) {
+ items.push({ value, label: value, description });
+ }
+ } else {
+ const value = line.trim();
+ if (value) {
+ items.push({ value, label: value });
+ }
+ }
+ }
+
+ if (items.length === 0) {
+ return null;
+ }
+
+ return {
+ items: items.slice(0, 30),
+ prefix,
+ };
+ } catch {
+ return null;
+ }
+}
+
+export const fishCompletionProvider: ShellCompletionProvider = {
+ getCompletions: getFishCompletions,
+};
dots/pi/agent/extensions/shell-completions/index.ts
@@ -0,0 +1,330 @@
+/**
+ * Shell Completions Extension for Pi
+ *
+ * Adds native shell completions (fish/zsh/bash) to pi's `!` and `!!` bash mode.
+ * Uses the user's actual shell completion configuration - if they haven't
+ * set up completions, we don't provide them (no magic).
+ *
+ * Usage: Place in ~/.pi/agent/extensions/shell-completions/index.ts
+ *
+ * Shell priority:
+ * 1. User's $SHELL (if fish/zsh/bash) - uses their configured completions
+ * 2. Fish (if available) - always has completions (core feature)
+ * 3. Zsh (if compinit is configured)
+ * 4. Bash (if bash-completion is installed)
+ *
+ * Philosophy: Don't magically provide completions the user hasn't configured.
+ * This means completions respect the user's shell setup, aliases, and customizations.
+ */
+
+import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import type { AutocompleteItem, AutocompleteProvider } from "@mariozechner/pi-tui";
+import * as fs from "node:fs";
+import * as path from "node:path";
+
+import type { ShellInfo, ShellType, CompletionResult } from "./types.js";
+import { getFishCompletions } from "./fish.js";
+import { getBashCompletions } from "./bash.js";
+import { getZshCompletions } from "./zsh.js";
+
+// ============================================================================
+// Shell Detection
+// ============================================================================
+
+/**
+ * Detect shell type from path.
+ */
+function detectShellType(shellPath: string): ShellType {
+ const name = path.basename(shellPath);
+ if (name === "fish" || name.startsWith("fish")) return "fish";
+ if (name === "zsh" || name.startsWith("zsh")) return "zsh";
+ return "bash";
+}
+
+/**
+ * Find a shell suitable for running completion scripts.
+ *
+ * Priority:
+ * 1. User's $SHELL if it's fish/zsh/bash (respects user's configured completions)
+ * 2. Fish if available (best completion UX)
+ * 3. Zsh if available
+ * 4. Bash as fallback
+ */
+function findCompletionShell(): ShellInfo {
+ // First, try user's $SHELL - they've configured their completions there
+ const userShell = process.env.SHELL;
+ if (userShell && fs.existsSync(userShell)) {
+ const shellType = detectShellType(userShell);
+ // Only use it if it's a shell we support (fish/zsh/bash)
+ if (shellType === "fish" || shellType === "zsh" || shellType === "bash") {
+ return { path: userShell, type: shellType };
+ }
+ }
+
+ // If user's shell isn't suitable, prefer fish for best completions
+ const fishPaths = [
+ "/opt/homebrew/bin/fish",
+ "/usr/local/bin/fish",
+ "/usr/bin/fish",
+ "/bin/fish",
+ ];
+ for (const fishPath of fishPaths) {
+ if (fs.existsSync(fishPath)) {
+ return { path: fishPath, type: "fish" };
+ }
+ }
+
+ // Then zsh
+ const zshPaths = [
+ "/bin/zsh",
+ "/usr/bin/zsh",
+ "/usr/local/bin/zsh",
+ "/opt/homebrew/bin/zsh",
+ ];
+ for (const zshPath of zshPaths) {
+ if (fs.existsSync(zshPath)) {
+ return { path: zshPath, type: "zsh" };
+ }
+ }
+
+ // Bash fallback
+ const bashPaths = [
+ "/bin/bash",
+ "/usr/bin/bash",
+ "/usr/local/bin/bash",
+ "/opt/homebrew/bin/bash",
+ ];
+ for (const bashPath of bashPaths) {
+ if (fs.existsSync(bashPath)) {
+ return { path: bashPath, type: "bash" };
+ }
+ }
+
+ return { path: "/bin/bash", type: "bash" };
+}
+
+// ============================================================================
+// Completion Context Extraction
+// ============================================================================
+
+/**
+ * Extract the command line and completion prefix from editor text.
+ */
+function extractCompletionContext(text: string): {
+ commandLine: string;
+ prefix: string;
+} {
+ // Remove ! or !! prefix
+ let commandLine = text.trimStart();
+ if (commandLine.startsWith("!!")) {
+ commandLine = commandLine.slice(2);
+ } else if (commandLine.startsWith("!")) {
+ commandLine = commandLine.slice(1);
+ }
+
+ const trimmed = commandLine.trimStart();
+
+ // If ends with space, completing a new word
+ if (trimmed.endsWith(" ")) {
+ return { commandLine: trimmed, prefix: "" };
+ }
+
+ // Last word is the prefix
+ const words = trimmed.split(/\s+/);
+ const prefix = words[words.length - 1] || "";
+
+ return { commandLine: trimmed, prefix };
+}
+
+// ============================================================================
+// Shell Completion Dispatcher
+// ============================================================================
+
+/**
+ * Get shell completions for a command line.
+ * Returns null if the user hasn't configured completions for their shell.
+ */
+function getShellCompletions(
+ text: string,
+ cwd: string,
+ shell: ShellInfo
+): CompletionResult | null {
+ const { commandLine } = extractCompletionContext(text);
+
+ if (!commandLine.trim()) {
+ return null;
+ }
+
+ // Each shell provider checks if user has completions configured
+ // and returns null if not
+ switch (shell.type) {
+ case "fish":
+ // Fish always has completions (it's a core feature)
+ return getFishCompletions(commandLine, cwd, shell.path);
+ case "bash":
+ // Bash: only works if bash-completion is available
+ return getBashCompletions(commandLine, cwd, shell.path);
+ case "zsh":
+ // Zsh: only works if user has compinit in their .zshrc
+ return getZshCompletions(commandLine, cwd, shell.path);
+ default:
+ return null;
+ }
+}
+
+// ============================================================================
+// Shell-Aware Autocomplete Provider Wrapper
+// ============================================================================
+
+/**
+ * Wraps an existing autocomplete provider to add shell completion support
+ * when in bash mode (text starts with ! or !!).
+ */
+function wrapWithShellCompletion(
+ baseProvider: AutocompleteProvider,
+ shell: ShellInfo
+): AutocompleteProvider {
+ const isBashMode = (lines: string[]): boolean => {
+ const text = lines.join("\n").trimStart();
+ return text.startsWith("!") || text.startsWith("!!");
+ };
+
+ const getTextUpToCursor = (
+ lines: string[],
+ cursorLine: number,
+ cursorCol: number
+ ): string => {
+ const textLines = lines.slice(0, cursorLine + 1);
+ if (textLines.length > 0) {
+ textLines[textLines.length - 1] = textLines[textLines.length - 1].slice(0, cursorCol);
+ }
+ return textLines.join("\n");
+ };
+
+ return {
+ getSuggestions(
+ lines: string[],
+ cursorLine: number,
+ cursorCol: number
+ ): { items: AutocompleteItem[]; prefix: string } | null {
+ if (isBashMode(lines)) {
+ const text = getTextUpToCursor(lines, cursorLine, cursorCol);
+ const result = getShellCompletions(text, process.cwd(), shell);
+ if (result && result.items.length > 0) {
+ return result;
+ }
+ }
+ return baseProvider.getSuggestions(lines, cursorLine, cursorCol);
+ },
+
+ applyCompletion(
+ lines: string[],
+ cursorLine: number,
+ cursorCol: number,
+ item: AutocompleteItem,
+ prefix: string
+ ): { lines: string[]; cursorLine: number; cursorCol: number } {
+ if (isBashMode(lines)) {
+ const currentLine = lines[cursorLine] || "";
+ const prefixStart = cursorCol - prefix.length;
+ const beforePrefix = currentLine.slice(0, prefixStart);
+ const afterCursor = currentLine.slice(cursorCol);
+
+ // Don't add space after directories
+ const isDirectory = item.value.endsWith("/");
+ const suffix = isDirectory ? "" : " ";
+
+ const newLine = beforePrefix + item.value + suffix + afterCursor;
+ const newLines = [...lines];
+ newLines[cursorLine] = newLine;
+
+ return {
+ lines: newLines,
+ cursorLine,
+ cursorCol: prefixStart + item.value.length + suffix.length,
+ };
+ }
+
+ return baseProvider.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
+ },
+
+ // Forward optional methods
+ getForceFileSuggestions(
+ lines: string[],
+ cursorLine: number,
+ cursorCol: number
+ ): { items: AutocompleteItem[]; prefix: string } | null {
+ if (isBashMode(lines)) {
+ const text = getTextUpToCursor(lines, cursorLine, cursorCol);
+ return getShellCompletions(text, process.cwd(), shell);
+ }
+ if ("getForceFileSuggestions" in baseProvider) {
+ return (baseProvider as any).getForceFileSuggestions(lines, cursorLine, cursorCol);
+ }
+ return this.getSuggestions(lines, cursorLine, cursorCol);
+ },
+
+ shouldTriggerFileCompletion(
+ lines: string[],
+ cursorLine: number,
+ cursorCol: number
+ ): boolean {
+ if (isBashMode(lines)) {
+ return true;
+ }
+ if ("shouldTriggerFileCompletion" in baseProvider) {
+ return (baseProvider as any).shouldTriggerFileCompletion(lines, cursorLine, cursorCol);
+ }
+ return true;
+ },
+ };
+}
+
+// ============================================================================
+// Custom Editor with Shell Completion
+// ============================================================================
+
+/**
+ * Custom editor that intercepts setAutocompleteProvider to wrap with shell completion.
+ */
+class ShellCompletionEditor extends CustomEditor {
+ private shell: ShellInfo;
+ private wrappedProvider = false;
+
+ constructor(tui: any, theme: any, keybindings: any, shell: ShellInfo) {
+ super(tui, theme, keybindings);
+ this.shell = shell;
+ }
+
+ // Override setAutocompleteProvider to wrap the base provider
+ setAutocompleteProvider(provider: AutocompleteProvider): void {
+ if (!this.wrappedProvider && provider) {
+ // Wrap the provider with shell completion support
+ const wrapped = wrapWithShellCompletion(provider, this.shell);
+ super.setAutocompleteProvider(wrapped);
+ this.wrappedProvider = true;
+ } else {
+ super.setAutocompleteProvider(provider);
+ }
+ }
+}
+
+// ============================================================================
+// Extension Entry Point
+// ============================================================================
+
+export default function (pi: ExtensionAPI) {
+ const shell = findCompletionShell();
+ const shellName = path.basename(shell.path);
+
+ pi.on("session_start", (_event, ctx) => {
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
+ return new ShellCompletionEditor(tui, theme, keybindings, shell);
+ });
+
+ ctx.ui.notify(`Shell completions enabled (${shellName})`, "info");
+ });
+}
+
+// Re-export types for potential external use
+export type { ShellInfo, ShellType, CompletionResult } from "./types.js";
dots/pi/agent/extensions/shell-completions/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "pi-shell-completions",
+ "version": "0.2.0",
+ "description": "Pi extension that adds native shell completions (fish/zsh/bash) to ! and !! bash mode commands",
+ "type": "module",
+ "keywords": ["pi-package"],
+ "license": "MIT",
+ "author": "laulauland",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/laulauland/dotfiles"
+ },
+ "pi": {
+ "extensions": ["./index.ts"]
+ },
+ "peerDependencies": {
+ "@mariozechner/pi-coding-agent": "*",
+ "@mariozechner/pi-tui": "*"
+ }
+}
dots/pi/agent/extensions/shell-completions/README.md
@@ -0,0 +1,70 @@
+# pi-shell-completions
+
+Adds native shell completions to pi's `!` and `!!` bash mode commands.
+
+## Installation
+
+```bash
+pi install npm:pi-shell-completions
+```
+
+Or for local development, place in `~/.pi/agent/extensions/shell-completions/`
+
+## How it works
+
+When you type `!git checkout ` in pi's prompt, this extension queries your shell's completion system and shows suggestions.
+
+### Shell support
+
+| Shell | How it works | Quality |
+|-------|--------------|---------|
+| **Fish** | Native `complete -C` command | ⭐⭐⭐ Excellent - all completions work |
+| **Bash** | Sources bash-completion scripts | ⭐⭐ Good - if bash-completion is installed |
+| **Zsh** | Fallback script for common tools | ⭐ Basic - see limitations |
+
+### Fish (recommended)
+
+Fish's completion system is designed to be queried programmatically via `complete -C "command "`. This means:
+
+- All your fish completions work automatically
+- Git branches, docker containers, ssh hosts, npm scripts — everything
+- Descriptions are included
+- Fast (10-30ms)
+
+Even if fish isn't your primary shell, installing it gives you great completions in pi.
+
+### Bash
+
+Bash-completion can be queried by setting up `COMP_*` environment variables and calling completion functions. This extension:
+
+- Sources completion scripts from standard locations (`/opt/homebrew/etc/bash_completion.d/`, `/usr/share/bash-completion/completions/`, etc.)
+- Calls the registered completion function for each command
+- Works if you have bash-completion installed
+
+### Zsh (limited)
+
+Zsh's completion system is tightly coupled to its line editor (ZLE) and cannot be easily queried programmatically. The `zpty` pseudo-terminal approach is complex and unreliable.
+
+**Current limitations:**
+- Does NOT use your full zsh completion config
+- Only handles common tools: git, ssh, make, npm/yarn/pnpm, docker
+- Falls back to file completion for other commands
+
+**Recommendation:** If you use zsh and want good completions in pi, install fish as a secondary shell. The extension will automatically prefer fish when available.
+
+## Shell priority
+
+1. Your `$SHELL` (if fish/zsh/bash)
+2. Fish (if available) — even if not your primary shell
+3. Zsh
+4. Bash
+
+## Requirements
+
+- One of: fish, zsh, or bash
+- For bash: bash-completion package installed
+- For best experience: fish
+
+## License
+
+MIT
dots/pi/agent/extensions/shell-completions/types.ts
@@ -0,0 +1,30 @@
+/**
+ * Shared types for shell completions extension.
+ */
+
+import type { AutocompleteItem } from "@mariozechner/pi-tui";
+
+export type ShellType = "fish" | "zsh" | "bash";
+
+export interface ShellInfo {
+ path: string;
+ type: ShellType;
+}
+
+export interface CompletionContext {
+ commandLine: string;
+ prefix: string;
+}
+
+export interface CompletionResult {
+ items: AutocompleteItem[];
+ prefix: string;
+}
+
+/**
+ * Interface for shell-specific completion providers.
+ * Returns null if the user hasn't configured completions for this shell.
+ */
+export interface ShellCompletionProvider {
+ getCompletions(commandLine: string, cwd: string, shellPath: string): CompletionResult | null;
+}
dots/pi/agent/extensions/shell-completions/zsh.ts
@@ -0,0 +1,110 @@
+/**
+ * Zsh completion support
+ *
+ * Unfortunately, zsh's completion system is tightly coupled to its line editor
+ * (ZLE) and cannot be easily queried programmatically without a pseudo-terminal.
+ * The zpty approach is complex and fragile.
+ *
+ * This implementation uses a simple fallback script that handles common cases
+ * (git, ssh, make, npm, docker) but does NOT tap into the user's full zsh
+ * completion configuration.
+ *
+ * For the best experience, install fish (even as a secondary shell) - its
+ * `complete -C` command provides excellent completions without complexity.
+ */
+
+import { spawnSync } from "node:child_process";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { fileURLToPath } from "node:url";
+import type { CompletionResult } from "./types.js";
+import type { AutocompleteItem } from "@mariozechner/pi-tui";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const CAPTURE_SCRIPT = path.join(__dirname, "scripts", "zsh-capture.zsh");
+
+/**
+ * Parse completion output (tab-separated: value\tdescription)
+ */
+function parseOutput(output: string): AutocompleteItem[] {
+ const lines = output.trim().split("\n").filter(Boolean);
+ const items: AutocompleteItem[] = [];
+ const seen = new Set<string>();
+
+ for (const line of lines) {
+ const tabIndex = line.indexOf("\t");
+ let value: string;
+ let description: string | undefined;
+
+ if (tabIndex >= 0) {
+ value = line.slice(0, tabIndex).trim();
+ description = line.slice(tabIndex + 1).trim() || undefined;
+ } else {
+ value = line.trim();
+ }
+
+ if (!value || seen.has(value)) continue;
+ seen.add(value);
+
+ // Skip internal refs
+ if (value.startsWith("refs/jj/keep/")) continue;
+
+ items.push({ value, label: value, description });
+ }
+
+ return items;
+}
+
+/**
+ * Get completions using zsh fallback script.
+ * Note: This does NOT use the user's full zsh completion config.
+ */
+export function getZshCompletions(
+ commandLine: string,
+ cwd: string,
+ zshPath: string
+): CompletionResult | null {
+ // Check if capture script exists
+ if (!fs.existsSync(CAPTURE_SCRIPT)) {
+ return null;
+ }
+
+ // Extract prefix
+ const trimmed = commandLine.trimStart();
+ let prefix = "";
+ if (!trimmed.endsWith(" ")) {
+ const words = trimmed.split(/\s+/);
+ prefix = words[words.length - 1] || "";
+ }
+
+ try {
+ const result = spawnSync(zshPath, [CAPTURE_SCRIPT, commandLine, cwd], {
+ encoding: "utf-8",
+ timeout: 500,
+ maxBuffer: 1024 * 100,
+ cwd,
+ });
+
+ if (result.error || !result.stdout) {
+ return null;
+ }
+
+ const items = parseOutput(result.stdout);
+
+ if (items.length === 0) {
+ return null;
+ }
+
+ return {
+ items: items.slice(0, 30),
+ prefix,
+ };
+ } catch {
+ return null;
+ }
+}
+
+export const zshCompletionProvider = {
+ name: "zsh" as const,
+ getCompletions: getZshCompletions,
+};
dots/pi/agent/extensions/threads/index.ts
@@ -0,0 +1,608 @@
+/**
+ * Thread Search and Reading Extension
+ *
+ * Provides find_threads and search_thread tools for searching and reading
+ * past conversation sessions.
+ */
+
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { Type } from "@sinclair/typebox";
+import { StringEnum } from "@mariozechner/pi-ai";
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import {
+ parseSessionEntries,
+ type FileEntry,
+ type SessionEntry,
+ type SessionHeader,
+} from "@mariozechner/pi-coding-agent";
+import { Text, Container, Spacer } from "@mariozechner/pi-tui";
+
+// ============================================================================
+// Session Parsing Utilities
+// ============================================================================
+
+function getSessionsDir(): string {
+ return path.join(os.homedir(), ".pi", "agent", "sessions");
+}
+
+function loadSessionFile(filePath: string): FileEntry[] {
+ const content = fs.readFileSync(filePath, "utf-8");
+ return parseSessionEntries(content);
+}
+
+function getSessionHeader(entries: FileEntry[]): SessionHeader | null {
+ return entries.find((e): e is SessionHeader => e.type === "session") ?? null;
+}
+
+function getSessionEntries(entries: FileEntry[]): SessionEntry[] {
+ return entries.filter((e): e is SessionEntry => e.type !== "session");
+}
+
+function getLeafEntry(entries: SessionEntry[]): SessionEntry | null {
+ if (entries.length === 0) return null;
+
+ const parentIds = new Set<string>();
+ for (const entry of entries) {
+ if ("parentId" in entry && entry.parentId) {
+ parentIds.add(entry.parentId);
+ }
+ }
+
+ for (let i = entries.length - 1; i >= 0; i--) {
+ const entry = entries[i]!;
+ if (!parentIds.has(entry.id)) {
+ return entry;
+ }
+ }
+
+ return entries[entries.length - 1] ?? null;
+}
+
+function getEntryPath(entries: SessionEntry[]): SessionEntry[] {
+ const leaf = getLeafEntry(entries);
+ if (!leaf) return [];
+
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
+ const path: SessionEntry[] = [];
+ let current: SessionEntry | undefined = leaf;
+
+ while (current) {
+ path.push(current);
+ const parentId = "parentId" in current ? current.parentId : null;
+ if (!parentId) break;
+ current = byId.get(parentId);
+ }
+
+ return path.reverse();
+}
+
+function extractTextContent(content: unknown): string {
+ if (typeof content === "string") return content;
+ if (Array.isArray(content)) {
+ const parts: string[] = [];
+ for (const part of content) {
+ if (part.type === "text" && part.text) {
+ parts.push(part.text);
+ } else if (part.type === "toolCall" && part.name) {
+ parts.push(`[Tool: ${part.name}]`);
+ }
+ }
+ return parts.join("\n");
+ }
+ return "";
+}
+
+function getFirstUserMessage(entries: SessionEntry[]): string {
+ for (const entry of entries) {
+ if (entry.type === "message" && entry.message.role === "user") {
+ const text = extractTextContent(entry.message.content);
+ if (text) return text.slice(0, 200);
+ }
+ }
+ return "(no user message)";
+}
+
+function countMessages(entries: SessionEntry[]): number {
+ return entries.filter((e) => e.type === "message").length;
+}
+
+// ============================================================================
+// Search Functions
+// ============================================================================
+
+async function searchWithGrep(
+ exec: (cmd: string, args: string[], opts?: { timeout?: number }) => Promise<{ stdout: string; stderr: string; code: number }>,
+ query: string,
+ sessionsDir: string,
+ onFallback?: () => void,
+): Promise<Map<string, number>> {
+ const results = new Map<string, number>();
+
+ // Try ripgrep first
+ try {
+ const { stdout, code } = await exec("rg", ["-c", "-i", "--", query, sessionsDir], { timeout: 10000 });
+ if (code === 0 || code === 1) { // 1 = no matches, which is fine
+ for (const line of stdout.split("\n")) {
+ if (!line.trim()) continue;
+ const match = line.match(/^(.+):(\d+)$/);
+ if (match) results.set(match[1], parseInt(match[2], 10));
+ }
+ return results;
+ }
+ } catch {
+ // ripgrep not found or failed, fall back to grep
+ }
+
+ // Fallback to grep
+ onFallback?.();
+ try {
+ const { stdout } = await exec("grep", ["-r", "-c", "-i", query, sessionsDir], { timeout: 30000 });
+ for (const line of stdout.split("\n")) {
+ if (!line.trim()) continue;
+ const match = line.match(/^(.+):(\d+)$/);
+ if (match && parseInt(match[2], 10) > 0) {
+ results.set(match[1], parseInt(match[2], 10));
+ }
+ }
+ } catch {
+ // grep also failed or no matches
+ }
+ return results;
+}
+
+async function getAllSessions(sessionsDir: string): Promise<string[]> {
+ const sessions: string[] = [];
+ if (!fs.existsSync(sessionsDir)) return sessions;
+
+ for (const dirEntry of fs.readdirSync(sessionsDir, { withFileTypes: true })) {
+ if (!dirEntry.isDirectory() || dirEntry.name.startsWith(".")) continue;
+ const dirPath = path.join(sessionsDir, dirEntry.name);
+ for (const fileEntry of fs.readdirSync(dirPath, { withFileTypes: true })) {
+ if (fileEntry.name.endsWith(".jsonl")) {
+ sessions.push(path.join(dirPath, fileEntry.name));
+ }
+ }
+ }
+ return sessions;
+}
+
+// ============================================================================
+// Extension
+// ============================================================================
+
+const FindThreadsParams = Type.Object({
+ query: Type.Optional(Type.String({ description: "Text to search for in messages (uses ripgrep)" })),
+ cwd: Type.Optional(Type.String({ description: "Filter by working directory (partial match)" })),
+ limit: Type.Optional(Type.Number({ description: "Maximum results to return (default: 10)", default: 10 })),
+ sort: Type.Optional(
+ StringEnum(["recent", "oldest", "relevance"] as const, {
+ description: "Sort order: recent (default), oldest, or relevance (by match count)",
+ default: "recent",
+ }),
+ ),
+});
+
+const SearchThreadParams = Type.Object({
+ thread_id: Type.String({ description: "Thread ID (session UUID) or file path" }),
+ query: Type.Optional(Type.String({ description: "Search for messages containing this text (case-insensitive). If omitted, returns all messages." })),
+ context: Type.Optional(Type.Number({ description: "Include N messages before/after each match (default: 0)", default: 0 })),
+ roles: Type.Optional(Type.Array(Type.String(), { description: "Filter to specific roles: user, assistant, toolResult (default: all)" })),
+ max_messages: Type.Optional(Type.Number({ description: "Maximum messages to return" })),
+ max_content_length: Type.Optional(Type.Number({ description: "Truncate each message content to N chars" })),
+});
+
+export default function (pi: ExtensionAPI) {
+ // ========================================================================
+ // find_threads tool
+ // ========================================================================
+ pi.registerTool({
+ name: "find_threads",
+ label: "Find Threads",
+ description:
+ "Search through past conversation sessions. Use to find previous discussions, code changes, or decisions. Searches message content using ripgrep for speed.",
+ parameters: FindThreadsParams,
+
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
+ const startTime = Date.now();
+ const sessionsDir = getSessionsDir();
+ const limit = params.limit ?? 10;
+ const sort = params.sort ?? "recent";
+
+ let sessionFiles = await getAllSessions(sessionsDir);
+ let matchCounts: Map<string, number> | null = null;
+
+ // Filter by query using ripgrep (with grep fallback)
+ if (params.query) {
+ matchCounts = await searchWithGrep(
+ pi.exec.bind(pi),
+ params.query,
+ sessionsDir,
+ () => ctx.ui.notify("ripgrep not found, falling back to grep (slower)", "warning"),
+ );
+ sessionFiles = sessionFiles.filter((f) => matchCounts!.has(f));
+ }
+
+ // Filter by cwd
+ if (params.cwd) {
+ const cwdFilter = params.cwd.toLowerCase();
+ sessionFiles = sessionFiles.filter((f) => {
+ const entries = loadSessionFile(f);
+ const header = getSessionHeader(entries);
+ return header?.cwd?.toLowerCase().includes(cwdFilter);
+ });
+ }
+
+ // Parse and build results
+ const results: Array<{
+ id: string;
+ cwd: string;
+ timestamp: string;
+ preview: string;
+ messageCount: number;
+ filePath: string;
+ matchCount?: number;
+ }> = [];
+
+ for (const filePath of sessionFiles) {
+ const entries = loadSessionFile(filePath);
+ const header = getSessionHeader(entries);
+ if (!header) continue;
+
+ const sessionEntries = getSessionEntries(entries);
+ results.push({
+ id: header.id,
+ cwd: header.cwd || "",
+ timestamp: header.timestamp,
+ preview: getFirstUserMessage(sessionEntries),
+ messageCount: countMessages(sessionEntries),
+ filePath,
+ matchCount: matchCounts?.get(filePath),
+ });
+ }
+
+ // Sort
+ if (sort === "recent") {
+ results.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
+ } else if (sort === "oldest") {
+ results.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
+ } else if (sort === "relevance" && matchCounts) {
+ results.sort((a, b) => (b.matchCount ?? 0) - (a.matchCount ?? 0));
+ }
+
+ const limitedResults = results.slice(0, limit);
+ const searchTime = Date.now() - startTime;
+
+ // Format text output
+ let text = `Found ${results.length} threads`;
+ if (params.query) text += ` matching "${params.query}"`;
+ if (params.cwd) text += ` in ${params.cwd}`;
+ text += ` (${searchTime}ms)\n\n`;
+
+ for (const r of limitedResults) {
+ const date = new Date(r.timestamp).toLocaleDateString();
+ text += `**${r.id}** (${date})\n`;
+ text += ` 📁 ${r.cwd}\n`;
+ text += ` 💬 ${r.messageCount} messages`;
+ if (r.matchCount) text += ` | ${r.matchCount} matches`;
+ text += `\n 📝 ${r.preview}\n\n`;
+ }
+
+ if (results.length > limit) {
+ text += `... and ${results.length - limit} more. Use limit parameter to see more.`;
+ }
+
+ return {
+ content: [{ type: "text", text }],
+ details: { threads: limitedResults, searchTime, totalSessions: sessionFiles.length },
+ };
+ },
+
+ renderCall(args, theme) {
+ let text = theme.fg("toolTitle", theme.bold("find_threads"));
+ if (args.query) text += " " + theme.fg("accent", `"${args.query}"`);
+ if (args.cwd) text += " " + theme.fg("muted", `in ${args.cwd}`);
+ if (args.limit) text += " " + theme.fg("dim", `limit:${args.limit}`);
+ return new Text(text, 0, 0);
+ },
+
+ renderResult(result, { expanded }, theme) {
+ const { details } = result;
+ if (!details?.threads) {
+ const text = result.content[0];
+ return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
+ }
+
+ const { threads, searchTime } = details;
+ const icon = threads.length > 0 ? theme.fg("success", "✓") : theme.fg("muted", "○");
+
+ if (expanded) {
+ const container = new Container();
+ container.addChild(
+ new Text(`${icon} Found ${theme.fg("accent", String(threads.length))} threads (${searchTime}ms)`, 0, 0),
+ );
+
+ for (const t of threads) {
+ container.addChild(new Spacer(1));
+ const date = new Date(t.timestamp).toLocaleDateString();
+ container.addChild(new Text(theme.fg("accent", t.id) + theme.fg("dim", ` (${date})`), 0, 0));
+ container.addChild(new Text(theme.fg("muted", ` 📁 ${t.cwd}`), 0, 0));
+ container.addChild(
+ new Text(
+ theme.fg("dim", ` 💬 ${t.messageCount} msgs`) +
+ (t.matchCount ? theme.fg("warning", ` | ${t.matchCount} matches`) : ""),
+ 0,
+ 0,
+ ),
+ );
+ const preview = t.preview.length > 80 ? t.preview.slice(0, 80) + "..." : t.preview;
+ container.addChild(new Text(theme.fg("toolOutput", ` ${preview}`), 0, 0));
+ }
+ return container;
+ }
+
+ // Collapsed view
+ let text = `${icon} Found ${theme.fg("accent", String(threads.length))} threads (${searchTime}ms)`;
+ for (const t of threads.slice(0, 3)) {
+ const date = new Date(t.timestamp).toLocaleDateString();
+ const preview = t.preview.length > 50 ? t.preview.slice(0, 50) + "..." : t.preview;
+ text += `\n ${theme.fg("accent", t.id.slice(0, 8))} ${theme.fg("dim", date)} ${theme.fg("muted", preview)}`;
+ }
+ if (threads.length > 3) {
+ text += `\n ${theme.fg("muted", `... +${threads.length - 3} more (Ctrl+O to expand)`)}`;
+ }
+ return new Text(text, 0, 0);
+ },
+ });
+
+ // ========================================================================
+ // search_thread tool
+ // ========================================================================
+ pi.registerTool({
+ name: "search_thread",
+ label: "Search Thread",
+ description:
+ "Search and read a specific conversation thread by ID or file path. Returns conversation messages with optional filtering by query text, roles, and context window.",
+ parameters: SearchThreadParams,
+
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
+ const { thread_id, query, context = 0, roles, max_messages, max_content_length } = params;
+ const sessionsDir = getSessionsDir();
+
+ // Find the session file
+ let filePath: string | null = null;
+
+ if (thread_id.endsWith(".jsonl") || thread_id.startsWith("/")) {
+ filePath = thread_id;
+ } else {
+ const allSessions = await getAllSessions(sessionsDir);
+ for (const sessionPath of allSessions) {
+ const entries = loadSessionFile(sessionPath);
+ const header = getSessionHeader(entries);
+ if (header?.id === thread_id) {
+ filePath = sessionPath;
+ break;
+ }
+ }
+ }
+
+ if (!filePath || !fs.existsSync(filePath)) {
+ return {
+ content: [{ type: "text", text: `Thread not found: ${thread_id}` }],
+ details: { thread: null, error: "Thread not found" },
+ isError: true,
+ };
+ }
+
+ const fileEntries = loadSessionFile(filePath);
+ const header = getSessionHeader(fileEntries);
+
+ if (!header) {
+ return {
+ content: [{ type: "text", text: `Invalid session file: ${filePath}` }],
+ details: { thread: null, error: "Invalid session file" },
+ isError: true,
+ };
+ }
+
+ const sessionEntries = getSessionEntries(fileEntries);
+ const branchEntries = getEntryPath(sessionEntries);
+
+ // Build message list
+ const allMessages: Array<{
+ role: string;
+ content: string;
+ timestamp?: string;
+ model?: string;
+ toolName?: string;
+ }> = [];
+ let totalTokens = 0;
+ let totalCost = 0;
+
+ for (const entry of branchEntries) {
+ if (entry.type === "custom_message") {
+ const customEntry = entry as any;
+ const customContent = extractTextContent(customEntry.content);
+ if (!customContent.trim()) continue;
+ allMessages.push({
+ role: customEntry.customType ? `custom:${customEntry.customType}` : "custom",
+ content: customContent.trim(),
+ timestamp: customEntry.timestamp,
+ });
+ continue;
+ }
+
+ if (entry.type !== "message") continue;
+
+ const msg = entry.message;
+ const content = extractTextContent(msg.content);
+ if (!content.trim()) continue;
+
+ allMessages.push({
+ role: msg.role,
+ content: content.trim(),
+ timestamp: entry.timestamp,
+ model: "model" in msg ? (msg as any).model : undefined,
+ toolName: "toolName" in msg ? (msg as any).toolName : undefined,
+ });
+
+ if ("usage" in msg && msg.usage) {
+ const usage = msg.usage as { input?: number; output?: number; cost?: { total?: number } };
+ totalTokens += (usage.input || 0) + (usage.output || 0);
+ totalCost += usage.cost?.total || 0;
+ }
+ }
+
+ // Apply filtering
+ let filteredMessages = allMessages;
+ const originalCount = allMessages.length;
+
+ // 1. Filter by roles if specified
+ if (roles && roles.length > 0) {
+ const rolesLower = roles.map(r => r.toLowerCase());
+ filteredMessages = filteredMessages.filter(msg =>
+ rolesLower.includes(msg.role.toLowerCase())
+ );
+ }
+
+ // 2. Filter by query if specified, with context
+ let matchCount = 0;
+ if (query) {
+ const queryLower = query.toLowerCase();
+ const matchIndices = new Set<number>();
+
+ // Find matching message indices
+ for (let i = 0; i < filteredMessages.length; i++) {
+ if (filteredMessages[i].content.toLowerCase().includes(queryLower)) {
+ matchCount++;
+ // Add the match and context
+ for (let j = Math.max(0, i - context); j <= Math.min(filteredMessages.length - 1, i + context); j++) {
+ matchIndices.add(j);
+ }
+ }
+ }
+
+ // Keep only messages in the match set (preserving order)
+ filteredMessages = filteredMessages.filter((_, idx) => matchIndices.has(idx));
+ }
+
+ // 3. Apply max_messages limit (from end)
+ const limitedMessages = max_messages ? filteredMessages.slice(-max_messages) : filteredMessages;
+
+ // 4. Apply content length truncation
+ const finalMessages = max_content_length
+ ? limitedMessages.map(msg => ({
+ ...msg,
+ content: msg.content.length > max_content_length
+ ? msg.content.slice(0, max_content_length) + "..."
+ : msg.content,
+ }))
+ : limitedMessages;
+
+ const thread = {
+ id: header.id,
+ cwd: header.cwd || "",
+ timestamp: header.timestamp,
+ messages: finalMessages,
+ totalTokens,
+ totalCost,
+ };
+
+ // Format output
+ let text = `## Thread ${thread.id}\n`;
+ text += `**Directory:** ${thread.cwd}\n`;
+ text += `**Started:** ${new Date(thread.timestamp).toLocaleString()}\n`;
+ text += `**Messages:** ${originalCount} total | **Tokens:** ${totalTokens.toLocaleString()} | **Cost:** $${totalCost.toFixed(4)}\n`;
+
+ // Show filtering info
+ const filters: string[] = [];
+ if (query) filters.push(`query="${query}" (${matchCount} matches)`);
+ if (roles) filters.push(`roles=[${roles.join(", ")}]`);
+ if (context > 0) filters.push(`context=${context}`);
+ if (max_messages) filters.push(`max_messages=${max_messages}`);
+ if (max_content_length) filters.push(`truncate=${max_content_length}`);
+
+ if (filters.length > 0) {
+ text += `**Filters:** ${filters.join(" | ")}\n`;
+ text += `**Showing:** ${finalMessages.length} of ${originalCount} messages\n`;
+ }
+
+ text += "\n---\n\n";
+
+ for (const msg of finalMessages) {
+ const roleIcon = msg.role === "user" ? "👤" : msg.role === "assistant" ? "🤖" : "🔧";
+ const roleLabel = msg.role === "toolResult" ? `tool:${msg.toolName}` : msg.role;
+ text += `### ${roleIcon} ${roleLabel}\n`;
+ if (msg.model) text += `*${msg.model}*\n`;
+ text += `\n${msg.content}\n\n`;
+ }
+
+ return {
+ content: [{ type: "text", text }],
+ details: { thread, matchCount, originalCount },
+ };
+ },
+
+ renderCall(args, theme) {
+ let text = theme.fg("toolTitle", theme.bold("search_thread"));
+ text += " " + theme.fg("accent", args.thread_id.slice(0, 36));
+ if (args.query) text += " " + theme.fg("warning", `"${args.query}"`);
+ if (args.roles) text += " " + theme.fg("dim", `roles:[${args.roles.join(",")}]`);
+ if (args.context) text += " " + theme.fg("dim", `ctx:${args.context}`);
+ if (args.max_messages) text += " " + theme.fg("dim", `last:${args.max_messages}`);
+ return new Text(text, 0, 0);
+ },
+
+ renderResult(result, { expanded }, theme) {
+ const { details } = result;
+ if (!details?.thread) {
+ const text = result.content[0];
+ return new Text(
+ text?.type === "text" ? theme.fg("error", text.text) : theme.fg("error", "(error)"),
+ 0,
+ 0,
+ );
+ }
+
+ const { thread, matchCount, originalCount } = details;
+ const icon = theme.fg("success", "✓");
+ const countInfo = matchCount !== undefined
+ ? `${matchCount} matches, ${thread.messages.length}/${originalCount} shown`
+ : `${thread.messages.length} messages`;
+
+ if (expanded) {
+ const container = new Container();
+ container.addChild(
+ new Text(`${icon} Thread ${theme.fg("accent", thread.id.slice(0, 8))} (${countInfo})`, 0, 0),
+ );
+ container.addChild(new Text(theme.fg("muted", `📁 ${thread.cwd}`), 0, 0));
+ container.addChild(
+ new Text(theme.fg("dim", `📊 ${thread.totalTokens.toLocaleString()} tokens | $${thread.totalCost.toFixed(4)}`), 0, 0),
+ );
+
+ for (const msg of thread.messages) {
+ container.addChild(new Spacer(1));
+ const roleIcon = msg.role === "user" ? "👤" : msg.role === "assistant" ? "🤖" : "🔧";
+ const preview = msg.content.length > 200 ? msg.content.slice(0, 200) + "..." : msg.content;
+ container.addChild(new Text(`${roleIcon} ${theme.fg("accent", msg.role)}`, 0, 0));
+ container.addChild(new Text(theme.fg("toolOutput", preview), 0, 0));
+ }
+ return container;
+ }
+
+ // Collapsed
+ let text = `${icon} Thread ${theme.fg("accent", thread.id.slice(0, 8))} (${countInfo})`;
+ text += `\n ${theme.fg("muted", thread.cwd)}`;
+ for (const msg of thread.messages.slice(0, 3)) {
+ const preview = msg.content.slice(0, 60).replace(/\n/g, " ");
+ const roleIcon = msg.role === "user" ? "👤" : msg.role === "assistant" ? "🤖" : "🔧";
+ text += `\n ${roleIcon} ${theme.fg("dim", preview)}${msg.content.length > 60 ? "..." : ""}`;
+ }
+ if (thread.messages.length > 3) {
+ text += `\n ${theme.fg("muted", `... +${thread.messages.length - 3} more (Ctrl+O to expand)`)}`;
+ }
+ return new Text(text, 0, 0);
+ },
+ });
+}
dots/pi/agent/extensions/threads/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "pi-threads",
+ "version": "0.2.1",
+ "description": "Pi extension for searching and reading past conversation threads with ripgrep-powered search",
+ "type": "module",
+ "keywords": ["pi-package"],
+ "license": "MIT",
+ "author": "laulauland",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/laulauland/dotfiles"
+ },
+ "pi": {
+ "extensions": ["./index.ts"]
+ },
+ "peerDependencies": {
+ "@mariozechner/pi-ai": "*",
+ "@mariozechner/pi-coding-agent": "*",
+ "@mariozechner/pi-tui": "*",
+ "@sinclair/typebox": "*"
+ }
+}
dots/pi/agent/extensions/threads/README.md
@@ -0,0 +1,66 @@
+# pi-threads
+
+A [pi](https://github.com/badlogic/pi-mono) extension that provides tools for searching and reading past conversation sessions.
+
+## Features
+
+- **`find_threads` tool**: Search through past conversations using ripgrep for sub-20ms search performance
+- **`read_thread` tool**: Read a specific conversation thread by ID or file path
+- **Custom rendering**: Compact and expanded views with proper theming
+- **Lazy parsing**: Only parse files that match search criteria
+
+## Installation
+
+```bash
+pi install npm:pi-threads
+```
+
+Or add to your `~/.pi/agent/settings.json`:
+
+```json
+{
+ "packages": ["npm:pi-threads"]
+}
+```
+
+## Tools
+
+### find_threads
+
+Search through past conversation sessions.
+
+**Parameters:**
+- `query` (optional): Text to search for in messages (uses ripgrep)
+- `cwd` (optional): Filter by working directory (partial match)
+- `limit` (optional): Maximum results to return (default: 10)
+- `sort` (optional): Sort order - "recent" (default), "oldest", or "relevance"
+
+**Example usage by LLM:**
+```
+Find threads about "authentication" in the auth-service project
+```
+
+### read_thread
+
+Read a specific conversation thread by ID or file path.
+
+**Parameters:**
+- `thread_id`: Thread ID (session UUID) or file path
+- `include_tool_results` (optional): Include tool call results in output (default: false)
+- `max_messages` (optional): Maximum messages to return (default: all)
+
+**Example usage by LLM:**
+```
+Read thread abc123-def456 to see the authentication implementation discussion
+```
+
+## Use Cases
+
+- **Context retrieval**: "What did we decide about the API design last week?"
+- **Code archaeology**: "Find the thread where we implemented the caching layer"
+- **Continuation**: "What was the status of the refactoring work?"
+- **Knowledge transfer**: "Show me conversations about the payment integration"
+
+## License
+
+MIT
dots/pi/agent/extensions/file-picker.ts
@@ -0,0 +1,1059 @@
+/**
+ * File Picker Extension
+ *
+ * Replaces the built-in @ file picker with an enhanced file browser.
+ * Selected files are attached to the prompt as context.
+ *
+ * Features:
+ * - @ shortcut opens file browser (replaces built-in)
+ * - Directory navigation with Enter
+ * - Space to toggle selection
+ * - Tab to toggle options panel (gitignore, hidden files)
+ * - Fuzzy search and glob patterns
+ * - Git-aware file listing (respects .gitignore)
+ * - Selected files injected as context on prompt submit
+ *
+ * Based on codemap extension by @kcosr
+ */
+
+import {
+ CustomEditor,
+ type ExtensionAPI,
+ type ExtensionContext,
+} from "@mariozechner/pi-coding-agent";
+import { matchesKey, visibleWidth, type EditorTheme, type TUI } from "@mariozechner/pi-tui";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import * as os from "node:os";
+import { execSync } from "node:child_process";
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Types
+// ═══════════════════════════════════════════════════════════════════════════
+
+interface FileEntry {
+ name: string;
+ isDirectory: boolean;
+ relativePath: string;
+}
+
+interface PickerState {
+ respectGitignore: boolean;
+ skipHidden: boolean;
+}
+
+interface PickerConfig {
+ respectGitignore?: boolean;
+ skipHidden?: boolean;
+ skipPatterns?: string[];
+}
+
+interface BrowserOption {
+ id: string;
+ label: string;
+ enabled: boolean;
+ visible: () => boolean;
+}
+
+interface SelectedPath {
+ path: string;
+ isDirectory: boolean;
+}
+
+type FileBrowserAction =
+ | { action: "confirm"; paths: SelectedPath[] }
+ | { action: "cancel" }
+ | { action: "select"; selected: SelectedPath; paths: SelectedPath[] };
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Config
+// ═══════════════════════════════════════════════════════════════════════════
+
+const DEFAULT_CONFIG: PickerConfig = {
+ respectGitignore: true,
+ skipHidden: true,
+ skipPatterns: ["node_modules"],
+};
+
+function loadConfig(): PickerConfig {
+ const configPath = path.join(
+ os.homedir(),
+ ".pi",
+ "agent",
+ "extensions",
+ "file-picker",
+ "config.json"
+ );
+ try {
+ if (fs.existsSync(configPath)) {
+ const content = fs.readFileSync(configPath, "utf-8");
+ const custom = JSON.parse(content) as Partial<PickerConfig>;
+ return { ...DEFAULT_CONFIG, ...custom };
+ }
+ } catch {
+ // Ignore errors, use default
+ }
+ return DEFAULT_CONFIG;
+}
+
+const config = loadConfig();
+const skipPatterns = config.skipPatterns ?? ["node_modules"];
+
+// ═══════════════════════════════════════════════════════════════════════════
+// State (per-session, reset on session switch)
+// ═══════════════════════════════════════════════════════════════════════════
+
+const state: PickerState = {
+ respectGitignore: config.respectGitignore ?? true,
+ skipHidden: config.skipHidden ?? true,
+};
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Theming
+// ═══════════════════════════════════════════════════════════════════════════
+
+interface PaletteTheme {
+ border: string;
+ title: string;
+ selected: string;
+ selectedText: string;
+ directory: string;
+ checked: string;
+ searchIcon: string;
+ placeholder: string;
+ hint: string;
+}
+
+const DEFAULT_THEME: PaletteTheme = {
+ border: "2",
+ title: "2",
+ selected: "36",
+ selectedText: "36",
+ directory: "34",
+ checked: "32",
+ searchIcon: "2",
+ placeholder: "2;3",
+ hint: "2",
+};
+
+function loadTheme(): PaletteTheme {
+ const themePath = path.join(
+ os.homedir(),
+ ".pi",
+ "agent",
+ "extensions",
+ "file-picker",
+ "theme.json"
+ );
+ try {
+ if (fs.existsSync(themePath)) {
+ const content = fs.readFileSync(themePath, "utf-8");
+ const custom = JSON.parse(content) as Partial<PaletteTheme>;
+ return { ...DEFAULT_THEME, ...custom };
+ }
+ } catch {
+ // Ignore errors
+ }
+ return DEFAULT_THEME;
+}
+
+function fg(code: string, text: string): string {
+ if (!code) return text;
+ return `\x1b[${code}m${text}\x1b[0m`;
+}
+
+const paletteTheme = loadTheme();
+
+// ═══════════════════════════════════════════════════════════════════════════
+// File System Utilities
+// ═══════════════════════════════════════════════════════════════════════════
+
+function getCwdRoot(): string {
+ return process.cwd();
+}
+
+function isWithinCwd(targetPath: string, cwdRoot: string): boolean {
+ const resolved = path.resolve(targetPath);
+ const normalizedCwd = path.resolve(cwdRoot);
+ return (
+ resolved === normalizedCwd ||
+ resolved.startsWith(normalizedCwd + path.sep)
+ );
+}
+
+function shouldSkipPattern(name: string): boolean {
+ return skipPatterns.some((pattern) => {
+ if (pattern.includes("*")) {
+ const regex = globToRegex(pattern);
+ return regex.test(name);
+ }
+ return name === pattern;
+ });
+}
+
+function listDirectoryWithGit(
+ dirPath: string,
+ cwdRoot: string,
+ gitFiles: Set<string> | null,
+ skipHidden: boolean
+): FileEntry[] {
+ const entries: FileEntry[] = [];
+
+ try {
+ const items = fs.readdirSync(dirPath, { withFileTypes: true });
+ const relDir = path.relative(cwdRoot, dirPath);
+
+ for (const item of items) {
+ if (skipHidden && item.name.startsWith(".")) continue;
+ if (shouldSkipPattern(item.name)) continue;
+
+ const fullPath = path.join(dirPath, item.name);
+ const relativePath = relDir ? path.join(relDir, item.name) : item.name;
+
+ let isDirectory = item.isDirectory();
+ if (item.isSymbolicLink()) {
+ try {
+ const stats = fs.statSync(fullPath);
+ isDirectory = stats.isDirectory();
+ } catch {
+ continue;
+ }
+ }
+
+ if (gitFiles !== null) {
+ if (isDirectory) {
+ let hasGitFiles = false;
+ const prefix = relativePath + "/";
+ for (const gitFile of gitFiles) {
+ if (gitFile.startsWith(prefix) || gitFile === relativePath) {
+ hasGitFiles = true;
+ break;
+ }
+ }
+ if (!hasGitFiles) continue;
+ } else {
+ if (!gitFiles.has(relativePath)) continue;
+ }
+ }
+
+ entries.push({
+ name: item.name,
+ isDirectory,
+ relativePath,
+ });
+ }
+
+ entries.sort((a, b) => {
+ if (a.isDirectory && !b.isDirectory) return -1;
+ if (!a.isDirectory && b.isDirectory) return 1;
+ return a.name.localeCompare(b.name);
+ });
+ } catch {
+ // Return empty on error
+ }
+
+ return entries;
+}
+
+function listAllFiles(
+ dirPath: string,
+ cwdRoot: string,
+ results: FileEntry[],
+ skipHidden: boolean
+): FileEntry[] {
+ try {
+ const items = fs.readdirSync(dirPath, { withFileTypes: true });
+
+ for (const item of items) {
+ if (skipHidden && item.name.startsWith(".")) continue;
+ if (shouldSkipPattern(item.name)) continue;
+
+ const fullPath = path.join(dirPath, item.name);
+ const relativePath = path.relative(cwdRoot, fullPath);
+
+ let isDirectory = item.isDirectory();
+ if (item.isSymbolicLink()) {
+ try {
+ const stats = fs.statSync(fullPath);
+ isDirectory = stats.isDirectory();
+ } catch {
+ continue;
+ }
+ }
+
+ results.push({
+ name: item.name,
+ isDirectory,
+ relativePath,
+ });
+
+ if (isDirectory) {
+ listAllFiles(fullPath, cwdRoot, results, skipHidden);
+ }
+ }
+ } catch {
+ // Skip inaccessible directories
+ }
+
+ return results;
+}
+
+function isGitRepo(cwdRoot: string): boolean {
+ try {
+ execSync("git rev-parse --is-inside-work-tree", {
+ cwd: cwdRoot,
+ encoding: "utf-8",
+ stdio: "pipe",
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function listGitFiles(cwdRoot: string): FileEntry[] {
+ const entries: FileEntry[] = [];
+
+ try {
+ const output = execSync(
+ "git ls-files --cached --others --exclude-standard",
+ {
+ cwd: cwdRoot,
+ encoding: "utf-8",
+ stdio: "pipe",
+ maxBuffer: 10 * 1024 * 1024,
+ }
+ );
+
+ const files = output
+ .trim()
+ .split("\n")
+ .filter((f) => f);
+
+ for (const relativePath of files) {
+ const fullPath = path.join(cwdRoot, relativePath);
+ const name = path.basename(relativePath);
+
+ let isDirectory = false;
+ try {
+ const stats = fs.statSync(fullPath);
+ isDirectory = stats.isDirectory();
+ } catch {
+ continue;
+ }
+
+ entries.push({
+ name,
+ isDirectory,
+ relativePath,
+ });
+ }
+
+ const dirs = new Set<string>();
+ for (const entry of entries) {
+ let dir = path.dirname(entry.relativePath);
+ while (dir && dir !== ".") {
+ dirs.add(dir);
+ dir = path.dirname(dir);
+ }
+ }
+
+ for (const dir of dirs) {
+ entries.push({
+ name: path.basename(dir),
+ isDirectory: true,
+ relativePath: dir,
+ });
+ }
+ } catch {
+ // Fall back to empty
+ }
+
+ return entries;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Search Utilities
+// ═══════════════════════════════════════════════════════════════════════════
+
+function isGlobPattern(query: string): boolean {
+ return /[*?[\]]/.test(query);
+}
+
+function globToRegex(pattern: string): RegExp {
+ let regex = "";
+ let i = 0;
+
+ while (i < pattern.length) {
+ const char = pattern[i];
+
+ if (char === "*") {
+ if (pattern[i + 1] === "*") {
+ regex += ".*";
+ i += 2;
+ if (pattern[i] === "/") i++;
+ } else {
+ regex += "[^/]*";
+ i++;
+ }
+ } else if (char === "?") {
+ regex += "[^/]";
+ i++;
+ } else if (char === "[") {
+ const end = pattern.indexOf("]", i);
+ if (end !== -1) {
+ regex += pattern.slice(i, end + 1);
+ i = end + 1;
+ } else {
+ regex += "\\[";
+ i++;
+ }
+ } else if (".+^${}()|\\".includes(char)) {
+ regex += "\\" + char;
+ i++;
+ } else {
+ regex += char;
+ i++;
+ }
+ }
+
+ return new RegExp("^" + regex + "$", "i");
+}
+
+function fuzzyScore(query: string, text: string): number {
+ const lowerQuery = query.toLowerCase();
+ const lowerText = text.toLowerCase();
+
+ if (lowerText.includes(lowerQuery)) {
+ return 100 + (lowerQuery.length / lowerText.length) * 50;
+ }
+
+ let score = 0;
+ let queryIndex = 0;
+ let consecutiveBonus = 0;
+
+ for (let i = 0; i < lowerText.length && queryIndex < lowerQuery.length; i++) {
+ if (lowerText[i] === lowerQuery[queryIndex]) {
+ score += 10 + consecutiveBonus;
+ consecutiveBonus += 5;
+ queryIndex++;
+ } else {
+ consecutiveBonus = 0;
+ }
+ }
+
+ return queryIndex === lowerQuery.length ? score : 0;
+}
+
+function filterEntries(entries: FileEntry[], query: string): FileEntry[] {
+ if (!query.trim()) return entries;
+
+ if (isGlobPattern(query)) {
+ const regex = globToRegex(query);
+ const filtered = entries.filter(
+ (entry) => regex.test(entry.name) || regex.test(entry.relativePath)
+ );
+ // Sort: files first, then directories
+ return filtered.sort((a, b) => {
+ if (a.isDirectory && !b.isDirectory) return 1;
+ if (!a.isDirectory && b.isDirectory) return -1;
+ return a.relativePath.localeCompare(b.relativePath);
+ });
+ }
+
+ const scored = entries
+ .map((entry) => ({
+ entry,
+ score: Math.max(
+ fuzzyScore(query, entry.name),
+ fuzzyScore(query, entry.relativePath) * 0.9
+ ),
+ }))
+ .filter((item) => item.score > 0)
+ .sort((a, b) => {
+ // Primary: files before directories
+ if (a.entry.isDirectory && !b.entry.isDirectory) return 1;
+ if (!a.entry.isDirectory && b.entry.isDirectory) return -1;
+ // Secondary: by score
+ return b.score - a.score;
+ });
+
+ return scored.map((item) => item.entry);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// File Browser Component
+// ═══════════════════════════════════════════════════════════════════════════
+
+class FileBrowserComponent {
+ readonly width = 100;
+ private readonly maxVisible = 10;
+ private cwdRoot: string;
+ private currentDir: string;
+ private allEntries: FileEntry[];
+ private allFilesRecursive: FileEntry[];
+ private filtered: FileEntry[];
+ private selected = 0;
+ private query = "";
+ private isSearchMode = false;
+ private selectedPaths: Map<string, boolean>; // path -> isDirectory
+ private rootParentView = false;
+ private inGitRepo: boolean;
+ private gitFiles: Set<string> | null = null;
+ private focusOnOptions = false;
+ private selectedOption = 0;
+ private options: BrowserOption[];
+ private done: (action: FileBrowserAction) => void;
+
+ constructor(done: (action: FileBrowserAction) => void) {
+ this.done = done;
+ this.cwdRoot = getCwdRoot();
+ this.currentDir = this.cwdRoot;
+ this.selectedPaths = new Map();
+ this.inGitRepo = isGitRepo(this.cwdRoot);
+
+ this.options = [
+ {
+ id: "gitignore",
+ label: "Respect .gitignore",
+ enabled: state.respectGitignore,
+ visible: () => this.inGitRepo,
+ },
+ {
+ id: "skipHidden",
+ label: "Skip hidden files",
+ enabled: state.skipHidden,
+ visible: () => true,
+ },
+ ];
+
+ this.rebuildFileLists();
+ }
+
+ private getOption(id: string): BrowserOption | undefined {
+ return this.options.find((o) => o.id === id);
+ }
+
+ private getVisibleOptions(): BrowserOption[] {
+ return this.options.filter((o) => o.visible());
+ }
+
+ private rebuildFileLists(): void {
+ const respectGitignore = this.getOption("gitignore")?.enabled ?? false;
+ const skipHidden = this.getOption("skipHidden")?.enabled ?? true;
+
+ if (this.inGitRepo && respectGitignore) {
+ const gitEntries = listGitFiles(this.cwdRoot);
+ this.gitFiles = new Set(gitEntries.map((e) => e.relativePath));
+ this.allFilesRecursive = gitEntries;
+ } else {
+ this.gitFiles = null;
+ this.allFilesRecursive = listAllFiles(this.cwdRoot, this.cwdRoot, [], skipHidden);
+ }
+
+ this.allEntries = this.listCurrentDirectory();
+ this.updateFilter();
+ }
+
+ private listCurrentDirectory(): FileEntry[] {
+ if (this.rootParentView) {
+ return [{
+ name: path.basename(this.cwdRoot),
+ isDirectory: true,
+ relativePath: ".",
+ }];
+ }
+
+ const skipHidden = this.getOption("skipHidden")?.enabled ?? true;
+ const entries = listDirectoryWithGit(
+ this.currentDir,
+ this.cwdRoot,
+ this.gitFiles,
+ skipHidden
+ );
+
+ entries.unshift({
+ name: "..",
+ isDirectory: true,
+ relativePath: "..",
+ });
+
+ return entries;
+ }
+
+ private isUpEntry(entry: FileEntry): boolean {
+ return entry.name === ".." && entry.relativePath === "..";
+ }
+
+ private navigateTo(dir: string): void {
+ if (!isWithinCwd(dir, this.cwdRoot)) return;
+
+ this.rootParentView = false;
+ this.currentDir = dir;
+ this.allEntries = this.listCurrentDirectory();
+ this.query = "";
+ this.isSearchMode = false;
+ this.filtered = this.allEntries;
+ this.selected = 0;
+ }
+
+ private goUp(): boolean {
+ if (this.rootParentView) return false;
+
+ if (this.currentDir === this.cwdRoot) {
+ this.rootParentView = true;
+ this.allEntries = this.listCurrentDirectory();
+ this.query = "";
+ this.isSearchMode = false;
+ this.filtered = this.allEntries;
+ this.selected = 0;
+ return true;
+ }
+
+ const parentDir = path.dirname(this.currentDir);
+ if (isWithinCwd(parentDir, this.cwdRoot)) {
+ this.navigateTo(parentDir);
+ return true;
+ }
+ return false;
+ }
+
+ handleInput(data: string): void {
+ if (matchesKey(data, "tab")) {
+ const visibleOptions = this.getVisibleOptions();
+ if (visibleOptions.length > 0) {
+ this.focusOnOptions = !this.focusOnOptions;
+ if (this.focusOnOptions) this.selectedOption = 0;
+ }
+ return;
+ }
+
+ if (this.focusOnOptions) {
+ this.handleOptionsInput(data);
+ } else {
+ this.handleBrowserInput(data);
+ }
+ }
+
+ private handleOptionsInput(data: string): void {
+ const visibleOptions = this.getVisibleOptions();
+ const currentOption = visibleOptions[this.selectedOption];
+
+ if (matchesKey(data, "escape")) {
+ this.focusOnOptions = false;
+ return;
+ }
+
+ if (matchesKey(data, "up")) {
+ if (visibleOptions.length > 0) {
+ this.selectedOption = this.selectedOption === 0
+ ? visibleOptions.length - 1
+ : this.selectedOption - 1;
+ }
+ return;
+ }
+
+ if (matchesKey(data, "down")) {
+ if (visibleOptions.length > 0) {
+ this.selectedOption = this.selectedOption === visibleOptions.length - 1
+ ? 0
+ : this.selectedOption + 1;
+ }
+ return;
+ }
+
+ if (data === " " || matchesKey(data, "return")) {
+ if (currentOption) {
+ currentOption.enabled = !currentOption.enabled;
+ // Sync to global state
+ if (currentOption.id === "gitignore") {
+ state.respectGitignore = currentOption.enabled;
+ } else if (currentOption.id === "skipHidden") {
+ state.skipHidden = currentOption.enabled;
+ }
+ this.rebuildFileLists();
+ }
+ }
+ }
+
+ private getSelectedPathsArray(): SelectedPath[] {
+ return Array.from(this.selectedPaths.entries()).map(([p, isDir]) => ({ path: p, isDirectory: isDir }));
+ }
+
+ private handleBrowserInput(data: string): void {
+ if (matchesKey(data, "escape")) {
+ if (!this.goUp()) {
+ this.done({ action: "confirm", paths: this.getSelectedPathsArray() });
+ }
+ return;
+ }
+
+ // Enter = select and insert (files or directories)
+ if (matchesKey(data, "return")) {
+ const entry = this.filtered[this.selected];
+ if (entry) {
+ if (entry.name === "..") {
+ this.goUp();
+ } else {
+ // Select and close (works for both files and directories)
+ const paths = this.getSelectedPathsArray();
+ const selected = { path: entry.relativePath, isDirectory: entry.isDirectory };
+ if (!this.selectedPaths.has(entry.relativePath)) {
+ paths.push(selected);
+ }
+ this.done({ action: "select", selected, paths });
+ }
+ }
+ return;
+ }
+
+ // Space or Right arrow = navigate directories, toggle files
+ if (data === " " || matchesKey(data, "right")) {
+ const entry = this.filtered[this.selected];
+ if (entry && !this.isUpEntry(entry)) {
+ if (entry.isDirectory) {
+ // Navigate into directory
+ this.navigateTo(path.join(this.cwdRoot, entry.relativePath));
+ } else {
+ // Toggle selection for multi-select (files only)
+ if (this.selectedPaths.has(entry.relativePath)) {
+ this.selectedPaths.delete(entry.relativePath);
+ } else {
+ this.selectedPaths.set(entry.relativePath, entry.isDirectory);
+ }
+ }
+ }
+ return;
+ }
+
+ if (matchesKey(data, "up")) {
+ if (this.filtered.length > 0) {
+ this.selected = this.selected === 0 ? this.filtered.length - 1 : this.selected - 1;
+ }
+ return;
+ }
+
+ if (matchesKey(data, "down")) {
+ if (this.filtered.length > 0) {
+ this.selected = this.selected === this.filtered.length - 1 ? 0 : this.selected + 1;
+ }
+ return;
+ }
+
+ // Left arrow = go up directory
+ if (matchesKey(data, "left")) {
+ this.goUp();
+ return;
+ }
+
+ if (matchesKey(data, "backspace")) {
+ if (this.query.length > 0) {
+ this.query = this.query.slice(0, -1);
+ this.updateFilter();
+ } else {
+ this.goUp();
+ }
+ return;
+ }
+
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
+ this.query += data;
+ this.updateFilter();
+ }
+ }
+
+ private updateFilter(): void {
+ if (this.query.trim()) {
+ this.isSearchMode = true;
+ this.filtered = filterEntries(this.allFilesRecursive, this.query);
+ } else {
+ this.isSearchMode = false;
+ this.filtered = this.allEntries;
+ }
+ this.selected = 0;
+ }
+
+ render(_width: number): string[] {
+ const w = this.width;
+ const innerW = w - 2;
+ const lines: string[] = [];
+
+ const t = paletteTheme;
+ const border = (s: string) => fg(t.border, s);
+ const title = (s: string) => fg(t.title, s);
+ const selected = (s: string) => fg(t.selected, s);
+ const selectedText = (s: string) => fg(t.selectedText, s);
+ const directory = (s: string) => fg(t.directory, s);
+ const checked = (s: string) => fg(t.checked, s);
+ const hint = (s: string) => fg(t.hint, s);
+ const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
+
+ const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
+
+ 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) => border("│") + pad(content, innerW) + border("│");
+
+ // Top border with title
+ let titleText: string;
+ if (this.isSearchMode) {
+ titleText = " Search ";
+ } else if (this.rootParentView) {
+ titleText = " Files ";
+ } else {
+ const relDir = path.relative(this.cwdRoot, this.currentDir);
+ titleText = relDir ? ` ${truncate(relDir, 40)} ` : " Files ";
+ }
+ const borderLen = Math.max(0, innerW - visibleWidth(titleText));
+ const leftBorder = Math.floor(borderLen / 2);
+ const rightBorder = borderLen - leftBorder;
+ lines.push(
+ border("╭" + "─".repeat(leftBorder)) +
+ title(titleText) +
+ border("─".repeat(rightBorder) + "╮")
+ );
+
+ // Search input
+ const searchPrompt = selected("❯ ");
+ const queryDisplay = this.query || hint("Search files...");
+ const modeIndicator = this.query && isGlobPattern(this.query) ? hint(" [glob]") : "";
+ lines.push(row(` ${searchPrompt}${queryDisplay}${modeIndicator}`));
+
+ // Options row
+ const visibleOptions = this.getVisibleOptions();
+ if (visibleOptions.length > 0) {
+ const optParts: string[] = [];
+ for (let i = 0; i < visibleOptions.length; i++) {
+ const opt = visibleOptions[i];
+ const isSelectedOpt = this.focusOnOptions && i === this.selectedOption;
+ const checkbox = opt.enabled ? checked("☑") : hint("☐");
+ const label = isSelectedOpt
+ ? selected(opt.label)
+ : opt.enabled
+ ? opt.label
+ : hint(opt.label);
+ const prefix = isSelectedOpt ? selected("▸") : " ";
+ optParts.push(`${prefix}${checkbox} ${label}`);
+ }
+ const optionsStr = optParts.join(" ");
+ const tabHint = this.focusOnOptions ? hint(" (space toggle, esc exit)") : hint(" (tab)");
+ lines.push(row(` ${optionsStr}${tabHint}`));
+ } else {
+ lines.push(row(""));
+ }
+
+ // Divider
+ lines.push(border(`├${"─".repeat(innerW)}┤`));
+
+ // File list - always render exactly maxVisible rows
+ const startIndex = Math.max(
+ 0,
+ Math.min(this.selected - Math.floor(this.maxVisible / 2), this.filtered.length - this.maxVisible)
+ );
+
+ for (let i = 0; i < this.maxVisible; i++) {
+ const actualIndex = startIndex + i;
+ if (actualIndex < this.filtered.length) {
+ const entry = this.filtered[actualIndex];
+ const isSelectedEntry = actualIndex === this.selected;
+ const isUpDir = this.isUpEntry(entry);
+ const isChecked = !isUpDir && this.selectedPaths.has(entry.relativePath);
+
+ const prefix = isSelectedEntry ? selected(" ▶ ") : " ";
+
+ let displayName: string;
+ if (isUpDir) {
+ displayName = "..";
+ } else if (this.isSearchMode) {
+ displayName = entry.relativePath + (entry.isDirectory ? "/" : "");
+ } else {
+ displayName = entry.name + (entry.isDirectory ? "/" : "");
+ }
+
+ const maxNameLen = innerW - 8;
+ const truncatedName = truncate(displayName, maxNameLen);
+
+ let nameStr: string;
+ if (isUpDir) {
+ nameStr = isSelectedEntry ? bold(selectedText(truncatedName)) : hint(truncatedName);
+ } else if (entry.isDirectory) {
+ nameStr = isSelectedEntry ? bold(selectedText(truncatedName)) : directory(truncatedName);
+ } else {
+ nameStr = isSelectedEntry ? bold(selectedText(truncatedName)) : truncatedName;
+ }
+
+ if (isUpDir) {
+ lines.push(row(`${prefix} ${nameStr}`));
+ } else {
+ const checkMark = isChecked ? checked("☑ ") : hint("☐ ");
+ lines.push(row(`${prefix}${checkMark}${nameStr}`));
+ }
+ } else if (i === 0 && this.filtered.length === 0) {
+ lines.push(row(hint(" No matching files")));
+ } else {
+ lines.push(row(""));
+ }
+ }
+
+ // Scroll/count indicator row
+ if (this.filtered.length > this.maxVisible) {
+ const shown = `${startIndex + 1}-${Math.min(startIndex + this.maxVisible, this.filtered.length)}`;
+ lines.push(row(hint(` (${shown} of ${this.filtered.length})`)));
+ } else if (this.filtered.length > 0) {
+ lines.push(row(hint(` (${this.filtered.length} file${this.filtered.length === 1 ? "" : "s"})`)));
+ } else {
+ lines.push(row(""));
+ }
+
+ // Selection summary section
+ lines.push(border(`├${"─".repeat(innerW)}┤`));
+ if (this.selectedPaths.size > 0) {
+ const selectedList = Array.from(this.selectedPaths.keys()).slice(0, 3);
+ const preview = selectedList.join(", ") + (this.selectedPaths.size > 3 ? ", ..." : "");
+ lines.push(row(` ${checked(`Selected (${this.selectedPaths.size}):`)} ${truncate(preview, innerW - 18)}`));
+ } else {
+ lines.push(row(hint(" No files selected")));
+ }
+
+ // Footer
+ lines.push(border(`├${"─".repeat(innerW)}┤`));
+ lines.push(row(hint(" ↑↓ navigate ←→ dirs space toggle enter select esc done")));
+
+ // Bottom border
+ lines.push(border(`╰${"─".repeat(innerW)}╯`));
+
+ return lines;
+ }
+
+ invalidate(): void {}
+ dispose(): void {}
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Shared File Picker Logic
+// ═══════════════════════════════════════════════════════════════════════════
+
+async function openFilePicker(ui: ExtensionContext["ui"]): Promise<void> {
+ const result = await ui.custom<FileBrowserAction>(
+ (_tui, _theme, _kb, done) => new FileBrowserComponent(done),
+ { overlay: true }
+ );
+
+ const paths = result.action === "cancel" ? [] : result.paths;
+ if (paths.length > 0) {
+ // Add trailing / for directories to make it clear
+ const refs = paths.map(p => `@${p.path}${p.isDirectory ? "/" : ""}`).join(" ");
+ const currentText = ui.getEditorText();
+ ui.setEditorText(currentText + refs + " ");
+ ui.notify(`Added ${paths.length} file${paths.length > 1 ? "s" : ""}`, "info");
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Custom Editor (intercepts @)
+// ═══════════════════════════════════════════════════════════════════════════
+
+class FilePickerEditor extends CustomEditor {
+ private opening = false;
+ private originalProvider: any = null;
+
+ constructor(
+ private tui: TUI,
+ theme: EditorTheme,
+ keybindings: any,
+ private ui: ExtensionContext["ui"]
+ ) {
+ super(tui, theme, keybindings);
+ }
+
+ // Wrap the autocomplete provider to filter out @ completions
+ setAutocompleteProvider(provider: any): void {
+ this.originalProvider = provider;
+
+ const wrappedProvider = {
+ getSuggestions: (lines: string[], cursorLine: number, cursorCol: number) => {
+ const line = lines[cursorLine] ?? "";
+ const beforeCursor = line.slice(0, cursorCol);
+
+ // If in @ context, return null (no suggestions) - we handle @ ourselves
+ if (beforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
+ return null;
+ }
+
+ return provider.getSuggestions(lines, cursorLine, cursorCol);
+ },
+ applyCompletion: (...args: any[]) => provider.applyCompletion(...args),
+ };
+
+ super.setAutocompleteProvider(wrappedProvider);
+ }
+
+ handleInput(data: string): void {
+ if (this.opening) return;
+
+ // Intercept @ to open our file picker instead of native
+ if (data === "@" && this.shouldTriggerFilePicker()) {
+ this.opening = true;
+ if (this.isShowingAutocomplete()) {
+ super.handleInput("\x1b");
+ }
+ openFilePicker(this.ui).finally(() => { this.opening = false; });
+ return;
+ }
+
+ super.handleInput(data);
+ }
+
+ private shouldTriggerFilePicker(): boolean {
+ const cursor = this.getCursor();
+ const line = this.getLines()[cursor.line] ?? "";
+
+ if (cursor.col === 0) return true;
+
+ const before = line[cursor.col - 1];
+ return before === " " || before === "\t" || before === undefined;
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Extension Export
+// ═══════════════════════════════════════════════════════════════════════════
+
+export default function (pi: ExtensionAPI) {
+ const attachEditor = (ctx: ExtensionContext) => {
+ if (!ctx.hasUI) return;
+ ctx.ui.setEditorComponent(
+ (tui, theme, keybindings) => new FilePickerEditor(tui, theme, keybindings, ctx.ui)
+ );
+ };
+
+ pi.on("session_start", (_event, ctx) => {
+ state.respectGitignore = config.respectGitignore ?? true;
+ state.skipHidden = config.skipHidden ?? true;
+ attachEditor(ctx);
+ });
+
+ pi.on("session_switch", (_event, ctx) => attachEditor(ctx));
+
+ pi.registerCommand("files", {
+ description: "Browse and select files to reference",
+ handler: async (_args, ctx) => {
+ if (!ctx.hasUI) {
+ ctx.ui.notify("File picker requires interactive mode", "error");
+ return;
+ }
+ await openFilePicker(ctx.ui);
+ },
+ });
+}
dots/pi/agent/extensions/orc-mode.ts
@@ -0,0 +1,210 @@
+/**
+ * Orchestrator Mode Extension
+ *
+ * A simple mode that transforms the main agent into an orchestrator.
+ * When enabled, only the subagent tool is available and the system prompt
+ * is updated to focus on coordination and delegation.
+ *
+ * Features:
+ * - /orc command to toggle orchestrator mode
+ * - In orc mode: only subagent tool available
+ * - System prompt modified to focus on orchestration
+ * - Orange status indicator when active
+ *
+ * Usage:
+ * 1. Use /orc to toggle orchestrator mode on/off
+ * 2. Or start with --orc flag
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+
+// Tools available in orchestrator mode - subagent for delegation, subagent_status for async monitoring
+const ORC_MODE_TOOLS = ["subagent", "subagent_status"];
+
+// Store the original tools to restore them when disabling orc mode
+let savedTools: string[] | null = null;
+
+export default function orcModeExtension(pi: ExtensionAPI) {
+ let orcModeEnabled = false;
+
+ // Register --orc CLI flag
+ pi.registerFlag("orc", {
+ description: "Start in orchestrator mode (coordinator-only)",
+ type: "boolean",
+ default: false,
+ });
+
+ // Helper to update status displays
+ function updateStatus(ctx: ExtensionContext) {
+ if (orcModeEnabled) {
+ ctx.ui.setStatus("orc-mode", ctx.ui.theme.fg("warning", "🎭 ORC"));
+ ctx.ui.setWidget("orc-mode", [
+ ctx.ui.theme.fg("warning", "🎭 Orchestrator mode active"),
+ ctx.ui.theme.fg("muted", "Only subagent tool available"),
+ ctx.ui.theme.fg("dim", "Use /orc to disable"),
+ ]);
+ } else {
+ ctx.ui.setStatus("orc-mode", undefined);
+ ctx.ui.setWidget("orc-mode", undefined);
+ }
+ }
+
+ function enableOrcMode(ctx: ExtensionContext) {
+ if (orcModeEnabled) return;
+
+ // Save current tools before switching
+ savedTools = pi.getActiveTools();
+ orcModeEnabled = true;
+ pi.setActiveTools(ORC_MODE_TOOLS);
+ ctx.ui.notify("🎭 Orchestrator mode enabled. Only subagent tool available.");
+ updateStatus(ctx);
+ persistState();
+ }
+
+ function disableOrcMode(ctx: ExtensionContext) {
+ if (!orcModeEnabled) return;
+
+ orcModeEnabled = false;
+ // Restore original tools or use defaults
+ if (savedTools && savedTools.length > 0) {
+ pi.setActiveTools(savedTools);
+ } else {
+ // Fallback to standard tools
+ pi.setActiveTools(["read", "bash", "edit", "write", "subagent"]);
+ }
+ savedTools = null;
+ ctx.ui.notify("Orchestrator mode disabled. Full tool access restored.");
+ updateStatus(ctx);
+ persistState();
+ }
+
+ function toggleOrcMode(ctx: ExtensionContext) {
+ if (orcModeEnabled) {
+ disableOrcMode(ctx);
+ } else {
+ enableOrcMode(ctx);
+ }
+ }
+
+ function persistState() {
+ pi.appendEntry("orc-mode-state", {
+ enabled: orcModeEnabled,
+ savedTools,
+ });
+ }
+
+ // Register /orc command
+ pi.registerCommand("orc", {
+ description: "Toggle orchestrator mode (only subagent tool available)",
+ handler: async (_args, ctx) => {
+ toggleOrcMode(ctx);
+ },
+ });
+
+ // Modify system prompt when orc mode is active
+ pi.on("before_agent_start", async (event) => {
+ if (!orcModeEnabled) return;
+
+ const orcSystemPrompt = `${event.systemPrompt}
+
+## ORCHESTRATOR MODE
+
+You are operating as an **orchestrator**. Your primary role is to coordinate, plan, and delegate work—not to implement directly.
+
+### Your Tools
+You only have access to the **subagent** tool. Use it to delegate all work:
+- **scout**: Fast codebase recon, find files, return compressed context for handoff
+- **planner**: Create implementation plans from context and requirements
+- **worker**: General-purpose subagent with full capabilities for implementation
+- **reviewer**: Code review specialist for quality and security analysis
+- **oracle**: Deep analysis, debugging, and architecture decisions
+
+### Subagent Modes
+
+**Single agent:**
+\`\`\`typescript
+{ agent: "explorer", task: "Find all auth-related files" }
+\`\`\`
+
+**Parallel tasks** (independent work):
+\`\`\`typescript
+{ tasks: [
+ { agent: "explorer", task: "Find frontend modules" },
+ { agent: "explorer", task: "Find backend modules" }
+]}
+\`\`\`
+
+**Chain** (sequential pipeline with \`{previous}\` carrying output forward):
+\`\`\`typescript
+{ chain: [
+ { agent: "explorer", task: "Gather context for auth refactor" },
+ { agent: "oracle", task: "Analyze and plan based on {previous}" },
+ { agent: "operator" }, // defaults to {previous}
+ { agent: "oracle", task: "Review changes from {previous}" }
+]}
+\`\`\`
+
+**Chain with parallel fan-out/fan-in:**
+\`\`\`typescript
+{ chain: [
+ { agent: "explorer", task: "Find all service modules" },
+ { parallel: [
+ { agent: "operator", task: "Refactor auth service from {previous}" },
+ { agent: "operator", task: "Refactor user service from {previous}" }
+ ]},
+ { agent: "oracle", task: "Review all changes from {previous}" }
+]}
+\`\`\`
+
+### Chain Variables
+- \`{task}\` - Original task from first step
+- \`{previous}\` - Output from prior step (or aggregated parallel outputs)
+- \`{chain_dir}\` - Shared artifacts directory for inter-step files
+
+### Workflow
+1. **Analyze** the user's request
+2. **Plan** the approach (break into steps if complex)
+3. **Delegate** each task to the appropriate subagent
+4. **Synthesize** results and report back
+
+### Guidelines
+- Always use subagent to delegate work
+- For complex multi-step tasks, use chain mode
+- For independent tasks, use parallel mode
+- Use parallel-in-chain for fan-out/fan-in patterns
+- Provide clear, specific task descriptions to subagents
+- After subagents complete, summarize findings or confirm changes`;
+
+ return { systemPrompt: orcSystemPrompt };
+ });
+
+ // Initialize state on session start
+ pi.on("session_start", async (_event, ctx) => {
+ // Check CLI flag
+ if (pi.getFlag("orc") === true) {
+ // Defer enabling to let other extensions initialize first
+ setTimeout(() => {
+ if (!orcModeEnabled) {
+ enableOrcMode(ctx);
+ }
+ }, 0);
+ return;
+ }
+
+ // Restore state from session
+ const entries = ctx.sessionManager.getEntries();
+ const stateEntry = entries
+ .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "orc-mode-state")
+ .pop() as { data?: { enabled: boolean; savedTools?: string[] | null } } | undefined;
+
+ if (stateEntry?.data) {
+ if (stateEntry.data.enabled) {
+ savedTools = stateEntry.data.savedTools ?? null;
+ orcModeEnabled = true;
+ pi.setActiveTools(ORC_MODE_TOOLS);
+ }
+ }
+
+ updateStatus(ctx);
+ });
+}
dots/pi/agent/extensions/tmux-reference.ts
@@ -0,0 +1,395 @@
+/**
+ * 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,
+ },
+ };
+ });
+}