Commit 0080be97fd64
Changed files (17)
dots
pi
agent
extensions
git
shell-completions
subagent
dots/pi/agent/extensions/git/README.md
@@ -19,10 +19,10 @@ Manage git worktrees easily without cluttering your repository with multiple che
### Upcoming Features
-- **Phase 2:** Smart rebase/fixup workflows
-- **Phase 3:** Commit helpers and validation
-- **Phase 4:** Branch management
-- **Phase 5:** Integration and polish
+See the org TODO tracking whether Phases 2-5 (rebase/fixup, commit helpers,
+branch management, polish) are still wanted. Plan draft:
+`~/.local/share/ai/plans/pi-git-extension.md`. These may be superseded by the
+`git` skill and AGENTS.md commit conventions.
## Installation
@@ -270,27 +270,6 @@ All operations include:
- Clear error messages
- Safe defaults
-## Roadmap
-
-### Phase 2: Smart Rebase/Fixup (Next)
-- `/fixup <commit>` - Create fixup commit
-- `/rebase-fixups` - Auto-squash fixups
-- Pre-push fixup detection
-
-### Phase 3: Commit Helpers
-- `/commit` - Interactive conventional commit
-- Commit message validation
-- AI-assisted commit messages
-
-### Phase 4: Branch Management
-- `/branch list` - List branches with info
-- `/branch cleanup` - Remove merged branches
-
-### Phase 5: Polish
-- Comprehensive error handling
-- Configuration system
-- Full documentation
-
## Related
- Plan: `~/.local/share/ai/plans/pi-git-extension.md`
dots/pi/agent/extensions/shell-completions/scripts/bash-complete.bash
@@ -1,49 +0,0 @@
-#!/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
- # shellcheck disable=SC1090
- [[ -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
- # shellcheck disable=SC1090
- [[ -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
@@ -1,149 +0,0 @@
-#!/usr/bin/env zsh
-# shellcheck disable=all
-# 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
@@ -1,125 +0,0 @@
-/**
- * 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 "@earendil-works/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
@@ -1,84 +0,0 @@
-/**
- * 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 "@earendil-works/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
@@ -1,338 +0,0 @@
-/**
- * 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 "@earendil-works/pi-coding-agent";
-import type { AutocompleteItem, AutocompleteProvider } from "@earendil-works/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" };
- }
- }
-
- // Try resolving from PATH (NixOS compat)
- try {
- const { execSync } = require("node:child_process");
- const which = execSync("which bash", { encoding: "utf-8", timeout: 5000 }).trim();
- if (which) return { path: which, type: "bash" };
- } catch { /* ignore */ }
-
- 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,
- options?: any
- ): { 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, options);
- },
-
- 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
@@ -1,20 +0,0 @@
-{
- "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": {
- "@earendil-works/pi-coding-agent": "*",
- "@earendil-works/pi-tui": "*"
- }
-}
dots/pi/agent/extensions/shell-completions/README.md
@@ -1,70 +0,0 @@
-# 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/tsconfig.json
@@ -1,12 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2022",
- "module": "ES2022",
- "moduleResolution": "bundler",
- "types": [],
- "skipLibCheck": true,
- "noEmit": true,
- "allowImportingTsExtensions": true
- },
- "include": ["*.ts"]
-}
dots/pi/agent/extensions/shell-completions/types.ts
@@ -1,30 +0,0 @@
-/**
- * Shared types for shell completions extension.
- */
-
-import type { AutocompleteItem } from "@earendil-works/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
@@ -1,110 +0,0 @@
-/**
- * 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 "@earendil-works/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/subagent/README.md
@@ -114,17 +114,6 @@ This might use:
- planner → `claude-sonnet-4-5@20250929` via `google-vertex-claude`
- worker → `claude-sonnet-4-5@20250929` via `google-vertex-claude`
-### Slash Commands
-
-The `subagent-commands.ts` extension provides shortcuts:
-
-```
-/scout find all authentication code
-/implement add caching to session store
-/scout-and-plan refactor database layer
-/review-code check security in API handlers
-```
-
## Example Agents
### Scout (Fast, Cheap)
dots/pi/agent/extensions/interactive-shell.ts
@@ -1,196 +0,0 @@
-/**
- * Interactive Shell Commands Extension
- *
- * Enables running interactive commands (vim, git rebase -i, htop, etc.)
- * with full terminal access. The TUI suspends while they run.
- *
- * Usage:
- * pi -e examples/extensions/interactive-shell.ts
- *
- * !vim file.txt # Auto-detected as interactive
- * !i any-command # Force interactive mode with !i prefix
- * !git rebase -i HEAD~3
- * !htop
- *
- * Configuration via environment variables:
- * INTERACTIVE_COMMANDS - Additional commands (comma-separated)
- * INTERACTIVE_EXCLUDE - Commands to exclude (comma-separated)
- *
- * Note: This only intercepts user `!` commands, not agent bash tool calls.
- * If the agent runs an interactive command, it will fail (which is fine).
- */
-
-import { spawnSync } from "node:child_process";
-import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
-
-// Default interactive commands - editors, pagers, git ops, TUIs
-const DEFAULT_INTERACTIVE_COMMANDS = [
- // Editors
- "vim",
- "nvim",
- "vi",
- "nano",
- "emacs",
- "pico",
- "micro",
- "helix",
- "hx",
- "kak",
- // Pagers
- "less",
- "more",
- "most",
- // Git interactive
- "git commit",
- "git rebase",
- "git merge",
- "git cherry-pick",
- "git revert",
- "git add -p",
- "git add --patch",
- "git add -i",
- "git add --interactive",
- "git stash -p",
- "git stash --patch",
- "git reset -p",
- "git reset --patch",
- "git checkout -p",
- "git checkout --patch",
- "git difftool",
- "git mergetool",
- // System monitors
- "htop",
- "top",
- "btop",
- "glances",
- // File managers
- "ranger",
- "nnn",
- "lf",
- "mc",
- "vifm",
- // Git TUIs
- "tig",
- "lazygit",
- "gitui",
- // Fuzzy finders
- "fzf",
- "sk",
- // Remote sessions
- "ssh",
- "telnet",
- "mosh",
- // Database clients
- "psql",
- "mysql",
- "sqlite3",
- "mongosh",
- "redis-cli",
- // Kubernetes/Docker
- "kubectl edit",
- "kubectl exec -it",
- "docker exec -it",
- "docker run -it",
- // Other
- "tmux",
- "screen",
- "ncdu",
-];
-
-function getInteractiveCommands(): string[] {
- const additional =
- process.env.INTERACTIVE_COMMANDS?.split(",")
- .map((s) => s.trim())
- .filter(Boolean) ?? [];
- const excluded = new Set(process.env.INTERACTIVE_EXCLUDE?.split(",").map((s) => s.trim().toLowerCase()) ?? []);
- return [...DEFAULT_INTERACTIVE_COMMANDS, ...additional].filter((cmd) => !excluded.has(cmd.toLowerCase()));
-}
-
-function isInteractiveCommand(command: string): boolean {
- const trimmed = command.trim().toLowerCase();
- const commands = getInteractiveCommands();
-
- for (const cmd of commands) {
- const cmdLower = cmd.toLowerCase();
- // Match at start
- if (trimmed === cmdLower || trimmed.startsWith(`${cmdLower} `) || trimmed.startsWith(`${cmdLower}\t`)) {
- return true;
- }
- // Match after pipe: "cat file | less"
- const pipeIdx = trimmed.lastIndexOf("|");
- if (pipeIdx !== -1) {
- const afterPipe = trimmed.slice(pipeIdx + 1).trim();
- if (afterPipe === cmdLower || afterPipe.startsWith(`${cmdLower} `)) {
- return true;
- }
- }
- }
- return false;
-}
-
-export default function (pi: ExtensionAPI) {
- pi.on("user_bash", async (event, ctx) => {
- let command = event.command;
- let forceInteractive = false;
-
- // Check for !i prefix (command comes without the leading !)
- // The prefix parsing happens before this event, so we check if command starts with "i "
- if (command.startsWith("i ") || command.startsWith("i\t")) {
- forceInteractive = true;
- command = command.slice(2).trim();
- }
-
- const shouldBeInteractive = forceInteractive || isInteractiveCommand(command);
- if (!shouldBeInteractive) {
- return; // Let normal handling proceed
- }
-
- // No UI available (print mode, RPC, etc.)
- if (!ctx.hasUI) {
- return {
- result: { output: "(interactive commands require TUI)", exitCode: 1, cancelled: false, truncated: false },
- };
- }
-
- // Use ctx.ui.custom() to get TUI access, then run the command
- const exitCode = await ctx.ui.custom<number | null>((tui, _theme, _kb, done) => {
- // Stop TUI to release terminal
- tui.stop();
-
- // Clear screen
- process.stdout.write("\x1b[2J\x1b[H");
-
- // Run command with full terminal access
- const shell = process.env.SHELL || "/bin/sh";
- const result = spawnSync(shell, ["-c", command], {
- stdio: "inherit",
- env: process.env,
- });
-
- // Restart TUI
- tui.start();
- tui.requestRender(true);
-
- // Signal completion
- done(result.status);
-
- // Return empty component (immediately disposed since done() was called)
- return { render: () => [], invalidate: () => {} };
- });
-
- // Return result to prevent default bash handling
- const output =
- exitCode === 0
- ? "(interactive command completed successfully)"
- : `(interactive command exited with code ${exitCode})`;
-
- return {
- result: {
- output,
- exitCode: exitCode ?? 1,
- cancelled: false,
- truncated: false,
- },
- };
- });
-}
dots/pi/agent/extensions/review.ts
@@ -1,1886 +0,0 @@
-/**
- * Code Review Extension (inspired by Codex's review feature)
- *
- * Provides a `/review` command that prompts the agent to review code changes.
- * Supports multiple review modes:
- * - Review a GitHub pull request (checks out the PR locally)
- * - Review against a base branch (PR style)
- * - Review uncommitted changes
- * - Review a specific commit
- * - Custom review instructions
- *
- * Usage:
- * - `/review` - show interactive selector
- * - `/review 123` - review PR #123 (checks out locally)
- * - `/review tektoncd/pipeline#123` - review PR #123 from tektoncd/pipeline
- * - `/review https://github.com/owner/repo/pull/123` - review PR from URL
- * - `/review pr 123` - review PR #123 (alternative syntax)
- * - `/review uncommitted` - review uncommitted changes directly
- * - `/review branch main` - review against main branch
- * - `/review commit abc123` - review specific commit
- * - `/review folder src docs` - review specific folders/files (snapshot, not diff)
- * - `/review custom "check for security issues"` - custom instructions
- *
- * Project-specific review guidelines:
- * - If a REVIEW_GUIDELINES.md file exists in the same directory as .pi,
- * its contents are appended to the review prompt.
- *
- * Note: PR review requires a clean working tree (no uncommitted changes to tracked files).
- */
-
-import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
-import { DynamicBorder, BorderedLoader } from "@earendil-works/pi-coding-agent";
-import { Container, type SelectItem, SelectList, Text, Key } from "@earendil-works/pi-tui";
-import path from "node:path";
-import { promises as fs, readdirSync, readFileSync } from "node:fs";
-import { execSync } from "node:child_process";
-
-// =============================================================================
-// Helper Functions for Saving Reviews
-// =============================================================================
-
-function getYearMonth(): string {
- const now = new Date();
- const year = now.getFullYear();
- const month = String(now.getMonth() + 1).padStart(2, "0");
- return `${year}-${month}`;
-}
-
-function formatTimestamp(date: Date): string {
- return date.toISOString().replace("T", " ").slice(0, 19);
-}
-
-function extractOrgRepo(remoteUrl: string): { org: string; repo: string } | null {
- // Extract org and repo from various git remote formats
- // Examples:
- // https://github.com/NixOS/nixpkgs.git -> { org: "nixos", repo: "nixpkgs" }
- // git@github.com:tektoncd/pipeline.git -> { org: "tektoncd", repo: "pipeline" }
- // https://github.com/kubernetes/kubernetes -> { org: "kubernetes", repo: "kubernetes" }
-
- // Remove .git suffix
- const cleanUrl = remoteUrl.replace(/\.git$/, "");
-
- // Extract org/repo pattern
- const match = cleanUrl.match(/[:/]([^/]+)\/([^/]+)$/);
- if (match) {
- // Lowercase for case-insensitive matching
- const org = match[1].toLowerCase();
- const repo = match[2].toLowerCase();
- return { org, repo };
- }
-
- return null;
-}
-
-async function loadRepositorySpecificRules(cwd: string): Promise<string | null> {
- try {
- // Get git remote URL
- const remoteUrl = execSync("git remote get-url origin", {
- cwd,
- encoding: "utf-8",
- }).trim();
-
- const orgRepo = extractOrgRepo(remoteUrl);
- if (!orgRepo) {
- return null;
- }
-
- const { org, repo } = orgRepo;
- const home = process.env.HOME || process.env.USERPROFILE || "";
- const skillsDir = path.join(home, ".config", "claude", "skills");
-
- // Priority 1: Review skill with repo-specific file
- // ~/.config/claude/skills/Review/repositories/<org>-<repo>.md
- const reviewSkillDir = path.join(skillsDir, "Review");
- const repoSpecificFile = path.join(reviewSkillDir, "repositories", `${org}-${repo}.md`);
-
- try {
- const content = await fs.readFile(repoSpecificFile, "utf-8");
- const filename = path.relative(skillsDir, repoSpecificFile);
- return `
-
-# Repository-Specific Review Guidelines: ${org}/${repo}
-
-From: ~/.config/claude/skills/${filename}
-
-${content}
-`;
- } catch {
- // File doesn't exist, continue to next priority
- }
-
- // Priority 2: Review skill general workflow
- // ~/.config/claude/skills/Review/workflows/Review.md
- const reviewWorkflow = path.join(reviewSkillDir, "workflows", "Review.md");
- try {
- const content = await fs.readFile(reviewWorkflow, "utf-8");
- const filename = path.relative(skillsDir, reviewWorkflow);
- return `
-
-# General Review Guidelines
-
-From: ~/.config/claude/skills/${filename}
-
-${content}
-`;
- } catch {
- // File doesn't exist, continue to next priority
- }
-
- // Priority 3: Review skill general guidelines
- // ~/.config/claude/skills/Review/SKILL.md
- const reviewSkill = path.join(reviewSkillDir, "SKILL.md");
- try {
- const content = await fs.readFile(reviewSkill, "utf-8");
- const filename = path.relative(skillsDir, reviewSkill);
- return `
-
-# General Review Guidelines
-
-From: ~/.config/claude/skills/${filename}
-
-${content}
-`;
- } catch {
- // File doesn't exist, continue to fallback
- }
-
- // Fallback: Legacy repo-specific skill (e.g., Nixpkgs skill)
- // Try to find a skill directory matching the repository name
- const skillDirs = await fs.readdir(skillsDir);
- const matchingSkill = skillDirs.find(
- (dir) => dir.toLowerCase() === repo
- );
-
- if (!matchingSkill) {
- return null;
- }
-
- const skillDir = path.join(skillsDir, matchingSkill);
-
- // Try to load review-specific files in priority order
- const reviewFiles = [
- path.join(skillDir, "review-checklist.md"),
- path.join(skillDir, "workflows", "Review.md"),
- path.join(skillDir, "SKILL.md"),
- ];
-
- for (const filepath of reviewFiles) {
- try {
- const content = await fs.readFile(filepath, "utf-8");
- const filename = path.relative(skillsDir, filepath);
-
- // For SKILL.md, try to extract review section
- if (filepath.endsWith("SKILL.md")) {
- const reviewSection = content.match(/## Review Best Practices[\s\S]*?(?=##\s+[A-Z]|$)/);
- if (reviewSection) {
- return `
-
-# Repository-Specific Review Guidelines: ${repo}
-
-From: ~/.config/claude/skills/${filename}
-
-${reviewSection[0]}
-`;
- }
- }
-
- // For other files, include the whole content
- return `
-
-# Repository-Specific Review Guidelines: ${repo}
-
-From: ~/.config/claude/skills/${filename}
-
-${content}
-`;
- } catch {
- // Try next file
- continue;
- }
- }
-
- // None found
- return null;
- } catch {
- return null;
- }
-}
-
-async function saveReviewMetadata(reviewData: {
- type: string;
- target: string;
- sessionFile: string;
- cwd: string;
-}): Promise<string> {
- const home = process.env.HOME || process.env.USERPROFILE || "";
- const yearMonth = getYearMonth();
- const reviewsDir = path.join(home, ".local", "share", "ai", "reviews", yearMonth);
-
- // Ensure directory exists
- await fs.mkdir(reviewsDir, { recursive: true });
-
- const timestamp = Date.now();
- const filename = `review-${timestamp}.md`;
- const filepath = path.join(reviewsDir, filename);
-
- const now = new Date();
-
- // Try to get git context
- let gitBranch = "unknown";
- let gitCommit = "unknown";
- try {
- gitBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: reviewData.cwd, encoding: "utf-8" }).trim();
- gitCommit = execSync("git rev-parse HEAD", { cwd: reviewData.cwd, encoding: "utf-8" }).trim().slice(0, 8);
- } catch {
- // Ignore errors
- }
-
- const markdown = `# Code Review: ${reviewData.target}
-
-**Date:** ${formatTimestamp(now)}
-**Type:** ${reviewData.type}
-**Session:** ${reviewData.sessionFile}
-**Branch:** ${gitBranch}
-**Commit:** ${gitCommit}
-
-## Review Target
-
-${reviewData.target}
-
-## Notes
-
-Review session created. See session file for AI-generated review findings.
-
-To extract findings and create TODOs, use the org-todos extension.
-
-## Next Actions
-
-- [ ] Review AI findings
-- [ ] Create TODOs for critical/high issues
-- [ ] Apply suggested fixes
-- [ ] Re-review after changes
-`;
-
- await fs.writeFile(filepath, markdown, "utf-8");
- return filepath;
-}
-
-// State to track fresh session review (where we branched from).
-// Module-level state means only one review can be active at a time.
-// This is intentional - the UI and /end-review command assume a single active review.
-let reviewOriginId: string | undefined = undefined;
-// Temporary clone directory for cross-repo PR reviews (cleaned up on /end-review)
-let reviewCloneDir: string | undefined = undefined;
-
-const REVIEW_STATE_TYPE = "review-session";
-
-type ReviewSessionState = {
- active: boolean;
- originId?: string;
- cloneDir?: string;
-};
-
-function setReviewWidget(ctx: ExtensionContext, active: boolean) {
- if (!ctx.hasUI) return;
- if (!active) {
- ctx.ui.setWidget("review", undefined);
- return;
- }
-
- ctx.ui.setWidget("review", (_tui, theme) => {
- const text = new Text(theme.fg("warning", "Review session active, return with /end-review"), 0, 0);
- return {
- render(width: number) {
- return text.render(width);
- },
- invalidate() {
- text.invalidate();
- },
- };
- });
-}
-
-function getReviewState(ctx: ExtensionContext): ReviewSessionState | undefined {
- let state: ReviewSessionState | undefined;
- for (const entry of ctx.sessionManager.getBranch()) {
- if (entry.type === "custom" && entry.customType === REVIEW_STATE_TYPE) {
- state = entry.data as ReviewSessionState | undefined;
- }
- }
-
- return state;
-}
-
-function applyReviewState(ctx: ExtensionContext) {
- const state = getReviewState(ctx);
-
- if (state?.active && state.originId) {
- reviewOriginId = state.originId;
- reviewCloneDir = state.cloneDir;
- setReviewWidget(ctx, true);
- return;
- }
-
- reviewOriginId = undefined;
- reviewCloneDir = undefined;
- setReviewWidget(ctx, false);
-}
-
-/**
- * Clean up review state and optionally remove the temporary clone directory.
- */
-async function clearReviewState(ctx: ExtensionContext, pi: ExtensionAPI) {
- setReviewWidget(ctx, false);
- reviewOriginId = undefined;
-
- // Clean up temporary clone directory for cross-repo PR reviews
- if (reviewCloneDir) {
- try {
- await fs.rm(reviewCloneDir, { recursive: true, force: true });
- ctx.ui.notify(`Cleaned up temporary clone: ${reviewCloneDir}`, "info");
- } catch {
- ctx.ui.notify(`Warning: Failed to clean up ${reviewCloneDir}`, "warning");
- }
- reviewCloneDir = undefined;
- }
-
- pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
-}
-
-// Review target types (matching Codex's approach)
-type ReviewTarget =
- | { type: "uncommitted" }
- | { type: "baseBranch"; branch: string }
- | { type: "commit"; sha: string; title?: string }
- | { type: "custom"; instructions: string }
- | { type: "pullRequest"; prNumber: number; baseBranch: string; title: string; repo?: string; cloneDir?: string }
- | { type: "folder"; paths: string[] };
-
-// Prompts (adapted from Codex)
-const UNCOMMITTED_PROMPT =
- "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.";
-
-const BASE_BRANCH_PROMPT_WITH_MERGE_BASE =
- "Review the code changes against the base branch '{baseBranch}'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes relative to {baseBranch}. Provide prioritized, actionable findings.";
-
-const BASE_BRANCH_PROMPT_FALLBACK =
- "Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch}'s upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{upstream}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings.";
-
-const COMMIT_PROMPT_WITH_TITLE =
- 'Review the code changes introduced by commit {sha} ("{title}"). Provide prioritized, actionable findings.';
-
-const COMMIT_PROMPT = "Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings.";
-
-const PULL_REQUEST_PROMPT =
- 'Review pull request #{prNumber} ("{title}") against the base branch \'{baseBranch}\'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes that would be merged. Provide prioritized, actionable findings.';
-
-const PULL_REQUEST_PROMPT_FALLBACK =
- 'Review pull request #{prNumber} ("{title}") against the base branch \'{baseBranch}\'. Start by finding the merge base between the current branch and {baseBranch} (e.g., `git merge-base HEAD {baseBranch}`), then run `git diff` against that SHA to see the changes that would be merged. Provide prioritized, actionable findings.';
-
-const FOLDER_REVIEW_PROMPT =
- "Review the code in the following paths: {paths}. This is a snapshot review (not a diff). Read the files directly in these paths and provide prioritized, actionable findings.";
-
-// =============================================================================
-// Review Focus Areas (dynamically discovered from reviewer agents)
-// =============================================================================
-
-interface ReviewerAgent {
- /** Focus key, e.g. "general", "security", "go" */
- focus: string;
- /** Agent name from frontmatter, e.g. "reviewer-security" */
- agent: string;
- /** Human-readable label, e.g. "Security" */
- label: string;
- /** Description from agent frontmatter */
- description: string;
-}
-
-/**
- * Discover reviewer agents from ~/.pi/agent/agents/reviewer*.md
- * The base `reviewer.md` is treated as "general".
- * Agents named `reviewer-<focus>.md` become focus areas.
- * Descriptions are read from the agent frontmatter.
- */
-function discoverReviewerAgents(): ReviewerAgent[] {
- const home = process.env.HOME || process.env.USERPROFILE || "";
- const agentsDir = path.join(home, ".pi", "agent", "agents");
-
- let entries: string[];
- try {
- entries = readdirSync(agentsDir).filter(
- (f) => f.startsWith("reviewer") && f.endsWith(".md"),
- );
- } catch {
- return [];
- }
-
- const agents: ReviewerAgent[] = [];
- for (const filename of entries.sort()) {
- const filePath = path.join(agentsDir, filename);
- let content: string;
- try {
- content = readFileSync(filePath, "utf-8");
- } catch {
- continue;
- }
-
- // Parse YAML frontmatter
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
- if (!fmMatch) continue;
-
- const fm = fmMatch[1];
- const nameMatch = fm.match(/^name:\s*(.+)$/m);
- const descMatch = fm.match(/^description:\s*(.+)$/m);
- if (!nameMatch) continue;
-
- const agentName = nameMatch[1].trim();
- const description = descMatch ? descMatch[1].trim() : agentName;
-
- // Derive focus key: "reviewer" → "general", "reviewer-security" → "security"
- let focus: string;
- let label: string;
- if (filename === "reviewer.md") {
- focus = "general";
- label = "General";
- } else {
- focus = filename.replace(/^reviewer-/, "").replace(/\.md$/, "");
- label = focus.charAt(0).toUpperCase() + focus.slice(1);
- }
-
- agents.push({ focus, agent: agentName, label, description });
- }
-
- return agents;
-}
-
-async function loadProjectReviewGuidelines(cwd: string): Promise<string | null> {
- let currentDir = path.resolve(cwd);
-
- while (true) {
- const piDir = path.join(currentDir, ".pi");
- const guidelinesPath = path.join(currentDir, "REVIEW_GUIDELINES.md");
-
- const piStats = await fs.stat(piDir).catch(() => null);
- if (piStats?.isDirectory()) {
- const guidelineStats = await fs.stat(guidelinesPath).catch(() => null);
- if (guidelineStats?.isFile()) {
- try {
- const content = await fs.readFile(guidelinesPath, "utf8");
- const trimmed = content.trim();
- return trimmed ? trimmed : null;
- } catch {
- return null;
- }
- }
- return null;
- }
-
- const parentDir = path.dirname(currentDir);
- if (parentDir === currentDir) {
- return null;
- }
- currentDir = parentDir;
- }
-}
-
-/**
- * Get the merge base between HEAD and a branch
- */
-async function getMergeBase(
- pi: ExtensionAPI,
- branch: string,
- cwd?: string,
-): Promise<string | null> {
- const execOpts = cwd ? { cwd } : undefined;
- try {
- // First try to get the upstream tracking branch
- const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [
- "rev-parse",
- "--abbrev-ref",
- `${branch}@{upstream}`,
- ], execOpts);
-
- if (upstreamCode === 0 && upstream.trim()) {
- const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", upstream.trim()], execOpts);
- if (code === 0 && mergeBase.trim()) {
- return mergeBase.trim();
- }
- }
-
- // Fall back to using the branch directly
- const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", branch], execOpts);
- if (code === 0 && mergeBase.trim()) {
- return mergeBase.trim();
- }
-
- return null;
- } catch {
- return null;
- }
-}
-
-/**
- * Get list of local branches
- */
-async function getLocalBranches(pi: ExtensionAPI): Promise<string[]> {
- const { stdout, code } = await pi.exec("git", ["branch", "--format=%(refname:short)"]);
- if (code !== 0) return [];
- return stdout
- .trim()
- .split("\n")
- .filter((b) => b.trim());
-}
-
-/**
- * Get list of recent commits
- */
-async function getRecentCommits(pi: ExtensionAPI, limit: number = 10): Promise<Array<{ sha: string; title: string }>> {
- const { stdout, code } = await pi.exec("git", ["log", `--oneline`, `-n`, `${limit}`]);
- if (code !== 0) return [];
-
- return stdout
- .trim()
- .split("\n")
- .filter((line) => line.trim())
- .map((line) => {
- const [sha, ...rest] = line.trim().split(" ");
- return { sha, title: rest.join(" ") };
- });
-}
-
-/**
- * Check if there are uncommitted changes (staged, unstaged, or untracked)
- */
-async function hasUncommittedChanges(pi: ExtensionAPI): Promise<boolean> {
- const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
- return code === 0 && stdout.trim().length > 0;
-}
-
-/**
- * Check if there are changes that would prevent switching branches
- * (staged or unstaged changes to tracked files - untracked files are fine)
- */
-async function hasPendingChanges(pi: ExtensionAPI): Promise<boolean> {
- // Check for staged or unstaged changes to tracked files
- const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
- if (code !== 0) return false;
-
- // Filter out untracked files (lines starting with ??)
- const lines = stdout.trim().split("\n").filter((line) => line.trim());
- const trackedChanges = lines.filter((line) => !line.startsWith("??"));
- return trackedChanges.length > 0;
-}
-
-/**
- * Parsed PR reference with optional repository context
- */
-type PrReference = {
- number: number;
- repo?: string; // "owner/repo" format, undefined means current repo
-};
-
-/**
- * Parse a PR reference (URL, number, or owner/repo#number) and return the PR number + optional repo
- */
-function parsePrReference(ref: string): PrReference | null {
- const trimmed = ref.trim();
-
- // Try as a number first
- const num = parseInt(trimmed, 10);
- if (!isNaN(num) && num > 0) {
- return { number: num };
- }
-
- // Try to extract from GitHub URL
- // Formats: https://github.com/owner/repo/pull/123
- // github.com/owner/repo/pull/123
- const urlMatch = trimmed.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
- if (urlMatch) {
- return { number: parseInt(urlMatch[2], 10), repo: urlMatch[1] };
- }
-
- // Try to extract from owner/repo#number format
- // Format: tektoncd/pipeline#1234
- const repoMatch = trimmed.match(/^([^/]+\/[^#]+)#(\d+)$/);
- if (repoMatch) {
- return { number: parseInt(repoMatch[2], 10), repo: repoMatch[1] };
- }
-
- return null;
-}
-
-/**
- * Get PR information from GitHub CLI
- */
-async function getPrInfo(pi: ExtensionAPI, prNumber: number, repo?: string): Promise<{ baseBranch: string; title: string; headBranch: string } | null> {
- const args = ["pr", "view", String(prNumber), "--json", "baseRefName,title,headRefName"];
- if (repo) {
- args.push("-R", repo);
- }
- const { stdout, code } = await pi.exec("gh", args);
-
- if (code !== 0) return null;
-
- try {
- const data = JSON.parse(stdout);
- return {
- baseBranch: data.baseRefName,
- title: data.title,
- headBranch: data.headRefName,
- };
- } catch {
- return null;
- }
-}
-
-/**
- * Checkout a PR using GitHub CLI
- */
-async function checkoutPr(pi: ExtensionAPI, prNumber: number, repo?: string): Promise<{ success: boolean; error?: string }> {
- const args = ["pr", "checkout", String(prNumber)];
- if (repo) {
- args.push("-R", repo);
- }
- const { stdout, stderr, code } = await pi.exec("gh", args);
-
- if (code !== 0) {
- return { success: false, error: stderr || stdout || "Failed to checkout PR" };
- }
-
- return { success: true };
-}
-
-/**
- * Get the current branch name
- */
-async function getCurrentBranch(pi: ExtensionAPI): Promise<string | null> {
- const { stdout, code } = await pi.exec("git", ["branch", "--show-current"]);
- if (code === 0 && stdout.trim()) {
- return stdout.trim();
- }
- return null;
-}
-
-/**
- * Get the default branch (main or master)
- */
-async function getDefaultBranch(pi: ExtensionAPI): Promise<string> {
- // Try to get from remote HEAD
- const { stdout, code } = await pi.exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]);
- if (code === 0 && stdout.trim()) {
- return stdout.trim().replace("origin/", "");
- }
-
- // Fall back to checking if main or master exists
- const branches = await getLocalBranches(pi);
- if (branches.includes("main")) return "main";
- if (branches.includes("master")) return "master";
-
- return "main"; // Default fallback
-}
-
-/**
- * Build the review prompt based on target
- */
-async function buildReviewPrompt(pi: ExtensionAPI, target: ReviewTarget): Promise<string> {
- switch (target.type) {
- case "uncommitted":
- return UNCOMMITTED_PROMPT;
-
- case "baseBranch": {
- const mergeBase = await getMergeBase(pi, target.branch);
- if (mergeBase) {
- return BASE_BRANCH_PROMPT_WITH_MERGE_BASE.replace(/{baseBranch}/g, target.branch).replace(
- /{mergeBaseSha}/g,
- mergeBase,
- );
- }
- return BASE_BRANCH_PROMPT_FALLBACK.replace(/{branch}/g, target.branch);
- }
-
- case "commit":
- if (target.title) {
- return COMMIT_PROMPT_WITH_TITLE.replace("{sha}", target.sha).replace("{title}", target.title);
- }
- return COMMIT_PROMPT.replace("{sha}", target.sha);
-
- case "custom":
- return target.instructions;
-
- case "pullRequest": {
- const mergeBase = await getMergeBase(pi, target.baseBranch, target.cloneDir);
- let prompt: string;
- if (mergeBase) {
- prompt = PULL_REQUEST_PROMPT
- .replace(/{prNumber}/g, String(target.prNumber))
- .replace(/{title}/g, target.title)
- .replace(/{baseBranch}/g, target.baseBranch)
- .replace(/{mergeBaseSha}/g, mergeBase);
- } else {
- prompt = PULL_REQUEST_PROMPT_FALLBACK
- .replace(/{prNumber}/g, String(target.prNumber))
- .replace(/{title}/g, target.title)
- .replace(/{baseBranch}/g, target.baseBranch);
- }
- if (target.cloneDir) {
- prompt += `\n\nIMPORTANT: This is a cross-repository PR. The repository has been cloned to \`${target.cloneDir}\`. You MUST \`cd ${target.cloneDir}\` before running any git or file commands. All file paths are relative to that directory.`;
- }
- return prompt;
- }
-
- case "folder":
- return FOLDER_REVIEW_PROMPT.replace("{paths}", target.paths.join(", "));
- }
-}
-
-/**
- * Get user-facing hint for the review target
- */
-function getUserFacingHint(target: ReviewTarget): string {
- switch (target.type) {
- case "uncommitted":
- return "current changes";
- case "baseBranch":
- return `changes against '${target.branch}'`;
- case "commit": {
- const shortSha = target.sha.slice(0, 7);
- return target.title ? `commit ${shortSha}: ${target.title}` : `commit ${shortSha}`;
- }
- case "custom":
- return target.instructions.length > 40 ? target.instructions.slice(0, 37) + "..." : target.instructions;
-
- case "pullRequest": {
- const shortTitle = target.title.length > 30 ? target.title.slice(0, 27) + "..." : target.title;
- const repoPrefix = target.repo ? `${target.repo}#` : "PR #";
- return `${repoPrefix}${target.prNumber}: ${shortTitle}`;
- }
-
- case "folder": {
- const joined = target.paths.join(", ");
- return joined.length > 40 ? `folders: ${joined.slice(0, 37)}...` : `folders: ${joined}`;
- }
- }
-}
-
-// Review preset options for the selector
-const REVIEW_PRESETS = [
- { value: "pullRequest", label: "Review a pull request", description: "(GitHub PR)" },
- { value: "baseBranch", label: "Review against a base branch", description: "(local)" },
- { value: "uncommitted", label: "Review uncommitted changes", description: "" },
- { value: "commit", label: "Review a commit", description: "" },
- { value: "folder", label: "Review a folder (or more)", description: "(snapshot, not diff)" },
- { value: "custom", label: "Custom review instructions", description: "" },
-] as const;
-
-export default function reviewExtension(pi: ExtensionAPI) {
- pi.on("session_start", (_event, ctx) => {
- applyReviewState(ctx);
- });
-
- pi.on("session_switch", (_event, ctx) => {
- applyReviewState(ctx);
- });
-
- pi.on("session_tree", (_event, ctx) => {
- applyReviewState(ctx);
- });
-
- /**
- * Determine the smart default review type based on git state
- */
- async function getSmartDefault(): Promise<"uncommitted" | "baseBranch" | "commit"> {
- // Priority 1: If there are uncommitted changes, default to reviewing them
- if (await hasUncommittedChanges(pi)) {
- return "uncommitted";
- }
-
- // Priority 2: If on a feature branch (not the default branch), default to PR-style review
- const currentBranch = await getCurrentBranch(pi);
- const defaultBranch = await getDefaultBranch(pi);
- if (currentBranch && currentBranch !== defaultBranch) {
- return "baseBranch";
- }
-
- // Priority 3: Default to reviewing a specific commit
- return "commit";
- }
-
- /**
- * Show the review preset selector
- */
- async function showReviewSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
- // Determine smart default and reorder items
- const smartDefault = await getSmartDefault();
- const items: SelectItem[] = REVIEW_PRESETS
- .slice() // copy to avoid mutating original
- .sort((a, b) => {
- // Put smart default first
- if (a.value === smartDefault) return -1;
- if (b.value === smartDefault) return 1;
- return 0;
- })
- .map((preset) => ({
- value: preset.value,
- label: preset.label,
- description: preset.description,
- }));
-
- while (true) {
- const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
- const container = new Container();
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
- container.addChild(new Text(theme.fg("accent", theme.bold("Select a review preset"))));
-
- const selectList = new SelectList(items, Math.min(items.length, 10), {
- selectedPrefix: (text) => theme.fg("accent", text),
- selectedText: (text) => theme.fg("accent", text),
- description: (text) => theme.fg("muted", text),
- scrollInfo: (text) => theme.fg("dim", text),
- noMatch: (text) => theme.fg("warning", text),
- });
-
- selectList.onSelect = (item) => done(item.value);
- selectList.onCancel = () => done(null);
-
- container.addChild(selectList);
- container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to go back")));
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
-
- return {
- render(width: number) {
- return container.render(width);
- },
- invalidate() {
- container.invalidate();
- },
- handleInput(data: string) {
- selectList.handleInput(data);
- tui.requestRender();
- },
- };
- });
-
- if (!result) return null;
-
- // Handle each preset type
- switch (result) {
- case "uncommitted":
- return { type: "uncommitted" };
-
- case "baseBranch": {
- const target = await showBranchSelector(ctx);
- if (target) return target;
- break;
- }
-
- case "commit": {
- const target = await showCommitSelector(ctx);
- if (target) return target;
- break;
- }
-
- case "custom": {
- const target = await showCustomInput(ctx);
- if (target) return target;
- break;
- }
-
- case "folder": {
- const target = await showFolderInput(ctx);
- if (target) return target;
- break;
- }
-
- case "pullRequest": {
- const target = await showPrInput(ctx);
- if (target) return target;
- break;
- }
-
- default:
- return null;
- }
- }
- }
-
- /**
- * Show branch selector for base branch review
- */
- async function showBranchSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
- const branches = await getLocalBranches(pi);
- const defaultBranch = await getDefaultBranch(pi);
-
- if (branches.length === 0) {
- ctx.ui.notify("No branches found", "error");
- return null;
- }
-
- // Sort branches with default branch first
- const sortedBranches = branches.sort((a, b) => {
- if (a === defaultBranch) return -1;
- if (b === defaultBranch) return 1;
- return a.localeCompare(b);
- });
-
- const items: SelectItem[] = sortedBranches.map((branch) => ({
- value: branch,
- label: branch,
- description: branch === defaultBranch ? "(default)" : "",
- }));
-
- const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
- const container = new Container();
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
- container.addChild(new Text(theme.fg("accent", theme.bold("Select base branch"))));
-
- const selectList = new SelectList(items, Math.min(items.length, 10), {
- selectedPrefix: (text) => theme.fg("accent", text),
- selectedText: (text) => theme.fg("accent", text),
- description: (text) => theme.fg("muted", text),
- scrollInfo: (text) => theme.fg("dim", text),
- noMatch: (text) => theme.fg("warning", text),
- });
-
- // Enable search
- selectList.searchable = true;
-
- selectList.onSelect = (item) => done(item.value);
- selectList.onCancel = () => done(null);
-
- container.addChild(selectList);
- container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
-
- return {
- render(width: number) {
- return container.render(width);
- },
- invalidate() {
- container.invalidate();
- },
- handleInput(data: string) {
- selectList.handleInput(data);
- tui.requestRender();
- },
- };
- });
-
- if (!result) return null;
- return { type: "baseBranch", branch: result };
- }
-
- /**
- * Show commit selector
- */
- async function showCommitSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
- const commits = await getRecentCommits(pi, 20);
-
- if (commits.length === 0) {
- ctx.ui.notify("No commits found", "error");
- return null;
- }
-
- const items: SelectItem[] = commits.map((commit) => ({
- value: commit.sha,
- label: `${commit.sha.slice(0, 7)} ${commit.title}`,
- description: "",
- }));
-
- const result = await ctx.ui.custom<{ sha: string; title: string } | null>((tui, theme, _kb, done) => {
- const container = new Container();
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
- container.addChild(new Text(theme.fg("accent", theme.bold("Select commit to review"))));
-
- const selectList = new SelectList(items, Math.min(items.length, 10), {
- selectedPrefix: (text) => theme.fg("accent", text),
- selectedText: (text) => theme.fg("accent", text),
- description: (text) => theme.fg("muted", text),
- scrollInfo: (text) => theme.fg("dim", text),
- noMatch: (text) => theme.fg("warning", text),
- });
-
- // Enable search
- selectList.searchable = true;
-
- selectList.onSelect = (item) => {
- const commit = commits.find((c) => c.sha === item.value);
- if (commit) {
- done(commit);
- } else {
- done(null);
- }
- };
- selectList.onCancel = () => done(null);
-
- container.addChild(selectList);
- container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
-
- return {
- render(width: number) {
- return container.render(width);
- },
- invalidate() {
- container.invalidate();
- },
- handleInput(data: string) {
- selectList.handleInput(data);
- tui.requestRender();
- },
- };
- });
-
- if (!result) return null;
- return { type: "commit", sha: result.sha, title: result.title };
- }
-
- /**
- * Show custom instructions input
- */
- async function showCustomInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
- const result = await ctx.ui.editor(
- "Enter review instructions:",
- "Review the code for security vulnerabilities and potential bugs...",
- );
-
- if (!result?.trim()) return null;
- return { type: "custom", instructions: result.trim() };
- }
-
- function parseReviewPaths(value: string): string[] {
- return value
- .split(/\s+/)
- .map((item) => item.trim())
- .filter((item) => item.length > 0);
- }
-
- /**
- * Show folder input
- */
- async function showFolderInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
- const result = await ctx.ui.editor(
- "Enter folders/files to review (space-separated or one per line):",
- ".",
- );
-
- if (!result?.trim()) return null;
- const paths = parseReviewPaths(result);
- if (paths.length === 0) return null;
-
- return { type: "folder", paths };
- }
-
- /**
- * Show focus area selector for the review.
- * Dynamically discovers reviewer-*.md agents from ~/.pi/agent/agents/.
- * Returns the focus key ("general", "security", "full", etc.) or null if cancelled.
- */
- async function showFocusSelector(ctx: ExtensionContext): Promise<{ focus: string; agents: ReviewerAgent[] } | null> {
- // Discover all reviewer agents fresh each time (allows adding agents mid-session)
- const allAgents = discoverReviewerAgents();
-
- if (allAgents.length === 0) {
- ctx.ui.notify("No reviewer agents found in ~/.pi/agent/agents/", "error");
- return null;
- }
-
- const items: SelectItem[] = allAgents.map((ra) => ({
- value: ra.focus,
- label: ra.label,
- description: ra.description,
- }));
-
- // Add "full" option at the end
- items.push({
- value: "full",
- label: "Full review",
- description: `All ${allAgents.length} reviewers in parallel`,
- });
-
- const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
- const container = new Container();
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
- container.addChild(new Text(theme.fg("accent", theme.bold("Select review focus"))));
-
- const selectList = new SelectList(items, Math.min(items.length, 10), {
- selectedPrefix: (text) => theme.fg("accent", text),
- selectedText: (text) => theme.fg("accent", text),
- description: (text) => theme.fg("muted", text),
- scrollInfo: (text) => theme.fg("dim", text),
- noMatch: (text) => theme.fg("warning", text),
- });
-
- selectList.onSelect = (item) => done(item.value);
- selectList.onCancel = () => done(null);
-
- container.addChild(selectList);
- container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to go back")));
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
-
- return {
- render(width: number) {
- return container.render(width);
- },
- invalidate() {
- container.invalidate();
- },
- handleInput(data: string) {
- selectList.handleInput(data);
- tui.requestRender();
- },
- };
- });
-
- if (!result) return null;
- return { focus: result, agents: allAgents };
- }
-
- /**
- * Fetch open PRs from GitHub for the selector
- */
- async function fetchOpenPRs(): Promise<Array<{ number: number; title: string; author: string; branch: string; base: string; isDraft: boolean; reviewDecision: string }>> {
- const { stdout, code } = await pi.exec("gh", [
- "pr", "list",
- "--state", "open",
- "--json", "number,title,author,headRefName,baseRefName,isDraft,reviewDecision",
- "--limit", "30",
- ]);
-
- if (code !== 0) return [];
-
- try {
- const data = JSON.parse(stdout);
- if (!Array.isArray(data)) return [];
- return data.map((item: any) => ({
- number: item.number ?? 0,
- title: item.title ?? "",
- author: item.author?.login ?? "",
- branch: item.headRefName ?? "",
- base: item.baseRefName ?? "",
- isDraft: item.isDraft ?? false,
- reviewDecision: item.reviewDecision ?? "",
- }));
- } catch {
- return [];
- }
- }
-
- /**
- * Get review decision label for display
- */
- function reviewDecisionLabel(decision: string): string {
- switch (decision) {
- case "APPROVED": return " ✓approved";
- case "CHANGES_REQUESTED": return " ✗changes requested";
- case "REVIEW_REQUIRED": return " ⏳review needed";
- default: return "";
- }
- }
-
- /**
- * Show PR selector with list of open PRs + manual entry option
- */
- async function showPrInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
- // Fetch open PRs
- ctx.ui.notify("Fetching open PRs...", "info");
- const prs = await fetchOpenPRs();
-
- // Try to get current user for smart ordering
- let currentUser = "";
- const { stdout: userOut, code: userCode } = await pi.exec("gh", ["api", "user", "--jq", ".login"], { timeout: 5000 });
- if (userCode === 0) currentUser = userOut.trim();
-
- // Sort: PRs needing your review first, then your own, then others
- const sorted = prs.slice().sort((a, b) => {
- const aScore = a.reviewDecision === "REVIEW_REQUIRED" && a.author !== currentUser ? 0
- : a.author === currentUser ? 1
- : 2;
- const bScore = b.reviewDecision === "REVIEW_REQUIRED" && b.author !== currentUser ? 0
- : b.author === currentUser ? 1
- : 2;
- return aScore - bScore;
- });
-
- // Build select items
- const allItems: SelectItem[] = sorted.map((pr) => {
- const draft = pr.isDraft ? " [draft]" : "";
- const review = reviewDecisionLabel(pr.reviewDecision);
- return {
- value: String(pr.number),
- label: `#${pr.number} ${pr.title}${draft}${review}`,
- description: `@${pr.author} ${pr.branch} → ${pr.base}`,
- };
- });
-
- // Add manual entry option at the bottom
- const manualItem: SelectItem = {
- value: "__manual__",
- label: "Enter PR number manually…",
- description: "(for cross-repo or closed PRs)",
- };
-
- /** Fuzzy-match: all terms (split by space) must appear somewhere in the searchable text */
- function fuzzyMatch(item: SelectItem, query: string): boolean {
- if (!query) return true;
- const searchable = `${item.label} ${item.description || ""}`.toLowerCase();
- const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
- return terms.every((term) => searchable.includes(term));
- }
-
- // Show selector with search-as-you-type
- const selected = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
- let searchQuery = "";
-
- function getFilteredItems(): SelectItem[] {
- if (!searchQuery) return [...allItems, manualItem];
- const filtered = allItems.filter((item) => fuzzyMatch(item, searchQuery));
- filtered.push(manualItem);
- return filtered;
- }
-
- let currentItems = getFilteredItems();
-
- const container = new Container();
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
-
- const headerText = new Text("", 0, 0);
- function updateHeader() {
- const title = theme.fg("accent", theme.bold("Select a pull request to review"));
- if (searchQuery) {
- headerText.setText(`${title} ${theme.fg("warning", `filter: ${searchQuery}`)}`);
- } else {
- headerText.setText(title);
- }
- }
- updateHeader();
- container.addChild(headerText);
-
- let selectList = new SelectList(currentItems, Math.min(currentItems.length, 12), {
- selectedPrefix: (text) => theme.fg("accent", text),
- selectedText: (text) => theme.fg("accent", text),
- description: (text) => theme.fg("muted", text),
- scrollInfo: (text) => theme.fg("dim", text),
- noMatch: (text) => theme.fg("warning", text),
- });
- selectList.onSelect = (item) => done(item.value);
- selectList.onCancel = () => done(null);
-
- container.addChild(selectList);
- container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
-
- function rebuildList() {
- currentItems = getFilteredItems();
- const newList = new SelectList(currentItems, Math.min(currentItems.length, 12), {
- selectedPrefix: (text) => theme.fg("accent", text),
- selectedText: (text) => theme.fg("accent", text),
- description: (text) => theme.fg("muted", text),
- scrollInfo: (text) => theme.fg("dim", text),
- noMatch: (text) => theme.fg("warning", text),
- });
- newList.onSelect = (item) => done(item.value);
- newList.onCancel = () => done(null);
- // Replace in container (index 2: after border + header)
- const idx = container.children.indexOf(selectList);
- if (idx !== -1) container.children[idx] = newList;
- selectList = newList;
- updateHeader();
- }
-
- return {
- render(width: number) { return container.render(width); },
- invalidate() { container.invalidate(); },
- handleInput(data: string) {
- // Backspace: remove last char from search
- if (data === "\x7f" || data === "\b") {
- if (searchQuery.length > 0) {
- searchQuery = searchQuery.slice(0, -1);
- rebuildList();
- tui.requestRender();
- }
- return;
- }
-
- // Printable characters: append to search
- if (data.length === 1 && data >= " " && data <= "~") {
- searchQuery += data;
- rebuildList();
- tui.requestRender();
- return;
- }
-
- // Everything else (arrows, enter, escape): pass to SelectList
- selectList.handleInput(data);
- tui.requestRender();
- },
- };
- });
-
- if (!selected) return null;
-
- // Build the ref string for handlePrCheckout
- let prRef: string;
- let prRepo: string | undefined;
- if (selected === "__manual__") {
- const input = await ctx.ui.editor(
- "Enter PR number, owner/repo#number, or URL (e.g. 123, tektoncd/pipeline#456, or https://github.com/owner/repo/pull/123):",
- "",
- );
- if (!input?.trim()) return null;
-
- const parsed = parsePrReference(input);
- if (!parsed) {
- ctx.ui.notify("Invalid PR reference. Enter a number, owner/repo#number, or GitHub PR URL.", "error");
- return null;
- }
- prRef = input.trim();
- prRepo = parsed.repo;
- } else {
- prRef = selected;
- }
-
- // Delegate to handlePrCheckout which handles both local and cross-repo PRs
- return await handlePrCheckout(ctx, prRef, prRepo);
- }
-
- /**
- * Execute the review
- */
- async function executeReview(ctx: ExtensionCommandContext, target: ReviewTarget, useFreshSession: boolean, focus: string, reviewerAgents: ReviewerAgent[]): Promise<void> {
- // Check if we're already in a review
- if (reviewOriginId) {
- ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning");
- return;
- }
-
- // Track clone directory for cross-repo PR reviews
- const cloneDir = target.type === "pullRequest" ? target.cloneDir : undefined;
- reviewCloneDir = cloneDir;
-
- // Handle fresh session mode
- if (useFreshSession) {
- // Store current position (where we'll return to)
- const originId = ctx.sessionManager.getLeafId() ?? undefined;
- if (!originId) {
- ctx.ui.notify("Failed to determine review origin. Try again from a session with messages.", "error");
- reviewCloneDir = undefined;
- return;
- }
- reviewOriginId = originId;
-
- // Keep a local copy so session_tree events during navigation don't wipe it
- const lockedOriginId = originId;
-
- // Find the first user message in the session
- const entries = ctx.sessionManager.getEntries();
- const firstUserMessage = entries.find(
- (e) => e.type === "message" && e.message.role === "user",
- );
-
- if (!firstUserMessage) {
- ctx.ui.notify("No user message found in session", "error");
- reviewOriginId = undefined;
- reviewCloneDir = undefined;
- return;
- }
-
- // Navigate to first user message to create a new branch from that point
- // Label it as "code-review" so it's visible in the tree
- try {
- const result = await ctx.navigateTree(firstUserMessage.id, { summarize: false, label: "code-review" });
- if (result.cancelled) {
- reviewOriginId = undefined;
- reviewCloneDir = undefined;
- return;
- }
- } catch (error) {
- // Clean up state if navigation fails
- reviewOriginId = undefined;
- reviewCloneDir = undefined;
- ctx.ui.notify(`Failed to start review: ${error instanceof Error ? error.message : String(error)}`, "error");
- return;
- }
-
- // Restore origin after navigation events (session_tree can reset it)
- reviewOriginId = lockedOriginId;
-
- // Clear the editor (navigating to user message fills it with the message text)
- ctx.ui.setEditorText("");
-
- // Show widget indicating review is active
- setReviewWidget(ctx, true);
-
- // Persist review state so tree navigation can restore/reset it
- pi.appendEntry(REVIEW_STATE_TYPE, { active: true, originId: lockedOriginId, cloneDir });
- }
-
- const reviewPrompt = await buildReviewPrompt(pi, target);
- const hint = getUserFacingHint(target);
- const projectGuidelines = await loadProjectReviewGuidelines(ctx.cwd);
- const repoRules = await loadRepositorySpecificRules(ctx.cwd);
-
- // Build the task context that will be sent to subagent(s)
- let taskContext = reviewPrompt;
-
- if (projectGuidelines) {
- taskContext += `\n\nThis project has additional instructions for code reviews:\n\n${projectGuidelines}`;
- }
-
- if (repoRules) {
- taskContext += repoRules;
- }
-
- // Look up the selected agent (or all agents for "full")
- const selectedAgent = reviewerAgents.find((ra) => ra.focus === focus);
- const focusLabel = focus === "full" ? "Full" : (selectedAgent?.label ?? focus);
- const modeHint = useFreshSession ? " (fresh session)" : "";
- ctx.ui.notify(`Starting ${focusLabel} review: ${hint}${modeHint}`, "info");
-
- // Save review metadata to ai-storage
- try {
- const reviewPath = await saveReviewMetadata({
- type: target.type,
- target: `[${focusLabel}] ${hint}`,
- sessionFile: ctx.sessionManager.getSessionFile(),
- cwd: ctx.cwd,
- });
-
- ctx.ui.notify(`Review metadata saved: ${path.basename(reviewPath)}`, "success");
- } catch (error: any) {
- // Don't fail the review if saving fails
- ctx.ui.notify(`Warning: Failed to save review metadata: ${error.message}`, "warning");
- }
-
- // Dispatch to subagent(s) based on focus area
- if (focus === "full") {
- // Run all discovered reviewer agents in parallel
- const subagentTasks = reviewerAgents.map((ra) => ({
- agent: ra.agent,
- task: `${ra.label}-focused review: ${taskContext}`,
- }));
-
- const focusNames = reviewerAgents.map((ra) => ra.focus).join("/");
- const tasksJson = JSON.stringify(subagentTasks);
- const subagentPrompt = `Run a full code review using parallel subagents. Dispatch the following reviewers using the subagent tool in parallel mode:
-
-${tasksJson}
-
-After all reviewers complete, present a **consolidated review report**:
-1. Group findings by file, deduplicating overlapping issues (keep the most specific)
-2. Note which reviewer (${focusNames}) flagged each issue
-3. Use the highest priority tag when reviewers disagree
-4. Provide a unified verdict: "correct" if no P0/P1 issues, "needs attention" otherwise`;
-
- pi.sendUserMessage(subagentPrompt);
- } else if (selectedAgent) {
- // Single focused review — dispatch to one subagent
- const subagentPrompt = `Run a ${focusLabel.toLowerCase()}-focused code review using the subagent tool.
-
-Dispatch to the \`${selectedAgent.agent}\` agent with this task:
-
-${taskContext}
-
-Present the subagent's findings directly to the user.`;
-
- pi.sendUserMessage(subagentPrompt);
- } else {
- ctx.ui.notify(`No reviewer agent found for focus "${focus}"`, "error");
- }
- }
-
- /**
- * Parse command arguments for direct invocation
- * Returns the target or a special marker for PR that needs async handling
- */
- function parseArgs(args: string | undefined): ReviewTarget | { type: "pr"; ref: string; repo?: string } | null {
- if (!args?.trim()) return null;
-
- const trimmed = args.trim();
-
- // Check if it looks like a PR reference (URL, number, or owner/repo#number)
- // This allows `/review 123`, `/review https://...`, or `/review tektoncd/pipeline#1234`
- const prRef = parsePrReference(trimmed);
- if (prRef !== null) {
- return { type: "pr", ref: trimmed, repo: prRef.repo };
- }
-
- const parts = trimmed.split(/\s+/);
- const subcommand = parts[0]?.toLowerCase();
-
- switch (subcommand) {
- case "uncommitted":
- return { type: "uncommitted" };
-
- case "branch": {
- const branch = parts[1];
- if (!branch) return null;
- return { type: "baseBranch", branch };
- }
-
- case "commit": {
- const sha = parts[1];
- if (!sha) return null;
- const title = parts.slice(2).join(" ") || undefined;
- return { type: "commit", sha, title };
- }
-
- case "custom": {
- const instructions = parts.slice(1).join(" ");
- if (!instructions) return null;
- return { type: "custom", instructions };
- }
-
- case "folder": {
- const paths = parseReviewPaths(parts.slice(1).join(" "));
- if (paths.length === 0) return null;
- return { type: "folder", paths };
- }
-
- case "pr": {
- const ref = parts[1];
- if (!ref) return { type: "pr", ref: "__select__" };
- const parsed = parsePrReference(ref);
- return { type: "pr", ref, repo: parsed?.repo };
- }
-
- default:
- return null;
- }
- }
-
- /**
- * Handle PR checkout and return a ReviewTarget (or null on failure)
- *
- * For cross-repo PRs (when repo is set), clones the repository to a temp
- * directory and checks out the PR there instead of in the current repo.
- */
- async function handlePrCheckout(ctx: ExtensionContext, ref: string, repo?: string): Promise<ReviewTarget | null> {
- const prRef = parsePrReference(ref);
- if (!prRef) {
- ctx.ui.notify("Invalid PR reference. Enter a number or GitHub PR URL.", "error");
- return null;
- }
-
- // Use repo from the parsed reference if not explicitly provided
- const effectiveRepo = repo ?? prRef.repo;
- const repoLabel = effectiveRepo ? ` (${effectiveRepo})` : "";
- const isCrossRepo = !!effectiveRepo;
-
- // For local repo PRs, check for pending changes (cross-repo clones to temp dir so no conflict)
- if (!isCrossRepo && await hasPendingChanges(pi)) {
- ctx.ui.notify("Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.", "error");
- return null;
- }
-
- // Get PR info
- ctx.ui.notify(`Fetching PR #${prRef.number} info${repoLabel}...`, "info");
- const prInfo = await getPrInfo(pi, prRef.number, effectiveRepo);
-
- if (!prInfo) {
- ctx.ui.notify(`Could not find PR #${prRef.number}${repoLabel}. Make sure gh is authenticated and the PR exists.`, "error");
- return null;
- }
-
- let cloneDir: string | undefined;
-
- if (isCrossRepo) {
- // Clone the repository to a temp directory for cross-repo PRs
- const tmpBase = path.join(process.env.TMPDIR || "/tmp", "pi-review");
- await fs.mkdir(tmpBase, { recursive: true });
- const sanitizedRepo = effectiveRepo!.replace(/\//g, "-");
- cloneDir = path.join(tmpBase, `${sanitizedRepo}-pr-${prRef.number}`);
-
- // Remove existing clone if present (stale from previous review)
- try {
- await fs.rm(cloneDir, { recursive: true, force: true });
- } catch {
- // Ignore
- }
-
- ctx.ui.notify(`Cloning ${effectiveRepo} to ${cloneDir}...`, "info");
- const { stderr: cloneErr, code: cloneCode } = await pi.exec("gh", [
- "repo", "clone", effectiveRepo!, cloneDir,
- ]);
-
- if (cloneCode !== 0) {
- ctx.ui.notify(`Failed to clone ${effectiveRepo}: ${cloneErr}`, "error");
- return null;
- }
-
- // Checkout the PR inside the clone
- ctx.ui.notify(`Checking out PR #${prRef.number} in clone...`, "info");
- const { stderr: coErr, code: coCode } = await pi.exec("gh", [
- "pr", "checkout", String(prRef.number), "-R", effectiveRepo!,
- ], { cwd: cloneDir });
-
- if (coCode !== 0) {
- ctx.ui.notify(`Failed to checkout PR: ${coErr}`, "error");
- // Clean up failed clone
- await fs.rm(cloneDir, { recursive: true, force: true }).catch(() => {});
- return null;
- }
-
- ctx.ui.notify(`Checked out PR #${prRef.number} (${prInfo.headBranch}) in ${cloneDir}`, "info");
- } else {
- // Local repo checkout
- ctx.ui.notify(`Checking out PR #${prRef.number}...`, "info");
- const checkoutResult = await checkoutPr(pi, prRef.number);
-
- if (!checkoutResult.success) {
- ctx.ui.notify(`Failed to checkout PR: ${checkoutResult.error}`, "error");
- return null;
- }
-
- ctx.ui.notify(`Checked out PR #${prRef.number} (${prInfo.headBranch})`, "info");
- }
-
- return {
- type: "pullRequest",
- prNumber: prRef.number,
- baseBranch: prInfo.baseBranch,
- title: prInfo.title,
- repo: effectiveRepo,
- cloneDir,
- };
- }
-
- // Register the /review command
- pi.registerCommand("review", {
- description: "Review code changes (PR, uncommitted, branch, commit, folder, or custom)",
- handler: async (args, ctx) => {
- if (!ctx.hasUI) {
- ctx.ui.notify("Review requires interactive mode", "error");
- return;
- }
-
- // Check if we're already in a review
- if (reviewOriginId) {
- ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning");
- return;
- }
-
- // Check if we're in a git repository
- const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
- if (code !== 0) {
- ctx.ui.notify("Not a git repository", "error");
- return;
- }
-
- // Try to parse direct arguments
- let target: ReviewTarget | null = null;
- let fromSelector = false;
- const parsed = parseArgs(args);
-
- if (parsed) {
- if (parsed.type === "pr") {
- if (parsed.ref === "__select__") {
- // `/review pr` with no number — show PR selector
- target = await showPrInput(ctx);
- } else {
- // `/review pr 123` or `/review 123` — direct checkout
- target = await handlePrCheckout(ctx, parsed.ref, parsed.repo);
- }
- if (!target) {
- ctx.ui.notify("PR review failed. Returning to review menu.", "warning");
- }
- } else {
- target = parsed;
- }
- }
-
- // If no args or invalid args, show selector
- if (!target) {
- fromSelector = true;
- }
-
- while (true) {
- if (!target && fromSelector) {
- target = await showReviewSelector(ctx);
- }
-
- if (!target) {
- ctx.ui.notify("Review cancelled", "info");
- return;
- }
-
- // Select review focus area
- const focusResult = await showFocusSelector(ctx);
-
- if (!focusResult) {
- if (fromSelector) {
- target = null;
- continue;
- }
- ctx.ui.notify("Review cancelled", "info");
- return;
- }
-
- // Determine if we should use fresh session mode
- // Check if this is a new session (no messages yet)
- const entries = ctx.sessionManager.getEntries();
- const messageCount = entries.filter((e) => e.type === "message").length;
-
- let useFreshSession = false;
-
- if (messageCount > 0) {
- // Existing session - ask user which mode they want
- const choice = await ctx.ui.select("Start review in:", ["Empty branch", "Current session"]);
-
- if (choice === undefined) {
- if (fromSelector) {
- target = null;
- continue;
- }
- ctx.ui.notify("Review cancelled", "info");
- return;
- }
-
- useFreshSession = choice === "Empty branch";
- }
- // If messageCount === 0, useFreshSession stays false (current session mode)
-
- await executeReview(ctx, target, useFreshSession, focusResult.focus, focusResult.agents);
- return;
- }
- },
- });
-
- // Custom prompt for review summaries - focuses on capturing review findings
- const REVIEW_SUMMARY_PROMPT = `We are switching to a coding session to continue working on the code.
-Create a structured summary of this review branch for context when returning later.
-
-You MUST summarize the code review that was performed in this branch so that the user can act on it.
-
-1. What was reviewed (files, changes, scope)
-2. Key findings and their priority levels (P0-P3)
-3. The overall verdict (correct vs needs attention)
-4. Any action items or recommendations
-
-YOU MUST append a message with this EXACT format at the end of your summary:
-
-## Next Steps
-1. [What should happen next to act on the review]
-
-## Constraints & Preferences
-- [Any constraints, preferences, or requirements mentioned]
-- [Or "(none)" if none were mentioned]
-
-## Code Review Findings
-
-[P0] Short Title
-
-File: path/to/file.ext:line_number
-
-\`\`\`
-affected code snippet
-\`\`\`
-
-Preserve exact file paths, function names, and error messages.
-`;
-
- // Register the /end-review command
- pi.registerCommand("end-review", {
- description: "Complete review and return to original position",
- handler: async (args, ctx) => {
- if (!ctx.hasUI) {
- ctx.ui.notify("End-review requires interactive mode", "error");
- return;
- }
-
- // Check if we're in a fresh session review
- if (!reviewOriginId) {
- const state = getReviewState(ctx);
- if (state?.active && state.originId) {
- reviewOriginId = state.originId;
- reviewCloneDir = state.cloneDir;
- } else if (state?.active) {
- await clearReviewState(ctx, pi);
- ctx.ui.notify("Review state was missing origin info; cleared review status.", "warning");
- return;
- } else {
- ctx.ui.notify("Not in a review branch (use /review first, or review was started in current session mode)", "info");
- return;
- }
- }
-
- // Ask about summarization (Summarize is default/first option)
- const summaryChoice = await ctx.ui.select("Summarize review branch?", [
- "Summarize",
- "No summary",
- ]);
-
- if (summaryChoice === undefined) {
- // User cancelled - keep state so they can call /end-review again
- ctx.ui.notify("Cancelled. Use /end-review to try again.", "info");
- return;
- }
-
- const wantsSummary = summaryChoice === "Summarize";
- const originId = reviewOriginId;
-
- if (wantsSummary) {
- // Show spinner while summarizing
- const result = await ctx.ui.custom<{ cancelled: boolean; error?: string } | null>((tui, theme, _kb, done) => {
- const loader = new BorderedLoader(tui, theme, "Summarizing review branch...");
- loader.onAbort = () => done(null);
-
- ctx.navigateTree(originId!, {
- summarize: true,
- customInstructions: REVIEW_SUMMARY_PROMPT,
- replaceInstructions: true,
- })
- .then(done)
- .catch((err) => done({ cancelled: false, error: err instanceof Error ? err.message : String(err) }));
-
- return loader;
- });
-
- if (result === null) {
- // User aborted - keep state so they can try again
- ctx.ui.notify("Summarization cancelled. Use /end-review to try again.", "info");
- return;
- }
-
- if (result.error) {
- // Real error - keep state so they can try again
- ctx.ui.notify(`Summarization failed: ${result.error}`, "error");
- return;
- }
-
- // Clear state only on success
- await clearReviewState(ctx, pi);
-
- if (result.cancelled) {
- ctx.ui.notify("Navigation cancelled", "info");
- return;
- }
-
- // Pre-fill prompt if editor is empty
- if (!ctx.ui.getEditorText().trim()) {
- ctx.ui.setEditorText("Act on the code review");
- }
-
- ctx.ui.notify("Review complete! Returned to original position.", "info");
- } else {
- // No summary - just navigate back
- try {
- const result = await ctx.navigateTree(originId!, { summarize: false });
-
- if (result.cancelled) {
- // Keep state so they can try again
- ctx.ui.notify("Navigation cancelled. Use /end-review to try again.", "info");
- return;
- }
-
- // Clear state only on success
- await clearReviewState(ctx, pi);
- ctx.ui.notify("Review complete! Returned to original position.", "info");
- } catch (error) {
- // Keep state so they can try again
- ctx.ui.notify(`Failed to return: ${error instanceof Error ? error.message : String(error)}`, "error");
- }
- }
- },
- });
-}
dots/pi/agent/extensions/subagent-commands.ts
@@ -1,170 +0,0 @@
-/**
- * Subagent Commands - Slash commands for common subagent workflows
- *
- * Provides easy-to-use slash commands that invoke the subagent tool:
- * - /scout <query> - Fast reconnaissance with scout agent
- * - /implement <query> - Full workflow: scout → planner → worker
- * - /scout-and-plan <query> - Planning workflow: scout → planner
- * - /review-code <query> - Review workflow: scout → reviewer
- */
-
-import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
-
-export default function (pi: ExtensionAPI) {
- // /scout - Quick reconnaissance
- pi.registerCommand("scout", {
- description: "Quick codebase reconnaissance with scout agent",
- handler: async (args, ctx) => {
- if (!args.trim()) {
- ctx.ui.notify("Usage: /scout <query>", "error");
- return;
- }
-
- // Send a message that will cause the AI to use the subagent tool
- ctx.ui.notify("Dispatching scout agent...", "info");
- pi.sendUserMessage(`Use the subagent tool to run the scout agent with this task: ${args}`);
- },
- });
-
- // /implement - Full workflow
- pi.registerCommand("implement", {
- description: "Full implementation workflow: scout → planner → worker",
- handler: async (args, ctx) => {
- if (!args.trim()) {
- ctx.ui.notify("Usage: /implement <query>", "error");
- return;
- }
-
- ctx.ui.notify("Starting implementation workflow...", "info");
- pi.sendUserMessage(
- `Use the subagent tool with chain mode to implement: ${args}\n\n` +
- `Chain:\n` +
- `1. scout agent: find all code relevant to: ${args}\n` +
- `2. planner agent: create implementation plan using {previous}\n` +
- `3. worker agent: implement the plan from {previous}`
- );
- },
- });
-
- // /scout-and-plan - Planning workflow
- pi.registerCommand("scout-and-plan", {
- description: "Planning workflow: scout → planner (no implementation)",
- handler: async (args, ctx) => {
- if (!args.trim()) {
- ctx.ui.notify("Usage: /scout-and-plan <query>", "error");
- return;
- }
-
- ctx.ui.notify("Starting planning workflow...", "info");
- pi.sendUserMessage(
- `Use the subagent tool with chain mode for planning: ${args}\n\n` +
- `Chain:\n` +
- `1. scout agent: find all code relevant to: ${args}\n` +
- `2. planner agent: create implementation plan using {previous}`
- );
- },
- });
-
- // /review-code - Review workflow
- pi.registerCommand("review-code", {
- description: "Code review workflow: scout → reviewer",
- handler: async (args, ctx) => {
- if (!args.trim()) {
- ctx.ui.notify("Usage: /review-code <query>", "error");
- return;
- }
-
- ctx.ui.notify("Starting code review...", "info");
- pi.sendUserMessage(
- `Use the subagent tool with chain mode to review: ${args}\n\n` +
- `Chain:\n` +
- `1. scout agent: find all code relevant to: ${args}\n` +
- `2. reviewer agent: review the code from {previous}`
- );
- },
- });
-
- // /oracle - Deep analysis and debugging
- pi.registerCommand("oracle", {
- description: "Deep analysis, debugging, and architecture investigation with oracle agent",
- handler: async (args, ctx) => {
- if (!args.trim()) {
- ctx.ui.notify("Usage: /oracle <question or problem>", "error");
- return;
- }
-
- ctx.ui.notify("Dispatching oracle agent...", "info");
- pi.sendUserMessage(`Use the subagent tool to run the oracle agent with this task: ${args}`);
- },
- });
-
- // /research - Technical research with web search
- pi.registerCommand("research", {
- description: "Technical research with web search, GitHub, and Stack Overflow via researcher agent",
- handler: async (args, ctx) => {
- if (!args.trim()) {
- ctx.ui.notify("Usage: /research <topic or question>", "error");
- return;
- }
-
- ctx.ui.notify("Dispatching researcher agent...", "info");
- pi.sendUserMessage(`Use the subagent tool to run the researcher agent with this task: ${args}`);
- },
- });
-
- // /subagent-help - Show available agents
- pi.registerCommand("subagent-help", {
- description: "Show available subagents and usage",
- handler: async (_args, ctx) => {
- const help = `
-# Subagent Extension Help
-
-## Available Slash Commands
-
-- \`/scout <query>\` - Quick reconnaissance (uses Haiku, fast & cheap)
-- \`/implement <query>\` - Full workflow: scout → planner → worker
-- \`/scout-and-plan <query>\` - Planning only: scout → planner
-- \`/review-code <query>\` - Code review: scout → reviewer
-- \`/oracle <query>\` - Deep analysis, debugging, architecture investigation
-- \`/research <query>\` - Technical research with web search
-
-## Available Agents
-
-- **scout** (Haiku) - Fast recon, returns compressed context
-- **planner** (Sonnet) - Creates implementation plans
-- **reviewer** (Sonnet) - Code review
-- **worker** (Sonnet) - General purpose, full capabilities
-- **oracle** (Opus) - Deep analysis, debugging, architecture decisions
-- **researcher** (Sonnet) - Technical research with web/GitHub/SO search
-
-## Direct Tool Usage
-
-You can also ask the AI to use the subagent tool directly:
-
-**Single agent:**
-> Use scout to find all authentication code
-
-**Parallel:**
-> Run scouts in parallel: find DB schemas, find API endpoints
-
-**Chain:**
-> Chain: scout finds auth code, then planner suggests improvements
-
-## Examples
-
-\`\`\`
-/scout find all Tekton Task definitions
-/implement add Redis caching to session store
-/scout-and-plan refactor authentication module
-/review-code check security in auth handlers
-\`\`\`
-
-## Agent Details
-
-Location: ~/.pi/agent/agents/*.md
-`;
- ctx.ui.notify("Subagent help displayed", "info");
- console.log(help);
- },
- });
-}
dots/pi/agent/extensions/usage-bar.ts
@@ -1,1080 +0,0 @@
-/**
- * Usage Bar Extension - Shows AI provider usage stats like CodexBar
- * Run /usage to see usage for Claude, Copilot, Gemini, and Codex
- *
- * Features:
- * - Usage stats with progress bars
- * - Provider status (outages/incidents)
- * - Reset countdowns
- */
-
-import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
-import { visibleWidth } from "@earendil-works/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 RateWindow {
- label: string;
- usedPercent: number;
- resetDescription?: string;
- resetsAt?: Date;
-}
-
-interface ProviderStatus {
- indicator: "none" | "minor" | "major" | "critical" | "maintenance" | "unknown";
- description?: string;
-}
-
-interface UsageSnapshot {
- provider: string;
- displayName: string;
- windows: RateWindow[];
- plan?: string;
- error?: string;
- status?: ProviderStatus;
-}
-
-// ============================================================================
-// Status Polling
-// ============================================================================
-
-const STATUS_URLS: Record<string, string> = {
- anthropic: "https://status.anthropic.com/api/v2/status.json",
- codex: "https://status.openai.com/api/v2/status.json",
- copilot: "https://www.githubstatus.com/api/v2/status.json",
-};
-
-async function fetchProviderStatus(provider: string): Promise<ProviderStatus> {
- const url = STATUS_URLS[provider];
- if (!url) return { indicator: "none" };
-
- try {
- const controller = new AbortController();
- setTimeout(() => controller.abort(), 5000);
-
- const res = await fetch(url, { signal: controller.signal });
- if (!res.ok) return { indicator: "unknown" };
-
- const data = await res.json() as any;
- const indicator = data.status?.indicator || "none";
- const description = data.status?.description;
-
- return {
- indicator: indicator as ProviderStatus["indicator"],
- description,
- };
- } catch {
- return { indicator: "unknown" };
- }
-}
-
-async function fetchGeminiStatus(): Promise<ProviderStatus> {
- try {
- const controller = new AbortController();
- setTimeout(() => controller.abort(), 5000);
-
- const res = await fetch("https://www.google.com/appsstatus/dashboard/incidents.json", {
- signal: controller.signal,
- });
- if (!res.ok) return { indicator: "unknown" };
-
- const incidents = await res.json() as any[];
-
- // Look for active Gemini incidents (product ID: npdyhgECDJ6tB66MxXyo)
- const geminiProductId = "npdyhgECDJ6tB66MxXyo";
- const activeIncidents = incidents.filter((inc: any) => {
- if (inc.end) return false; // Not active
- const affected = inc.currently_affected_products || inc.affected_products || [];
- return affected.some((p: any) => p.id === geminiProductId);
- });
-
- if (activeIncidents.length === 0) {
- return { indicator: "none" };
- }
-
- // Find most severe
- let worstIndicator: ProviderStatus["indicator"] = "minor";
- let description: string | undefined;
-
- for (const inc of activeIncidents) {
- const status = inc.most_recent_update?.status || inc.status_impact;
- if (status === "SERVICE_OUTAGE") {
- worstIndicator = "critical";
- description = inc.external_desc;
- } else if (status === "SERVICE_DISRUPTION" && worstIndicator !== "critical") {
- worstIndicator = "major";
- description = inc.external_desc;
- }
- }
-
- return { indicator: worstIndicator, description };
- } catch {
- return { indicator: "unknown" };
- }
-}
-
-// ============================================================================
-// Claude Usage
-// ============================================================================
-
-function loadClaudeToken(): string | undefined {
- // Try pi's auth.json first (has user:profile scope)
- const piAuthPath = path.join(os.homedir(), ".pi", "agent", "auth.json");
- try {
- if (fs.existsSync(piAuthPath)) {
- const data = JSON.parse(fs.readFileSync(piAuthPath, "utf-8"));
- if (data.anthropic?.access) return data.anthropic.access;
- }
- } catch {}
-
- // Fallback to Claude CLI keychain (macOS)
- try {
- const keychainData = execSync(
- 'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
- { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
- ).trim();
- if (keychainData) {
- const parsed = JSON.parse(keychainData);
- const scopes = parsed.claudeAiOauth?.scopes || [];
- if (scopes.includes("user:profile") && parsed.claudeAiOauth?.accessToken) {
- return parsed.claudeAiOauth.accessToken;
- }
- }
- } catch {}
-
- return undefined;
-}
-
-async function fetchClaudeUsage(): Promise<UsageSnapshot> {
- const token = loadClaudeToken();
- if (!token) {
- return { provider: "anthropic", displayName: "Claude", windows: [], error: "No credentials" };
- }
-
- try {
- const controller = new AbortController();
- setTimeout(() => controller.abort(), 5000);
-
- const res = await fetch("https://api.anthropic.com/api/oauth/usage", {
- headers: {
- Authorization: `Bearer ${token}`,
- "anthropic-beta": "oauth-2025-04-20",
- },
- signal: controller.signal,
- });
-
- if (!res.ok) {
- return { provider: "anthropic", displayName: "Claude", windows: [], error: `HTTP ${res.status}` };
- }
-
- const data = await res.json() as any;
- const windows: RateWindow[] = [];
-
- if (data.five_hour?.utilization !== undefined) {
- windows.push({
- label: "5h",
- usedPercent: data.five_hour.utilization,
- resetDescription: data.five_hour.resets_at ? formatReset(new Date(data.five_hour.resets_at)) : undefined,
- });
- }
-
- if (data.seven_day?.utilization !== undefined) {
- windows.push({
- label: "Week",
- usedPercent: data.seven_day.utilization,
- resetDescription: data.seven_day.resets_at ? formatReset(new Date(data.seven_day.resets_at)) : undefined,
- });
- }
-
- const modelWindow = data.seven_day_sonnet || data.seven_day_opus;
- if (modelWindow?.utilization !== undefined) {
- windows.push({
- label: data.seven_day_sonnet ? "Sonnet" : "Opus",
- usedPercent: modelWindow.utilization,
- });
- }
-
- return { provider: "anthropic", displayName: "Claude", windows };
- } catch (e) {
- return { provider: "anthropic", displayName: "Claude", windows: [], error: String(e) };
- }
-}
-
-// ============================================================================
-// Copilot Usage
-// ============================================================================
-
-function loadCopilotRefreshToken(): string | undefined {
- // The copilot_internal/user endpoint needs the GitHub OAuth token (ghu_*),
- // NOT the Copilot session token (tid=*). The refresh token IS the GitHub OAuth token.
- const authPath = path.join(os.homedir(), ".pi", "agent", "auth.json");
- try {
- if (fs.existsSync(authPath)) {
- const data = JSON.parse(fs.readFileSync(authPath, "utf-8"));
- // Use refresh token (GitHub OAuth token ghu_*) for the usage API
- if (data["github-copilot"]?.refresh) return data["github-copilot"].refresh;
- }
- } catch {}
-
- return undefined;
-}
-
-async function fetchCopilotUsage(_modelRegistry: any): Promise<UsageSnapshot> {
- const token = loadCopilotRefreshToken();
- if (!token) {
- return { provider: "copilot", displayName: "Copilot", windows: [], error: "No token" };
- }
-
- const headersBase = {
- "Editor-Version": "vscode/1.96.2",
- "User-Agent": "GitHubCopilotChat/0.26.7",
- "X-Github-Api-Version": "2025-04-01",
- Accept: "application/json",
- };
-
- const tryFetch = async (authHeader: string) => {
- const controller = new AbortController();
- setTimeout(() => controller.abort(), 5000);
-
- const res = await fetch("https://api.github.com/copilot_internal/user", {
- headers: {
- ...headersBase,
- Authorization: authHeader,
- },
- signal: controller.signal,
- });
- return res;
- };
-
- try {
- // Copilot access tokens (from /login github-copilot) expect Bearer. PATs accept "token".
- // GitHub OAuth token (ghu_*) requires "token" prefix, not Bearer
- const attempts = [`token ${token}`];
- let lastStatus: number | undefined;
- let res: Response | undefined;
-
- for (const auth of attempts) {
- res = await tryFetch(auth);
- lastStatus = res.status;
- if (res.ok) break;
- if (res.status === 401 || res.status === 403) continue; // try next scheme
- break;
- }
-
- if (!res || !res.ok) {
- const status = lastStatus ?? 0;
- return { provider: "copilot", displayName: "Copilot", windows: [], error: `HTTP ${status}` };
- }
-
- const data = await res.json() as any;
- const windows: RateWindow[] = [];
-
- // Parse reset date for display
- const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined;
- const resetDesc = resetDate ? formatReset(resetDate) : undefined;
-
- // Premium interactions (e.g., Claude, o1 models) - has a cap
- if (data.quota_snapshots?.premium_interactions) {
- const pi = data.quota_snapshots.premium_interactions;
- const remaining = pi.remaining ?? 0;
- const entitlement = pi.entitlement ?? 0;
- const usedPercent = Math.max(0, 100 - (pi.percent_remaining || 0));
- windows.push({
- label: `Premium`,
- usedPercent,
- resetDescription: resetDesc ? `${resetDesc} (${remaining}/${entitlement})` : `${remaining}/${entitlement}`,
- });
- }
-
- // Chat quota - often unlimited, only show if limited
- if (data.quota_snapshots?.chat && !data.quota_snapshots.chat.unlimited) {
- const chat = data.quota_snapshots.chat;
- windows.push({
- label: "Chat",
- usedPercent: Math.max(0, 100 - (chat.percent_remaining || 0)),
- resetDescription: resetDesc,
- });
- }
-
- return {
- provider: "copilot",
- displayName: "Copilot",
- windows,
- plan: data.copilot_plan,
- };
- } catch (e) {
- return { provider: "copilot", displayName: "Copilot", windows: [], error: String(e) };
- }
-}
-
-// ============================================================================
-// Gemini Usage
-// ============================================================================
-
-async function fetchGeminiUsage(_modelRegistry: any): Promise<UsageSnapshot> {
- let token: string | undefined;
-
- // Read directly from pi's auth.json
- const piAuthPath = path.join(os.homedir(), ".pi", "agent", "auth.json");
- try {
- if (fs.existsSync(piAuthPath)) {
- const data = JSON.parse(fs.readFileSync(piAuthPath, "utf-8"));
- token = data["google-gemini-cli"]?.access;
- }
- } catch {}
-
- // Fallback to ~/.gemini/oauth_creds.json
- if (!token) {
- const credPath = path.join(os.homedir(), ".gemini", "oauth_creds.json");
- try {
- if (fs.existsSync(credPath)) {
- const data = JSON.parse(fs.readFileSync(credPath, "utf-8"));
- token = data.access_token;
- }
- } catch {}
- }
-
- if (!token) {
- return { provider: "gemini", displayName: "Gemini", windows: [], error: "No credentials" };
- }
-
- try {
- const controller = new AbortController();
- setTimeout(() => controller.abort(), 5000);
-
- const res = await fetch("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", {
- method: "POST",
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
- body: "{}",
- signal: controller.signal,
- });
-
- if (!res.ok) {
- return { provider: "gemini", displayName: "Gemini", windows: [], error: `HTTP ${res.status}` };
- }
-
- const data = await res.json() as any;
- const quotas: Record<string, number> = {};
-
- for (const bucket of data.buckets || []) {
- const model = bucket.modelId || "unknown";
- const frac = bucket.remainingFraction ?? 1;
- if (!quotas[model] || frac < quotas[model]) quotas[model] = frac;
- }
-
- const windows: RateWindow[] = [];
- let proMin = 1, flashMin = 1;
- let hasProModel = false, hasFlashModel = false;
-
- for (const [model, frac] of Object.entries(quotas)) {
- if (model.toLowerCase().includes("pro")) {
- hasProModel = true;
- if (frac < proMin) proMin = frac;
- }
- if (model.toLowerCase().includes("flash")) {
- hasFlashModel = true;
- if (frac < flashMin) flashMin = frac;
- }
- }
-
- // Always show windows if model exists (even at 0% usage)
- if (hasProModel) windows.push({ label: "Pro", usedPercent: (1 - proMin) * 100 });
- if (hasFlashModel) windows.push({ label: "Flash", usedPercent: (1 - flashMin) * 100 });
-
- return { provider: "gemini", displayName: "Gemini", windows };
- } catch (e) {
- return { provider: "gemini", displayName: "Gemini", windows: [], error: String(e) };
- }
-}
-
-// ============================================================================
-// Antigravity Usage
-// ============================================================================
-
-type AntigravityAuth = {
- accessToken: string;
- refreshToken?: string;
- expiresAt?: number;
- projectId?: string;
-};
-
-function loadAntigravityAuthFromPiAuthJson(): AntigravityAuth | undefined {
- const piAuthPath = path.join(os.homedir(), ".pi", "agent", "auth.json");
- try {
- if (!fs.existsSync(piAuthPath)) return undefined;
- const data = JSON.parse(fs.readFileSync(piAuthPath, "utf-8"));
-
- // Provider is called "google-antigravity" in pi.
- const cred = data["google-antigravity"] ?? data["antigravity"] ?? data["anti-gravity"];
- if (!cred) return undefined;
-
- const accessToken = typeof cred.access === "string" ? cred.access : undefined;
- if (!accessToken) return undefined;
-
- return {
- accessToken,
- refreshToken: typeof cred.refresh === "string" ? cred.refresh : undefined,
- expiresAt: typeof cred.expires === "number" ? cred.expires : undefined,
- projectId: typeof cred.projectId === "string" ? cred.projectId : typeof cred.project_id === "string" ? cred.project_id : undefined,
- };
- } catch {
- return undefined;
- }
-}
-
-async function loadAntigravityAuth(modelRegistry: any): Promise<AntigravityAuth | undefined> {
- // Prefer model registry auth storage first (may auto-refresh).
- try {
- const accessToken = await Promise.resolve(modelRegistry?.authStorage?.getApiKey?.("google-antigravity"));
- const raw = await Promise.resolve(modelRegistry?.authStorage?.get?.("google-antigravity"));
-
- const projectId = typeof raw?.projectId === "string" ? raw.projectId : undefined;
- const refreshToken = typeof raw?.refresh === "string" ? raw.refresh : undefined;
- const expiresAt = typeof raw?.expires === "number" ? raw.expires : undefined;
-
- if (typeof accessToken === "string" && accessToken.length > 0) {
- return { accessToken, projectId, refreshToken, expiresAt };
- }
- } catch {}
-
- // Fallback to pi auth.json
- const fromPi = loadAntigravityAuthFromPiAuthJson();
- if (fromPi) return fromPi;
-
- // Last resort: env var (won't have projectId; request will likely fail)
- if (process.env.ANTIGRAVITY_API_KEY) {
- return { accessToken: process.env.ANTIGRAVITY_API_KEY };
- }
-
- return undefined;
-}
-
-async function refreshAntigravityAccessToken(refreshToken: string): Promise<{ accessToken: string; expiresAt?: number } | null> {
- try {
- const controller = new AbortController();
- setTimeout(() => controller.abort(), 5000);
-
- // From the reference snippet in CodexBar issue #129.
- const clientId = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
- const clientSecret = "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";
-
- const res = await fetch("https://oauth2.googleapis.com/token", {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- client_id: clientId,
- client_secret: clientSecret,
- refresh_token: refreshToken,
- grant_type: "refresh_token",
- }).toString(),
- signal: controller.signal,
- });
-
- if (!res.ok) return null;
- const data = (await res.json()) as any;
- const accessToken = typeof data.access_token === "string" ? data.access_token : undefined;
- if (!accessToken) return null;
- const expiresIn = typeof data.expires_in === "number" ? data.expires_in : undefined;
- return {
- accessToken,
- expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined,
- };
- } catch {
- return null;
- }
-}
-
-async function fetchAntigravityUsage(modelRegistry: any): Promise<UsageSnapshot> {
- const auth = await loadAntigravityAuth(modelRegistry);
- if (!auth?.accessToken) {
- return { provider: "antigravity", displayName: "Antigravity", windows: [], error: "No credentials" };
- }
-
- if (!auth.projectId) {
- return { provider: "antigravity", displayName: "Antigravity", windows: [], error: "Missing projectId" };
- }
-
- let accessToken = auth.accessToken;
-
- // Refresh if likely expired.
- if (auth.refreshToken && auth.expiresAt && auth.expiresAt < Date.now() + 5 * 60 * 1000) {
- const refreshed = await refreshAntigravityAccessToken(auth.refreshToken);
- if (refreshed?.accessToken) accessToken = refreshed.accessToken;
- }
-
- const fetchModels = async (token: string): Promise<Response> => {
- const controller = new AbortController();
- setTimeout(() => controller.abort(), 5000);
-
- return fetch("https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", {
- method: "POST",
- headers: {
- Authorization: `Bearer ${token}`,
- "Content-Type": "application/json",
- "User-Agent": "antigravity/1.12.4",
- "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
- Accept: "application/json",
- },
- body: JSON.stringify({ project: auth.projectId }),
- signal: controller.signal,
- });
- };
-
- try {
- let res = await fetchModels(accessToken);
-
- if ((res.status === 401 || res.status === 403) && auth.refreshToken) {
- const refreshed = await refreshAntigravityAccessToken(auth.refreshToken);
- if (refreshed?.accessToken) {
- accessToken = refreshed.accessToken;
- res = await fetchModels(accessToken);
- }
- }
-
- if (res.status === 401 || res.status === 403) {
- return { provider: "antigravity", displayName: "Antigravity", windows: [], error: "Unauthorized" };
- }
-
- if (!res.ok) {
- return { provider: "antigravity", displayName: "Antigravity", windows: [], error: `HTTP ${res.status}` };
- }
-
- const data = (await res.json()) as any;
- const models: Record<string, any> = data.models || {};
-
- const getQuotaInfo = (modelKeys: string[]): { usedPercent: number; resetDescription?: string } | null => {
- for (const key of modelKeys) {
- const qi = models?.[key]?.quotaInfo;
- if (!qi) continue;
- // In practice (CodexBar issue #129), some models only provide resetTime.
- // Treat missing remainingFraction as 0% remaining (100% used), which matches Antigravity's behavior when quota is exhausted.
- const remainingFraction = typeof qi.remainingFraction === "number" ? qi.remainingFraction : 0;
- const usedPercent = Math.min(100, Math.max(0, (1 - remainingFraction) * 100));
- const resetTime = qi.resetTime ? new Date(qi.resetTime) : undefined;
- return { usedPercent, resetDescription: resetTime ? formatReset(resetTime) : undefined };
- }
- return null;
- };
-
- // Quota groups from the reference snippet in CodexBar issue #129.
- const windows: RateWindow[] = [];
-
- const claudeOrGptOss = getQuotaInfo([
- "claude-sonnet-4-6",
- "claude-sonnet-4-6-thinking",
- "claude-sonnet-4-5",
- "claude-sonnet-4-5-thinking",
- "claude-opus-4-5-thinking",
- "gpt-oss-120b-medium",
- ]);
- if (claudeOrGptOss) {
- windows.push({ label: "Claude", usedPercent: claudeOrGptOss.usedPercent, resetDescription: claudeOrGptOss.resetDescription });
- }
-
- const gemini3Pro = getQuotaInfo(["gemini-3-pro-high", "gemini-3-pro-low", "gemini-3-pro-preview"]);
- if (gemini3Pro) {
- windows.push({ label: "G3 Pro", usedPercent: gemini3Pro.usedPercent, resetDescription: gemini3Pro.resetDescription });
- }
-
- const gemini3Flash = getQuotaInfo(["gemini-3-flash"]);
- if (gemini3Flash) {
- windows.push({ label: "G3 Flash", usedPercent: gemini3Flash.usedPercent, resetDescription: gemini3Flash.resetDescription });
- }
-
- if (windows.length === 0) {
- return { provider: "antigravity", displayName: "Antigravity", windows: [], error: "No quota data" };
- }
-
- return { provider: "antigravity", displayName: "Antigravity", windows };
- } catch (e) {
- return { provider: "antigravity", displayName: "Antigravity", windows: [], error: String(e) };
- }
-}
-
-// ============================================================================
-// Codex (OpenAI) Usage
-// ============================================================================
-
-async function fetchCodexUsage(modelRegistry: any): Promise<UsageSnapshot> {
- // Try to get token from pi's auth storage first
- let accessToken: string | undefined;
- let accountId: string | undefined;
-
- try {
- // Try openai-codex provider first (pi's built-in)
- accessToken = await modelRegistry?.authStorage?.getApiKey?.("openai-codex");
-
- // Get account ID if available from OAuth credentials
- const cred = modelRegistry?.authStorage?.get?.("openai-codex");
- if (cred?.type === "oauth") {
- accountId = (cred as any).accountId;
- }
- } catch {}
-
- // Fallback to ~/.codex/auth.json if not in pi's auth
- if (!accessToken) {
- const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
- const authPath = path.join(codexHome, "auth.json");
-
- try {
- if (fs.existsSync(authPath)) {
- const data = JSON.parse(fs.readFileSync(authPath, "utf-8"));
-
- if (data.OPENAI_API_KEY) {
- accessToken = data.OPENAI_API_KEY;
- } else if (data.tokens?.access_token) {
- accessToken = data.tokens.access_token;
- accountId = data.tokens.account_id;
- }
- }
- } catch {}
- }
-
- if (!accessToken) {
- return { provider: "codex", displayName: "Codex", windows: [], error: "No credentials" };
- }
-
- try {
- const controller = new AbortController();
- setTimeout(() => controller.abort(), 5000);
-
- const headers: Record<string, string> = {
- Authorization: `Bearer ${accessToken}`,
- "User-Agent": "CodexBar",
- Accept: "application/json",
- };
-
- if (accountId) {
- headers["ChatGPT-Account-Id"] = accountId;
- }
-
- const res = await fetch("https://chatgpt.com/backend-api/wham/usage", {
- method: "GET",
- headers,
- signal: controller.signal,
- });
-
- if (res.status === 401 || res.status === 403) {
- return { provider: "codex", displayName: "Codex", windows: [], error: "Token expired" };
- }
-
- if (!res.ok) {
- return { provider: "codex", displayName: "Codex", windows: [], error: `HTTP ${res.status}` };
- }
-
- const data = await res.json() as any;
- const windows: RateWindow[] = [];
-
- // Primary window (usually 3-hour)
- if (data.rate_limit?.primary_window) {
- const pw = data.rate_limit.primary_window;
- const resetDate = pw.reset_at ? new Date(pw.reset_at * 1000) : undefined;
- const windowHours = Math.round((pw.limit_window_seconds || 10800) / 3600);
- windows.push({
- label: `${windowHours}h`,
- usedPercent: pw.used_percent || 0,
- resetDescription: resetDate ? formatReset(resetDate) : undefined,
- });
- }
-
- // Secondary window (usually daily)
- if (data.rate_limit?.secondary_window) {
- const sw = data.rate_limit.secondary_window;
- const resetDate = sw.reset_at ? new Date(sw.reset_at * 1000) : undefined;
- const windowHours = Math.round((sw.limit_window_seconds || 86400) / 3600);
- const label = windowHours >= 24 ? "Day" : `${windowHours}h`;
- windows.push({
- label,
- usedPercent: sw.used_percent || 0,
- resetDescription: resetDate ? formatReset(resetDate) : undefined,
- });
- }
-
- // Credits info
- let plan = data.plan_type;
- if (data.credits?.balance !== undefined && data.credits.balance !== null) {
- const balance = typeof data.credits.balance === 'number'
- ? data.credits.balance
- : parseFloat(data.credits.balance) || 0;
- plan = plan ? `${plan} ($${balance.toFixed(2)})` : `$${balance.toFixed(2)}`;
- }
-
- return { provider: "codex", displayName: "Codex", windows, plan };
- } catch (e) {
- return { provider: "codex", displayName: "Codex", windows: [], error: String(e) };
- }
-}
-
-// ============================================================================
-// Kiro (AWS)
-// ============================================================================
-
-function stripAnsi(text: string): string {
- return text.replace(/\x1B\[[0-9;?]*[A-Za-z]|\x1B\].*?\x07/g, "");
-}
-
-function whichSync(cmd: string): string | null {
- try {
- return execSync(`which ${cmd}`, { encoding: "utf-8" }).trim();
- } catch {
- return null;
- }
-}
-
-async function fetchKiroUsage(): Promise<UsageSnapshot> {
- const kiroBinary = whichSync("kiro-cli");
- if (!kiroBinary) {
- return { provider: "kiro", displayName: "Kiro", windows: [], error: "kiro-cli not found" };
- }
-
- try {
- // Check if logged in
- try {
- execSync("kiro-cli whoami", { encoding: "utf-8", timeout: 5000 });
- } catch {
- return { provider: "kiro", displayName: "Kiro", windows: [], error: "Not logged in" };
- }
-
- // Get usage
- const output = execSync("kiro-cli chat --no-interactive /usage", {
- encoding: "utf-8",
- timeout: 10000,
- env: { ...process.env, TERM: "xterm-256color" }
- });
-
- const stripped = stripAnsi(output);
- const windows: RateWindow[] = [];
-
- // Parse plan name from "| KIRO FREE" or similar
- let planName = "Kiro";
- const planMatch = stripped.match(/\|\s*(KIRO\s+\w+)/i);
- if (planMatch) {
- planName = planMatch[1].trim();
- }
-
- // Parse credits percentage from "████...█ X%"
- let creditsPercent = 0;
- const percentMatch = stripped.match(/█+\s*(\d+)%/);
- if (percentMatch) {
- creditsPercent = parseInt(percentMatch[1], 10);
- }
-
- // Parse credits used/total from "(X.XX of Y covered in plan)"
- let creditsUsed = 0;
- let creditsTotal = 50;
- const creditsMatch = stripped.match(/\((\d+\.?\d*)\s+of\s+(\d+)\s+covered/);
- if (creditsMatch) {
- creditsUsed = parseFloat(creditsMatch[1]);
- creditsTotal = parseFloat(creditsMatch[2]);
- if (!percentMatch && creditsTotal > 0) {
- creditsPercent = (creditsUsed / creditsTotal) * 100;
- }
- }
-
- // Parse reset date from "resets on 01/01"
- let resetsAt: Date | undefined;
- const resetMatch = stripped.match(/resets on (\d{2}\/\d{2})/);
- if (resetMatch) {
- const [month, day] = resetMatch[1].split("/").map(Number);
- const now = new Date();
- const year = now.getFullYear();
- resetsAt = new Date(year, month - 1, day);
- if (resetsAt < now) resetsAt.setFullYear(year + 1);
- }
-
- windows.push({
- label: "Credits",
- usedPercent: creditsPercent,
- resetDescription: resetsAt ? formatReset(resetsAt) : undefined,
- });
-
- // Parse bonus credits
- const bonusMatch = stripped.match(/Bonus credits:\s*(\d+\.?\d*)\/(\d+)/);
- if (bonusMatch) {
- const bonusUsed = parseFloat(bonusMatch[1]);
- const bonusTotal = parseFloat(bonusMatch[2]);
- const bonusPercent = bonusTotal > 0 ? (bonusUsed / bonusTotal) * 100 : 0;
- const expiryMatch = stripped.match(/expires in (\d+) days?/);
- windows.push({
- label: "Bonus",
- usedPercent: bonusPercent,
- resetDescription: expiryMatch ? `${expiryMatch[1]}d left` : undefined,
- });
- }
-
- return { provider: "kiro", displayName: "Kiro", windows, plan: planName };
- } catch (e) {
- return { provider: "kiro", displayName: "Kiro", windows: [], error: String(e) };
- }
-}
-
-// ============================================================================
-// z.ai
-// ============================================================================
-
-async function fetchZaiUsage(): Promise<UsageSnapshot> {
- // Check for API key in environment or pi auth
- let apiKey = process.env.Z_AI_API_KEY;
-
- if (!apiKey) {
- // Try pi auth storage
- try {
- const authPath = path.join(os.homedir(), ".pi", "agent", "auth.json");
- if (fs.existsSync(authPath)) {
- const auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
- apiKey = auth["z-ai"]?.access || auth["zai"]?.access;
- }
- } catch {}
- }
-
- if (!apiKey) {
- return { provider: "zai", displayName: "z.ai", windows: [], error: "No API key" };
- }
-
- try {
- const controller = new AbortController();
- setTimeout(() => controller.abort(), 5000);
-
- const res = await fetch("https://api.z.ai/api/monitor/usage/quota/limit", {
- method: "GET",
- headers: {
- Authorization: `Bearer ${apiKey}`,
- Accept: "application/json",
- },
- signal: controller.signal,
- });
-
- if (!res.ok) {
- return { provider: "zai", displayName: "z.ai", windows: [], error: `HTTP ${res.status}` };
- }
-
- const data = await res.json() as any;
- if (!data.success || data.code !== 200) {
- return { provider: "zai", displayName: "z.ai", windows: [], error: data.msg || "API error" };
- }
-
- const windows: RateWindow[] = [];
- const limits = data.data?.limits || [];
-
- for (const limit of limits) {
- const type = limit.type;
- const usage = limit.usage || 0;
- const remaining = limit.remaining || 0;
- const percent = limit.percentage || 0;
- const nextReset = limit.nextResetTime ? new Date(limit.nextResetTime) : undefined;
-
- // Unit: 1=days, 3=hours, 5=minutes
- let windowLabel = "Limit";
- if (limit.unit === 1) windowLabel = `${limit.number}d`;
- else if (limit.unit === 3) windowLabel = `${limit.number}h`;
- else if (limit.unit === 5) windowLabel = `${limit.number}m`;
-
- if (type === "TOKENS_LIMIT") {
- windows.push({
- label: `Tokens (${windowLabel})`,
- usedPercent: percent,
- resetDescription: nextReset ? formatReset(nextReset) : undefined,
- });
- } else if (type === "TIME_LIMIT") {
- windows.push({
- label: "Monthly",
- usedPercent: percent,
- resetDescription: nextReset ? formatReset(nextReset) : undefined,
- });
- }
- }
-
- const planName = data.data?.planName || data.data?.plan || undefined;
- return { provider: "zai", displayName: "z.ai", windows, plan: planName };
- } catch (e) {
- return { provider: "zai", displayName: "z.ai", windows: [], error: String(e) };
- }
-}
-
-// ============================================================================
-// Helpers
-// ============================================================================
-
-function formatReset(date: Date): string {
- const diffMs = date.getTime() - Date.now();
- if (diffMs < 0) return "now";
-
- const diffMins = Math.floor(diffMs / 60000);
- if (diffMins < 60) return `${diffMins}m`;
-
- const hours = Math.floor(diffMins / 60);
- const mins = diffMins % 60;
- if (hours < 24) return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
-
- const days = Math.floor(hours / 24);
- if (days < 7) return `${days}d ${hours % 24}h`;
-
- return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" }).format(date);
-}
-
-function getStatusEmoji(status?: ProviderStatus): string {
- if (!status) return "";
- switch (status.indicator) {
- case "none": return "✅";
- case "minor": return "⚠️";
- case "major": return "🟠";
- case "critical": return "🔴";
- case "maintenance": return "🔧";
- default: return "";
- }
-}
-
-// ============================================================================
-// UI Component
-// ============================================================================
-
-class UsageComponent {
- private usages: UsageSnapshot[] = [];
- private loading = true;
- private tui: { requestRender: () => void };
- private theme: any;
- private onClose: () => void;
- private modelRegistry: any;
-
- constructor(tui: { requestRender: () => void }, theme: any, onClose: () => void, modelRegistry: any) {
- this.tui = tui;
- this.theme = theme;
- this.onClose = onClose;
- this.modelRegistry = modelRegistry;
- this.load();
- }
-
- private async load() {
- const timeout = <T>(p: Promise<T>, ms: number, fallback: T) =>
- Promise.race([p, new Promise<T>((r) => setTimeout(() => r(fallback), ms))]);
-
- // Fetch usage and status in parallel
- const [claude, copilot, gemini, codex, antigravity, kiro, zai, claudeStatus, copilotStatus, geminiStatus, codexStatus] = await Promise.all([
- timeout(fetchClaudeUsage(), 6000, { provider: "anthropic", displayName: "Claude", windows: [], error: "Timeout" }),
- timeout(fetchCopilotUsage(this.modelRegistry), 6000, { provider: "copilot", displayName: "Copilot", windows: [], error: "Timeout" }),
- timeout(fetchGeminiUsage(this.modelRegistry), 6000, { provider: "gemini", displayName: "Gemini", windows: [], error: "Timeout" }),
- timeout(fetchCodexUsage(this.modelRegistry), 6000, { provider: "codex", displayName: "Codex", windows: [], error: "Timeout" }),
- timeout(fetchAntigravityUsage(this.modelRegistry), 6000, { provider: "antigravity", displayName: "Antigravity", windows: [], error: "Timeout" }),
- timeout(fetchKiroUsage(), 6000, { provider: "kiro", displayName: "Kiro", windows: [], error: "Timeout" }),
- timeout(fetchZaiUsage(), 6000, { provider: "zai", displayName: "z.ai", windows: [], error: "Timeout" }),
- timeout(fetchProviderStatus("anthropic"), 3000, { indicator: "unknown" as const }),
- timeout(fetchProviderStatus("copilot"), 3000, { indicator: "unknown" as const }),
- timeout(fetchGeminiStatus(), 3000, { indicator: "unknown" as const }),
- timeout(fetchProviderStatus("codex"), 3000, { indicator: "unknown" as const }),
- ]);
-
- // Attach status to usage
- claude.status = claudeStatus;
- copilot.status = copilotStatus;
- gemini.status = geminiStatus;
- codex.status = codexStatus;
-
- // Filter out providers with no data and no error (not configured)
- const allUsages = [claude, copilot, gemini, codex, antigravity, kiro, zai];
- this.usages = allUsages.filter(u => u.windows.length > 0 || u.error !== "No credentials" && u.error !== "kiro-cli not found" && u.error !== "No API key");
- this.loading = false;
- this.tui.requestRender();
- }
-
- handleInput(_data: string): void {
- this.onClose();
- }
-
- invalidate(): void {}
-
- render(width: number): string[] {
- const t = this.theme;
- const dim = (s: string) => t.fg("muted", s);
- const bold = (s: string) => t.bold(s);
- const accent = (s: string) => t.fg("accent", s);
-
- // Box dimensions: total width includes borders
- const totalW = Math.min(55, width - 4);
- const innerW = totalW - 4; // subtract "│ " and " │"
- const hLine = "─".repeat(totalW - 2); // subtract corners
-
- const box = (content: string) => {
- const contentW = visibleWidth(content);
- const pad = Math.max(0, innerW - contentW);
- return dim("│ ") + content + " ".repeat(pad) + dim(" │");
- };
-
- const lines: string[] = [];
- lines.push(dim(`╭${hLine}╮`));
- lines.push(box(bold(accent("AI Usage"))));
- lines.push(dim(`├${hLine}┤`));
-
- if (this.loading) {
- lines.push(box("Loading..."));
- } else {
- for (const u of this.usages) {
- // Provider header with status emoji and plan
- const statusEmoji = getStatusEmoji(u.status);
- const planStr = u.plan ? dim(` (${u.plan})`) : "";
- const statusStr = statusEmoji ? ` ${statusEmoji}` : "";
- lines.push(box(bold(u.displayName) + planStr + statusStr));
-
- // Show incident description if any
- if (u.status?.indicator && u.status.indicator !== "none" && u.status.indicator !== "unknown" && u.status.description) {
- const desc = u.status.description.length > 40
- ? u.status.description.substring(0, 37) + "..."
- : u.status.description;
- lines.push(box(t.fg("warning", ` ⚡ ${desc}`)));
- }
-
- if (u.error) {
- lines.push(box(dim(` ${u.error}`)));
- } else if (u.windows.length === 0) {
- lines.push(box(dim(" No data")));
- } else {
- for (const w of u.windows) {
- const remaining = Math.max(0, 100 - w.usedPercent);
- const barW = 12;
- const filled = Math.min(barW, Math.round((w.usedPercent / 100) * barW));
- const empty = barW - filled;
- const color = remaining <= 10 ? "error" : remaining <= 30 ? "warning" : "success";
- const bar = t.fg(color, "█".repeat(filled)) + dim("░".repeat(empty));
- const reset = w.resetDescription ? dim(` ⏱ ${w.resetDescription}`) : "";
- lines.push(box(` ${w.label.padEnd(7)} ${bar} ${remaining.toFixed(0).padStart(3)}%${reset}`));
- }
- }
- lines.push(box(""));
- }
- }
-
- lines.push(dim(`├${hLine}┤`));
- lines.push(box(dim("Press any key to close")));
- lines.push(dim(`╰${hLine}╯`));
-
- return lines;
- }
-
- dispose(): void {}
-}
-
-// ============================================================================
-// Hook
-// ============================================================================
-
-export default function (pi: ExtensionAPI) {
- pi.registerCommand("usage", {
- description: "Show AI provider usage statistics",
- handler: async (_args, ctx) => {
- if (!ctx.hasUI) {
- ctx.ui.notify("Usage requires interactive mode", "error");
- return;
- }
-
- const modelRegistry = ctx.modelRegistry;
- await ctx.ui.custom((tui, theme, _kb, done) => {
- return new UsageComponent(tui, theme, () => done(), modelRegistry);
- });
- },
- });
-}
dots/pi/agent/README.md
@@ -140,9 +140,7 @@ Custom extensions in `extensions/`:
- **`org-todos/`** - Org-mode TODO integration
- **`vertex-claude/`** - Google Vertex AI Claude models
- **`defaults/`** - Default extensions wrapper
-- **`lsp/`** - LSP integration
- **`filter-output/`** - Output filtering
-- **`shell-completions/`** - Shell completion enhancements
- **`threads/`** - Thread/session management
Extensions are symlinked to `~/.pi/agent/extensions/` and npm dependencies are auto-installed.