diff --git a/scripts/pi-extensions-sync b/scripts/pi-extensions-sync new file mode 100644 --- /dev/null +++ b/scripts/pi-extensions-sync @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# pi-extensions-sync +# +# Pulls the latest changes in the upstream source repos for each pi extension +# under dot_pi/agent/extensions/, diffs the upstream file against the local +# (chezmoi) copy, and prints a short recommendation about what to port. +# +# Each extension's index.ts is expected to begin with a 2-line header: +# +# // Source: owner/repo (https://github.com/owner/repo) +# // Path: +# +# Usage: +# scripts/pi-extensions-sync # all extensions +# scripts/pi-extensions-sync answer review # only these +# scripts/pi-extensions-sync --no-pull ... # skip git pull +# scripts/pi-extensions-sync --full ... # print full diff, not summary + +set -euo pipefail + +# shellcheck source=./utils +UTILS="$(dirname "$0")/utils" +# shellcheck disable=SC1090 +[ -f "$UTILS" ] && source "$UTILS" || true + +have() { command -v "$1" >/dev/null 2>&1; } + +say() { if have gum; then gum style --foreground 12 "$*"; else echo "==> $*"; fi; } +warn() { if have gum; then gum style --foreground 3 "$*"; else echo "!! $*" >&2; fi; } +ok() { if have gum; then gum style --foreground 2 "$*"; else echo "✓ $*"; fi; } +hr() { if have gum; then gum style --foreground 8 "$(printf '─%.0s' $(seq 1 60))"; else echo "------------------------------------------------------------"; fi; } + +CHEZMOI_DIR="${CHEZMOI_SOURCE_DIR:-$HOME/.local/share/chezmoi}" +EXT_DIR="$CHEZMOI_DIR/dot_pi/agent/extensions" + +# Candidate parent dirs to search for each repo clone. +CLONE_ROOTS=( + "$HOME/Code/ai/pi" + "$HOME/Code/ai" + "$HOME/Code" +) + +PULL=1 +FULL_DIFF=0 +NORMALIZE=0 +TARGETS=() + +while [ $# -gt 0 ]; do + case "$1" in + --no-pull) PULL=0 ;; + --full) FULL_DIFF=1 ;; + --format) NORMALIZE=1 ;; + -h|--help) + sed -n '2,20p' "$0" + exit 0 + ;; + -*) warn "unknown flag: $1"; exit 2 ;; + *) TARGETS+=("$1") ;; + esac + shift +done + +# Find local clone for a repo "owner/name". +find_clone() { + local repo="$1" name="${1##*/}" + for root in "${CLONE_ROOTS[@]}"; do + [ -d "$root/$name/.git" ] && { echo "$root/$name"; return 0; } + done + # Fallback: scan for any git remote matching the repo. + for root in "${CLONE_ROOTS[@]}"; do + [ -d "$root" ] || continue + while IFS= read -r -d '' gitdir; do + local dir="${gitdir%/.git}" + local url + url="$(git -C "$dir" remote get-url origin 2>/dev/null || true)" + case "$url" in + *"$repo"*) echo "$dir"; return 0 ;; + esac + done < <(find "$root" -maxdepth 3 -type d -name .git -print0 2>/dev/null) + done + return 1 +} + +# Parse the 2-line header. Echoes "repo|url|path". +parse_header() { + local file="$1" + local line1 line2 + line1="$(sed -n '1p' "$file")" + line2="$(sed -n '2p' "$file")" + case "$line1" in + "// Source: "*) : ;; + *) return 1 ;; + esac + local repo url path + # line1: "// Source: owner/repo (https://...)" + repo="${line1#// Source: }"; repo="${repo%% *}" + url="${line1#*\(}"; url="${url%\)*}" + # line2: "// Path: " + path="${line2#*Path: }" + echo "$repo|$url|$path" +} + +# Diff one extension. +process_one() { + local ext_name="$1" + local local_file="$EXT_DIR/$ext_name/index.ts" + + if [ ! -f "$local_file" ]; then + warn "$ext_name: no index.ts at $local_file" + return + fi + + local hdr + if ! hdr="$(parse_header "$local_file")"; then + warn "$ext_name: no '// Source:' header — skipping" + return + fi + local repo url path + IFS='|' read -r repo url path <<<"$hdr" + + hr + say "$ext_name ($repo :: $path)" + + local clone + if ! clone="$(find_clone "$repo")"; then + warn " clone not found locally for $repo — skipping" + return + fi + + if [ "$PULL" -eq 1 ]; then + if ! git -C "$clone" diff --quiet || ! git -C "$clone" diff --cached --quiet; then + warn " $clone has local changes, skipping pull" + else + if (cd "$clone" && git pull --ff-only --quiet); then + ok " pulled $clone" + else + warn " pull failed in $clone" + fi + fi + fi + + local upstream_file="$clone/$path" + if [ "$path" = "." ] || [ -d "$upstream_file" ]; then + warn " upstream path is a directory — compare manually: $upstream_file" + return + fi + if [ ! -f "$upstream_file" ]; then + warn " upstream file missing: $upstream_file" + return + fi + + # Strip the 2-line header from the local file for a fair diff. + local stripped upstream_copy + stripped="$(mktemp)".ts + upstream_copy="$(mktemp)".ts + tail -n +3 "$local_file" > "$stripped" + cp "$upstream_file" "$upstream_copy" + + # Optionally normalize both sides with the same formatter so we only see + # *semantic* differences, not indentation / import-wrap churn. + if [ "$NORMALIZE" -eq 1 ] && have prettier; then + prettier --write --no-config --log-level silent \ + --parser typescript \ + --use-tabs false --tab-width 2 --print-width 100 --trailing-comma all \ + "$stripped" "$upstream_copy" 2>/dev/null || warn " prettier normalization failed" + fi + upstream_file="$upstream_copy" + + if diff -q "$stripped" "$upstream_file" >/dev/null 2>&1; then + ok " in sync with upstream" + rm -f "$stripped" "$upstream_copy" + return + fi + + local added removed + added=$({ diff "$stripped" "$upstream_file" || true; } | grep -c '^> ' || true) + removed=$({ diff "$stripped" "$upstream_file" || true; } | grep -c '^< ' || true) + echo " upstream vs local: +$added / -$removed lines" + + if [ "$FULL_DIFF" -eq 1 ]; then + diff -u "$stripped" "$upstream_file" || true + else + # short context diff, truncated + { diff -u "$stripped" "$upstream_file" || true; } | head -60 || true + local total + total=$({ diff -u "$stripped" "$upstream_file" || true; } | wc -l | tr -d ' ') + if [ "$total" -gt 60 ]; then + echo " … diff truncated ($total lines total). Re-run with --full to see everything." + fi + fi + + # Heuristic suggestion + echo + if [ "$added" -gt 0 ] && [ "$removed" -lt 20 ]; then + say " 💡 Suggestion: upstream has $added new lines. Likely safe to port; review for new features/bugfixes to merge into your local copy." + elif [ "$added" -gt 0 ] && [ "$removed" -ge 20 ]; then + say " ⚠️ Suggestion: divergent ($removed local-only lines). Cherry-pick carefully — your customizations may conflict with upstream refactors." + else + say " ℹ️ Suggestion: upstream removed/changed lines, little new content. Review to ensure you're not missing a bugfix." + fi + + rm -f "$stripped" "$upstream_copy" +} + +# Figure out targets +if [ ${#TARGETS[@]} -eq 0 ]; then + for d in "$EXT_DIR"/*/; do + [ -f "$d/index.ts" ] || continue + TARGETS+=("$(basename "$d")") + done +fi + +say "pi-extensions-sync — ${#TARGETS[@]} extension(s)" +for t in "${TARGETS[@]}"; do + process_one "$t" +done +hr +ok "done" diff --git a/dot_pi/agent/extensions/answer/index.ts b/dot_pi/agent/extensions/answer/index.ts --- a/dot_pi/agent/extensions/answer/index.ts +++ b/dot_pi/agent/extensions/answer/index.ts @@ -1,3 +1,5 @@ +// Source: mitsuhiko/agent-stuff (https://github.com/mitsuhiko/agent-stuff) +// Path: extensions/answer.ts /** * Q&A extraction hook - extracts questions from assistant responses * @@ -19,6 +21,7 @@ import type { ExtensionAPI, ExtensionContext, + ModelRegistry, } from "@mariozechner/pi-coding-agent"; import { BorderedLoader } from "@mariozechner/pi-coding-agent"; import { @@ -83,15 +86,7 @@ */ async function selectExtractionModel( currentModel: Model, - modelRegistry: { - find: (provider: string, modelId: string) => Model | undefined; - getApiKeyAndHeaders: (model: Model) => Promise<{ - ok: boolean; - apiKey?: string; - headers?: Record; - error?: string; - }>; - }, + modelRegistry: ModelRegistry, ): Promise> { const codexModel = modelRegistry.find("openai-codex", OPENAI_MODEL_ID); if (codexModel) { @@ -497,7 +492,9 @@ const doExtract = async () => { const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel); - if (!auth.ok) throw new Error(auth.error); + if (!auth.ok) { + throw new Error(auth.error); + } const userMessage: UserMessage = { role: "user", content: [{ type: "text", text: lastAssistantText! }], diff --git a/dot_pi/agent/extensions/antigravity-image-gen/index.ts b/dot_pi/agent/extensions/antigravity-image-gen/index.ts --- a/dot_pi/agent/extensions/antigravity-image-gen/index.ts +++ b/dot_pi/agent/extensions/antigravity-image-gen/index.ts @@ -1,9 +1,14 @@ +// Source: ben-vargas/pi-packages (https://github.com/ben-vargas/pi-packages) +// Path: packages/pi-antigravity-image-gen/extensions/index.ts /** * Antigravity Image Generation * - * Generates images via Google Antigravity's image models (gemini-3-pro-image). + * Generates images via Google Antigravity's Gemini 3 Pro Image model. * Returns images as tool result attachments for inline terminal rendering. * Requires OAuth login via /login for google-antigravity. + * + * Note: Only gemini-3-pro-image is available via the Antigravity API. + * Imagen models and gemini-2.5-flash-image are NOT supported by this endpoint. * * Usage: * "Generate an image of a sunset over mountains" @@ -23,18 +28,26 @@ * ~/.pi/agent/extensions/antigravity-image-gen.json * /.pi/extensions/antigravity-image-gen.json * Example: { "save": "global" } + * + * Based on opencode-antigravity-img by ominiverdi (MIT) + * and opencode-antigravity-auth by NoeFabris (MIT). */ import { randomUUID } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { StringEnum } from "@mariozechner/pi-ai"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { type Static, Type } from "@sinclair/typebox"; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + const PROVIDER = "google-antigravity"; +const IMAGE_MODEL = "gemini-3-pro-image"; const ASPECT_RATIOS = [ "1:1", @@ -48,43 +61,51 @@ "16:9", "21:9", ] as const; - type AspectRatio = (typeof ASPECT_RATIOS)[number]; -const DEFAULT_MODEL = "gemini-3-pro-image"; -const FALLBACK_MODELS = [] as const; const DEFAULT_ASPECT_RATIO: AspectRatio = "1:1"; const DEFAULT_SAVE_MODE = "none"; -const MAX_RETRIES_PER_MODEL = 4; -const BASE_RETRY_DELAY_MS = 1000; +const DEFAULT_CONFIG_FILE: ExtensionConfig = { save: "global" }; const SAVE_MODES = ["none", "project", "global", "custom"] as const; type SaveMode = (typeof SAVE_MODES)[number]; -const ANTIGRAVITY_ENDPOINT = - "https://daily-cloudcode-pa.sandbox.googleapis.com"; +/** Endpoint fallback order — daily first (most permissive), prod last. */ +const ANTIGRAVITY_ENDPOINTS = [ + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://autopush-cloudcode-pa.sandbox.googleapis.com", + "https://cloudcode-pa.googleapis.com", +] as const; -const ANTIGRAVITY_HEADERS = { - "User-Agent": "antigravity/1.21.9 darwin/arm64", +/** Prod endpoint for quota checks. */ +const ANTIGRAVITY_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"; + +// Keep Antigravity version in sync with known working UA versions. +// Using an outdated version can yield "This version of Antigravity is no longer supported". +const ANTIGRAVITY_VERSION = "1.15.8"; + +const ANTIGRAVITY_HEADERS: Record = { + "User-Agent": `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Antigravity/${ANTIGRAVITY_VERSION} Chrome/138.0.7204.235 Electron/37.3.1 Safari/537.36`, "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", - "Client-Metadata": JSON.stringify({ - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }), + "Client-Metadata": + '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}', }; const IMAGE_SYSTEM_INSTRUCTION = "You are an AI image generator. Generate images based on user descriptions. Focus on creating high-quality, visually appealing images that match the user's request."; +/** Image generation typically takes 10-30 seconds. */ +const IMAGE_GENERATION_TIMEOUT_MS = 60_000; + +/** Quota fetch timeout. */ +const QUOTA_TIMEOUT_MS = 10_000; + +// --------------------------------------------------------------------------- +// Tool parameters +// --------------------------------------------------------------------------- + const TOOL_PARAMS = Type.Object({ prompt: Type.String({ description: "Image description." }), - model: Type.Optional( - Type.String({ - description: - "Image model id (e.g., gemini-3-pro-image). Default: gemini-3-pro-image.", - }), - ), aspectRatio: Type.Optional(StringEnum(ASPECT_RATIOS)), save: Type.Optional(StringEnum(SAVE_MODES)), saveDir: Type.Optional( @@ -97,6 +118,10 @@ type ToolParams = Static; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + interface CloudCodeAssistRequest { project: string; model: string; @@ -105,8 +130,7 @@ sessionId?: string; systemInstruction?: { role?: string; parts: { text: string }[] }; generationConfig?: { - maxOutputTokens?: number; - temperature?: number; + responseModalities?: string[]; imageConfig?: { aspectRatio?: string }; candidateCount?: number; }; @@ -172,6 +196,26 @@ outputDir?: string; } +interface QuotaInfo { + remainingPercent: number; + resetIn: string; + resetTime?: string; +} + +interface FetchAvailableModelsResponse { + models?: Record< + string, + { + quotaInfo?: { remainingFraction?: number; resetTime?: string }; + displayName?: string; + } + >; +} + +// --------------------------------------------------------------------------- +// Credentials +// --------------------------------------------------------------------------- + function parseOAuthCredentials(raw: string): ParsedCredentials { let parsed: { token?: string; projectId?: string }; try { @@ -189,6 +233,24 @@ return { accessToken: parsed.token, projectId: parsed.projectId }; } +async function getCredentials(ctx: { + modelRegistry: { + getApiKeyForProvider: (provider: string) => Promise; + }; +}): Promise { + const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER); + if (!apiKey) { + throw new Error( + "Missing Google Antigravity OAuth credentials. Run /login for google-antigravity.", + ); + } + return parseOAuthCredentials(apiKey); +} + +// --------------------------------------------------------------------------- +// Config / save helpers +// --------------------------------------------------------------------------- + function readConfigFile(path: string): ExtensionConfig { if (!existsSync(path)) { return {}; @@ -203,13 +265,45 @@ } function loadConfig(cwd: string): ExtensionConfig { - const globalConfig = readConfigFile( - join(homedir(), ".pi", "agent", "extensions", "antigravity-image-gen.json"), + const globalPath = join( + homedir(), + ".pi", + "agent", + "extensions", + "antigravity-image-gen.json", ); - const projectConfig = readConfigFile( - join(cwd, ".pi", "extensions", "antigravity-image-gen.json"), + const projectPath = join( + cwd, + ".pi", + "extensions", + "antigravity-image-gen.json", ); + ensureDefaultConfigFile(projectPath, globalPath); + const globalConfig = readConfigFile(globalPath); + const projectConfig = readConfigFile(projectPath); return { ...globalConfig, ...projectConfig }; +} + +function ensureDefaultConfigFile( + projectConfigPath: string, + globalConfigPath: string, +): void { + if (existsSync(projectConfigPath) || existsSync(globalConfigPath)) { + return; + } + try { + mkdirSync(dirname(globalConfigPath), { recursive: true }); + writeFileSync( + globalConfigPath, + `${JSON.stringify(DEFAULT_CONFIG_FILE, null, 2)}\n`, + "utf-8", + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `[pi-antigravity-image-gen] Failed to write ${globalConfigPath}: ${message}`, + ); + } } function resolveSaveConfig(params: ToolParams, cwd: string): SaveConfig { @@ -248,43 +342,6 @@ return { mode }; } -function isRetryableStatus(status: number): boolean { - return status === 429 || status === 503; -} - -function parseRetryDelayMs(errorText: string): number | undefined { - try { - const parsed = JSON.parse(errorText) as { - error?: { details?: Array<{ "@type"?: string; retryDelay?: string }> }; - }; - const retryInfo = parsed.error?.details?.find( - (d) => d["@type"] === "type.googleapis.com/google.rpc.RetryInfo", - ); - const raw = retryInfo?.retryDelay; - if (!raw || !raw.endsWith("s")) return undefined; - const seconds = Number.parseFloat(raw.slice(0, -1)); - if (!Number.isFinite(seconds) || seconds <= 0) return undefined; - return Math.ceil(seconds * 1000); - } catch { - return undefined; - } -} - -async function waitForRetry(ms: number, signal?: AbortSignal): Promise { - if (signal?.aborted) throw new Error("Request was aborted"); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - const onAbort = () => { - clearTimeout(timer); - reject(new Error("Request was aborted")); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} - function imageExtension(mimeType: string): string { const lower = mimeType.toLowerCase(); if (lower.includes("jpeg") || lower.includes("jpg")) return "jpg"; @@ -307,15 +364,18 @@ return filePath; } +// --------------------------------------------------------------------------- +// Request building +// --------------------------------------------------------------------------- + function buildRequest( prompt: string, - model: string, projectId: string, aspectRatio: string, ): CloudCodeAssistRequest { return { project: projectId, - model, + model: IMAGE_MODEL, request: { contents: [ { @@ -327,6 +387,7 @@ parts: [{ text: IMAGE_SYSTEM_INSTRUCTION }], }, generationConfig: { + responseModalities: ["TEXT", "IMAGE"], imageConfig: { aspectRatio }, candidateCount: 1, }, @@ -352,6 +413,10 @@ userAgent: "antigravity", }; } + +// --------------------------------------------------------------------------- +// SSE parsing +// --------------------------------------------------------------------------- async function parseSseForImage( response: Response, @@ -382,7 +447,7 @@ for (const line of lines) { if (!line.startsWith("data:")) continue; const jsonStr = line.slice(5).trim(); - if (!jsonStr) continue; + if (!jsonStr || jsonStr === "[DONE]") continue; let chunk: CloudCodeAssistResponseChunk; try { @@ -419,149 +484,207 @@ reader.releaseLock(); } + if (textParts.length > 0) { + const summary = textParts.join(" ").replace(/\s+/g, " ").trim(); + const snippet = + summary.length > 400 ? `${summary.slice(0, 400)}…` : summary; + throw new Error( + `No image data returned by the model. Response text: ${snippet}`, + ); + } + throw new Error("No image data returned by the model"); } -async function getCredentials(ctx: { - modelRegistry: { - getApiKeyForProvider: (provider: string) => Promise; - }; -}): Promise { - const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER); - if (!apiKey) { - throw new Error( - "Missing Google Antigravity OAuth credentials. Run /login for google-antigravity.", - ); +// --------------------------------------------------------------------------- +// Image generation with endpoint fallback +// --------------------------------------------------------------------------- + +async function generateImage( + accessToken: string, + projectId: string, + prompt: string, + aspectRatio: string, + signal?: AbortSignal, +): Promise<{ image: { data: string; mimeType: string }; text: string[] }> { + const requestBody = buildRequest(prompt, projectId, aspectRatio); + const errors: string[] = []; + + for (const endpoint of ANTIGRAVITY_ENDPOINTS) { + try { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + IMAGE_GENERATION_TIMEOUT_MS, + ); + + // Chain with caller's signal + if (signal) { + signal.addEventListener("abort", () => controller.abort(), { + once: true, + }); + } + + let response: Response; + try { + response = await fetch( + `${endpoint}/v1internal:streamGenerateContent?alt=sse`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Accept: "text/event-stream", + ...ANTIGRAVITY_HEADERS, + }, + body: JSON.stringify(requestBody), + signal: controller.signal, + }, + ); + } finally { + clearTimeout(timeout); + } + + if (response.status === 429) { + // Rate limited — try next endpoint + errors.push(`${endpoint}: rate limited (429)`); + continue; + } + + if (!response.ok) { + const errorText = await response.text(); + errors.push( + `${endpoint}: ${response.status} ${errorText.slice(0, 200)}`, + ); + continue; + } + + return await parseSseForImage(response, signal); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + if (signal?.aborted) { + throw new Error("Request was aborted"); + } + // Timeout — try next endpoint + errors.push(`${endpoint}: timeout`); + continue; + } + errors.push( + `${endpoint}: ${err instanceof Error ? err.message : String(err)}`, + ); + } } - return parseOAuthCredentials(apiKey); + + throw new Error(`All endpoints failed:\n${errors.join("\n")}`); } +// --------------------------------------------------------------------------- +// Quota check +// --------------------------------------------------------------------------- + +async function getImageQuota( + accessToken: string, + projectId: string, +): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), QUOTA_TIMEOUT_MS); + + let response: Response; + try { + response = await fetch( + `${ANTIGRAVITY_ENDPOINT_PROD}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": ANTIGRAVITY_HEADERS["User-Agent"], + }, + body: JSON.stringify({ project: projectId }), + signal: controller.signal, + }, + ); + } finally { + clearTimeout(timeout); + } + + if (!response.ok) return null; + + const data = (await response.json()) as FetchAvailableModelsResponse; + if (!data.models) return null; + + // Find the image model entry + const imageEntry = data.models[IMAGE_MODEL]; + if (!imageEntry?.quotaInfo) return null; + + const quota = imageEntry.quotaInfo; + const remainingPercent = (quota.remainingFraction ?? 0) * 100; + const resetTime = quota.resetTime || ""; + + let resetIn = "N/A"; + if (resetTime) { + const resetDate = new Date(resetTime); + const diffMs = resetDate.getTime() - Date.now(); + if (diffMs > 0) { + const hours = Math.floor(diffMs / 3600000); + const mins = Math.floor((diffMs % 3600000) / 60000); + resetIn = `${hours}h ${mins}m`; + } else { + resetIn = "now"; + } + } + + return { remainingPercent, resetIn, resetTime }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Extension entry point +// --------------------------------------------------------------------------- + +export { + buildRequest, + parseOAuthCredentials, + resolveSaveConfig, + ensureDefaultConfigFile, + DEFAULT_CONFIG_FILE, +}; + export default function antigravityImageGen(pi: ExtensionAPI) { + // Tool: generate_image pi.registerTool({ name: "generate_image", label: "Generate image", description: - "Generate an image via Google Antigravity image models. Returns the image as a tool result attachment. Optional saving via save=project|global|custom|none, or PI_IMAGE_SAVE_MODE/PI_IMAGE_SAVE_DIR.", - promptSnippet: "Generate images via Google Antigravity models with optional saving to project/global/custom paths", + "Generate an image via Google Antigravity's Gemini 3 Pro Image model. " + + "Returns the image as a tool result attachment for inline terminal rendering. " + + "Optional saving via save=project|global|custom|none, or PI_IMAGE_SAVE_MODE/PI_IMAGE_SAVE_DIR.", parameters: TOOL_PARAMS, async execute(_toolCallId, params: ToolParams, signal, onUpdate, ctx) { const { accessToken, projectId } = await getCredentials(ctx); const aspectRatio = params.aspectRatio || DEFAULT_ASPECT_RATIO; - const modelsToTry = params.model - ? [params.model] - : [DEFAULT_MODEL, ...FALLBACK_MODELS]; - let parsed: Awaited> | undefined; - let model = modelsToTry[0]; - const errors: string[] = []; + onUpdate?.({ + content: [ + { + type: "text", + text: `Generating image (${IMAGE_MODEL}, ${aspectRatio})...`, + }, + ], + details: { provider: PROVIDER, model: IMAGE_MODEL, aspectRatio }, + }); - for (const candidateModel of modelsToTry) { - model = candidateModel; - for (let attempt = 1; attempt <= MAX_RETRIES_PER_MODEL; attempt++) { - const requestBody = buildRequest( - params.prompt, - candidateModel, - projectId, - aspectRatio, - ); + const parsed = await generateImage( + accessToken, + projectId, + params.prompt, + aspectRatio, + signal, + ); - onUpdate?.({ - content: [ - { - type: "text", - text: - attempt === 1 - ? `Requesting image from ${PROVIDER}/${candidateModel}...` - : `Retrying ${PROVIDER}/${candidateModel} (attempt ${attempt}/${MAX_RETRIES_PER_MODEL})...`, - }, - ], - details: { - provider: PROVIDER, - model: candidateModel, - aspectRatio, - attempt, - }, - }); - - try { - const response = await fetch( - `${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, - { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - Accept: "text/event-stream", - ...ANTIGRAVITY_HEADERS, - }, - body: JSON.stringify(requestBody), - signal, - }, - ); - - if (!response.ok) { - const errorText = await response.text(); - const canRetry = - isRetryableStatus(response.status) && - attempt < MAX_RETRIES_PER_MODEL; - if (canRetry) { - const retryMs = - parseRetryDelayMs(errorText) || - BASE_RETRY_DELAY_MS * 2 ** (attempt - 1); - onUpdate?.({ - content: [ - { - type: "text", - text: `Model ${candidateModel} is temporarily unavailable (${response.status}). Waiting ${retryMs}ms before retry...`, - }, - ], - details: { - provider: PROVIDER, - model: candidateModel, - retryMs, - attempt, - }, - }); - await waitForRetry(retryMs, signal); - continue; - } - throw new Error( - `Image request failed (${response.status}): ${errorText}`, - ); - } - - parsed = await parseSseForImage(response, signal); - break; - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - if (message === "Request was aborted") { - throw new Error(message); - } - - const statusMatch = message.match(/Image request failed \((\d+)\):/); - const statusCode = statusMatch - ? Number.parseInt(statusMatch[1] || "", 10) - : undefined; - const retryable = statusCode - ? isRetryableStatus(statusCode) - : false; - - if (!retryable || attempt >= MAX_RETRIES_PER_MODEL) { - errors.push(`${candidateModel}: ${message}`); - break; - } - } - } - - if (parsed) break; - } - - if (!parsed) { - throw new Error( - `Image request failed for all candidate models: ${errors.join(" | ")}`, - ); - } const saveConfig = resolveSaveConfig(params, ctx.cwd); let savedPath: string | undefined; let saveError: string | undefined; @@ -576,14 +699,15 @@ saveError = error instanceof Error ? error.message : String(error); } } + const summaryParts = [ - `Generated image via ${PROVIDER}/${model}.`, + `Generated image via ${PROVIDER}/${IMAGE_MODEL}.`, `Aspect ratio: ${aspectRatio}.`, ]; if (savedPath) { - summaryParts.push(`Saved image to: ${savedPath}`); + summaryParts.push(`Saved to: ${savedPath}`); } else if (saveError) { - summaryParts.push(`Failed to save image: ${saveError}`); + summaryParts.push(`Failed to save: ${saveError}`); } if (parsed.text.length > 0) { summaryParts.push(`Model notes: ${parsed.text.join(" ")}`); @@ -600,11 +724,65 @@ ], details: { provider: PROVIDER, - model, + model: IMAGE_MODEL, aspectRatio, savedPath, saveMode: saveConfig.mode, }, + }; + }, + }); + + // Tool: image_quota + pi.registerTool({ + name: "image_quota", + label: "Image quota", + description: + "Check remaining image generation quota for the Gemini 3 Pro Image model. " + + "Shows percentage remaining and time until reset. " + + "Image generation uses a separate quota from text models. Quota resets every ~5 hours.", + parameters: Type.Object({}), + async execute( + _toolCallId, + _params: Record, + _signal, + _onUpdate, + ctx, + ) { + const { accessToken, projectId } = await getCredentials(ctx); + const quota = await getImageQuota(accessToken, projectId); + + if (!quota) { + return { + content: [ + { + type: "text", + text: "Could not fetch quota information. The API may be temporarily unavailable.", + }, + ], + details: { provider: PROVIDER, model: IMAGE_MODEL, quota: null }, + }; + } + + const barWidth = 20; + const filled = Math.round((quota.remainingPercent / 100) * barWidth); + const empty = barWidth - filled; + const bar = "#".repeat(filled) + ".".repeat(empty); + + const lines = [ + `${IMAGE_MODEL}`, + `[${bar}] ${quota.remainingPercent.toFixed(0)}% remaining`, + `Resets in: ${quota.resetIn}`, + ]; + + if (quota.resetTime) { + const resetDate = new Date(quota.resetTime); + lines[2] += ` (at ${resetDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })})`; + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { provider: PROVIDER, model: IMAGE_MODEL, quota }, }; }, }); diff --git a/dot_pi/agent/extensions/auto-session-name/index.ts b/dot_pi/agent/extensions/auto-session-name/index.ts --- a/dot_pi/agent/extensions/auto-session-name/index.ts +++ b/dot_pi/agent/extensions/auto-session-name/index.ts @@ -1,3 +1,5 @@ +// Source: samfoy/pi-essentials (https://github.com/samfoy/pi-essentials) +// Path: src/auto-session-name.ts /** * oh-pi Auto Session Name Extension * diff --git a/dot_pi/agent/extensions/context/index.ts b/dot_pi/agent/extensions/context/index.ts --- a/dot_pi/agent/extensions/context/index.ts +++ b/dot_pi/agent/extensions/context/index.ts @@ -1,3 +1,5 @@ +// Source: mitsuhiko/agent-stuff (https://github.com/mitsuhiko/agent-stuff) +// Path: extensions/context.ts /** * /context * @@ -14,10 +16,7 @@ ExtensionContext, ToolResultEvent, } from "@mariozechner/pi-coding-agent"; -import { - DynamicBorder, - loadProjectContextFiles as loadProjectContextFilesFromCli, -} from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; import { Container, Key, @@ -28,7 +27,8 @@ } from "@mariozechner/pi-tui"; import os from "node:os"; import path from "node:path"; - +import fs from "node:fs/promises"; +import { existsSync } from "node:fs"; function formatUsd(cost: number): string { if (!Number.isFinite(cost) || cost <= 0) return "$0.00"; @@ -52,15 +52,88 @@ return path.resolve(p); } -function loadProjectContextFilesWithTokens( +function getAgentDir(): string { + // Mirrors pi's behavior reasonably well. + const envCandidates = ["PI_CODING_AGENT_DIR", "TAU_CODING_AGENT_DIR"]; + let envDir: string | undefined; + for (const k of envCandidates) { + if (process.env[k]) { + envDir = process.env[k]; + break; + } + } + if (!envDir) { + for (const [k, v] of Object.entries(process.env)) { + if (k.endsWith("_CODING_AGENT_DIR") && v) { + envDir = v; + break; + } + } + } + + if (envDir) { + if (envDir === "~") return os.homedir(); + if (envDir.startsWith("~/")) + return path.join(os.homedir(), envDir.slice(2)); + return envDir; + } + return path.join(os.homedir(), ".pi", "agent"); +} + +async function readFileIfExists( + filePath: string, +): Promise<{ path: string; content: string; bytes: number } | null> { + if (!existsSync(filePath)) return null; + try { + const buf = await fs.readFile(filePath); + return { + path: filePath, + content: buf.toString("utf8"), + bytes: buf.byteLength, + }; + } catch { + return null; + } +} + +async function loadProjectContextFiles( cwd: string, -): Array<{ path: string; tokens: number; bytes: number }> { - const files = loadProjectContextFilesFromCli({ cwd }); - return files.map((f) => ({ - path: f.path, - tokens: estimateTokens(f.content), - bytes: Buffer.byteLength(f.content, "utf8"), - })); +): Promise> { + const out: Array<{ path: string; tokens: number; bytes: number }> = []; + const seen = new Set(); + + const loadFromDir = async (dir: string) => { + for (const name of ["AGENTS.md", "CLAUDE.md"]) { + const p = path.join(dir, name); + const f = await readFileIfExists(p); + if (f && !seen.has(f.path)) { + seen.add(f.path); + out.push({ + path: f.path, + tokens: estimateTokens(f.content), + bytes: f.bytes, + }); + // pi loads at most one of those per dir + return; + } + } + }; + + await loadFromDir(getAgentDir()); + + // Ancestors: root → cwd (same order as pi) + const stack: string[] = []; + let current = path.resolve(cwd); + while (true) { + stack.push(current); + const parent = path.resolve(current, ".."); + if (parent === current) break; + current = parent; + } + stack.reverse(); + for (const dir of stack) await loadFromDir(dir); + + return out; } function normalizeSkillName(name: string): string { @@ -78,7 +151,9 @@ .getCommands() .filter((c) => c.source === "skill") .map((c) => { - const p = c.sourceInfo?.path ? normalizeReadPath(c.sourceInfo.path, cwd) : ""; + const p = c.sourceInfo?.path + ? normalizeReadPath(c.sourceInfo.path, cwd) + : ""; return { name: normalizeSkillName(c.name), skillFilePath: p, @@ -476,7 +551,7 @@ .map((c) => normalizeSkillName(c.name)) .sort((a, b) => a.localeCompare(b)); - const agentFiles = loadProjectContextFilesWithTokens(ctx.cwd); + const agentFiles = await loadProjectContextFiles(ctx.cwd); const agentFilePaths = agentFiles.map((f) => shortenPath(f.path, ctx.cwd), ); diff --git a/dot_pi/agent/extensions/ghostty/index.ts b/dot_pi/agent/extensions/ghostty/index.ts --- a/dot_pi/agent/extensions/ghostty/index.ts +++ b/dot_pi/agent/extensions/ghostty/index.ts @@ -1,3 +1,5 @@ +// Source: HazAT/pi-ghostty (https://github.com/HazAT/pi-ghostty) +// Path: extensions/ghostty.ts /** * ghostty - Ghostty terminal title and progress bar integration. * diff --git a/dot_pi/agent/extensions/intercepted-commands/SOURCE.md b/dot_pi/agent/extensions/intercepted-commands/SOURCE.md new file mode 100644 --- /dev/null +++ b/dot_pi/agent/extensions/intercepted-commands/SOURCE.md @@ -0,0 +1,4 @@ +# Source + +- Repo: `mitsuhiko/agent-stuff` — https://github.com/mitsuhiko/agent-stuff +- Path: `intercepted-commands/` diff --git a/dot_pi/agent/extensions/loop/index.ts b/dot_pi/agent/extensions/loop/index.ts --- a/dot_pi/agent/extensions/loop/index.ts +++ b/dot_pi/agent/extensions/loop/index.ts @@ -1,3 +1,5 @@ +// Source: mitsuhiko/agent-stuff (https://github.com/mitsuhiko/agent-stuff) +// Path: extensions/loop.ts /** * Loop Extension * @@ -16,6 +18,7 @@ import type { ExtensionAPI, ExtensionContext, + SessionSwitchEvent, } from "@mariozechner/pi-coding-agent"; import { compact } from "@mariozechner/pi-coding-agent"; import { @@ -99,9 +102,11 @@ } } -async function selectSummaryModel( - ctx: ExtensionContext, -): Promise<{ model: Model; apiKey: string; headers?: Record } | null> { +async function selectSummaryModel(ctx: ExtensionContext): Promise<{ + model: Model; + apiKey?: string; + headers?: Record; +} | null> { if (!ctx.model) return null; if (ctx.model.provider === "anthropic") { @@ -109,14 +114,18 @@ if (haikuModel) { const auth = await ctx.modelRegistry.getApiKeyAndHeaders(haikuModel); if (auth.ok) { - return { model: haikuModel, apiKey: auth.apiKey!, headers: auth.headers }; + return { + model: haikuModel, + apiKey: auth.apiKey, + headers: auth.headers, + }; } } } const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model); if (!auth.ok) return null; - return { model: ctx.model, apiKey: auth.apiKey!, headers: auth.headers }; + return { model: ctx.model, apiKey: auth.apiKey, headers: auth.headers }; } async function summarizeBreakoutCondition( @@ -358,7 +367,6 @@ label: "Signal Loop Success", description: "Stop the active loop when the breakout condition is satisfied. Only call this tool when explicitly instructed to do so by the user, tool or system prompt.", - promptSnippet: "Stop the active loop when the breakout condition is satisfied", parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { if (!loopState.active) { @@ -469,7 +477,8 @@ const compaction = await compact( event.preparation, ctx.model, - auth.apiKey!, + auth.apiKey ?? "", + auth.headers, instructionParts, event.signal, ); @@ -509,4 +518,7 @@ await restoreLoopState(ctx); }); + pi.on("session_switch", async (_event: SessionSwitchEvent, ctx) => { + await restoreLoopState(ctx); + }); } diff --git a/dot_pi/agent/extensions/multi-edit/index.ts b/dot_pi/agent/extensions/multi-edit/index.ts --- a/dot_pi/agent/extensions/multi-edit/index.ts +++ b/dot_pi/agent/extensions/multi-edit/index.ts @@ -1,3 +1,5 @@ +// Source: mitsuhiko/agent-stuff (https://github.com/mitsuhiko/agent-stuff) +// Path: extensions/multi-edit.ts /** * Multi-Edit Extension — replaces the built-in `edit` tool. * @@ -13,587 +15,684 @@ * - patch mode: preflight by applying patch operations on a virtual filesystem */ -import type { ExtensionAPI, EditToolDetails, Theme } from "@mariozechner/pi-coding-agent"; -import { highlightCode, getLanguageFromPath } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import * as Diff from "diff"; import { constants } from "fs"; -import { access as fsAccess, readFile as fsReadFile, unlink as fsUnlink, writeFile as fsWriteFile } from "fs/promises"; -import { homedir } from "os"; +import { + access as fsAccess, + readFile as fsReadFile, + unlink as fsUnlink, + writeFile as fsWriteFile, +} from "fs/promises"; import { isAbsolute, resolve as resolvePath } from "path"; +import type { EditToolDetails, Theme } from "@mariozechner/pi-coding-agent"; +import { highlightCode } from "@mariozechner/pi-coding-agent"; +import { homedir } from "os"; import { Text, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui"; const editItemSchema = Type.Object({ - path: Type.Optional(Type.String({ description: "Path to the file to edit (relative or absolute). Inherits from top-level path if omitted." })), - oldText: Type.String({ description: "Exact text to find and replace (must match exactly)" }), - newText: Type.String({ description: "New text to replace the old text with" }), + path: Type.Optional( + Type.String({ + description: + "Path to the file to edit (relative or absolute). Inherits from top-level path if omitted.", + }), + ), + oldText: Type.String({ + description: "Exact text to find and replace (must match exactly)", + }), + newText: Type.String({ + description: "New text to replace the old text with", + }), }); const multiEditSchema = Type.Object({ - path: Type.Optional(Type.String({ description: "Path to the file to edit (relative or absolute)" })), - oldText: Type.Optional(Type.String({ description: "Exact text to find and replace (must match exactly)" })), - newText: Type.Optional(Type.String({ description: "New text to replace the old text with" })), - multi: Type.Optional( - Type.Array(editItemSchema, { - description: "Multiple edits to apply in sequence. Each item has path, oldText, and newText.", - }), - ), - patch: Type.Optional( - Type.String({ - description: - "Codex-style apply_patch payload (*** Begin Patch ... *** End Patch). Mutually exclusive with path/oldText/newText/multi.", - }), - ), + path: Type.Optional( + Type.String({ + description: "Path to the file to edit (relative or absolute)", + }), + ), + oldText: Type.Optional( + Type.String({ + description: "Exact text to find and replace (must match exactly)", + }), + ), + newText: Type.Optional( + Type.String({ description: "New text to replace the old text with" }), + ), + multi: Type.Optional( + Type.Array(editItemSchema, { + description: + "Multiple edits to apply in sequence. Each item has path, oldText, and newText.", + }), + ), + patch: Type.Optional( + Type.String({ + description: + "Codex-style apply_patch payload (*** Begin Patch ... *** End Patch). Mutually exclusive with path/oldText/newText/multi.", + }), + ), }); interface EditItem { - path: string; - oldText: string; - newText: string; + path: string; + oldText: string; + newText: string; } interface EditResult { - path: string; - success: boolean; - message: string; - diff?: string; - firstChangedLine?: number; + path: string; + success: boolean; + message: string; + diff?: string; + firstChangedLine?: number; } interface UpdateChunk { - changeContext?: string; - oldLines: string[]; - newLines: string[]; - isEndOfFile: boolean; + changeContext?: string; + oldLines: string[]; + newLines: string[]; + isEndOfFile: boolean; } type PatchOperation = - | { kind: "add"; path: string; contents: string } - | { kind: "delete"; path: string } - | { kind: "update"; path: string; chunks: UpdateChunk[] }; + | { kind: "add"; path: string; contents: string } + | { kind: "delete"; path: string } + | { kind: "update"; path: string; chunks: UpdateChunk[] }; interface PatchOpResult { - path: string; - message: string; - diff?: string; - firstChangedLine?: number; + path: string; + message: string; + diff?: string; + firstChangedLine?: number; } function generateDiffString( - oldContent: string, - newContent: string, - contextLines = 4, + oldContent: string, + newContent: string, + contextLines = 4, ): { diff: string; firstChangedLine: number | undefined } { - const parts = Diff.diffLines(oldContent, newContent); - const output: string[] = []; + const parts = Diff.diffLines(oldContent, newContent); + const output: string[] = []; - const oldLines = oldContent.split("\n"); - const newLines = newContent.split("\n"); - const maxLineNum = Math.max(oldLines.length, newLines.length); - const lineNumWidth = String(maxLineNum).length; + const oldLines = oldContent.split("\n"); + const newLines = newContent.split("\n"); + const maxLineNum = Math.max(oldLines.length, newLines.length); + const lineNumWidth = String(maxLineNum).length; - let oldLineNum = 1; - let newLineNum = 1; - let lastWasChange = false; - let firstChangedLine: number | undefined; + let oldLineNum = 1; + let newLineNum = 1; + let lastWasChange = false; + let firstChangedLine: number | undefined; - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const raw = part.value.split("\n"); - if (raw[raw.length - 1] === "") { - raw.pop(); - } + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const raw = part.value.split("\n"); + if (raw[raw.length - 1] === "") { + raw.pop(); + } - if (part.added || part.removed) { - if (firstChangedLine === undefined) { - firstChangedLine = newLineNum; - } + if (part.added || part.removed) { + if (firstChangedLine === undefined) { + firstChangedLine = newLineNum; + } - for (const line of raw) { - if (part.added) { - const lineNum = String(newLineNum).padStart(lineNumWidth, " "); - output.push(`+${lineNum} ${line}`); - newLineNum++; - } else { - const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); - output.push(`-${lineNum} ${line}`); - oldLineNum++; - } - } - lastWasChange = true; - } else { - const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); + for (const line of raw) { + if (part.added) { + const lineNum = String(newLineNum).padStart(lineNumWidth, " "); + output.push(`+${lineNum} ${line}`); + newLineNum++; + } else { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(`-${lineNum} ${line}`); + oldLineNum++; + } + } + lastWasChange = true; + } else { + const nextPartIsChange = + i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); - if (lastWasChange || nextPartIsChange) { - // Determine how many lines to show at the start and end of this - // unchanged block. When the block sits between two changes we - // show context on both sides but collapse the middle. - const showAtStart = lastWasChange ? contextLines : 0; - const showAtEnd = nextPartIsChange ? contextLines : 0; + if (lastWasChange || nextPartIsChange) { + // Determine how many lines to show at the start and end of this + // unchanged block. When the block sits between two changes we + // show context on both sides but collapse the middle. + const showAtStart = lastWasChange ? contextLines : 0; + const showAtEnd = nextPartIsChange ? contextLines : 0; - if (raw.length <= showAtStart + showAtEnd) { - // Block is small enough — show it entirely. - for (const line of raw) { - const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); - output.push(` ${lineNum} ${line}`); - oldLineNum++; - newLineNum++; - } - } else { - // Show head context. - for (let j = 0; j < showAtStart; j++) { - const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); - output.push(` ${lineNum} ${raw[j]}`); - oldLineNum++; - newLineNum++; - } + if (raw.length <= showAtStart + showAtEnd) { + // Block is small enough — show it entirely. + for (const line of raw) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + } else { + // Show head context. + for (let j = 0; j < showAtStart; j++) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${raw[j]}`); + oldLineNum++; + newLineNum++; + } - // Collapse the middle. - const skipped = raw.length - showAtStart - showAtEnd; - if (skipped > 0) { - output.push(` ${"".padStart(lineNumWidth, " ")} ...`); - oldLineNum += skipped; - newLineNum += skipped; - } + // Collapse the middle. + const skipped = raw.length - showAtStart - showAtEnd; + if (skipped > 0) { + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + oldLineNum += skipped; + newLineNum += skipped; + } - // Show tail context. - for (let j = raw.length - showAtEnd; j < raw.length; j++) { - const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); - output.push(` ${lineNum} ${raw[j]}`); - oldLineNum++; - newLineNum++; - } - } - } else { - oldLineNum += raw.length; - newLineNum += raw.length; - } + // Show tail context. + for (let j = raw.length - showAtEnd; j < raw.length; j++) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${raw[j]}`); + oldLineNum++; + newLineNum++; + } + } + } else { + oldLineNum += raw.length; + newLineNum += raw.length; + } - lastWasChange = false; - } - } + lastWasChange = false; + } + } - return { diff: output.join("\n"), firstChangedLine }; + return { diff: output.join("\n"), firstChangedLine }; } interface Workspace { - readText: (absolutePath: string) => Promise; - writeText: (absolutePath: string, content: string) => Promise; - deleteFile: (absolutePath: string) => Promise; - exists: (absolutePath: string) => Promise; - /** Check that the file is writable. Rejects if not. No-op on virtual workspaces. */ - checkWriteAccess: (absolutePath: string) => Promise; + readText: (absolutePath: string) => Promise; + writeText: (absolutePath: string, content: string) => Promise; + deleteFile: (absolutePath: string) => Promise; + exists: (absolutePath: string) => Promise; + /** Check that the file is writable. Rejects if not. No-op on virtual workspaces. */ + checkWriteAccess: (absolutePath: string) => Promise; } function normalizeToLF(text: string): string { - return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } function resolvePatchPath(cwd: string, filePath: string): string { - const trimmed = filePath.trim(); - if (!trimmed) { - throw new Error("Patch path cannot be empty"); - } - return isAbsolute(trimmed) ? resolvePath(trimmed) : resolvePath(cwd, trimmed); + const trimmed = filePath.trim(); + if (!trimmed) { + throw new Error("Patch path cannot be empty"); + } + return isAbsolute(trimmed) ? resolvePath(trimmed) : resolvePath(cwd, trimmed); } function ensureTrailingNewline(content: string): string { - return content.endsWith("\n") ? content : `${content}\n`; + return content.endsWith("\n") ? content : `${content}\n`; } function normaliseLineForFuzzyMatch(s: string): string { - return s - .trim() - .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-") - .replace(/[\u2018\u2019\u201A\u201B]/g, "'") - .replace(/[\u201C\u201D\u201E\u201F]/g, '"') - .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " "); + return s + .trim() + .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-") + .replace(/[\u2018\u2019\u201A\u201B]/g, "'") + .replace(/[\u201C\u201D\u201E\u201F]/g, '"') + .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " "); } -function seekSequence(lines: string[], pattern: string[], start: number, eof: boolean): number | undefined { - if (pattern.length === 0) return start; - if (pattern.length > lines.length) return undefined; +function seekSequence( + lines: string[], + pattern: string[], + start: number, + eof: boolean, +): number | undefined { + if (pattern.length === 0) return start; + if (pattern.length > lines.length) return undefined; - const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start; - const searchEnd = lines.length - pattern.length; + const searchStart = + eof && lines.length >= pattern.length + ? lines.length - pattern.length + : start; + const searchEnd = lines.length - pattern.length; - const exactEqual = (a: string, b: string) => a === b; - const rstripEqual = (a: string, b: string) => a.trimEnd() === b.trimEnd(); - const trimEqual = (a: string, b: string) => a.trim() === b.trim(); - const fuzzyEqual = (a: string, b: string) => normaliseLineForFuzzyMatch(a) === normaliseLineForFuzzyMatch(b); + const exactEqual = (a: string, b: string) => a === b; + const rstripEqual = (a: string, b: string) => a.trimEnd() === b.trimEnd(); + const trimEqual = (a: string, b: string) => a.trim() === b.trim(); + const fuzzyEqual = (a: string, b: string) => + normaliseLineForFuzzyMatch(a) === normaliseLineForFuzzyMatch(b); - const passes = [exactEqual, rstripEqual, trimEqual, fuzzyEqual]; + const passes = [exactEqual, rstripEqual, trimEqual, fuzzyEqual]; - for (const eq of passes) { - for (let i = searchStart; i <= searchEnd; i++) { - let ok = true; - for (let p = 0; p < pattern.length; p++) { - if (!eq(lines[i + p], pattern[p])) { - ok = false; - break; - } - } - if (ok) return i; - } - } + for (const eq of passes) { + for (let i = searchStart; i <= searchEnd; i++) { + let ok = true; + for (let p = 0; p < pattern.length; p++) { + if (!eq(lines[i + p], pattern[p])) { + ok = false; + break; + } + } + if (ok) return i; + } + } - return undefined; + return undefined; } -function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] { - const next = [...lines]; +function applyReplacements( + lines: string[], + replacements: Array<[number, number, string[]]>, +): string[] { + const next = [...lines]; - for (const [start, oldLen, newSegment] of [...replacements].sort((a, b) => b[0] - a[0])) { - next.splice(start, oldLen, ...newSegment); - } + for (const [start, oldLen, newSegment] of [...replacements].sort( + (a, b) => b[0] - a[0], + )) { + next.splice(start, oldLen, ...newSegment); + } - return next; + return next; } -function deriveUpdatedContent(filePath: string, currentContent: string, chunks: UpdateChunk[]): string { - const originalLines = currentContent.split("\n"); - if (originalLines[originalLines.length - 1] === "") { - originalLines.pop(); - } +function deriveUpdatedContent( + filePath: string, + currentContent: string, + chunks: UpdateChunk[], +): string { + const originalLines = currentContent.split("\n"); + if (originalLines[originalLines.length - 1] === "") { + originalLines.pop(); + } - const replacements: Array<[number, number, string[]]> = []; - let lineIndex = 0; + const replacements: Array<[number, number, string[]]> = []; + let lineIndex = 0; - for (const chunk of chunks) { - if (chunk.changeContext !== undefined) { - const ctxIndex = seekSequence(originalLines, [chunk.changeContext], lineIndex, false); - if (ctxIndex === undefined) { - throw new Error(`Failed to find context '${chunk.changeContext}' in ${filePath}`); - } - lineIndex = ctxIndex + 1; - } + for (const chunk of chunks) { + if (chunk.changeContext !== undefined) { + const ctxIndex = seekSequence( + originalLines, + [chunk.changeContext], + lineIndex, + false, + ); + if (ctxIndex === undefined) { + throw new Error( + `Failed to find context '${chunk.changeContext}' in ${filePath}`, + ); + } + lineIndex = ctxIndex + 1; + } - if (chunk.oldLines.length === 0) { - replacements.push([originalLines.length, 0, [...chunk.newLines]]); - continue; - } + if (chunk.oldLines.length === 0) { + replacements.push([originalLines.length, 0, [...chunk.newLines]]); + continue; + } - let pattern = chunk.oldLines; - let newSlice = chunk.newLines; + let pattern = chunk.oldLines; + let newSlice = chunk.newLines; - let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile); - if (found === undefined && pattern[pattern.length - 1] === "") { - pattern = pattern.slice(0, -1); - if (newSlice[newSlice.length - 1] === "") { - newSlice = newSlice.slice(0, -1); - } - found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile); - } + let found = seekSequence( + originalLines, + pattern, + lineIndex, + chunk.isEndOfFile, + ); + if (found === undefined && pattern[pattern.length - 1] === "") { + pattern = pattern.slice(0, -1); + if (newSlice[newSlice.length - 1] === "") { + newSlice = newSlice.slice(0, -1); + } + found = seekSequence( + originalLines, + pattern, + lineIndex, + chunk.isEndOfFile, + ); + } - if (found === undefined) { - throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.oldLines.join("\n")}`); - } + if (found === undefined) { + throw new Error( + `Failed to find expected lines in ${filePath}:\n${chunk.oldLines.join("\n")}`, + ); + } - replacements.push([found, pattern.length, [...newSlice]]); - lineIndex = found + pattern.length; - } + replacements.push([found, pattern.length, [...newSlice]]); + lineIndex = found + pattern.length; + } - const newLines = applyReplacements(originalLines, replacements); - if (newLines[newLines.length - 1] !== "") { - newLines.push(""); - } - return newLines.join("\n"); + const newLines = applyReplacements(originalLines, replacements); + if (newLines[newLines.length - 1] !== "") { + newLines.push(""); + } + return newLines.join("\n"); } function parseUpdateChunk( - lines: string[], - startIndex: number, - lastContentLine: number, - allowMissingContext: boolean, + lines: string[], + startIndex: number, + lastContentLine: number, + allowMissingContext: boolean, ): { chunk: UpdateChunk; nextIndex: number } { - let i = startIndex; - let changeContext: string | undefined; - const first = lines[i].trimEnd(); + let i = startIndex; + let changeContext: string | undefined; + const first = lines[i].trimEnd(); - if (first === "@@") { - i++; - } else if (first.startsWith("@@ ")) { - changeContext = first.slice(3); - i++; - } else if (!allowMissingContext) { - throw new Error(`Expected update hunk to start with @@ context marker, got: '${lines[i]}'`); - } + if (first === "@@") { + i++; + } else if (first.startsWith("@@ ")) { + changeContext = first.slice(3); + i++; + } else if (!allowMissingContext) { + throw new Error( + `Expected update hunk to start with @@ context marker, got: '${lines[i]}'`, + ); + } - const oldLines: string[] = []; - const newLines: string[] = []; - let parsed = 0; - let isEndOfFile = false; + const oldLines: string[] = []; + const newLines: string[] = []; + let parsed = 0; + let isEndOfFile = false; - while (i <= lastContentLine) { - const raw = lines[i]; - const trimmed = raw.trimEnd(); + while (i <= lastContentLine) { + const raw = lines[i]; + const trimmed = raw.trimEnd(); - if (trimmed === "*** End of File") { - if (parsed === 0) { - throw new Error("Update hunk does not contain any lines"); - } - isEndOfFile = true; - i++; - break; - } + if (trimmed === "*** End of File") { + if (parsed === 0) { + throw new Error("Update hunk does not contain any lines"); + } + isEndOfFile = true; + i++; + break; + } - if (parsed > 0 && (trimmed.startsWith("@@") || trimmed.startsWith("*** "))) { - break; - } + if ( + parsed > 0 && + (trimmed.startsWith("@@") || trimmed.startsWith("*** ")) + ) { + break; + } - if (raw.length === 0) { - oldLines.push(""); - newLines.push(""); - parsed++; - i++; - continue; - } + if (raw.length === 0) { + oldLines.push(""); + newLines.push(""); + parsed++; + i++; + continue; + } - const marker = raw[0]; - const body = raw.slice(1); - if (marker === " ") { - oldLines.push(body); - newLines.push(body); - } else if (marker === "-") { - oldLines.push(body); - } else if (marker === "+") { - newLines.push(body); - } else if (parsed === 0) { - throw new Error( - `Unexpected line found in update hunk: '${raw}'. Every line should start with ' ', '+', or '-'.`, - ); - } else { - break; - } + const marker = raw[0]; + const body = raw.slice(1); + if (marker === " ") { + oldLines.push(body); + newLines.push(body); + } else if (marker === "-") { + oldLines.push(body); + } else if (marker === "+") { + newLines.push(body); + } else if (parsed === 0) { + throw new Error( + `Unexpected line found in update hunk: '${raw}'. Every line should start with ' ', '+', or '-'.`, + ); + } else { + break; + } - parsed++; - i++; - } + parsed++; + i++; + } - if (parsed === 0) { - throw new Error("Update hunk does not contain any lines"); - } + if (parsed === 0) { + throw new Error("Update hunk does not contain any lines"); + } - return { - chunk: { changeContext, oldLines, newLines, isEndOfFile }, - nextIndex: i, - }; + return { + chunk: { changeContext, oldLines, newLines, isEndOfFile }, + nextIndex: i, + }; } function parsePatch(patchText: string): PatchOperation[] { - const lines = normalizeToLF(patchText).trim().split("\n"); - if (lines.length < 2) { - throw new Error("Patch is empty or invalid"); - } - if (lines[0].trim() !== "*** Begin Patch") { - throw new Error("The first line of the patch must be '*** Begin Patch'"); - } - if (lines[lines.length - 1].trim() !== "*** End Patch") { - throw new Error("The last line of the patch must be '*** End Patch'"); - } + const lines = normalizeToLF(patchText).trim().split("\n"); + if (lines.length < 2) { + throw new Error("Patch is empty or invalid"); + } + if (lines[0].trim() !== "*** Begin Patch") { + throw new Error("The first line of the patch must be '*** Begin Patch'"); + } + if (lines[lines.length - 1].trim() !== "*** End Patch") { + throw new Error("The last line of the patch must be '*** End Patch'"); + } - const operations: PatchOperation[] = []; - let i = 1; - const lastContentLine = lines.length - 2; + const operations: PatchOperation[] = []; + let i = 1; + const lastContentLine = lines.length - 2; - while (i <= lastContentLine) { - if (lines[i].trim() === "") { - i++; - continue; - } + while (i <= lastContentLine) { + if (lines[i].trim() === "") { + i++; + continue; + } - const line = lines[i].trim(); - if (line.startsWith("*** Add File: ")) { - const path = line.slice("*** Add File: ".length); - i++; - const contentLines: string[] = []; - while (i <= lastContentLine) { - const next = lines[i]; - if (next.trim().startsWith("*** ")) break; - if (!next.startsWith("+")) { - throw new Error(`Invalid add-file line '${next}'. Add file lines must start with '+'`); - } - contentLines.push(next.slice(1)); - i++; - } - operations.push({ kind: "add", path, contents: contentLines.length > 0 ? `${contentLines.join("\n")}\n` : "" }); - continue; - } + const line = lines[i].trim(); + if (line.startsWith("*** Add File: ")) { + const path = line.slice("*** Add File: ".length); + i++; + const contentLines: string[] = []; + while (i <= lastContentLine) { + const next = lines[i]; + if (next.trim().startsWith("*** ")) break; + if (!next.startsWith("+")) { + throw new Error( + `Invalid add-file line '${next}'. Add file lines must start with '+'`, + ); + } + contentLines.push(next.slice(1)); + i++; + } + operations.push({ + kind: "add", + path, + contents: contentLines.length > 0 ? `${contentLines.join("\n")}\n` : "", + }); + continue; + } - if (line.startsWith("*** Delete File: ")) { - const path = line.slice("*** Delete File: ".length); - operations.push({ kind: "delete", path }); - i++; - continue; - } + if (line.startsWith("*** Delete File: ")) { + const path = line.slice("*** Delete File: ".length); + operations.push({ kind: "delete", path }); + i++; + continue; + } - if (line.startsWith("*** Update File: ")) { - const path = line.slice("*** Update File: ".length); - i++; + if (line.startsWith("*** Update File: ")) { + const path = line.slice("*** Update File: ".length); + i++; - if (i <= lastContentLine && lines[i].trim().startsWith("*** Move to: ")) { - throw new Error("Patch move operations (*** Move to:) are not supported."); - } + if (i <= lastContentLine && lines[i].trim().startsWith("*** Move to: ")) { + throw new Error( + "Patch move operations (*** Move to:) are not supported.", + ); + } - const chunks: UpdateChunk[] = []; - while (i <= lastContentLine) { - if (lines[i].trim() === "") { - i++; - continue; - } - if (lines[i].trim().startsWith("*** ")) { - break; - } + const chunks: UpdateChunk[] = []; + while (i <= lastContentLine) { + if (lines[i].trim() === "") { + i++; + continue; + } + if (lines[i].trim().startsWith("*** ")) { + break; + } - const parsed = parseUpdateChunk(lines, i, lastContentLine, chunks.length === 0); - chunks.push(parsed.chunk); - i = parsed.nextIndex; - } + const parsed = parseUpdateChunk( + lines, + i, + lastContentLine, + chunks.length === 0, + ); + chunks.push(parsed.chunk); + i = parsed.nextIndex; + } - if (chunks.length === 0) { - throw new Error(`Update file hunk for path '${path}' is empty`); - } + if (chunks.length === 0) { + throw new Error(`Update file hunk for path '${path}' is empty`); + } - operations.push({ kind: "update", path, chunks }); - continue; - } + operations.push({ kind: "update", path, chunks }); + continue; + } - throw new Error( - `'${line}' is not a valid hunk header. Valid headers: '*** Add File:', '*** Delete File:', '*** Update File:'`, - ); - } + throw new Error( + `'${line}' is not a valid hunk header. Valid headers: '*** Add File:', '*** Delete File:', '*** Update File:'`, + ); + } - return operations; + return operations; } function createRealWorkspace(): Workspace { - return { - readText: (absolutePath: string) => fsReadFile(absolutePath, "utf-8"), - writeText: (absolutePath: string, content: string) => fsWriteFile(absolutePath, content, "utf-8"), - deleteFile: (absolutePath: string) => fsUnlink(absolutePath), - exists: async (absolutePath: string) => { - try { - await fsAccess(absolutePath, constants.F_OK); - return true; - } catch { - return false; - } - }, - checkWriteAccess: (absolutePath: string) => fsAccess(absolutePath, constants.R_OK | constants.W_OK), - }; + return { + readText: (absolutePath: string) => fsReadFile(absolutePath, "utf-8"), + writeText: (absolutePath: string, content: string) => + fsWriteFile(absolutePath, content, "utf-8"), + deleteFile: (absolutePath: string) => fsUnlink(absolutePath), + exists: async (absolutePath: string) => { + try { + await fsAccess(absolutePath, constants.F_OK); + return true; + } catch { + return false; + } + }, + checkWriteAccess: (absolutePath: string) => + fsAccess(absolutePath, constants.R_OK | constants.W_OK), + }; } function createVirtualWorkspace(cwd: string): Workspace { - const state = new Map(); + const state = new Map(); - async function ensureLoaded(absolutePath: string): Promise { - if (state.has(absolutePath)) return; - try { - const content = await fsReadFile(absolutePath, "utf-8"); - state.set(absolutePath, content); - } catch { - state.set(absolutePath, null); - } - } + async function ensureLoaded(absolutePath: string): Promise { + if (state.has(absolutePath)) return; + try { + const content = await fsReadFile(absolutePath, "utf-8"); + state.set(absolutePath, content); + } catch { + state.set(absolutePath, null); + } + } - return { - readText: async (absolutePath) => { - await ensureLoaded(absolutePath); - const content = state.get(absolutePath); - if (content === null || content === undefined) { - throw new Error(`File not found: ${absolutePath.replace(`${cwd}/`, "")}`); - } - return content; - }, - writeText: async (absolutePath, content) => { - state.set(absolutePath, content); - }, - deleteFile: async (absolutePath) => { - await ensureLoaded(absolutePath); - if (state.get(absolutePath) === null) { - throw new Error(`File not found: ${absolutePath.replace(`${cwd}/`, "")}`); - } - state.set(absolutePath, null); - }, - exists: async (absolutePath) => { - await ensureLoaded(absolutePath); - return state.get(absolutePath) !== null; - }, - checkWriteAccess: async () => { - // No-op for virtual workspace — permission checks happen on the real pass. - }, - }; + return { + readText: async (absolutePath) => { + await ensureLoaded(absolutePath); + const content = state.get(absolutePath); + if (content === null || content === undefined) { + throw new Error( + `File not found: ${absolutePath.replace(`${cwd}/`, "")}`, + ); + } + return content; + }, + writeText: async (absolutePath, content) => { + state.set(absolutePath, content); + }, + deleteFile: async (absolutePath) => { + await ensureLoaded(absolutePath); + if (state.get(absolutePath) === null) { + throw new Error( + `File not found: ${absolutePath.replace(`${cwd}/`, "")}`, + ); + } + state.set(absolutePath, null); + }, + exists: async (absolutePath) => { + await ensureLoaded(absolutePath); + return state.get(absolutePath) !== null; + }, + checkWriteAccess: async () => { + // No-op for virtual workspace — permission checks happen on the real pass. + }, + }; } async function applyPatchOperations( - ops: PatchOperation[], - workspace: Workspace, - cwd: string, - signal?: AbortSignal, - options?: { collectDiff?: boolean }, + ops: PatchOperation[], + workspace: Workspace, + cwd: string, + signal?: AbortSignal, + options?: { collectDiff?: boolean }, ): Promise { - const results: PatchOpResult[] = []; - const collectDiff = options?.collectDiff ?? false; + const results: PatchOpResult[] = []; + const collectDiff = options?.collectDiff ?? false; - for (const op of ops) { - if (signal?.aborted) { - throw new Error("Operation aborted"); - } + for (const op of ops) { + if (signal?.aborted) { + throw new Error("Operation aborted"); + } - if (op.kind === "add") { - const abs = resolvePatchPath(cwd, op.path); - let oldText = ""; - if (collectDiff && (await workspace.exists(abs))) { - oldText = await workspace.readText(abs); - } - const newText = ensureTrailingNewline(op.contents); - await workspace.writeText(abs, newText); - const result: PatchOpResult = { path: op.path, message: `Added file ${op.path}.` }; - if (collectDiff) { - const diffResult = generateDiffString(oldText, newText); - result.diff = diffResult.diff; - result.firstChangedLine = diffResult.firstChangedLine; - } - results.push(result); - continue; - } + if (op.kind === "add") { + const abs = resolvePatchPath(cwd, op.path); + let oldText = ""; + if (collectDiff && (await workspace.exists(abs))) { + oldText = await workspace.readText(abs); + } + const newText = ensureTrailingNewline(op.contents); + await workspace.writeText(abs, newText); + const result: PatchOpResult = { + path: op.path, + message: `Added file ${op.path}.`, + }; + if (collectDiff) { + const diffResult = generateDiffString(oldText, newText); + result.diff = diffResult.diff; + result.firstChangedLine = diffResult.firstChangedLine; + } + results.push(result); + continue; + } - if (op.kind === "delete") { - const abs = resolvePatchPath(cwd, op.path); - const exists = await workspace.exists(abs); - if (!exists) { - throw new Error(`Failed to delete ${op.path}: file does not exist`); - } - let oldText = ""; - if (collectDiff) { - oldText = await workspace.readText(abs); - } - await workspace.deleteFile(abs); - const result: PatchOpResult = { path: op.path, message: `Deleted file ${op.path}.` }; - if (collectDiff) { - const diffResult = generateDiffString(oldText, ""); - result.diff = diffResult.diff; - result.firstChangedLine = diffResult.firstChangedLine; - } - results.push(result); - continue; - } + if (op.kind === "delete") { + const abs = resolvePatchPath(cwd, op.path); + const exists = await workspace.exists(abs); + if (!exists) { + throw new Error(`Failed to delete ${op.path}: file does not exist`); + } + let oldText = ""; + if (collectDiff) { + oldText = await workspace.readText(abs); + } + await workspace.deleteFile(abs); + const result: PatchOpResult = { + path: op.path, + message: `Deleted file ${op.path}.`, + }; + if (collectDiff) { + const diffResult = generateDiffString(oldText, ""); + result.diff = diffResult.diff; + result.firstChangedLine = diffResult.firstChangedLine; + } + results.push(result); + continue; + } - const sourceAbs = resolvePatchPath(cwd, op.path); - const sourceText = await workspace.readText(sourceAbs); - const updated = deriveUpdatedContent(op.path, sourceText, op.chunks); + const sourceAbs = resolvePatchPath(cwd, op.path); + const sourceText = await workspace.readText(sourceAbs); + const updated = deriveUpdatedContent(op.path, sourceText, op.chunks); - await workspace.writeText(sourceAbs, updated); - const result: PatchOpResult = { path: op.path, message: `Updated ${op.path}.` }; - if (collectDiff) { - const diffResult = generateDiffString(sourceText, updated); - result.diff = diffResult.diff; - result.firstChangedLine = diffResult.firstChangedLine; - } - results.push(result); - } + await workspace.writeText(sourceAbs, updated); + const result: PatchOpResult = { + path: op.path, + message: `Updated ${op.path}.`, + }; + if (collectDiff) { + const diffResult = generateDiffString(sourceText, updated); + result.diff = diffResult.diff; + result.firstChangedLine = diffResult.firstChangedLine; + } + results.push(result); + } - return results; + return results; } /** @@ -607,120 +706,128 @@ * duplicate oldText snippets are disambiguated by position. */ async function applyClassicEdits( - edits: EditItem[], - workspace: Workspace, - cwd: string, - signal?: AbortSignal, - options?: { collectDiff?: boolean }, + edits: EditItem[], + workspace: Workspace, + cwd: string, + signal?: AbortSignal, + options?: { collectDiff?: boolean }, ): Promise { - const collectDiff = options?.collectDiff ?? false; + const collectDiff = options?.collectDiff ?? false; - // Group edits by resolved absolute path, preserving order. - const fileGroups = new Map(); - const editOrder: string[] = []; // track insertion order of keys + // Group edits by resolved absolute path, preserving order. + const fileGroups = new Map(); + const editOrder: string[] = []; // track insertion order of keys - for (let i = 0; i < edits.length; i++) { - const abs = isAbsolute(edits[i].path) ? resolvePath(edits[i].path) : resolvePath(cwd, edits[i].path); - if (!fileGroups.has(abs)) { - fileGroups.set(abs, []); - editOrder.push(abs); - } - fileGroups.get(abs)!.push({ index: i, edit: edits[i] }); - } + for (let i = 0; i < edits.length; i++) { + const abs = isAbsolute(edits[i].path) + ? resolvePath(edits[i].path) + : resolvePath(cwd, edits[i].path); + if (!fileGroups.has(abs)) { + fileGroups.set(abs, []); + editOrder.push(abs); + } + fileGroups.get(abs)!.push({ index: i, edit: edits[i] }); + } - const results: EditResult[] = new Array(edits.length); + const results: EditResult[] = new Array(edits.length); - // Verify write access to all target files before mutating anything. - for (const absPath of editOrder) { - await workspace.checkWriteAccess(absPath); - } + // Verify write access to all target files before mutating anything. + for (const absPath of editOrder) { + await workspace.checkWriteAccess(absPath); + } - for (const absPath of editOrder) { - const group = fileGroups.get(absPath)!; + for (const absPath of editOrder) { + const group = fileGroups.get(absPath)!; - if (signal?.aborted) { - throw new Error("Operation aborted"); - } + if (signal?.aborted) { + throw new Error("Operation aborted"); + } - const originalContent = await workspace.readText(absPath); + const originalContent = await workspace.readText(absPath); - // Sort same-file edits by their position in the original content so that - // the forward cursor always works, regardless of the order the model - // listed them. Edits whose oldText is not found sort to the end and - // will produce an error during the apply loop below. - if (group.length > 1) { - const positions = new Map<{ index: number; edit: EditItem }, number>(); - for (const entry of group) { - const pos = originalContent.indexOf(entry.edit.oldText); - positions.set(entry, pos === -1 ? Number.MAX_SAFE_INTEGER : pos); - } - group.sort((a, b) => positions.get(a)! - positions.get(b)!); - } + // Sort same-file edits by their position in the original content so that + // the forward cursor always works, regardless of the order the model + // listed them. Edits whose oldText is not found sort to the end and + // will produce an error during the apply loop below. + if (group.length > 1) { + const positions = new Map<{ index: number; edit: EditItem }, number>(); + for (const entry of group) { + const pos = originalContent.indexOf(entry.edit.oldText); + positions.set(entry, pos === -1 ? Number.MAX_SAFE_INTEGER : pos); + } + group.sort((a, b) => positions.get(a)! - positions.get(b)!); + } - let content = originalContent; - let searchOffset = 0; + let content = originalContent; + let searchOffset = 0; - // Track successfully applied oldText→newText pairs in this file so we - // can detect redundant duplicate edits (model listed more replacements - // than actual occurrences). - const appliedPairs = new Set(); + // Track successfully applied oldText→newText pairs in this file so we + // can detect redundant duplicate edits (model listed more replacements + // than actual occurrences). + const appliedPairs = new Set(); - for (const { index, edit } of group) { - if (signal?.aborted) { - throw new Error("Operation aborted"); - } + for (const { index, edit } of group) { + if (signal?.aborted) { + throw new Error("Operation aborted"); + } - // Find oldText starting from the cursor position (positional ordering). - const pos = content.indexOf(edit.oldText, searchOffset); + // Find oldText starting from the cursor position (positional ordering). + const pos = content.indexOf(edit.oldText, searchOffset); - if (pos === -1) { - // If the exact same oldText→newText pair was already applied in - // this file, the model likely just over-counted occurrences. - // Skip gracefully instead of aborting the entire batch. - const pairKey = `${edit.oldText}\0${edit.newText}`; - if (appliedPairs.has(pairKey)) { - results[index] = { - path: edit.path, - success: true, - message: `Skipped redundant edit in ${edit.path} (already replaced all occurrences).`, - }; - continue; - } + if (pos === -1) { + // If the exact same oldText→newText pair was already applied in + // this file, the model likely just over-counted occurrences. + // Skip gracefully instead of aborting the entire batch. + const pairKey = `${edit.oldText}\0${edit.newText}`; + if (appliedPairs.has(pairKey)) { + results[index] = { + path: edit.path, + success: true, + message: `Skipped redundant edit in ${edit.path} (already replaced all occurrences).`, + }; + continue; + } - results[index] = { - path: edit.path, - success: false, - message: `Could not find the exact text in ${edit.path}. The old text must match exactly including all whitespace and newlines.`, - }; - // Fill remaining edits in this group as skipped. - const filled = Array.from({ length: edits.length }, (_, i) => results[i]).filter(Boolean); - throw new Error(formatResults(filled, edits.length)); - } + results[index] = { + path: edit.path, + success: false, + message: `Could not find the exact text in ${edit.path}. The old text must match exactly including all whitespace and newlines.`, + }; + // Fill remaining edits in this group as skipped. + const filled = Array.from( + { length: edits.length }, + (_, i) => results[i], + ).filter(Boolean); + throw new Error(formatResults(filled, edits.length)); + } - content = content.slice(0, pos) + edit.newText + content.slice(pos + edit.oldText.length); - searchOffset = pos + edit.newText.length; - appliedPairs.add(`${edit.oldText}\0${edit.newText}`); + content = + content.slice(0, pos) + + edit.newText + + content.slice(pos + edit.oldText.length); + searchOffset = pos + edit.newText.length; + appliedPairs.add(`${edit.oldText}\0${edit.newText}`); - results[index] = { - path: edit.path, - success: true, - message: `Edited ${edit.path}.`, - }; - } + results[index] = { + path: edit.path, + success: true, + message: `Edited ${edit.path}.`, + }; + } - // Write back the fully-edited file. - await workspace.writeText(absPath, content); + // Write back the fully-edited file. + await workspace.writeText(absPath, content); - // Generate a single diff for all edits to this file; attach to first edit. - if (collectDiff) { - const diffResult = generateDiffString(originalContent, content); - const firstIdx = group[0].index; - results[firstIdx].diff = diffResult.diff; - results[firstIdx].firstChangedLine = diffResult.firstChangedLine; - } - } + // Generate a single diff for all edits to this file; attach to first edit. + if (collectDiff) { + const diffResult = generateDiffString(originalContent, content); + const firstIdx = group[0].index; + results[firstIdx].diff = diffResult.diff; + results[firstIdx].firstChangedLine = diffResult.firstChangedLine; + } + } - return results; + return results; } // ── Diff rendering helpers (merged from edit-diff-lines) ────────────────── @@ -732,370 +839,470 @@ const STRIP_ANSI = /\x1b\[[0-9;]*m/g; function parseDiffLine(line: string) { - const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/); - if (!match) return null; - return { prefix: match[1], lineNum: match[2], content: match[3] }; + const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/); + if (!match) return null; + return { prefix: match[1], lineNum: match[2], content: match[3] }; } function collectDiffLines(lines: string[], i: number, prefix: string) { - const collected: { lineNum: string; content: string }[] = []; - while (i < lines.length) { - const p = parseDiffLine(lines[i]); - if (!p || p.prefix !== prefix) break; - collected.push({ lineNum: p.lineNum, content: p.content }); - i++; - } - return { collected, i }; + const collected: { lineNum: string; content: string }[] = []; + while (i < lines.length) { + const p = parseDiffLine(lines[i]); + if (!p || p.prefix !== prefix) break; + collected.push({ lineNum: p.lineNum, content: p.content }); + i++; + } + return { collected, i }; } function applyBgToRange( - ansiStr: string, - start: number, - end: number, - bg: string, - restoreBg: string, + ansiStr: string, + start: number, + end: number, + bg: string, + restoreBg: string, ): string { - if (!ansiStr || start >= end || end <= 0) return ansiStr; - const rangeStart = Math.max(0, start); - const rangeEnd = Math.max(rangeStart, end); - let result = ""; - let visIdx = 0; - let i = 0; - let inRange = false; - while (i < ansiStr.length) { - if (ansiStr[i] === "\x1b") { - const escEnd = ansiStr.indexOf("m", i); - if (escEnd !== -1) { - result += ansiStr.slice(i, escEnd + 1); - i = escEnd + 1; - continue; - } - } - if (visIdx === rangeStart && !inRange) { result += bg; inRange = true; } - if (visIdx === rangeEnd && inRange) { result += restoreBg; inRange = false; } - result += ansiStr[i]; - visIdx++; - i++; - } - if (inRange) result += restoreBg; - return result; + if (!ansiStr || start >= end || end <= 0) return ansiStr; + const rangeStart = Math.max(0, start); + const rangeEnd = Math.max(rangeStart, end); + let result = ""; + let visIdx = 0; + let i = 0; + let inRange = false; + while (i < ansiStr.length) { + if (ansiStr[i] === "\x1b") { + const escEnd = ansiStr.indexOf("m", i); + if (escEnd !== -1) { + result += ansiStr.slice(i, escEnd + 1); + i = escEnd + 1; + continue; + } + } + if (visIdx === rangeStart && !inRange) { + result += bg; + inRange = true; + } + if (visIdx === rangeEnd && inRange) { + result += restoreBg; + inRange = false; + } + result += ansiStr[i]; + visIdx++; + i++; + } + if (inRange) result += restoreBg; + return result; } function highlightLine(content: string, lang?: string): string { - if (!lang) return content; - const lines = highlightCode(content, lang); - return lines[0] ?? content; + if (!lang) return content; + const lines = highlightCode(content, lang); + return lines[0] ?? content; } function renderIntraLineDiff( - oldPlain: string, - newPlain: string, - oldHighlighted: string, - newHighlighted: string, - removedWordBg: string, - addedWordBg: string, - removedLineBg: string, - addedLineBg: string, + oldPlain: string, + newPlain: string, + oldHighlighted: string, + newHighlighted: string, + removedWordBg: string, + addedWordBg: string, + removedLineBg: string, + addedLineBg: string, ) { - const parts = Diff.diffWords(oldPlain, newPlain); - let oldPos = 0; - let newPos = 0; - let removedLine = oldHighlighted; - let addedLine = newHighlighted; - const removedRanges: { start: number; end: number }[] = []; - const addedRanges: { start: number; end: number }[] = []; - const addChangedRange = ( - ranges: { start: number; end: number }[], - pos: number, - value: string, - skipLeadingWhitespace: boolean, - ) => { - const len = value.length; - const wsLen = skipLeadingWhitespace ? (value.match(/^(\s*)/)?.[1] || "").length : 0; - if (len > wsLen) ranges.push({ start: pos + wsLen, end: pos + len }); - return pos + len; - }; - for (const part of parts) { - const isFirst = removedRanges.length === 0 && addedRanges.length === 0; - if (part.removed) { oldPos = addChangedRange(removedRanges, oldPos, part.value, isFirst); } - else if (part.added) { newPos = addChangedRange(addedRanges, newPos, part.value, isFirst); } - else { oldPos += part.value.length; newPos += part.value.length; } - } - for (let r = removedRanges.length - 1; r >= 0; r--) { - removedLine = applyBgToRange(removedLine, removedRanges[r].start, removedRanges[r].end, removedWordBg, removedLineBg); - } - for (let r = addedRanges.length - 1; r >= 0; r--) { - addedLine = applyBgToRange(addedLine, addedRanges[r].start, addedRanges[r].end, addedWordBg, addedLineBg); - } - return { removedLine, addedLine }; + const parts = Diff.diffWords(oldPlain, newPlain); + let oldPos = 0; + let newPos = 0; + let removedLine = oldHighlighted; + let addedLine = newHighlighted; + const removedRanges: { start: number; end: number }[] = []; + const addedRanges: { start: number; end: number }[] = []; + const addChangedRange = ( + ranges: { start: number; end: number }[], + pos: number, + value: string, + skipLeadingWhitespace: boolean, + ) => { + const len = value.length; + const wsLen = skipLeadingWhitespace + ? (value.match(/^(\s*)/)?.[1] || "").length + : 0; + if (len > wsLen) ranges.push({ start: pos + wsLen, end: pos + len }); + return pos + len; + }; + for (const part of parts) { + const isFirst = removedRanges.length === 0 && addedRanges.length === 0; + if (part.removed) { + oldPos = addChangedRange(removedRanges, oldPos, part.value, isFirst); + } else if (part.added) { + newPos = addChangedRange(addedRanges, newPos, part.value, isFirst); + } else { + oldPos += part.value.length; + newPos += part.value.length; + } + } + for (let r = removedRanges.length - 1; r >= 0; r--) { + removedLine = applyBgToRange( + removedLine, + removedRanges[r].start, + removedRanges[r].end, + removedWordBg, + removedLineBg, + ); + } + for (let r = addedRanges.length - 1; r >= 0; r--) { + addedLine = applyBgToRange( + addedLine, + addedRanges[r].start, + addedRanges[r].end, + addedWordBg, + addedLineBg, + ); + } + return { removedLine, addedLine }; } function fmtDiffLine( - theme: Theme, - color: "toolDiffRemoved" | "toolDiffAdded" | "toolDiffContext", - prefix: string, - lineNum: string, - content: string, + theme: Theme, + color: "toolDiffRemoved" | "toolDiffAdded" | "toolDiffContext", + prefix: string, + lineNum: string, + content: string, ) { - return theme.fg(color, `${prefix}${lineNum} `) + content; + return theme.fg(color, `${prefix}${lineNum} `) + content; } function renderDiff(diffText: string, theme: Theme, lang?: string): string { - const lines = diffText.split("\n"); - const tabs = (s: string) => s.replace(/\t/g, " "); - const hl = (plain: string) => highlightLine(plain, lang); - const result: string[] = []; - let i = 0; - while (i < lines.length) { - const parsed = parseDiffLine(lines[i]); - if (!parsed) { result.push(theme.fg("toolDiffContext", lines[i])); i++; continue; } - if (parsed.prefix === "-") { - const rem = collectDiffLines(lines, i, "-"); - const add = collectDiffLines(lines, rem.i, "+"); - i = add.i; - if (rem.collected.length === 1 && add.collected.length === 1) { - const r = rem.collected[0], a = add.collected[0]; - const rPlain = tabs(r.content), aPlain = tabs(a.content); - const { removedLine, addedLine } = renderIntraLineDiff( - rPlain, aPlain, hl(rPlain), hl(aPlain), - REMOVED_WORD_BG, ADDED_WORD_BG, REMOVED_LINE_BG, ADDED_LINE_BG, - ); - result.push(fmtDiffLine(theme, "toolDiffRemoved", "-", r.lineNum, removedLine)); - result.push(fmtDiffLine(theme, "toolDiffAdded", "+", a.lineNum, addedLine)); - } else { - for (const r of rem.collected) result.push(fmtDiffLine(theme, "toolDiffRemoved", "-", r.lineNum, hl(tabs(r.content)))); - for (const a of add.collected) result.push(fmtDiffLine(theme, "toolDiffAdded", "+", a.lineNum, hl(tabs(a.content)))); - } - } else if (parsed.prefix === "+") { - result.push(fmtDiffLine(theme, "toolDiffAdded", "+", parsed.lineNum, hl(tabs(parsed.content)))); - i++; - } else { - result.push(fmtDiffLine(theme, "toolDiffContext", " ", parsed.lineNum, hl(tabs(parsed.content)))); - i++; - } - } - return result.join("\n"); + const lines = diffText.split("\n"); + const tabs = (s: string) => s.replace(/\t/g, " "); + const hl = (plain: string) => highlightLine(plain, lang); + const result: string[] = []; + let i = 0; + while (i < lines.length) { + const parsed = parseDiffLine(lines[i]); + if (!parsed) { + result.push(theme.fg("toolDiffContext", lines[i])); + i++; + continue; + } + if (parsed.prefix === "-") { + const rem = collectDiffLines(lines, i, "-"); + const add = collectDiffLines(lines, rem.i, "+"); + i = add.i; + if (rem.collected.length === 1 && add.collected.length === 1) { + const r = rem.collected[0], + a = add.collected[0]; + const rPlain = tabs(r.content), + aPlain = tabs(a.content); + const { removedLine, addedLine } = renderIntraLineDiff( + rPlain, + aPlain, + hl(rPlain), + hl(aPlain), + REMOVED_WORD_BG, + ADDED_WORD_BG, + REMOVED_LINE_BG, + ADDED_LINE_BG, + ); + result.push( + fmtDiffLine(theme, "toolDiffRemoved", "-", r.lineNum, removedLine), + ); + result.push( + fmtDiffLine(theme, "toolDiffAdded", "+", a.lineNum, addedLine), + ); + } else { + for (const r of rem.collected) + result.push( + fmtDiffLine( + theme, + "toolDiffRemoved", + "-", + r.lineNum, + hl(tabs(r.content)), + ), + ); + for (const a of add.collected) + result.push( + fmtDiffLine( + theme, + "toolDiffAdded", + "+", + a.lineNum, + hl(tabs(a.content)), + ), + ); + } + } else if (parsed.prefix === "+") { + result.push( + fmtDiffLine( + theme, + "toolDiffAdded", + "+", + parsed.lineNum, + hl(tabs(parsed.content)), + ), + ); + i++; + } else { + result.push( + fmtDiffLine( + theme, + "toolDiffContext", + " ", + parsed.lineNum, + hl(tabs(parsed.content)), + ), + ); + i++; + } + } + return result.join("\n"); } class DiffText { - private text: string; - private boxBg: string; - private borderAnsi: string; - private cachedWidth: number | undefined; - private cachedLines: string[] | undefined; - constructor(text: string, boxBg: string, borderAnsi: string) { - this.text = text; - this.boxBg = boxBg; - this.borderAnsi = borderAnsi; - } - invalidate() { this.cachedWidth = undefined; this.cachedLines = undefined; } - render(width: number): string[] { - if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; - const sep = this.borderAnsi + "─".repeat(width) + "\x1b[39m"; - const lines = this.text.split("\n").map((line) => { - const raw = line.replace(STRIP_ANSI, ""); - if (raw.startsWith("+") || raw.startsWith("-")) { - const bg = raw.startsWith("+") ? ADDED_LINE_BG : REMOVED_LINE_BG; - const truncated = truncateToWidth(line, width); - const pad = Math.max(0, width - visibleWidth(truncated)); - return `${bg}${truncated}${" ".repeat(pad)}${this.boxBg}`; - } - return truncateToWidth(line, width); - }); - this.cachedWidth = width; - this.cachedLines = [sep, ...lines, sep]; - return this.cachedLines; - } + private text: string; + private boxBg: string; + private borderAnsi: string; + private cachedWidth: number | undefined; + private cachedLines: string[] | undefined; + constructor(text: string, boxBg: string, borderAnsi: string) { + this.text = text; + this.boxBg = boxBg; + this.borderAnsi = borderAnsi; + } + invalidate() { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; + const sep = this.borderAnsi + "─".repeat(width) + "\x1b[39m"; + const lines = this.text.split("\n").map((line) => { + const raw = line.replace(STRIP_ANSI, ""); + if (raw.startsWith("+") || raw.startsWith("-")) { + const bg = raw.startsWith("+") ? ADDED_LINE_BG : REMOVED_LINE_BG; + const truncated = truncateToWidth(line, width); + const pad = Math.max(0, width - visibleWidth(truncated)); + return `${bg}${truncated}${" ".repeat(pad)}${this.boxBg}`; + } + return truncateToWidth(line, width); + }); + this.cachedWidth = width; + this.cachedLines = [sep, ...lines, sep]; + return this.cachedLines; + } } function shortenPath(path: string): string { - const home = homedir(); - return path.startsWith(home) ? `~${path.slice(home.length)}` : path; + const home = homedir(); + return path.startsWith(home) ? `~${path.slice(home.length)}` : path; } - -// ── Extension entry point ────────────────────────────────────────────── - export default function (pi: ExtensionAPI) { - let lastEditPath: string | undefined; + pi.registerTool({ + name: "edit", + label: "edit", + description: + "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits. Supports a `multi` parameter for batch edits across one or more files, and a `patch` parameter for Codex-style patches.", + promptSnippet: + "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.", + promptGuidelines: [ + "Use edit for precise changes (old text must match exactly)", + "Use the `multi` parameter to apply multiple edits in a single tool call", + "Use the `patch` parameter for Codex-style multi-file / hunk-based edits", + ], + parameters: multiEditSchema, - pi.registerTool({ - name: "edit", - label: "edit", - description: - "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits. Supports a `multi` parameter for batch edits across one or more files, and a `patch` parameter for Codex-style patches.", - promptSnippet: - "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.", - promptGuidelines: [ - "Use edit for precise changes (old text must match exactly)", - "Use the `multi` parameter to apply multiple edits in a single tool call", - "Use the `patch` parameter for Codex-style multi-file / hunk-based edits", - ], - parameters: multiEditSchema, + async execute(toolCallId, params, signal, onUpdate, ctx) { + const { path, oldText, newText, multi, patch } = params; - renderCall(args, theme) { - const rawPath = args?.path as string | undefined; - lastEditPath = rawPath; - const path = rawPath ? shortenPath(rawPath.replace(/^@/, "")) : "..."; - const display = rawPath - ? theme.fg("accent", path) - : theme.fg("toolOutput", "..."); - const title = `${theme.fg("toolTitle", theme.bold("edit"))} ${display}`; - return { - invalidate() {}, - render(_width: number) { return [title]; }, - } as any; - }, + const hasAnyClassicParam = + path !== undefined || + oldText !== undefined || + newText !== undefined || + multi !== undefined; + if (patch !== undefined && hasAnyClassicParam) { + throw new Error( + "The `patch` parameter is mutually exclusive with path/oldText/newText/multi.", + ); + } - renderResult(result, { isPartial }, theme) { - const { details, isError } = result as { details?: EditToolDetails; isError?: boolean }; - if (isPartial) return new Text(theme.fg("warning", "Editing..."), 0, 0); - const text = result.content - ?.filter((c: any) => c.type === "text") - .map((c: any) => c.text) - .join("\n") ?? ""; - if (isError || !details?.diff) { - return new Text(isError ? theme.fg("error", text) : text, 0, 0); - } - const lang = lastEditPath - ? getLanguageFromPath(lastEditPath.replace(/^@/, "")) - : undefined; - const rendered = renderDiff(details.diff, theme, lang); - const boxBg = theme.getBgAnsi("toolSuccessBg"); - const borderAnsi = theme.getFgAnsi("borderMuted"); - return new DiffText(rendered, boxBg, borderAnsi) as any; - }, + if (patch !== undefined) { + const ops = parsePatch(patch); - async execute(toolCallId, params, signal, onUpdate, ctx) { - const { path, oldText, newText, multi, patch } = params; + // Preflight on virtual filesystem before mutating real files. + await applyPatchOperations( + ops, + createVirtualWorkspace(ctx.cwd), + ctx.cwd, + signal, + { collectDiff: false }, + ); - const hasAnyClassicParam = path !== undefined || oldText !== undefined || newText !== undefined || multi !== undefined; - if (patch !== undefined && hasAnyClassicParam) { - throw new Error("The `patch` parameter is mutually exclusive with path/oldText/newText/multi."); - } + // Apply for real. + const applied = await applyPatchOperations( + ops, + createRealWorkspace(), + ctx.cwd, + signal, + { collectDiff: true }, + ); + const summary = applied + .map((r, i) => `${i + 1}. ${r.message}`) + .join("\n"); + const combinedDiff = applied + .filter((r) => r.diff) + .map((r) => `File: ${r.path}\n${r.diff}`) + .join("\n\n"); + const firstChangedLine = applied.find( + (r) => r.firstChangedLine !== undefined, + )?.firstChangedLine; + return { + content: [ + { + type: "text" as const, + text: `Applied patch with ${applied.length} operation(s).\n${summary}`, + }, + ], + details: { + diff: combinedDiff, + firstChangedLine, + }, + }; + } - if (patch !== undefined) { - const ops = parsePatch(patch); + // Build classic edit list. + const edits: EditItem[] = []; + const hasTopLevel = + path !== undefined && oldText !== undefined && newText !== undefined; - // Preflight on virtual filesystem before mutating real files. - await applyPatchOperations(ops, createVirtualWorkspace(ctx.cwd), ctx.cwd, signal, { collectDiff: false }); + if (hasTopLevel) { + edits.push({ path: path!, oldText: oldText!, newText: newText! }); + } else if ( + path !== undefined || + oldText !== undefined || + newText !== undefined + ) { + // When multi is present, only a bare top-level `path` (for inheritance) is allowed. + // Any other partial combination (e.g. path+oldText, oldText+newText) is an error. + const hasOnlyPath = + path !== undefined && oldText === undefined && newText === undefined; + if (!hasOnlyPath || multi === undefined) { + const missing: string[] = []; + if (path === undefined) missing.push("path"); + if (oldText === undefined) missing.push("oldText"); + if (newText === undefined) missing.push("newText"); + throw new Error( + `Incomplete top-level edit: missing ${missing.join(", ")}. Provide all three (path, oldText, newText) or use only the multi parameter.`, + ); + } + // path-only top-level with multi is fine — path is inherited below. + } - // Apply for real. - const applied = await applyPatchOperations(ops, createRealWorkspace(), ctx.cwd, signal, { collectDiff: true }); - const summary = applied.map((r, i) => `${i + 1}. ${r.message}`).join("\n"); - const combinedDiff = applied - .filter((r) => r.diff) - .map((r) => `File: ${r.path}\n${r.diff}`) - .join("\n\n"); - const firstChangedLine = applied.find((r) => r.firstChangedLine !== undefined)?.firstChangedLine; - return { - content: [{ type: "text" as const, text: `Applied patch with ${applied.length} operation(s).\n${summary}` }], - details: { - diff: combinedDiff, - firstChangedLine, - }, - }; - } + if (multi) { + for (const item of multi) { + edits.push({ + path: item.path ?? path ?? "", + oldText: item.oldText, + newText: item.newText, + }); + } + } - // Build classic edit list. - const edits: EditItem[] = []; - const hasTopLevel = path !== undefined && oldText !== undefined && newText !== undefined; + if (edits.length === 0) { + throw new Error( + "No edits provided. Supply path/oldText/newText, a multi array, or a patch.", + ); + } - if (hasTopLevel) { - edits.push({ path: path!, oldText: oldText!, newText: newText! }); - } else if (path !== undefined || oldText !== undefined || newText !== undefined) { - // When multi is present, only a bare top-level `path` (for inheritance) is allowed. - // Any other partial combination (e.g. path+oldText, oldText+newText) is an error. - const hasOnlyPath = path !== undefined && oldText === undefined && newText === undefined; - if (!hasOnlyPath || multi === undefined) { - const missing: string[] = []; - if (path === undefined) missing.push("path"); - if (oldText === undefined) missing.push("oldText"); - if (newText === undefined) missing.push("newText"); - throw new Error( - `Incomplete top-level edit: missing ${missing.join(", ")}. Provide all three (path, oldText, newText) or use only the multi parameter.`, - ); - } - // path-only top-level with multi is fine — path is inherited below. - } + // Validate that every edit has a path. + for (let i = 0; i < edits.length; i++) { + if (!edits[i].path) { + throw new Error( + `Edit ${i + 1} is missing a path. Provide a path on each multi item or set a top-level path to inherit.`, + ); + } + } - if (multi) { - for (const item of multi) { - edits.push({ - path: item.path ?? path ?? "", - oldText: item.oldText, - newText: item.newText, - }); - } - } + // Preflight pass on virtual workspace before mutating real files. + // Uses sequential occurrence matching so same-file edits are resolved + // in file order (positional ordering). + try { + await applyClassicEdits( + edits, + createVirtualWorkspace(ctx.cwd), + ctx.cwd, + signal, + { collectDiff: false }, + ); + } catch (err: any) { + throw new Error( + `Preflight failed before mutating files.\n${err.message ?? String(err)}`, + ); + } - if (edits.length === 0) { - throw new Error("No edits provided. Supply path/oldText/newText, a multi array, or a patch."); - } + // Apply for real. + const results = await applyClassicEdits( + edits, + createRealWorkspace(), + ctx.cwd, + signal, + { collectDiff: true }, + ); - // Validate that every edit has a path. - for (let i = 0; i < edits.length; i++) { - if (!edits[i].path) { - throw new Error( - `Edit ${i + 1} is missing a path. Provide a path on each multi item or set a top-level path to inherit.`, - ); - } - } + if (results.length === 1) { + const r = results[0]; + return { + content: [{ type: "text" as const, text: r.message }], + details: { + diff: r.diff ?? "", + firstChangedLine: r.firstChangedLine, + }, + }; + } - // Preflight pass on virtual workspace before mutating real files. - // Uses sequential occurrence matching so same-file edits are resolved - // in file order (positional ordering). - try { - await applyClassicEdits(edits, createVirtualWorkspace(ctx.cwd), ctx.cwd, signal, { collectDiff: false }); - } catch (err: any) { - throw new Error(`Preflight failed before mutating files.\n${err.message ?? String(err)}`); - } + const combinedDiff = results + .filter((r) => r.diff) + .map((r) => r.diff) + .join("\n"); - // Apply for real. - const results = await applyClassicEdits(edits, createRealWorkspace(), ctx.cwd, signal, { collectDiff: true }); + const firstChanged = results.find( + (r) => r.firstChangedLine !== undefined, + )?.firstChangedLine; + const summary = results + .map((r, i) => `${i + 1}. ${r.message}`) + .join("\n"); - if (results.length === 1) { - const r = results[0]; - return { - content: [{ type: "text" as const, text: r.message }], - details: { - diff: r.diff ?? "", - firstChangedLine: r.firstChangedLine, - }, - }; - } - - const combinedDiff = results - .filter((r) => r.diff) - .map((r) => r.diff) - .join("\n"); - - const firstChanged = results.find((r) => r.firstChangedLine !== undefined)?.firstChangedLine; - const summary = results.map((r, i) => `${i + 1}. ${r.message}`).join("\n"); - - return { - content: [{ type: "text" as const, text: `Applied ${results.length} edit(s) successfully.\n${summary}` }], - details: { - diff: combinedDiff, - firstChangedLine: firstChanged, - }, - }; - }, - }); + return { + content: [ + { + type: "text" as const, + text: `Applied ${results.length} edit(s) successfully.\n${summary}`, + }, + ], + details: { + diff: combinedDiff, + firstChangedLine: firstChanged, + }, + }; + }, + }); } function formatResults(results: EditResult[], totalEdits: number): string { - const lines: string[] = []; + const lines: string[] = []; - for (let i = 0; i < results.length; i++) { - const r = results[i]; - const status = r.success ? "✓" : "✗"; - lines.push(`${status} Edit ${i + 1}/${totalEdits} (${r.path}): ${r.message}`); - } + for (let i = 0; i < results.length; i++) { + const r = results[i]; + const status = r.success ? "✓" : "✗"; + lines.push( + `${status} Edit ${i + 1}/${totalEdits} (${r.path}): ${r.message}`, + ); + } - const remaining = totalEdits - results.length; - if (remaining > 0) { - lines.push(`⊘ ${remaining} remaining edit(s) skipped due to error.`); - } + const remaining = totalEdits - results.length; + if (remaining > 0) { + lines.push(`⊘ ${remaining} remaining edit(s) skipped due to error.`); + } - return lines.join("\n"); + return lines.join("\n"); } diff --git a/dot_pi/agent/extensions/notify/index.ts b/dot_pi/agent/extensions/notify/index.ts --- a/dot_pi/agent/extensions/notify/index.ts +++ b/dot_pi/agent/extensions/notify/index.ts @@ -1,3 +1,5 @@ +// Source: mitsuhiko/agent-stuff (https://github.com/mitsuhiko/agent-stuff) +// Path: extensions/notify.ts /** * Desktop Notification Extension * diff --git a/dot_pi/agent/extensions/pi-fff/index.ts b/dot_pi/agent/extensions/pi-fff/index.ts --- a/dot_pi/agent/extensions/pi-fff/index.ts +++ b/dot_pi/agent/extensions/pi-fff/index.ts @@ -1,1 +1,3 @@ +// Source: ShpetimA/pi-fff (https://github.com/ShpetimA/pi-fff) +// Path: . export { default } from "./src/index.ts"; diff --git a/dot_pi/agent/extensions/prompt-editor/index.ts b/dot_pi/agent/extensions/prompt-editor/index.ts --- a/dot_pi/agent/extensions/prompt-editor/index.ts +++ b/dot_pi/agent/extensions/prompt-editor/index.ts @@ -1,3 +1,5 @@ +// Source: mitsuhiko/agent-stuff (https://github.com/mitsuhiko/agent-stuff) +// Path: extensions/prompt-editor.ts /** * Prompt Editor - Mode-based model and thinking level switching * @@ -5,8 +7,17 @@ * its own provider, model, and thinking level. Modes are persisted and * the editor border color reflects the current thinking level. */ -import type { ExtensionAPI, ExtensionContext, ModelSelectEvent, ThinkingLevel } from "@mariozechner/pi-coding-agent"; -import { CustomEditor, ModelSelectorComponent, SettingsManager } from "@mariozechner/pi-coding-agent"; +import type { + ExtensionAPI, + ExtensionContext, + ModelSelectEvent, + ThinkingLevel, +} from "@mariozechner/pi-coding-agent"; +import { + CustomEditor, + ModelSelectorComponent, + SettingsManager, +} from "@mariozechner/pi-coding-agent"; import path from "node:path"; import os from "node:os"; import fs from "node:fs/promises"; @@ -19,20 +30,20 @@ type ModeName = string; type ModeSpec = { - provider?: string; - modelId?: string; - thinkingLevel?: ThinkingLevel; - /** - * Optional theme color token to use for the editor border. - * If unset, the border color is derived from the (current) thinking level. - */ - color?: string; + provider?: string; + modelId?: string; + thinkingLevel?: ThinkingLevel; + /** + * Optional theme color token to use for the editor border. + * If unset, the border color is derived from the (current) thinking level. + */ + color?: string; }; type ModesFile = { - version: 1; - currentMode: ModeName; - modes: Record; + version: 1; + currentMode: ModeName; + modes: Record; }; // Only "default" is a forced/built-in mode. Others are just initial suggestions and can be renamed/deleted. @@ -40,480 +51,551 @@ const CUSTOM_MODE_NAME = "custom" as const; function expandUserPath(p: string): string { - if (p === "~") return os.homedir(); - if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); - return p; + if (p === "~") return os.homedir(); + if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); + return p; } function getGlobalAgentDir(): string { - // Mirror pi-coding-agent's getAgentDir() behavior (best-effort). - // For the canonical implementation see pi-mono/packages/coding-agent/src/config.ts - const env = process.env.PI_CODING_AGENT_DIR; - if (env) return expandUserPath(env); - return path.join(os.homedir(), ".pi", "agent"); + // Mirror pi-coding-agent's getAgentDir() behavior (best-effort). + // For the canonical implementation see pi-mono/packages/coding-agent/src/config.ts + const env = process.env.PI_CODING_AGENT_DIR; + if (env) return expandUserPath(env); + return path.join(os.homedir(), ".pi", "agent"); } function getGlobalModesPath(): string { - return path.join(getGlobalAgentDir(), "modes.json"); + return path.join(getGlobalAgentDir(), "modes.json"); } function getProjectModesPath(cwd: string): string { - return path.join(cwd, ".pi", "modes.json"); + return path.join(cwd, ".pi", "modes.json"); } async function fileExists(p: string): Promise { - try { - await fs.stat(p); - return true; - } catch { - return false; - } + try { + await fs.stat(p); + return true; + } catch { + return false; + } } async function ensureDirForFile(filePath: string): Promise { - await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.mkdir(path.dirname(filePath), { recursive: true }); } async function getMtimeMs(p: string): Promise { - try { - const st = await fs.stat(p); - return st.mtimeMs; - } catch { - return null; - } + try { + const st = await fs.stat(p); + return st.mtimeMs; + } catch { + return null; + } } function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } function getLockPathForFile(filePath: string): string { - // Lock file next to the json so it works across processes. - return `${filePath}.lock`; + // Lock file next to the json so it works across processes. + return `${filePath}.lock`; } -async function withFileLock(filePath: string, fn: () => Promise): Promise { - const lockPath = getLockPathForFile(filePath); - await ensureDirForFile(lockPath); +async function withFileLock( + filePath: string, + fn: () => Promise, +): Promise { + const lockPath = getLockPathForFile(filePath); + await ensureDirForFile(lockPath); - const start = Date.now(); - while (true) { - try { - const handle = await fs.open(lockPath, "wx"); - try { - // Best-effort metadata for debugging stale locks. - await handle.writeFile( - JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }) + "\n", - "utf8" - ); - } catch { - // ignore - } + const start = Date.now(); + while (true) { + try { + const handle = await fs.open(lockPath, "wx"); + try { + // Best-effort metadata for debugging stale locks. + await handle.writeFile( + JSON.stringify({ + pid: process.pid, + createdAt: new Date().toISOString(), + }) + "\n", + "utf8", + ); + } catch { + // ignore + } - try { - return await fn(); - } finally { - await handle.close().catch(() => {}); - await fs.unlink(lockPath).catch(() => {}); - } - } catch (err: any) { - if (err?.code !== "EEXIST") throw err; + try { + return await fn(); + } finally { + await handle.close().catch(() => {}); + await fs.unlink(lockPath).catch(() => {}); + } + } catch (err: any) { + if (err?.code !== "EEXIST") throw err; - // If the lock looks stale (crash), break it. - try { - const st = await fs.stat(lockPath); - if (Date.now() - st.mtimeMs > 30_000) { - await fs.unlink(lockPath); - continue; - } - } catch { - // ignore - } + // If the lock looks stale (crash), break it. + try { + const st = await fs.stat(lockPath); + if (Date.now() - st.mtimeMs > 30_000) { + await fs.unlink(lockPath); + continue; + } + } catch { + // ignore + } - if (Date.now() - start > 5_000) { - // Don't hang the UI forever. - throw new Error(`Timed out waiting for lock: ${lockPath}`); - } - await sleep(40 + Math.random() * 80); - } - } + if (Date.now() - start > 5_000) { + // Don't hang the UI forever. + throw new Error(`Timed out waiting for lock: ${lockPath}`); + } + await sleep(40 + Math.random() * 80); + } + } } -async function atomicWriteUtf8(filePath: string, content: string): Promise { - await ensureDirForFile(filePath); +async function atomicWriteUtf8( + filePath: string, + content: string, +): Promise { + await ensureDirForFile(filePath); - const dir = path.dirname(filePath); - const base = path.basename(filePath); - const tmpPath = path.join(dir, `.${base}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`); + const dir = path.dirname(filePath); + const base = path.basename(filePath); + const tmpPath = path.join( + dir, + `.${base}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`, + ); - await fs.writeFile(tmpPath, content, "utf8"); + await fs.writeFile(tmpPath, content, "utf8"); - try { - // POSIX: atomic replace. - await fs.rename(tmpPath, filePath); - } catch (err: any) { - // Windows: rename can't overwrite. - if (err?.code === "EEXIST" || err?.code === "EPERM") { - await fs.unlink(filePath).catch(() => {}); - await fs.rename(tmpPath, filePath); - } else { - // best-effort cleanup - await fs.unlink(tmpPath).catch(() => {}); - throw err; - } - } + try { + // POSIX: atomic replace. + await fs.rename(tmpPath, filePath); + } catch (err: any) { + // Windows: rename can't overwrite. + if (err?.code === "EEXIST" || err?.code === "EPERM") { + await fs.unlink(filePath).catch(() => {}); + await fs.rename(tmpPath, filePath); + } else { + // best-effort cleanup + await fs.unlink(tmpPath).catch(() => {}); + throw err; + } + } } function cloneModesFile(file: ModesFile): ModesFile { - // JSON-based clone is fine here (small, plain data structure). - return JSON.parse(JSON.stringify(file)) as ModesFile; + // JSON-based clone is fine here (small, plain data structure). + return JSON.parse(JSON.stringify(file)) as ModesFile; } type ModeSpecPatch = { - provider?: string | null; - modelId?: string | null; - thinkingLevel?: ThinkingLevel | null; - color?: string | null; + provider?: string | null; + modelId?: string | null; + thinkingLevel?: ThinkingLevel | null; + color?: string | null; }; type ModesPatch = { - currentMode?: ModeName; - modes?: Record; + currentMode?: ModeName; + modes?: Record; }; -function computeModesPatch(base: ModesFile, next: ModesFile, includeCurrentMode: boolean): ModesPatch | null { - const patch: ModesPatch = {}; +function computeModesPatch( + base: ModesFile, + next: ModesFile, + includeCurrentMode: boolean, +): ModesPatch | null { + const patch: ModesPatch = {}; - if (includeCurrentMode && base.currentMode !== next.currentMode) { - patch.currentMode = next.currentMode; - } + if (includeCurrentMode && base.currentMode !== next.currentMode) { + patch.currentMode = next.currentMode; + } - const keys = new Set([...Object.keys(base.modes), ...Object.keys(next.modes)]); - const modesPatch: Record = {}; + const keys = new Set([ + ...Object.keys(base.modes), + ...Object.keys(next.modes), + ]); + const modesPatch: Record = {}; - for (const k of keys) { - const a = base.modes[k]; - const b = next.modes[k]; + for (const k of keys) { + const a = base.modes[k]; + const b = next.modes[k]; - if (!b) { - if (a) modesPatch[k] = null; - continue; - } - if (!a) { - modesPatch[k] = { ...b }; - continue; - } + if (!b) { + if (a) modesPatch[k] = null; + continue; + } + if (!a) { + modesPatch[k] = { ...b }; + continue; + } - const diff: ModeSpecPatch = {}; - const fields: (keyof ModeSpec)[] = ["provider", "modelId", "thinkingLevel", "color"]; - for (const f of fields) { - const av = a[f]; - const bv = b[f]; - if (av !== bv) { - (diff as any)[f] = bv === undefined ? null : bv; - } - } - if (Object.keys(diff).length > 0) { - modesPatch[k] = diff; - } - } + const diff: ModeSpecPatch = {}; + const fields: (keyof ModeSpec)[] = [ + "provider", + "modelId", + "thinkingLevel", + "color", + ]; + for (const f of fields) { + const av = a[f]; + const bv = b[f]; + if (av !== bv) { + (diff as any)[f] = bv === undefined ? null : bv; + } + } + if (Object.keys(diff).length > 0) { + modesPatch[k] = diff; + } + } - if (Object.keys(modesPatch).length > 0) { - patch.modes = modesPatch; - } + if (Object.keys(modesPatch).length > 0) { + patch.modes = modesPatch; + } - if (!patch.modes && patch.currentMode === undefined) return null; - return patch; + if (!patch.modes && patch.currentMode === undefined) return null; + return patch; } function applyModesPatch(target: ModesFile, patch: ModesPatch): void { - if (patch.currentMode !== undefined) { - target.currentMode = patch.currentMode; - } + if (patch.currentMode !== undefined) { + target.currentMode = patch.currentMode; + } - if (!patch.modes) return; - for (const [mode, specPatch] of Object.entries(patch.modes)) { - if (specPatch === null) { - delete target.modes[mode]; - continue; - } + if (!patch.modes) return; + for (const [mode, specPatch] of Object.entries(patch.modes)) { + if (specPatch === null) { + delete target.modes[mode]; + continue; + } - const targetSpec: Record = ((target.modes[mode] ??= {}) as any) ?? {}; - for (const [k, v] of Object.entries(specPatch)) { - if (v === null || v === undefined) { - delete targetSpec[k]; - } else { - targetSpec[k] = v; - } - } - } + const targetSpec: Record = + ((target.modes[mode] ??= {}) as any) ?? {}; + for (const [k, v] of Object.entries(specPatch)) { + if (v === null || v === undefined) { + delete targetSpec[k]; + } else { + targetSpec[k] = v; + } + } + } } function normalizeThinkingLevel(level: unknown): ThinkingLevel | undefined { - if (typeof level !== "string") return undefined; - const v = level as ThinkingLevel; - // Keep the list local to avoid importing internal enums. - const allowed: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; - return allowed.includes(v) ? v : undefined; + if (typeof level !== "string") return undefined; + const v = level as ThinkingLevel; + // Keep the list local to avoid importing internal enums. + const allowed: ThinkingLevel[] = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + ]; + return allowed.includes(v) ? v : undefined; } function sanitizeModeSpec(spec: unknown): ModeSpec { - const obj = (spec && typeof spec === "object" ? spec : {}) as Record; - return { - provider: typeof obj.provider === "string" ? obj.provider : undefined, - modelId: typeof obj.modelId === "string" ? obj.modelId : undefined, - thinkingLevel: normalizeThinkingLevel(obj.thinkingLevel), - color: typeof obj.color === "string" ? obj.color : undefined, - }; + const obj = (spec && typeof spec === "object" ? spec : {}) as Record< + string, + unknown + >; + return { + provider: typeof obj.provider === "string" ? obj.provider : undefined, + modelId: typeof obj.modelId === "string" ? obj.modelId : undefined, + thinkingLevel: normalizeThinkingLevel(obj.thinkingLevel), + color: typeof obj.color === "string" ? obj.color : undefined, + }; } -function createDefaultModes(ctx: ExtensionContext, pi: ExtensionAPI): ModesFile { - const currentModel = ctx.model; - const currentThinking = pi.getThinkingLevel(); +function createDefaultModes( + ctx: ExtensionContext, + pi: ExtensionAPI, +): ModesFile { + const currentModel = ctx.model; + const currentThinking = pi.getThinkingLevel(); - const base: ModeSpec = { - provider: currentModel?.provider, - modelId: currentModel?.id, - thinkingLevel: currentThinking, - }; + const base: ModeSpec = { + provider: currentModel?.provider, + modelId: currentModel?.id, + thinkingLevel: currentThinking, + }; - return { - version: 1, - currentMode: "default", - modes: { - // Forced default mode - default: { ...base }, - // Convenience mode (user can delete/rename) - fast: { ...base, thinkingLevel: "off" }, - }, - }; + return { + version: 1, + currentMode: "default", + modes: { + // Forced default mode + default: { ...base }, + // Convenience mode (user can delete/rename) + fast: { ...base, thinkingLevel: "off" }, + }, + }; } -function ensureDefaultModeEntries(file: ModesFile, ctx: ExtensionContext, pi: ExtensionAPI): void { - for (const name of DEFAULT_MODE_ORDER) { - if (!file.modes[name]) { - const defaults = createDefaultModes(ctx, pi); - file.modes[name] = defaults.modes[name]; - } - } +function ensureDefaultModeEntries( + file: ModesFile, + ctx: ExtensionContext, + pi: ExtensionAPI, +): void { + for (const name of DEFAULT_MODE_ORDER) { + if (!file.modes[name]) { + const defaults = createDefaultModes(ctx, pi); + file.modes[name] = defaults.modes[name]; + } + } - // "custom" is an overlay mode; never treat it as a valid persisted current mode. - if (file.currentMode === CUSTOM_MODE_NAME) { - file.currentMode = "" as any; - } + // "custom" is an overlay mode; never treat it as a valid persisted current mode. + if (file.currentMode === CUSTOM_MODE_NAME) { + file.currentMode = "" as any; + } - if (!file.currentMode || !(file.currentMode in file.modes) || file.currentMode === CUSTOM_MODE_NAME) { - const first = Object.keys(file.modes).find((k) => k !== CUSTOM_MODE_NAME); - file.currentMode = file.modes.default ? "default" : first || "default"; - } + if ( + !file.currentMode || + !(file.currentMode in file.modes) || + file.currentMode === CUSTOM_MODE_NAME + ) { + const first = Object.keys(file.modes).find((k) => k !== CUSTOM_MODE_NAME); + file.currentMode = file.modes.default ? "default" : first || "default"; + } } -async function loadModesFile(filePath: string, ctx: ExtensionContext, pi: ExtensionAPI): Promise { - try { - const raw = await fs.readFile(filePath, "utf8"); - const parsed = JSON.parse(raw) as Record; - const currentMode = typeof parsed.currentMode === "string" ? parsed.currentMode : "default"; - const modesRaw = parsed.modes && typeof parsed.modes === "object" ? (parsed.modes as Record) : {}; - const modes: Record = {}; - for (const [k, v] of Object.entries(modesRaw)) { - modes[k] = sanitizeModeSpec(v); - } - const file: ModesFile = { - version: 1, - currentMode, - modes, - }; - ensureDefaultModeEntries(file, ctx, pi); - return file; - } catch { - return createDefaultModes(ctx, pi); - } +async function loadModesFile( + filePath: string, + ctx: ExtensionContext, + pi: ExtensionAPI, +): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as Record; + const currentMode = + typeof parsed.currentMode === "string" ? parsed.currentMode : "default"; + const modesRaw = + parsed.modes && typeof parsed.modes === "object" + ? (parsed.modes as Record) + : {}; + const modes: Record = {}; + for (const [k, v] of Object.entries(modesRaw)) { + modes[k] = sanitizeModeSpec(v); + } + const file: ModesFile = { + version: 1, + currentMode, + modes, + }; + ensureDefaultModeEntries(file, ctx, pi); + return file; + } catch { + return createDefaultModes(ctx, pi); + } } async function saveModesFile(filePath: string, data: ModesFile): Promise { - await atomicWriteUtf8(filePath, JSON.stringify(data, null, 2) + "\n"); + await atomicWriteUtf8(filePath, JSON.stringify(data, null, 2) + "\n"); } function orderedModeNames(modes: Record): string[] { - // Preserve insertion order from the JSON file. - // Object key iteration order is stable in modern JS runtimes. - // NOTE: "custom" is an overlay mode and must not be selectable/persisted. - return Object.keys(modes).filter((name) => name !== CUSTOM_MODE_NAME); + // Preserve insertion order from the JSON file. + // Object key iteration order is stable in modern JS runtimes. + // NOTE: "custom" is an overlay mode and must not be selectable/persisted. + return Object.keys(modes).filter((name) => name !== CUSTOM_MODE_NAME); } -function getModeBorderColor(ctx: ExtensionContext, pi: ExtensionAPI, mode: string): (text: string) => string { - const theme = ctx.ui.theme; - const spec = runtime.data.modes[mode]; +function getModeBorderColor( + ctx: ExtensionContext, + pi: ExtensionAPI, + mode: string, +): (text: string) => string { + const theme = ctx.ui.theme; + const spec = runtime.data.modes[mode]; - // Explicit color override in JSON. - if (spec?.color) { - try { - // Validate early so we don't crash during render. - theme.getFgAnsi(spec.color as any); - return (text: string) => theme.fg(spec.color as any, text); - } catch { - // fall through to thinking-based colors - } - } + // Explicit color override in JSON. + if (spec?.color) { + try { + // Validate early so we don't crash during render. + theme.getFgAnsi(spec.color as any); + return (text: string) => theme.fg(spec.color as any, text); + } catch { + // fall through to thinking-based colors + } + } - // Default: derive from the current thinking level. - return theme.getThinkingBorderColor(pi.getThinkingLevel()); + // Default: derive from the current thinking level. + return theme.getThinkingBorderColor(pi.getThinkingLevel()); } function formatModeLabel(mode: string): string { - return mode; + return mode; } async function resolveModesPath(cwd: string): Promise { - const projectPath = getProjectModesPath(cwd); - if (await fileExists(projectPath)) return projectPath; - return getGlobalModesPath(); + const projectPath = getProjectModesPath(cwd); + if (await fileExists(projectPath)) return projectPath; + return getGlobalModesPath(); } -function inferModeFromSelection(ctx: ExtensionContext, pi: ExtensionAPI, data: ModesFile): string | null { - const provider = ctx.model?.provider; - const modelId = ctx.model?.id; - const thinkingLevel = pi.getThinkingLevel(); - if (!provider || !modelId) return null; +function inferModeFromSelection( + ctx: ExtensionContext, + pi: ExtensionAPI, + data: ModesFile, +): string | null { + const provider = ctx.model?.provider; + const modelId = ctx.model?.id; + const thinkingLevel = pi.getThinkingLevel(); + if (!provider || !modelId) return null; - // Only consider persisted/real modes (exclude the overlay "custom"). - const names = orderedModeNames(data.modes); + // Only consider persisted/real modes (exclude the overlay "custom"). + const names = orderedModeNames(data.modes); - const supportsThinking = Boolean(ctx.model?.reasoning); + const supportsThinking = Boolean(ctx.model?.reasoning); - // 1) If thinking is supported, require an exact match so modes can differ by thinking level. - if (supportsThinking) { - for (const name of names) { - const spec = data.modes[name]; - if (!spec) continue; - if (spec.provider !== provider || spec.modelId !== modelId) continue; - if ((spec.thinkingLevel ?? undefined) !== thinkingLevel) continue; - return name; - } - return null; - } + // 1) If thinking is supported, require an exact match so modes can differ by thinking level. + if (supportsThinking) { + for (const name of names) { + const spec = data.modes[name]; + if (!spec) continue; + if (spec.provider !== provider || spec.modelId !== modelId) continue; + if ((spec.thinkingLevel ?? undefined) !== thinkingLevel) continue; + return name; + } + return null; + } - // 2) If thinking is NOT supported by the model, the effective level will always be "off". - // In that case, treat thinkingLevel differences in modes.json as non-distinguishing. - const candidates: string[] = []; - for (const name of names) { - const spec = data.modes[name]; - if (!spec) continue; - if (spec.provider !== provider || spec.modelId !== modelId) continue; - candidates.push(name); - } - if (candidates.length === 0) return null; + // 2) If thinking is NOT supported by the model, the effective level will always be "off". + // In that case, treat thinkingLevel differences in modes.json as non-distinguishing. + const candidates: string[] = []; + for (const name of names) { + const spec = data.modes[name]; + if (!spec) continue; + if (spec.provider !== provider || spec.modelId !== modelId) continue; + candidates.push(name); + } + if (candidates.length === 0) return null; - // Prefer a candidate that explicitly matches the effective thinking level. - for (const name of candidates) { - const spec = data.modes[name]; - if (!spec) continue; - if ((spec.thinkingLevel ?? "off") === thinkingLevel) return name; - } + // Prefer a candidate that explicitly matches the effective thinking level. + for (const name of candidates) { + const spec = data.modes[name]; + if (!spec) continue; + if ((spec.thinkingLevel ?? "off") === thinkingLevel) return name; + } - // Next prefer a candidate with no thinkingLevel configured. - for (const name of candidates) { - const spec = data.modes[name]; - if (!spec) continue; - if (!spec.thinkingLevel) return name; - } + // Next prefer a candidate with no thinkingLevel configured. + for (const name of candidates) { + const spec = data.modes[name]; + if (!spec) continue; + if (!spec.thinkingLevel) return name; + } - return candidates[0] ?? null; + return candidates[0] ?? null; } type ModeRuntime = { - filePath: string; - fileMtimeMs: number | null; - /** - * Snapshot of what we last loaded/synced from disk. Used to compute patches so - * multiple running pi processes don't clobber each other's mode edits. - */ - baseline: ModesFile | null; - data: ModesFile; + filePath: string; + fileMtimeMs: number | null; + /** + * Snapshot of what we last loaded/synced from disk. Used to compute patches so + * multiple running pi processes don't clobber each other's mode edits. + */ + baseline: ModesFile | null; + data: ModesFile; - /** - * Last non-overlay mode. Used as cycle base while in the overlay "custom" mode. - */ - lastRealMode: string; + /** + * Last non-overlay mode. Used as cycle base while in the overlay "custom" mode. + */ + lastRealMode: string; - /** - * The effective current mode. Can temporarily be "custom" (overlay), - * which is *not* persisted and not selectable via /mode. - */ - currentMode: string; - // guard against feedback loops when we switch model ourselves - applying: boolean; + /** + * The effective current mode. Can temporarily be "custom" (overlay), + * which is *not* persisted and not selectable via /mode. + */ + currentMode: string; + // guard against feedback loops when we switch model ourselves + applying: boolean; }; const runtime: ModeRuntime = { - filePath: "", - fileMtimeMs: null, - baseline: null, - data: { version: 1, currentMode: "default", modes: {} }, - lastRealMode: "default", - currentMode: "default", - applying: false, + filePath: "", + fileMtimeMs: null, + baseline: null, + data: { version: 1, currentMode: "default", modes: {} }, + lastRealMode: "default", + currentMode: "default", + applying: false, }; // Updated by setEditor() when the custom editor is instantiated. let requestEditorRender: (() => void) | undefined; -async function ensureRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise { - const filePath = await resolveModesPath(ctx.cwd); +async function ensureRuntime( + pi: ExtensionAPI, + ctx: ExtensionContext, +): Promise { + const filePath = await resolveModesPath(ctx.cwd); - const mtimeMs = await getMtimeMs(filePath); - const filePathChanged = runtime.filePath !== filePath; - const fileChanged = filePathChanged || runtime.fileMtimeMs !== mtimeMs; + const mtimeMs = await getMtimeMs(filePath); + const filePathChanged = runtime.filePath !== filePath; + const fileChanged = filePathChanged || runtime.fileMtimeMs !== mtimeMs; - if (fileChanged) { - runtime.filePath = filePath; - runtime.fileMtimeMs = mtimeMs; + if (fileChanged) { + runtime.filePath = filePath; + runtime.fileMtimeMs = mtimeMs; - const loaded = await loadModesFile(filePath, ctx, pi); - // Normalize/ensure defaults *before* we snapshot baseline so later persistence - // only reflects explicit user actions ("store"). - ensureDefaultModeEntries(loaded, ctx, pi); - runtime.data = loaded; - runtime.baseline = cloneModesFile(runtime.data); + const loaded = await loadModesFile(filePath, ctx, pi); + // Normalize/ensure defaults *before* we snapshot baseline so later persistence + // only reflects explicit user actions ("store"). + ensureDefaultModeEntries(loaded, ctx, pi); + runtime.data = loaded; + runtime.baseline = cloneModesFile(runtime.data); - // Reset overlay when switching projects. - if (filePathChanged && runtime.currentMode !== CUSTOM_MODE_NAME) { - runtime.currentMode = runtime.data.currentMode; - runtime.lastRealMode = runtime.currentMode; - } - } + // Reset overlay when switching projects. + if (filePathChanged && runtime.currentMode !== CUSTOM_MODE_NAME) { + runtime.currentMode = runtime.data.currentMode; + runtime.lastRealMode = runtime.currentMode; + } + } - // If we're not in the overlay "custom" mode, ensure currentMode is valid. - if (runtime.currentMode !== CUSTOM_MODE_NAME) { - if (!runtime.currentMode || !(runtime.currentMode in runtime.data.modes)) { - runtime.currentMode = runtime.data.currentMode; - } - if (!runtime.lastRealMode || !(runtime.lastRealMode in runtime.data.modes)) { - runtime.lastRealMode = runtime.currentMode; - } - } + // If we're not in the overlay "custom" mode, ensure currentMode is valid. + if (runtime.currentMode !== CUSTOM_MODE_NAME) { + if (!runtime.currentMode || !(runtime.currentMode in runtime.data.modes)) { + runtime.currentMode = runtime.data.currentMode; + } + if ( + !runtime.lastRealMode || + !(runtime.lastRealMode in runtime.data.modes) + ) { + runtime.lastRealMode = runtime.currentMode; + } + } } -async function persistRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise { - if (!runtime.filePath) return; +async function persistRuntime( + pi: ExtensionAPI, + ctx: ExtensionContext, +): Promise { + if (!runtime.filePath) return; - // Do not persist currentMode; multiple running pi sessions would fight over it. - // Instead we infer the mode on startup from the active model + thinking level. - runtime.baseline ??= cloneModesFile(runtime.data); - const patch = computeModesPatch(runtime.baseline, runtime.data, false); - if (!patch) return; + // Do not persist currentMode; multiple running pi sessions would fight over it. + // Instead we infer the mode on startup from the active model + thinking level. + runtime.baseline ??= cloneModesFile(runtime.data); + const patch = computeModesPatch(runtime.baseline, runtime.data, false); + if (!patch) return; - await withFileLock(runtime.filePath, async () => { - // Merge our local patch into the latest on disk to avoid clobbering other agents. - const latest = await loadModesFile(runtime.filePath, ctx, pi); - applyModesPatch(latest, patch); - ensureDefaultModeEntries(latest, ctx, pi); - await saveModesFile(runtime.filePath, latest); + await withFileLock(runtime.filePath, async () => { + // Merge our local patch into the latest on disk to avoid clobbering other agents. + const latest = await loadModesFile(runtime.filePath, ctx, pi); + applyModesPatch(latest, patch); + ensureDefaultModeEntries(latest, ctx, pi); + await saveModesFile(runtime.filePath, latest); - runtime.data = latest; - runtime.baseline = cloneModesFile(latest); - runtime.fileMtimeMs = await getMtimeMs(runtime.filePath); - }); + runtime.data = latest; + runtime.baseline = cloneModesFile(latest); + runtime.fileMtimeMs = await getMtimeMs(runtime.filePath); + }); } // We cannot reliably read the *current* model immediately after pi.setModel() in the same tick, @@ -521,404 +603,494 @@ // Track the last observed model ourselves and use it for overlays / storing. let lastObservedModel: { provider?: string; modelId?: string } = {}; -function getCurrentSelectionSpec(pi: ExtensionAPI, _ctx: ExtensionContext): ModeSpec { - return { - provider: lastObservedModel.provider, - modelId: lastObservedModel.modelId, - thinkingLevel: pi.getThinkingLevel(), - }; +function getCurrentSelectionSpec( + pi: ExtensionAPI, + _ctx: ExtensionContext, +): ModeSpec { + return { + provider: lastObservedModel.provider, + modelId: lastObservedModel.modelId, + thinkingLevel: pi.getThinkingLevel(), + }; } -async function storeSelectionIntoMode(pi: ExtensionAPI, ctx: ExtensionContext, mode: string, selection: ModeSpec): Promise { - // "custom" is an overlay; it is not persisted. - if (mode === CUSTOM_MODE_NAME) return; +async function storeSelectionIntoMode( + pi: ExtensionAPI, + ctx: ExtensionContext, + mode: string, + selection: ModeSpec, +): Promise { + // "custom" is an overlay; it is not persisted. + if (mode === CUSTOM_MODE_NAME) return; - await ensureRuntime(pi, ctx); + await ensureRuntime(pi, ctx); - const existingTarget = runtime.data.modes[mode] ?? {}; - const next: ModeSpec = { ...existingTarget }; + const existingTarget = runtime.data.modes[mode] ?? {}; + const next: ModeSpec = { ...existingTarget }; - // Only overwrite fields that we can actually observe. - if (selection.provider && selection.modelId) { - next.provider = selection.provider; - next.modelId = selection.modelId; - } - if (selection.thinkingLevel) next.thinkingLevel = selection.thinkingLevel; + // Only overwrite fields that we can actually observe. + if (selection.provider && selection.modelId) { + next.provider = selection.provider; + next.modelId = selection.modelId; + } + if (selection.thinkingLevel) next.thinkingLevel = selection.thinkingLevel; - runtime.data.modes[mode] = next; - await persistRuntime(pi, ctx); + runtime.data.modes[mode] = next; + await persistRuntime(pi, ctx); } -async function applyMode(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise { - await ensureRuntime(pi, ctx); +async function applyMode( + pi: ExtensionAPI, + ctx: ExtensionContext, + mode: string, +): Promise { + await ensureRuntime(pi, ctx); - // "custom" is a runtime-only overlay mode. - if (mode === CUSTOM_MODE_NAME) { - runtime.currentMode = CUSTOM_MODE_NAME; - customOverlay = getCurrentSelectionSpec(pi, ctx); - if (ctx.hasUI) requestEditorRender?.(); - return; - } + // "custom" is a runtime-only overlay mode. + if (mode === CUSTOM_MODE_NAME) { + runtime.currentMode = CUSTOM_MODE_NAME; + customOverlay = getCurrentSelectionSpec(pi, ctx); + if (ctx.hasUI) requestEditorRender?.(); + return; + } - const spec = runtime.data.modes[mode]; - if (!spec) { - if (ctx.hasUI) { - ctx.ui.notify(`Unknown mode: ${mode}`, "warning"); - } - return; - } + const spec = runtime.data.modes[mode]; + if (!spec) { + if (ctx.hasUI) { + ctx.ui.notify(`Unknown mode: ${mode}`, "warning"); + } + return; + } - runtime.currentMode = mode; - runtime.lastRealMode = mode; - customOverlay = null; + runtime.currentMode = mode; + runtime.lastRealMode = mode; + customOverlay = null; - runtime.applying = true; - let modelAppliedOk = true; - try { - // Apply model - if (spec.provider && spec.modelId) { - const m = ctx.modelRegistry.find(spec.provider, spec.modelId); - if (m) { - const ok = await pi.setModel(m); - modelAppliedOk = ok; - if (!ok && ctx.hasUI) { - ctx.ui.notify(`No API key available for ${spec.provider}/${spec.modelId}`, "warning"); - } - } else { - modelAppliedOk = false; - if (ctx.hasUI) { - ctx.ui.notify(`Mode "${mode}" references unknown model ${spec.provider}/${spec.modelId}`, "warning"); - } - } - } + runtime.applying = true; + let modelAppliedOk = true; + try { + // Apply model + if (spec.provider && spec.modelId) { + const m = ctx.modelRegistry.find(spec.provider, spec.modelId); + if (m) { + const ok = await pi.setModel(m); + modelAppliedOk = ok; + if (!ok && ctx.hasUI) { + ctx.ui.notify( + `No API key available for ${spec.provider}/${spec.modelId}`, + "warning", + ); + } + } else { + modelAppliedOk = false; + if (ctx.hasUI) { + ctx.ui.notify( + `Mode "${mode}" references unknown model ${spec.provider}/${spec.modelId}`, + "warning", + ); + } + } + } - // Apply thinking level - if (spec.thinkingLevel) { - pi.setThinkingLevel(spec.thinkingLevel); - } - } finally { - runtime.applying = false; - } + // Apply thinking level + if (spec.thinkingLevel) { + pi.setThinkingLevel(spec.thinkingLevel); + } + } finally { + runtime.applying = false; + } - // If we couldn't apply the requested model (e.g. missing API key), switch to overlay. - // We do *not* treat thinking-level clamping as a failure: clamping is expected when - // switching between models with different thinking capabilities. - if (!modelAppliedOk) { - runtime.currentMode = CUSTOM_MODE_NAME; - customOverlay = getCurrentSelectionSpec(pi, ctx); - } + // If we couldn't apply the requested model (e.g. missing API key), switch to overlay. + // We do *not* treat thinking-level clamping as a failure: clamping is expected when + // switching between models with different thinking capabilities. + if (!modelAppliedOk) { + runtime.currentMode = CUSTOM_MODE_NAME; + customOverlay = getCurrentSelectionSpec(pi, ctx); + } - if (ctx.hasUI) { - requestEditorRender?.(); - } + if (ctx.hasUI) { + requestEditorRender?.(); + } } const MODE_UI_CONFIGURE = "Configure modes…"; const MODE_UI_ADD = "Add mode…"; const MODE_UI_BACK = "Back"; -const ALL_THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; +const ALL_THINKING_LEVELS: ThinkingLevel[] = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", +]; const THINKING_UNSET_LABEL = "(don't change)"; function isDefaultModeName(name: string): boolean { - return (DEFAULT_MODE_ORDER as readonly string[]).includes(name); + return (DEFAULT_MODE_ORDER as readonly string[]).includes(name); } function isReservedModeName(name: string): boolean { - return name === CUSTOM_MODE_NAME || name === MODE_UI_CONFIGURE || name === MODE_UI_ADD || name === MODE_UI_BACK; + return ( + name === CUSTOM_MODE_NAME || + name === MODE_UI_CONFIGURE || + name === MODE_UI_ADD || + name === MODE_UI_BACK + ); } function normalizeModeNameInput(name: string | undefined): string { - return (name ?? "").trim(); + return (name ?? "").trim(); } function validateModeNameOrError( - name: string, - existing: Record, - opts?: { allowExisting?: boolean }, + name: string, + existing: Record, + opts?: { allowExisting?: boolean }, ): string | null { - if (!name) return "Mode name cannot be empty"; - if (/\s/.test(name)) return "Mode name cannot contain whitespace"; - if (isReservedModeName(name)) return `Mode name \"${name}\" is reserved`; - if (!opts?.allowExisting && existing[name]) return `Mode \"${name}\" already exists`; - return null; + if (!name) return "Mode name cannot be empty"; + if (/\s/.test(name)) return "Mode name cannot contain whitespace"; + if (isReservedModeName(name)) return `Mode name \"${name}\" is reserved`; + if (!opts?.allowExisting && existing[name]) + return `Mode \"${name}\" already exists`; + return null; } -async function handleModeChoiceUI(pi: ExtensionAPI, ctx: ExtensionContext, choice: string): Promise { - // Special behavior: when we're in "custom" and select another mode, - // offer to either *use* it (switch) or *store* the current custom selection into it. - if (runtime.currentMode === CUSTOM_MODE_NAME && choice !== CUSTOM_MODE_NAME) { - const action = await ctx.ui.select(`Mode \"${choice}\"`, ["use", "store"]); - if (!action) return; +async function handleModeChoiceUI( + pi: ExtensionAPI, + ctx: ExtensionContext, + choice: string, +): Promise { + // Special behavior: when we're in "custom" and select another mode, + // offer to either *use* it (switch) or *store* the current custom selection into it. + if (runtime.currentMode === CUSTOM_MODE_NAME && choice !== CUSTOM_MODE_NAME) { + const action = await ctx.ui.select(`Mode \"${choice}\"`, ["use", "store"]); + if (!action) return; - if (action === "use") { - await applyMode(pi, ctx, choice); - return; - } + if (action === "use") { + await applyMode(pi, ctx, choice); + return; + } - // "store": overwrite target mode with the current overlay selection (keep target color if set) - await ensureRuntime(pi, ctx); - const overlay = customOverlay ?? getCurrentSelectionSpec(pi, ctx); - await storeSelectionIntoMode(pi, ctx, choice, overlay); - await applyMode(pi, ctx, choice); - ctx.ui.notify(`Stored ${CUSTOM_MODE_NAME} into \"${choice}\"`, "info"); - return; - } + // "store": overwrite target mode with the current overlay selection (keep target color if set) + await ensureRuntime(pi, ctx); + const overlay = customOverlay ?? getCurrentSelectionSpec(pi, ctx); + await storeSelectionIntoMode(pi, ctx, choice, overlay); + await applyMode(pi, ctx, choice); + ctx.ui.notify(`Stored ${CUSTOM_MODE_NAME} into \"${choice}\"`, "info"); + return; + } - await applyMode(pi, ctx, choice); + await applyMode(pi, ctx, choice); } -async function selectModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise { - if (!ctx.hasUI) return; +async function selectModeUI( + pi: ExtensionAPI, + ctx: ExtensionContext, +): Promise { + if (!ctx.hasUI) return; - while (true) { - await ensureRuntime(pi, ctx); - const names = orderedModeNames(runtime.data.modes); - const choice = await ctx.ui.select(`Mode (current: ${runtime.currentMode})`, [...names, MODE_UI_CONFIGURE]); - if (!choice) return; + while (true) { + await ensureRuntime(pi, ctx); + const names = orderedModeNames(runtime.data.modes); + const choice = await ctx.ui.select( + `Mode (current: ${runtime.currentMode})`, + [...names, MODE_UI_CONFIGURE], + ); + if (!choice) return; - if (choice === MODE_UI_CONFIGURE) { - await configureModesUI(pi, ctx); - continue; - } + if (choice === MODE_UI_CONFIGURE) { + await configureModesUI(pi, ctx); + continue; + } - await handleModeChoiceUI(pi, ctx, choice); - return; - } + await handleModeChoiceUI(pi, ctx, choice); + return; + } } -async function configureModesUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise { - if (!ctx.hasUI) return; +async function configureModesUI( + pi: ExtensionAPI, + ctx: ExtensionContext, +): Promise { + if (!ctx.hasUI) return; - while (true) { - await ensureRuntime(pi, ctx); - const names = orderedModeNames(runtime.data.modes); - const choice = await ctx.ui.select("Configure modes", [...names, MODE_UI_ADD, MODE_UI_BACK]); - if (!choice || choice === MODE_UI_BACK) return; + while (true) { + await ensureRuntime(pi, ctx); + const names = orderedModeNames(runtime.data.modes); + const choice = await ctx.ui.select("Configure modes", [ + ...names, + MODE_UI_ADD, + MODE_UI_BACK, + ]); + if (!choice || choice === MODE_UI_BACK) return; - if (choice === MODE_UI_ADD) { - const created = await addModeUI(pi, ctx); - if (created) { - await editModeUI(pi, ctx, created); - } - continue; - } + if (choice === MODE_UI_ADD) { + const created = await addModeUI(pi, ctx); + if (created) { + await editModeUI(pi, ctx, created); + } + continue; + } - await editModeUI(pi, ctx, choice); - } + await editModeUI(pi, ctx, choice); + } } -async function addModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise { - if (!ctx.hasUI) return undefined; - await ensureRuntime(pi, ctx); +async function addModeUI( + pi: ExtensionAPI, + ctx: ExtensionContext, +): Promise { + if (!ctx.hasUI) return undefined; + await ensureRuntime(pi, ctx); - while (true) { - const raw = await ctx.ui.input("New mode name", "e.g. docs, review, planning"); - if (raw === undefined) return undefined; + while (true) { + const raw = await ctx.ui.input( + "New mode name", + "e.g. docs, review, planning", + ); + if (raw === undefined) return undefined; - const name = normalizeModeNameInput(raw); - const err = validateModeNameOrError(name, runtime.data.modes); - if (err) { - ctx.ui.notify(err, "warning"); - continue; - } + const name = normalizeModeNameInput(raw); + const err = validateModeNameOrError(name, runtime.data.modes); + if (err) { + ctx.ui.notify(err, "warning"); + continue; + } - // Default new modes to the current selection so they behave as expected immediately. - const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx); - runtime.data.modes[name] = { - provider: selection.provider, - modelId: selection.modelId, - thinkingLevel: selection.thinkingLevel, - }; - await persistRuntime(pi, ctx); - ctx.ui.notify(`Added mode \"${name}\"`, "info"); - return name; - } + // Default new modes to the current selection so they behave as expected immediately. + const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx); + runtime.data.modes[name] = { + provider: selection.provider, + modelId: selection.modelId, + thinkingLevel: selection.thinkingLevel, + }; + await persistRuntime(pi, ctx); + ctx.ui.notify(`Added mode \"${name}\"`, "info"); + return name; + } } -async function editModeUI(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise { - if (!ctx.hasUI) return; +async function editModeUI( + pi: ExtensionAPI, + ctx: ExtensionContext, + mode: string, +): Promise { + if (!ctx.hasUI) return; - let modeName = mode; + let modeName = mode; - while (true) { - await ensureRuntime(pi, ctx); - const spec = runtime.data.modes[modeName]; - if (!spec) return; + while (true) { + await ensureRuntime(pi, ctx); + const spec = runtime.data.modes[modeName]; + if (!spec) return; - const modelLabel = spec.provider && spec.modelId ? `${spec.provider}/${spec.modelId}` : "(no model)"; - const thinkingLabel = spec.thinkingLevel ?? THINKING_UNSET_LABEL; + const modelLabel = + spec.provider && spec.modelId + ? `${spec.provider}/${spec.modelId}` + : "(no model)"; + const thinkingLabel = spec.thinkingLevel ?? THINKING_UNSET_LABEL; - const actions = ["Change name", "Change model", "Change thinking level"]; - if (!isDefaultModeName(modeName)) actions.push("Delete mode"); - actions.push(MODE_UI_BACK); + const actions = ["Change name", "Change model", "Change thinking level"]; + if (!isDefaultModeName(modeName)) actions.push("Delete mode"); + actions.push(MODE_UI_BACK); - const action = await ctx.ui.select( - `Edit mode \"${modeName}\" model: ${modelLabel} thinking: ${thinkingLabel}`, - actions, - ); - if (!action || action === MODE_UI_BACK) return; + const action = await ctx.ui.select( + `Edit mode \"${modeName}\" model: ${modelLabel} thinking: ${thinkingLabel}`, + actions, + ); + if (!action || action === MODE_UI_BACK) return; - if (action === "Change name") { - const renamed = await renameModeUI(pi, ctx, modeName); - if (renamed) modeName = renamed; - continue; - } + if (action === "Change name") { + const renamed = await renameModeUI(pi, ctx, modeName); + if (renamed) modeName = renamed; + continue; + } - if (action === "Change model") { - const selected = await pickModelForModeUI(ctx, spec); - if (!selected) continue; - spec.provider = selected.provider; - spec.modelId = selected.modelId; - runtime.data.modes[modeName] = spec; - await persistRuntime(pi, ctx); - ctx.ui.notify(`Updated model for \"${modeName}\"`, "info"); + if (action === "Change model") { + const selected = await pickModelForModeUI(ctx, spec); + if (!selected) continue; + spec.provider = selected.provider; + spec.modelId = selected.modelId; + runtime.data.modes[modeName] = spec; + await persistRuntime(pi, ctx); + ctx.ui.notify(`Updated model for \"${modeName}\"`, "info"); - if (runtime.currentMode === modeName) { - await applyMode(pi, ctx, modeName); - } - continue; - } + if (runtime.currentMode === modeName) { + await applyMode(pi, ctx, modeName); + } + continue; + } - if (action === "Change thinking level") { - const level = await pickThinkingLevelForModeUI(ctx, spec.thinkingLevel); - if (level === undefined) continue; + if (action === "Change thinking level") { + const level = await pickThinkingLevelForModeUI(ctx, spec.thinkingLevel); + if (level === undefined) continue; - if (level === null) { - delete spec.thinkingLevel; - } else { - spec.thinkingLevel = level; - } + if (level === null) { + delete spec.thinkingLevel; + } else { + spec.thinkingLevel = level; + } - runtime.data.modes[modeName] = spec; - await persistRuntime(pi, ctx); - ctx.ui.notify(`Updated thinking level for \"${modeName}\"`, "info"); + runtime.data.modes[modeName] = spec; + await persistRuntime(pi, ctx); + ctx.ui.notify(`Updated thinking level for \"${modeName}\"`, "info"); - if (runtime.currentMode === modeName) { - await applyMode(pi, ctx, modeName); - } - continue; - } + if (runtime.currentMode === modeName) { + await applyMode(pi, ctx, modeName); + } + continue; + } - if (action === "Delete mode") { - const ok = await ctx.ui.confirm("Delete mode", `Delete mode \"${modeName}\"?`); - if (!ok) continue; + if (action === "Delete mode") { + const ok = await ctx.ui.confirm( + "Delete mode", + `Delete mode \"${modeName}\"?`, + ); + if (!ok) continue; - delete runtime.data.modes[modeName]; - await persistRuntime(pi, ctx); + delete runtime.data.modes[modeName]; + await persistRuntime(pi, ctx); - if (runtime.currentMode === modeName) { - runtime.currentMode = CUSTOM_MODE_NAME; - customOverlay = getCurrentSelectionSpec(pi, ctx); - } - if (runtime.lastRealMode === modeName) { - runtime.lastRealMode = "default"; - } - requestEditorRender?.(); - ctx.ui.notify(`Deleted mode \"${modeName}\"`, "info"); - return; - } - } + if (runtime.currentMode === modeName) { + runtime.currentMode = CUSTOM_MODE_NAME; + customOverlay = getCurrentSelectionSpec(pi, ctx); + } + if (runtime.lastRealMode === modeName) { + runtime.lastRealMode = "default"; + } + requestEditorRender?.(); + ctx.ui.notify(`Deleted mode \"${modeName}\"`, "info"); + return; + } + } } -function renameModesRecord(modes: Record, oldName: string, newName: string): Record { - const out: Record = {}; - for (const [k, v] of Object.entries(modes)) { - if (k === oldName) out[newName] = v; - else out[k] = v; - } - return out; +function renameModesRecord( + modes: Record, + oldName: string, + newName: string, +): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(modes)) { + if (k === oldName) out[newName] = v; + else out[k] = v; + } + return out; } -async function renameModeUI(pi: ExtensionAPI, ctx: ExtensionContext, oldName: string): Promise { - if (!ctx.hasUI) return undefined; +async function renameModeUI( + pi: ExtensionAPI, + ctx: ExtensionContext, + oldName: string, +): Promise { + if (!ctx.hasUI) return undefined; - if (isDefaultModeName(oldName)) { - ctx.ui.notify(`Cannot rename default mode \"${oldName}\"`, "warning"); - return oldName; - } + if (isDefaultModeName(oldName)) { + ctx.ui.notify(`Cannot rename default mode \"${oldName}\"`, "warning"); + return oldName; + } - await ensureRuntime(pi, ctx); + await ensureRuntime(pi, ctx); - while (true) { - const raw = await ctx.ui.input(`Rename mode \"${oldName}\"`, oldName); - if (raw === undefined) return undefined; + while (true) { + const raw = await ctx.ui.input(`Rename mode \"${oldName}\"`, oldName); + if (raw === undefined) return undefined; - const newName = normalizeModeNameInput(raw); - if (!newName || newName === oldName) return oldName; + const newName = normalizeModeNameInput(raw); + if (!newName || newName === oldName) return oldName; - const err = validateModeNameOrError(newName, runtime.data.modes); - if (err) { - ctx.ui.notify(err, "warning"); - continue; - } + const err = validateModeNameOrError(newName, runtime.data.modes); + if (err) { + ctx.ui.notify(err, "warning"); + continue; + } - runtime.data.modes = renameModesRecord(runtime.data.modes, oldName, newName); - await persistRuntime(pi, ctx); + runtime.data.modes = renameModesRecord( + runtime.data.modes, + oldName, + newName, + ); + await persistRuntime(pi, ctx); - if (runtime.currentMode === oldName) runtime.currentMode = newName; - if (runtime.lastRealMode === oldName) runtime.lastRealMode = newName; - requestEditorRender?.(); + if (runtime.currentMode === oldName) runtime.currentMode = newName; + if (runtime.lastRealMode === oldName) runtime.lastRealMode = newName; + requestEditorRender?.(); - ctx.ui.notify(`Renamed \"${oldName}\" → \"${newName}\"`, "info"); - return newName; - } + ctx.ui.notify(`Renamed \"${oldName}\" → \"${newName}\"`, "info"); + return newName; + } } async function pickModelForModeUI( - ctx: ExtensionContext, - spec: ModeSpec, + ctx: ExtensionContext, + spec: ModeSpec, ): Promise<{ provider: string; modelId: string } | undefined> { - if (!ctx.hasUI) return undefined; + if (!ctx.hasUI) return undefined; - const settingsManager = SettingsManager.inMemory(); - const currentModel = spec.provider && spec.modelId ? ctx.modelRegistry.find(spec.provider, spec.modelId) : ctx.model; + const settingsManager = SettingsManager.inMemory(); + const currentModel = + spec.provider && spec.modelId + ? ctx.modelRegistry.find(spec.provider, spec.modelId) + : ctx.model; - const scopedModels: Array<{ model: any; thinkingLevel: string }> = []; + const scopedModels: Array<{ model: any; thinkingLevel: string }> = []; - return ctx.ui.custom<{ provider: string; modelId: string } | undefined>((tui, _theme, _keybindings, done) => { - const selector = new ModelSelectorComponent( - tui, - currentModel, - settingsManager, - ctx.modelRegistry as any, - scopedModels as any, - (model) => done({ provider: model.provider, modelId: model.id }), - () => done(undefined), - ); - return selector; - }); + return ctx.ui.custom<{ provider: string; modelId: string } | undefined>( + (tui, _theme, _keybindings, done) => { + const selector = new ModelSelectorComponent( + tui, + currentModel, + settingsManager, + ctx.modelRegistry as any, + scopedModels as any, + (model) => done({ provider: model.provider, modelId: model.id }), + () => done(undefined), + ); + return selector; + }, + ); } async function pickThinkingLevelForModeUI( - ctx: ExtensionContext, - current: ThinkingLevel | undefined, + ctx: ExtensionContext, + current: ThinkingLevel | undefined, ): Promise { - if (!ctx.hasUI) return undefined; + if (!ctx.hasUI) return undefined; - const defaultValue = current ?? "off"; - const options = [...ALL_THINKING_LEVELS, THINKING_UNSET_LABEL]; - // Prefer the current selection by ordering it first. - const ordered = [defaultValue, ...options.filter((x) => x !== defaultValue)]; + const defaultValue = current ?? "off"; + const options = [...ALL_THINKING_LEVELS, THINKING_UNSET_LABEL]; + // Prefer the current selection by ordering it first. + const ordered = [defaultValue, ...options.filter((x) => x !== defaultValue)]; - const choice = await ctx.ui.select("Thinking level", ordered); - if (!choice) return undefined; - if (choice === THINKING_UNSET_LABEL) return null; - if (ALL_THINKING_LEVELS.includes(choice as ThinkingLevel)) return choice as ThinkingLevel; - return undefined; + const choice = await ctx.ui.select("Thinking level", ordered); + if (!choice) return undefined; + if (choice === THINKING_UNSET_LABEL) return null; + if (ALL_THINKING_LEVELS.includes(choice as ThinkingLevel)) + return choice as ThinkingLevel; + return undefined; } -async function cycleMode(pi: ExtensionAPI, ctx: ExtensionContext, direction: 1 | -1 = 1): Promise { - if (!ctx.hasUI) return; - await ensureRuntime(pi, ctx); - const names = orderedModeNames(runtime.data.modes); - if (names.length === 0) return; +async function cycleMode( + pi: ExtensionAPI, + ctx: ExtensionContext, + direction: 1 | -1 = 1, +): Promise { + if (!ctx.hasUI) return; + await ensureRuntime(pi, ctx); + const names = orderedModeNames(runtime.data.modes); + if (names.length === 0) return; - // If we're currently in the overlay mode, cycle relative to the last real mode. - const baseMode = runtime.currentMode === CUSTOM_MODE_NAME ? runtime.lastRealMode : runtime.currentMode; - const idx = Math.max(0, names.indexOf(baseMode)); - const next = names[(idx + direction + names.length) % names.length] ?? names[0]!; - await applyMode(pi, ctx, next); + // If we're currently in the overlay mode, cycle relative to the last real mode. + const baseMode = + runtime.currentMode === CUSTOM_MODE_NAME + ? runtime.lastRealMode + : runtime.currentMode; + const idx = Math.max(0, names.indexOf(baseMode)); + const next = + names[(idx + direction + names.length) % names.length] ?? names[0]!; + await applyMode(pi, ctx, next); } // ============================================================================= @@ -929,211 +1101,246 @@ const MAX_RECENT_PROMPTS = 30; interface PromptEntry { - text: string; - timestamp: number; + text: string; + timestamp: number; } class PromptEditor extends CustomEditor { - public modeLabelProvider?: () => string; - /** - * Color function for the mode label. If unset, the label inherits the border color. - * We use this to keep the label consistent (e.g. same as the footer/status bar). - */ - public modeLabelColor?: (text: string) => string; - private lockedBorder = false; - private _borderColor?: (text: string) => string; + public modeLabelProvider?: () => string; + /** + * Color function for the mode label. If unset, the label inherits the border color. + * We use this to keep the label consistent (e.g. same as the footer/status bar). + */ + public modeLabelColor?: (text: string) => string; + private lockedBorder = false; + private _borderColor?: (text: string) => string; - constructor( - tui: ConstructorParameters[0], - theme: ConstructorParameters[1], - keybindings: ConstructorParameters[2], - ) { - super(tui, theme, keybindings); - delete (this as { borderColor?: (text: string) => string }).borderColor; - Object.defineProperty(this, "borderColor", { - get: () => this._borderColor ?? ((text: string) => text), - set: (value: (text: string) => string) => { - if (this.lockedBorder) return; - this._borderColor = value; - }, - configurable: true, - enumerable: true, - }); - } + constructor( + tui: ConstructorParameters[0], + theme: ConstructorParameters[1], + keybindings: ConstructorParameters[2], + ) { + super(tui, theme, keybindings); + delete (this as { borderColor?: (text: string) => string }).borderColor; + Object.defineProperty(this, "borderColor", { + get: () => this._borderColor ?? ((text: string) => text), + set: (value: (text: string) => string) => { + if (this.lockedBorder) return; + this._borderColor = value; + }, + configurable: true, + enumerable: true, + }); + } - lockBorderColor() { - this.lockedBorder = true; - } + lockBorderColor() { + this.lockedBorder = true; + } - render(width: number): string[] { - const lines = super.render(width); - const mode = this.modeLabelProvider?.(); - if (!mode) return lines; + render(width: number): string[] { + const lines = super.render(width); + const mode = this.modeLabelProvider?.(); + if (!mode) return lines; - const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ""); - const topPlain = stripAnsi(lines[0] ?? ""); + const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ""); + const topPlain = stripAnsi(lines[0] ?? ""); - // If the editor is scrolled, the built-in editor renders a scroll indicator on the top border. - // Preserve it, but still show the mode label. - const scrollPrefixMatch = topPlain.match(/^(─── ↑ \d+ more )/); - const prefix = scrollPrefixMatch?.[1] ?? "──"; + // If the editor is scrolled, the built-in editor renders a scroll indicator on the top border. + // Preserve it, but still show the mode label. + const scrollPrefixMatch = topPlain.match(/^(─── ↑ \d+ more )/); + const prefix = scrollPrefixMatch?.[1] ?? "──"; - let label = formatModeLabel(mode); + let label = formatModeLabel(mode); - // Compute how much room we have for the label core (without truncating the prefix). - const labelLeftSpace = prefix.endsWith(" ") ? "" : " "; - const labelRightSpace = " "; - const minRightBorder = 1; // keep at least one border cell on the right - const maxLabelLen = Math.max(0, width - prefix.length - labelLeftSpace.length - labelRightSpace.length - minRightBorder); - if (maxLabelLen <= 0) return lines; - if (label.length > maxLabelLen) label = label.slice(0, maxLabelLen); + // Compute how much room we have for the label core (without truncating the prefix). + const labelLeftSpace = prefix.endsWith(" ") ? "" : " "; + const labelRightSpace = " "; + const minRightBorder = 1; // keep at least one border cell on the right + const maxLabelLen = Math.max( + 0, + width - + prefix.length - + labelLeftSpace.length - + labelRightSpace.length - + minRightBorder, + ); + if (maxLabelLen <= 0) return lines; + if (label.length > maxLabelLen) label = label.slice(0, maxLabelLen); - const labelChunk = `${labelLeftSpace}${label}${labelRightSpace}`; + const labelChunk = `${labelLeftSpace}${label}${labelRightSpace}`; - const remaining = width - prefix.length - labelChunk.length; - if (remaining < 0) return lines; + const remaining = width - prefix.length - labelChunk.length; + if (remaining < 0) return lines; - const right = "─".repeat(Math.max(0, remaining)); + const right = "─".repeat(Math.max(0, remaining)); - const labelColor = this.modeLabelColor ?? ((text: string) => this.borderColor(text)); - lines[0] = this.borderColor(prefix) + labelColor(labelChunk) + this.borderColor(right); - return lines; - } + const labelColor = + this.modeLabelColor ?? ((text: string) => this.borderColor(text)); + lines[0] = + this.borderColor(prefix) + + labelColor(labelChunk) + + this.borderColor(right); + return lines; + } - public requestRenderNow(): void { - this.tui.requestRender(); - } + public requestRenderNow(): void { + this.tui.requestRender(); + } } function extractText(content: Array<{ type: string; text?: string }>): string { - return content - .filter((item) => item.type === "text" && typeof item.text === "string") - .map((item) => item.text ?? "") - .join("") - .trim(); + return content + .filter((item) => item.type === "text" && typeof item.text === "string") + .map((item) => item.text ?? "") + .join("") + .trim(); } function collectUserPromptsFromEntries(entries: Array): PromptEntry[] { - const prompts: PromptEntry[] = []; + const prompts: PromptEntry[] = []; - for (const entry of entries) { - if (entry?.type !== "message") continue; - const message = entry?.message; - if (!message || message.role !== "user" || !Array.isArray(message.content)) continue; - const text = extractText(message.content); - if (!text) continue; - const timestamp = Number(message.timestamp ?? entry.timestamp ?? Date.now()); - prompts.push({ text, timestamp }); - } + for (const entry of entries) { + if (entry?.type !== "message") continue; + const message = entry?.message; + if (!message || message.role !== "user" || !Array.isArray(message.content)) + continue; + const text = extractText(message.content); + if (!text) continue; + const timestamp = Number( + message.timestamp ?? entry.timestamp ?? Date.now(), + ); + prompts.push({ text, timestamp }); + } - return prompts; + return prompts; } function getSessionDirForCwd(cwd: string): string { - const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - return path.join(getGlobalAgentDir(), "sessions", safePath); + const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + return path.join(getGlobalAgentDir(), "sessions", safePath); } -async function readTail(filePath: string, maxBytes = 256 * 1024): Promise { - let fileHandle: fs.FileHandle | undefined; - try { - const stats = await fs.stat(filePath); - const size = stats.size; - const start = Math.max(0, size - maxBytes); - const length = size - start; - if (length <= 0) return ""; +async function readTail( + filePath: string, + maxBytes = 256 * 1024, +): Promise { + let fileHandle: fs.FileHandle | undefined; + try { + const stats = await fs.stat(filePath); + const size = stats.size; + const start = Math.max(0, size - maxBytes); + const length = size - start; + if (length <= 0) return ""; - const buffer = Buffer.alloc(length); - fileHandle = await fs.open(filePath, "r"); - const { bytesRead } = await fileHandle.read(buffer, 0, length, start); - if (bytesRead === 0) return ""; - let chunk = buffer.subarray(0, bytesRead).toString("utf8"); - if (start > 0) { - const firstNewline = chunk.indexOf("\n"); - if (firstNewline !== -1) { - chunk = chunk.slice(firstNewline + 1); - } - } - return chunk; - } catch { - return ""; - } finally { - await fileHandle?.close(); - } + const buffer = Buffer.alloc(length); + fileHandle = await fs.open(filePath, "r"); + const { bytesRead } = await fileHandle.read(buffer, 0, length, start); + if (bytesRead === 0) return ""; + let chunk = buffer.subarray(0, bytesRead).toString("utf8"); + if (start > 0) { + const firstNewline = chunk.indexOf("\n"); + if (firstNewline !== -1) { + chunk = chunk.slice(firstNewline + 1); + } + } + return chunk; + } catch { + return ""; + } finally { + await fileHandle?.close(); + } } -async function loadPromptHistoryForCwd(cwd: string, excludeSessionFile?: string): Promise { - const sessionDir = getSessionDirForCwd(path.resolve(cwd)); - const resolvedExclude = excludeSessionFile ? path.resolve(excludeSessionFile) : undefined; - const prompts: PromptEntry[] = []; +async function loadPromptHistoryForCwd( + cwd: string, + excludeSessionFile?: string, +): Promise { + const sessionDir = getSessionDirForCwd(path.resolve(cwd)); + const resolvedExclude = excludeSessionFile + ? path.resolve(excludeSessionFile) + : undefined; + const prompts: PromptEntry[] = []; - let entries: Dirent[] = []; - try { - entries = await fs.readdir(sessionDir, { withFileTypes: true }); - } catch { - return prompts; - } + let entries: Dirent[] = []; + try { + entries = await fs.readdir(sessionDir, { withFileTypes: true }); + } catch { + return prompts; + } - const files = await Promise.all( - entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) - .map(async (entry) => { - const filePath = path.join(sessionDir, entry.name); - try { - const stats = await fs.stat(filePath); - return { filePath, mtimeMs: stats.mtimeMs }; - } catch { - return undefined; - } - }), - ); + const files = await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map(async (entry) => { + const filePath = path.join(sessionDir, entry.name); + try { + const stats = await fs.stat(filePath); + return { filePath, mtimeMs: stats.mtimeMs }; + } catch { + return undefined; + } + }), + ); - const sortedFiles = files - .filter((file): file is { filePath: string; mtimeMs: number } => Boolean(file)) - .sort((a, b) => b.mtimeMs - a.mtimeMs); + const sortedFiles = files + .filter((file): file is { filePath: string; mtimeMs: number } => + Boolean(file), + ) + .sort((a, b) => b.mtimeMs - a.mtimeMs); - for (const file of sortedFiles) { - if (resolvedExclude && path.resolve(file.filePath) === resolvedExclude) continue; + for (const file of sortedFiles) { + if (resolvedExclude && path.resolve(file.filePath) === resolvedExclude) + continue; - const tail = await readTail(file.filePath); - if (!tail) continue; - const lines = tail.split("\n").filter(Boolean); - for (const line of lines) { - let entry: any; - try { - entry = JSON.parse(line); - } catch { - continue; - } - if (entry?.type !== "message") continue; - const message = entry?.message; - if (!message || message.role !== "user" || !Array.isArray(message.content)) continue; - const text = extractText(message.content); - if (!text) continue; - const timestamp = Number(message.timestamp ?? entry.timestamp ?? Date.now()); - prompts.push({ text, timestamp }); - if (prompts.length >= MAX_RECENT_PROMPTS) break; - } - if (prompts.length >= MAX_RECENT_PROMPTS) break; - } + const tail = await readTail(file.filePath); + if (!tail) continue; + const lines = tail.split("\n").filter(Boolean); + for (const line of lines) { + let entry: any; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (entry?.type !== "message") continue; + const message = entry?.message; + if ( + !message || + message.role !== "user" || + !Array.isArray(message.content) + ) + continue; + const text = extractText(message.content); + if (!text) continue; + const timestamp = Number( + message.timestamp ?? entry.timestamp ?? Date.now(), + ); + prompts.push({ text, timestamp }); + if (prompts.length >= MAX_RECENT_PROMPTS) break; + } + if (prompts.length >= MAX_RECENT_PROMPTS) break; + } - return prompts; + return prompts; } -function buildHistoryList(currentSession: PromptEntry[], previousSessions: PromptEntry[]): PromptEntry[] { - const all = [...currentSession, ...previousSessions]; - all.sort((a, b) => a.timestamp - b.timestamp); +function buildHistoryList( + currentSession: PromptEntry[], + previousSessions: PromptEntry[], +): PromptEntry[] { + const all = [...currentSession, ...previousSessions]; + all.sort((a, b) => a.timestamp - b.timestamp); - const seen = new Set(); - const deduped: PromptEntry[] = []; - for (const prompt of all) { - const key = `${prompt.timestamp}:${prompt.text}`; - if (seen.has(key)) continue; - seen.add(key); - deduped.push(prompt); - } + const seen = new Set(); + const deduped: PromptEntry[] = []; + for (const prompt of all) { + const key = `${prompt.timestamp}:${prompt.text}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(prompt); + } - return deduped.slice(-MAX_HISTORY_ENTRIES); + return deduped.slice(-MAX_HISTORY_ENTRIES); } // Overlay mode state ("custom"). Not selectable, not cycled into. @@ -1142,57 +1349,65 @@ let loadCounter = 0; function historiesMatch(a: PromptEntry[], b: PromptEntry[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i += 1) { - if (a[i]?.text !== b[i]?.text || a[i]?.timestamp !== b[i]?.timestamp) return false; - } - return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i]?.text !== b[i]?.text || a[i]?.timestamp !== b[i]?.timestamp) + return false; + } + return true; } -function setEditor(pi: ExtensionAPI, ctx: ExtensionContext, history: PromptEntry[]) { - ctx.ui.setEditorComponent((tui, theme, keybindings) => { - const editor = new PromptEditor(tui, theme, keybindings); - requestEditorRender = () => editor.requestRenderNow(); - editor.modeLabelProvider = () => runtime.currentMode; - // Keep the mode label color stable (match footer/status bar). - editor.modeLabelColor = (text: string) => ctx.ui.theme.fg("dim", text); - const borderColor = (text: string) => { - const isBashMode = editor.getText().trimStart().startsWith("!"); - if (isBashMode) { - return ctx.ui.theme.getBashModeBorderColor()(text); - } - return getModeBorderColor(ctx, pi, runtime.currentMode)(text); - }; +function setEditor( + pi: ExtensionAPI, + ctx: ExtensionContext, + history: PromptEntry[], +) { + ctx.ui.setEditorComponent((tui, theme, keybindings) => { + const editor = new PromptEditor(tui, theme, keybindings); + requestEditorRender = () => editor.requestRenderNow(); + editor.modeLabelProvider = () => runtime.currentMode; + // Keep the mode label color stable (match footer/status bar). + editor.modeLabelColor = (text: string) => ctx.ui.theme.fg("dim", text); + const borderColor = (text: string) => { + const isBashMode = editor.getText().trimStart().startsWith("!"); + if (isBashMode) { + return ctx.ui.theme.getBashModeBorderColor()(text); + } + return getModeBorderColor(ctx, pi, runtime.currentMode)(text); + }; - editor.borderColor = borderColor; - editor.lockBorderColor(); - for (const prompt of history) { - editor.addToHistory?.(prompt.text); - } - return editor; - }); + editor.borderColor = borderColor; + editor.lockBorderColor(); + for (const prompt of history) { + editor.addToHistory?.(prompt.text); + } + return editor; + }); } function applyEditor(pi: ExtensionAPI, ctx: ExtensionContext) { - if (!ctx.hasUI) return; + if (!ctx.hasUI) return; - const sessionFile = ctx.sessionManager.getSessionFile(); - const currentEntries = ctx.sessionManager.getBranch(); - const currentPrompts = collectUserPromptsFromEntries(currentEntries); - const immediateHistory = buildHistoryList(currentPrompts, []); + const sessionFile = ctx.sessionManager.getSessionFile(); + const currentEntries = ctx.sessionManager.getBranch(); + const currentPrompts = collectUserPromptsFromEntries(currentEntries); + const immediateHistory = buildHistoryList(currentPrompts, []); - const currentLoad = ++loadCounter; - const initialText = ctx.ui.getEditorText(); - setEditor(pi, ctx, immediateHistory); + const currentLoad = ++loadCounter; + const initialText = ctx.ui.getEditorText(); + setEditor(pi, ctx, immediateHistory); - void (async () => { - const previousPrompts = await loadPromptHistoryForCwd(ctx.cwd, sessionFile ?? undefined); - if (currentLoad !== loadCounter) return; - if (ctx.ui.getEditorText() !== initialText) return; - const history = buildHistoryList(currentPrompts, previousPrompts); - if (historiesMatch(history, immediateHistory)) return; - setEditor(pi, ctx, history); - })(); + void (async () => { + const previousPrompts = await loadPromptHistoryForCwd( + ctx.cwd, + sessionFile ?? undefined, + ); + if (currentLoad !== loadCounter) return; + if (ctx.ui.getEditorText() !== initialText) return; + const history = buildHistoryList(currentPrompts, previousPrompts); + if (historiesMatch(history, immediateHistory)) return; + setEditor(pi, ctx, history); + })(); } // ============================================================================= @@ -1200,113 +1415,135 @@ // ============================================================================= export default function (pi: ExtensionAPI) { - pi.registerCommand("mode", { - description: "Select prompt mode", - handler: async (args, ctx) => { - const tokens = args - .split(/\s+/) - .map((x) => x.trim()) - .filter(Boolean); + pi.registerCommand("mode", { + description: "Select prompt mode", + handler: async (args, ctx) => { + const tokens = args + .split(/\s+/) + .map((x) => x.trim()) + .filter(Boolean); - // /mode - if (tokens.length === 0) { - await selectModeUI(pi, ctx); - return; - } + // /mode + if (tokens.length === 0) { + await selectModeUI(pi, ctx); + return; + } - // /mode store [name] - if (tokens[0] === "store") { - await ensureRuntime(pi, ctx); + // /mode store [name] + if (tokens[0] === "store") { + await ensureRuntime(pi, ctx); - let target = tokens[1]; - if (!target) { - if (!ctx.hasUI) return; - const names = orderedModeNames(runtime.data.modes); - target = await ctx.ui.select("Store current selection into mode", names); - if (!target) return; - } + let target = tokens[1]; + if (!target) { + if (!ctx.hasUI) return; + const names = orderedModeNames(runtime.data.modes); + target = await ctx.ui.select( + "Store current selection into mode", + names, + ); + if (!target) return; + } - if (target === CUSTOM_MODE_NAME) { - if (ctx.hasUI) ctx.ui.notify(`Cannot store into "${CUSTOM_MODE_NAME}"`, "warning"); - return; - } + if (target === CUSTOM_MODE_NAME) { + if (ctx.hasUI) + ctx.ui.notify(`Cannot store into "${CUSTOM_MODE_NAME}"`, "warning"); + return; + } - const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx); - await storeSelectionIntoMode(pi, ctx, target, selection); - if (ctx.hasUI) ctx.ui.notify(`Stored current selection into "${target}"`, "info"); - return; - } + const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx); + await storeSelectionIntoMode(pi, ctx, target, selection); + if (ctx.hasUI) + ctx.ui.notify(`Stored current selection into "${target}"`, "info"); + return; + } - // /mode - await applyMode(pi, ctx, tokens[0]!); - }, - }); + // /mode + await applyMode(pi, ctx, tokens[0]!); + }, + }); - pi.registerShortcut("ctrl+shift+m", { - description: "Select prompt mode", - handler: async (ctx) => { - await selectModeUI(pi, ctx); - }, - }); + pi.registerShortcut("ctrl+shift+m", { + description: "Select prompt mode", + handler: async (ctx) => { + await selectModeUI(pi, ctx); + }, + }); - pi.registerShortcut("ctrl+space", { - description: "Cycle prompt mode", - handler: async (ctx) => { - await cycleMode(pi, ctx, 1); - }, - }); + pi.registerShortcut("ctrl+space", { + description: "Cycle prompt mode", + handler: async (ctx) => { + await cycleMode(pi, ctx, 1); + }, + }); - // Cancel any pending async editor loads when the user opens a selector - // (e.g., /resume). Without this, the async loadPromptHistoryForCwd can - // complete while the session selector is showing and call setEditorComponent, - // which clears the editorContainer and causes rendering artifacts. - pi.on("session_before_switch", async () => { - ++loadCounter; - }); + pi.on("session_start", async (_event, ctx) => { + lastObservedModel = { + provider: ctx.model?.provider, + modelId: ctx.model?.id, + }; + await ensureRuntime(pi, ctx); + customOverlay = null; - pi.on("session_start", async (_event, ctx) => { - lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id }; - await ensureRuntime(pi, ctx); - customOverlay = null; + const inferred = inferModeFromSelection(ctx, pi, runtime.data); + if (inferred) { + runtime.currentMode = inferred; + runtime.lastRealMode = inferred; + } else { + // No exact match → treat as overlay. + runtime.currentMode = CUSTOM_MODE_NAME; + customOverlay = getCurrentSelectionSpec(pi, ctx); + } - const inferred = inferModeFromSelection(ctx, pi, runtime.data); - if (inferred) { - runtime.currentMode = inferred; - runtime.lastRealMode = inferred; - } else { - // No exact match → treat as overlay. - runtime.currentMode = CUSTOM_MODE_NAME; - customOverlay = getCurrentSelectionSpec(pi, ctx); - } + applyEditor(pi, ctx); + }); - applyEditor(pi, ctx); - }); + pi.on("session_switch", async (_event, ctx) => { + lastObservedModel = { + provider: ctx.model?.provider, + modelId: ctx.model?.id, + }; + await ensureRuntime(pi, ctx); + customOverlay = null; - pi.on("model_select", async (event: ModelSelectEvent, ctx) => { - // Always track the last observed model for overlay/store correctness. - lastObservedModel = { provider: event.model.provider, modelId: event.model.id }; + const inferred = inferModeFromSelection(ctx, pi, runtime.data); + if (inferred) { + runtime.currentMode = inferred; + runtime.lastRealMode = inferred; + } else { + runtime.currentMode = CUSTOM_MODE_NAME; + customOverlay = getCurrentSelectionSpec(pi, ctx); + } - // Skip mode switching triggered by applyMode() itself, otherwise we'd jump to "custom" - // while we are in the middle of applying a mode. - if (runtime.applying) return; + applyEditor(pi, ctx); + }); - // Manual model changes always go into the overlay "custom" mode. - await ensureRuntime(pi, ctx); - if (runtime.currentMode !== CUSTOM_MODE_NAME) { - runtime.lastRealMode = runtime.currentMode; - } - runtime.currentMode = CUSTOM_MODE_NAME; + pi.on("model_select", async (event: ModelSelectEvent, ctx) => { + // Always track the last observed model for overlay/store correctness. + lastObservedModel = { + provider: event.model.provider, + modelId: event.model.id, + }; - customOverlay = { - provider: event.model.provider, - modelId: event.model.id, - thinkingLevel: pi.getThinkingLevel(), - }; + // Skip mode switching triggered by applyMode() itself, otherwise we'd jump to "custom" + // while we are in the middle of applying a mode. + if (runtime.applying) return; - // Do not persist/select custom. - if (ctx.hasUI) { - requestEditorRender?.(); - } - }); + // Manual model changes always go into the overlay "custom" mode. + await ensureRuntime(pi, ctx); + if (runtime.currentMode !== CUSTOM_MODE_NAME) { + runtime.lastRealMode = runtime.currentMode; + } + runtime.currentMode = CUSTOM_MODE_NAME; + customOverlay = { + provider: event.model.provider, + modelId: event.model.id, + thinkingLevel: pi.getThinkingLevel(), + }; + + // Do not persist/select custom. + if (ctx.hasUI) { + requestEditorRender?.(); + } + }); } diff --git a/dot_pi/agent/extensions/review/index.ts b/dot_pi/agent/extensions/review/index.ts deleted file mode 100644 --- a/dot_pi/agent/extensions/review/index.ts +++ /dev/null @@ -1,2441 +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 - * - Shared custom review instructions (applied to all review modes when configured) - * - * Usage: - * - `/review` - show interactive selector - * - `/review pr 123` - review PR #123 (checks out locally) - * - `/review pr https://github.com/owner/repo/pull/123` - review PR from URL - * - `/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` selector includes Add/Remove custom review instructions (applies to all modes) - * - `/review --extra "focus on performance regressions"` - add extra review instruction (works with any mode) - * - * 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 "@mariozechner/pi-coding-agent"; -import { DynamicBorder, BorderedLoader } from "@mariozechner/pi-coding-agent"; -import { - Container, - fuzzyFilter, - Input, - type SelectItem, - SelectList, - Spacer, - Text, -} from "@mariozechner/pi-tui"; -import path from "node:path"; -import { promises as fs } from "node:fs"; - -// 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; -let endReviewInProgress = false; -let reviewLoopFixingEnabled = false; -let reviewCustomInstructions: string | undefined = undefined; -let reviewLoopInProgress = false; - -const REVIEW_STATE_TYPE = "review-session"; -const REVIEW_ANCHOR_TYPE = "review-anchor"; -const REVIEW_SETTINGS_TYPE = "review-settings"; -const REVIEW_LOOP_MAX_ITERATIONS = 10; -const REVIEW_LOOP_START_TIMEOUT_MS = 15000; -const REVIEW_LOOP_START_POLL_MS = 50; - -type ReviewSessionState = { - active: boolean; - originId?: string; -}; - -type ReviewSettingsState = { - loopFixingEnabled?: boolean; - customInstructions?: 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 message = reviewLoopInProgress - ? "Review session active (loop fixing running)" - : reviewLoopFixingEnabled - ? "Review session active (loop fixing enabled), return with /end-review" - : "Review session active, return with /end-review"; - const text = new Text(theme.fg("warning", message), 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; - setReviewWidget(ctx, true); - return; - } - - reviewOriginId = undefined; - setReviewWidget(ctx, false); -} - -function getReviewSettings(ctx: ExtensionContext): ReviewSettingsState { - let state: ReviewSettingsState | undefined; - for (const entry of ctx.sessionManager.getEntries()) { - if (entry.type === "custom" && entry.customType === REVIEW_SETTINGS_TYPE) { - state = entry.data as ReviewSettingsState | undefined; - } - } - - return { - loopFixingEnabled: state?.loopFixingEnabled === true, - customInstructions: state?.customInstructions?.trim() || undefined, - }; -} - -function applyReviewSettings(ctx: ExtensionContext) { - const state = getReviewSettings(ctx); - reviewLoopFixingEnabled = state.loopFixingEnabled === true; - reviewCustomInstructions = state.customInstructions?.trim() || undefined; -} - -function parseMarkdownHeading( - line: string, -): { level: number; title: string } | null { - const headingMatch = line.match(/^\s*(#{1,6})\s+(.+?)\s*$/); - if (!headingMatch) { - return null; - } - - const rawTitle = headingMatch[2].replace(/\s+#+\s*$/, "").trim(); - return { - level: headingMatch[1].length, - title: rawTitle, - }; -} - -function getFindingsSectionBounds( - lines: string[], -): { start: number; end: number } | null { - let start = -1; - let findingsHeadingLevel: number | null = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const heading = parseMarkdownHeading(line); - if (heading && /^findings\b/i.test(heading.title)) { - start = i + 1; - findingsHeadingLevel = heading.level; - break; - } - if (/^\s*findings\s*:?\s*$/i.test(line)) { - start = i + 1; - break; - } - } - - if (start < 0) { - return null; - } - - let end = lines.length; - for (let i = start; i < lines.length; i++) { - const line = lines[i]; - const heading = parseMarkdownHeading(line); - if (heading) { - const normalizedTitle = heading.title.replace(/[*_`]/g, "").trim(); - if ( - /^(review scope|verdict|overall verdict|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test( - normalizedTitle, - ) - ) { - end = i; - break; - } - - if (/\[P[0-3]\]/i.test(heading.title)) { - continue; - } - - if ( - findingsHeadingLevel !== null && - heading.level <= findingsHeadingLevel - ) { - end = i; - break; - } - } - - if ( - /^\s*(review scope|verdict|overall verdict|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test( - line, - ) - ) { - end = i; - break; - } - } - - return { start, end }; -} - -function isLikelyFindingLine(line: string): boolean { - if (!/\[P[0-3]\]/i.test(line)) { - return false; - } - - if (/^\s*(?:[-*+]|(?:\d+)[.)]|#{1,6})\s+priority\s+tag\b/i.test(line)) { - return false; - } - - if ( - /^\s*(?:[-*+]|(?:\d+)[.)]|#{1,6})\s+\[P[0-3]\]\s*-\s*(?:drop everything|urgent|normal|low|nice to have)\b/i.test( - line, - ) - ) { - return false; - } - - const allPriorityTags = line.match(/\[P[0-3]\]/gi) ?? []; - if (allPriorityTags.length > 1) { - return false; - } - - if (/^\s*(?:[-*+]|(?:\d+)[.)])\s+/.test(line)) { - return true; - } - - if (/^\s*#{1,6}\s+/.test(line)) { - return true; - } - - if (/^\s*(?:\*\*|__)?\[P[0-3]\](?:\*\*|__)?(?=\s|:|-)/i.test(line)) { - return true; - } - - return false; -} - -function normalizeVerdictValue(value: string): string { - return value - .trim() - .replace(/^[-*+]\s*/, "") - .replace(/^['"`]+|['"`]+$/g, "") - .toLowerCase(); -} - -function isNeedsAttentionVerdictValue(value: string): boolean { - const normalized = normalizeVerdictValue(value); - if (!normalized.includes("needs attention")) { - return false; - } - - if (/\bnot\s+needs\s+attention\b/.test(normalized)) { - return false; - } - - // Reject rubric/choice phrasing like "correct or needs attention", but - // keep legitimate verdict text that may contain unrelated "or". - if (/\bcorrect\b/.test(normalized) && /\bor\b/.test(normalized)) { - return false; - } - - return true; -} - -function hasNeedsAttentionVerdict(messageText: string): boolean { - const lines = messageText.split(/\r?\n/); - - for (const line of lines) { - const inlineMatch = line.match( - /^\s*(?:[*-+]\s*)?(?:overall\s+)?verdict\s*:\s*(.+)$/i, - ); - if (inlineMatch && isNeedsAttentionVerdictValue(inlineMatch[1])) { - return true; - } - } - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const heading = parseMarkdownHeading(line); - - let verdictLevel: number | null = null; - if (heading) { - const normalizedHeading = heading.title.replace(/[*_`]/g, "").trim(); - if (!/^(?:overall\s+)?verdict\b/i.test(normalizedHeading)) { - continue; - } - verdictLevel = heading.level; - } else if (!/^\s*(?:overall\s+)?verdict\s*:?\s*$/i.test(line)) { - continue; - } - - for (let j = i + 1; j < lines.length; j++) { - const verdictLine = lines[j]; - const nextHeading = parseMarkdownHeading(verdictLine); - if (nextHeading) { - const normalizedNextHeading = nextHeading.title - .replace(/[*_`]/g, "") - .trim(); - if (verdictLevel === null || nextHeading.level <= verdictLevel) { - break; - } - if ( - /^(review scope|findings|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test( - normalizedNextHeading, - ) - ) { - break; - } - } - - const trimmed = verdictLine.trim(); - if (!trimmed) { - continue; - } - - if (isNeedsAttentionVerdictValue(trimmed)) { - return true; - } - - if (/\bcorrect\b/i.test(normalizeVerdictValue(trimmed))) { - break; - } - } - } - - return false; -} - -function hasBlockingReviewFindings(messageText: string): boolean { - const lines = messageText.split(/\r?\n/); - const bounds = getFindingsSectionBounds(lines); - const candidateLines = bounds ? lines.slice(bounds.start, bounds.end) : lines; - - let inCodeFence = false; - let foundTaggedFinding = false; - for (const line of candidateLines) { - if (/^\s*```/.test(line)) { - inCodeFence = !inCodeFence; - continue; - } - if (inCodeFence) { - continue; - } - - if (!isLikelyFindingLine(line)) { - continue; - } - - foundTaggedFinding = true; - if (/\[(P0|P1|P2)\]/i.test(line)) { - return true; - } - } - - if (foundTaggedFinding) { - return false; - } - - return hasNeedsAttentionVerdict(messageText); -} - -// Review target types (matching Codex's approach) -type ReviewTarget = - | { type: "uncommitted" } - | { type: "baseBranch"; branch: string } - | { type: "commit"; sha: string; title?: string } - | { type: "pullRequest"; prNumber: number; baseBranch: string; title: 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 LOCAL_CHANGES_REVIEW_INSTRUCTIONS = - "Also include local working-tree changes (staged, unstaged, and untracked files) from this branch. Use `git status --porcelain`, `git diff`, `git diff --staged`, and `git ls-files --others --exclude-standard` so local fixes are part of this review cycle."; - -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."; - -// The detailed review rubric (adapted from Codex's review_prompt.md) -const REVIEW_RUBRIC = `# Review Guidelines - -You are acting as a code reviewer for a proposed code change made by another engineer. - -Below are default guidelines for determining what to flag. These are not the final word — if you encounter more specific guidelines elsewhere (in a developer message, user message, file, or project review guidelines appended below), those override these general instructions. - -## Determining what to flag - -Flag issues that: -1. Meaningfully impact the accuracy, performance, security, or maintainability of the code. -2. Are discrete and actionable (not general issues or multiple combined issues). -3. Don't demand rigor inconsistent with the rest of the codebase. -4. Were introduced in the changes being reviewed (not pre-existing bugs). -5. The author would likely fix if aware of them. -6. Don't rely on unstated assumptions about the codebase or author's intent. -7. Have provable impact on other parts of the code — it is not enough to speculate that a change may disrupt another part, you must identify the parts that are provably affected. -8. Are clearly not intentional changes by the author. -9. Be particularly careful with untrusted user input and follow the specific guidelines to review. -10. Treat silent local error recovery (especially parsing/IO/network fallbacks) as high-signal review candidates unless there is explicit boundary-level justification. - -## Untrusted User Input - -1. Be careful with open redirects, they must always be checked to only go to trusted domains (?next_page=...) -2. Always flag SQL that is not parametrized -3. In systems with user supplied URL input, http fetches always need to be protected against access to local resources (intercept DNS resolver!) -4. Escape, don't sanitize if you have the option (eg: HTML escaping) - -## Comment guidelines - -1. Be clear about why the issue is a problem. -2. Communicate severity appropriately - don't exaggerate. -3. Be brief - at most 1 paragraph. -4. Keep code snippets under 3 lines, wrapped in inline code or code blocks. -5. Use \`\`\`suggestion blocks ONLY for concrete replacement code (minimal lines; no commentary inside the block). Preserve the exact leading whitespace of the replaced lines. -6. Explicitly state scenarios/environments where the issue arises. -7. Use a matter-of-fact tone - helpful AI assistant, not accusatory. -8. Write for quick comprehension without close reading. -9. Avoid excessive flattery or unhelpful phrases like "Great job...". - -## Review priorities - -1. Surface critical non-blocking human callouts (migrations, dependency churn, auth/permissions, compatibility, destructive operations) at the end. -2. Prefer simple, direct solutions over wrappers or abstractions without clear value. -3. Treat back pressure handling as critical to system stability. -4. Apply system-level thinking; flag changes that increase operational risk or on-call wakeups. -5. Ensure that errors are always checked against codes or stable identifiers, never error messages. - -## Fail-fast error handling (strict) - -When reviewing added or modified error handling, default to fail-fast behavior. - -1. Evaluate every new or changed \`try/catch\`: identify what can fail and why local handling is correct at that exact layer. -2. Prefer propagation over local recovery. If the current scope cannot fully recover while preserving correctness, rethrow (optionally with context) instead of returning fallbacks. -3. Flag catch blocks that hide failure signals (e.g. returning \`null\`/\`[]\`/\`false\`, swallowing JSON parse failures, logging-and-continue, or “best effort” silent recovery). -4. JSON parsing/decoding should fail loudly by default. Quiet fallback parsing is only acceptable with an explicit compatibility requirement and clear tested behavior. -5. Boundary handlers (HTTP routes, CLI entrypoints, supervisors) may translate errors, but must not pretend success or silently degrade. -6. If a catch exists only to satisfy lint/style without real handling, treat it as a bug. -7. When uncertain, prefer crashing fast over silent degradation. - -## Required human callouts (non-blocking, at the very end) - -After findings/verdict, you MUST append this final section: - -## Human Reviewer Callouts (Non-Blocking) - -Include only applicable callouts (no yes/no lines): - -- **This change adds a database migration:** -- **This change introduces a new dependency:** -- **This change changes a dependency (or the lockfile):** -- **This change modifies auth/permission behavior:** -- **This change introduces backwards-incompatible public schema/API/contract changes:** -- **This change includes irreversible or destructive operations:** - -Rules for this section: -1. These are informational callouts for the human reviewer, not fix items. -2. Do not include them in Findings unless there is an independent defect. -3. These callouts alone must not change the verdict. -4. Only include callouts that apply to the reviewed change. -5. Keep each emitted callout bold exactly as written. -6. If none apply, write "- (none)". - -## Priority levels - -Tag each finding with a priority level in the title: -- [P0] - Drop everything to fix. Blocking release/operations. Only for universal issues that do not depend on assumptions about inputs. -- [P1] - Urgent. Should be addressed in the next cycle. -- [P2] - Normal. To be fixed eventually. -- [P3] - Low. Nice to have. - -## Output format - -Provide your findings in a clear, structured format: -1. List each finding with its priority tag, file location, and explanation. -2. Findings must reference locations that overlap with the actual diff — don't flag pre-existing code. -3. Keep line references as short as possible (avoid ranges over 5-10 lines; pick the most suitable subrange). -4. Provide an overall verdict: "correct" (no blocking issues) or "needs attention" (has blocking issues). -5. Ignore trivial style issues unless they obscure meaning or violate documented standards. -6. Do not generate a full PR fix — only flag issues and optionally provide short suggestion blocks. -7. End with the required "Human Reviewer Callouts (Non-Blocking)" section and all applicable bold callouts (no yes/no). - -Output all findings the author would fix if they knew about them. If there are no qualifying findings, explicitly state the code looks good. Don't stop at the first finding - list every qualifying issue. Then append the required non-blocking callouts section.`; - -async function loadProjectReviewGuidelines( - cwd: string, -): Promise { - 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, -): Promise { - try { - // First try to get the upstream tracking branch - const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [ - "rev-parse", - "--abbrev-ref", - `${branch}@{upstream}`, - ]); - - if (upstreamCode === 0 && upstream.trim()) { - const { stdout: mergeBase, code } = await pi.exec("git", [ - "merge-base", - "HEAD", - upstream.trim(), - ]); - 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, - ]); - if (code === 0 && mergeBase.trim()) { - return mergeBase.trim(); - } - - return null; - } catch { - return null; - } -} - -/** - * Get list of local branches - */ -async function getLocalBranches(pi: ExtensionAPI): Promise { - 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> { - 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 { - 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 { - // 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; -} - -/** - * Parse a PR reference (URL or number) and return the PR number - */ -function parsePrReference(ref: string): number | null { - const trimmed = ref.trim(); - - // Try as a number first - const num = parseInt(trimmed, 10); - if (!isNaN(num) && num > 0) { - return 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 parseInt(urlMatch[1], 10); - } - - return null; -} - -/** - * Get PR information from GitHub CLI - */ -async function getPrInfo( - pi: ExtensionAPI, - prNumber: number, -): Promise<{ baseBranch: string; title: string; headBranch: string } | null> { - const { stdout, code } = await pi.exec("gh", [ - "pr", - "view", - String(prNumber), - "--json", - "baseRefName,title,headRefName", - ]); - - 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, -): Promise<{ success: boolean; error?: string }> { - const { stdout, stderr, code } = await pi.exec("gh", [ - "pr", - "checkout", - String(prNumber), - ]); - - 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 { - 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 { - // 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, - options?: { includeLocalChanges?: boolean }, -): Promise { - const includeLocalChanges = options?.includeLocalChanges === true; - - switch (target.type) { - case "uncommitted": - return UNCOMMITTED_PROMPT; - - case "baseBranch": { - const mergeBase = await getMergeBase(pi, target.branch); - const basePrompt = mergeBase - ? BASE_BRANCH_PROMPT_WITH_MERGE_BASE.replace( - /{baseBranch}/g, - target.branch, - ).replace(/{mergeBaseSha}/g, mergeBase) - : BASE_BRANCH_PROMPT_FALLBACK.replace(/{branch}/g, target.branch); - return includeLocalChanges - ? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}` - : basePrompt; - } - - 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 "pullRequest": { - const mergeBase = await getMergeBase(pi, target.baseBranch); - const basePrompt = mergeBase - ? PULL_REQUEST_PROMPT.replace(/{prNumber}/g, String(target.prNumber)) - .replace(/{title}/g, target.title) - .replace(/{baseBranch}/g, target.baseBranch) - .replace(/{mergeBaseSha}/g, mergeBase) - : PULL_REQUEST_PROMPT_FALLBACK.replace( - /{prNumber}/g, - String(target.prNumber), - ) - .replace(/{title}/g, target.title) - .replace(/{baseBranch}/g, target.baseBranch); - return includeLocalChanges - ? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}` - : basePrompt; - } - - 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 "pullRequest": { - const shortTitle = - target.title.length > 30 - ? target.title.slice(0, 27) + "..." - : target.title; - return `PR #${target.prNumber}: ${shortTitle}`; - } - - case "folder": { - const joined = target.paths.join(", "); - return joined.length > 40 - ? `folders: ${joined.slice(0, 37)}...` - : `folders: ${joined}`; - } - } -} - -type AssistantSnapshot = { - id: string; - text: string; - stopReason?: string; -}; - -function extractAssistantTextContent(content: unknown): string { - if (typeof content === "string") { - return content.trim(); - } - - if (!Array.isArray(content)) { - return ""; - } - - const textParts = content - .filter((part): part is { type: "text"; text: string } => - Boolean( - part && - typeof part === "object" && - "type" in part && - part.type === "text" && - "text" in part, - ), - ) - .map((part) => part.text); - return textParts.join("\n").trim(); -} - -function getLastAssistantSnapshot( - ctx: ExtensionContext, -): AssistantSnapshot | null { - const entries = ctx.sessionManager.getBranch(); - for (let i = entries.length - 1; i >= 0; i--) { - const entry = entries[i]; - if (entry.type !== "message" || entry.message.role !== "assistant") { - continue; - } - - const assistantMessage = entry.message as { - content?: unknown; - stopReason?: string; - }; - return { - id: entry.id, - text: extractAssistantTextContent(assistantMessage.content), - stopReason: assistantMessage.stopReason, - }; - } - - return null; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function waitForLoopTurnToStart( - ctx: ExtensionContext, - previousAssistantId?: string, -): Promise { - const deadline = Date.now() + REVIEW_LOOP_START_TIMEOUT_MS; - - while (Date.now() < deadline) { - const lastAssistantId = getLastAssistantSnapshot(ctx)?.id; - if ( - !ctx.isIdle() || - ctx.hasPendingMessages() || - (lastAssistantId && lastAssistantId !== previousAssistantId) - ) { - return true; - } - await sleep(REVIEW_LOOP_START_POLL_MS); - } - - return false; -} - -// Review preset options for the selector (keep this order stable) -const REVIEW_PRESETS = [ - { - value: "uncommitted", - label: "Review uncommitted changes", - description: "", - }, - { - value: "baseBranch", - label: "Review against a base branch", - description: "(local)", - }, - { value: "commit", label: "Review a commit", description: "" }, - { - value: "pullRequest", - label: "Review a pull request", - description: "(GitHub PR)", - }, - { - value: "folder", - label: "Review a folder (or more)", - description: "(snapshot, not diff)", - }, -] as const; - -const TOGGLE_LOOP_FIXING_VALUE = "toggleLoopFixing" as const; -const TOGGLE_CUSTOM_INSTRUCTIONS_VALUE = "toggleCustomInstructions" as const; -type ReviewPresetValue = - | (typeof REVIEW_PRESETS)[number]["value"] - | typeof TOGGLE_LOOP_FIXING_VALUE - | typeof TOGGLE_CUSTOM_INSTRUCTIONS_VALUE; - -export default function reviewExtension(pi: ExtensionAPI) { - function persistReviewSettings() { - pi.appendEntry(REVIEW_SETTINGS_TYPE, { - loopFixingEnabled: reviewLoopFixingEnabled, - customInstructions: reviewCustomInstructions, - }); - } - - function setReviewLoopFixingEnabled(enabled: boolean) { - reviewLoopFixingEnabled = enabled; - persistReviewSettings(); - } - - function setReviewCustomInstructions(instructions: string | undefined) { - reviewCustomInstructions = instructions?.trim() || undefined; - persistReviewSettings(); - } - - function applyAllReviewState(ctx: ExtensionContext) { - applyReviewSettings(ctx); - applyReviewState(ctx); - } - - pi.on("session_start", (_event, ctx) => { - applyAllReviewState(ctx); - }); - - pi.on("session_tree", (_event, ctx) => { - applyAllReviewState(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 { - // Determine smart default (but keep the list order stable) - const smartDefault = await getSmartDefault(); - const presetItems: SelectItem[] = REVIEW_PRESETS.map((preset) => ({ - value: preset.value, - label: preset.label, - description: preset.description, - })); - const smartDefaultIndex = presetItems.findIndex( - (item) => item.value === smartDefault, - ); - - while (true) { - const customInstructionsLabel = reviewCustomInstructions - ? "Remove custom review instructions" - : "Add custom review instructions"; - const customInstructionsDescription = reviewCustomInstructions - ? "(currently set)" - : "(applies to all review modes)"; - const loopToggleLabel = reviewLoopFixingEnabled - ? "Disable Loop Fixing" - : "Enable Loop Fixing"; - const loopToggleDescription = reviewLoopFixingEnabled - ? "(currently on)" - : "(currently off)"; - const items: SelectItem[] = [ - ...presetItems, - { - value: TOGGLE_CUSTOM_INSTRUCTIONS_VALUE, - label: customInstructionsLabel, - description: customInstructionsDescription, - }, - { - value: TOGGLE_LOOP_FIXING_VALUE, - label: loopToggleLabel, - description: loopToggleDescription, - }, - ]; - - const result = await ctx.ui.custom( - (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), - }); - - // Preselect the smart default without reordering the list - if (smartDefaultIndex >= 0) { - selectList.setSelectedIndex(smartDefaultIndex); - } - - selectList.onSelect = (item) => done(item.value as ReviewPresetValue); - 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; - - if (result === TOGGLE_LOOP_FIXING_VALUE) { - const nextEnabled = !reviewLoopFixingEnabled; - setReviewLoopFixingEnabled(nextEnabled); - ctx.ui.notify( - nextEnabled ? "Loop fixing enabled" : "Loop fixing disabled", - "info", - ); - continue; - } - - if (result === TOGGLE_CUSTOM_INSTRUCTIONS_VALUE) { - if (reviewCustomInstructions) { - setReviewCustomInstructions(undefined); - ctx.ui.notify("Custom review instructions removed", "info"); - continue; - } - - const customInstructions = await ctx.ui.editor( - "Enter custom review instructions (applies to all review modes):", - "", - ); - - if (!customInstructions?.trim()) { - ctx.ui.notify("Custom review instructions not changed", "info"); - continue; - } - - setReviewCustomInstructions(customInstructions); - ctx.ui.notify("Custom review instructions saved", "info"); - continue; - } - - // 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": { - if (reviewLoopFixingEnabled) { - ctx.ui.notify( - "Loop mode does not work with commit review.", - "error", - ); - break; - } - const target = await showCommitSelector(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 { - const branches = await getLocalBranches(pi); - const currentBranch = await getCurrentBranch(pi); - const defaultBranch = await getDefaultBranch(pi); - - // Never offer the current branch as a base branch (reviewing against itself is meaningless). - const candidateBranches = currentBranch - ? branches.filter((b) => b !== currentBranch) - : branches; - - if (candidateBranches.length === 0) { - ctx.ui.notify( - currentBranch - ? `No other branches found (current branch: ${currentBranch})` - : "No branches found", - "error", - ); - return null; - } - - // Sort branches with default branch first - const sortedBranches = candidateBranches.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( - (tui, theme, keybindings, 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 searchInput = new Input(); - container.addChild(searchInput); - container.addChild(new Spacer(1)); - - const listContainer = new Container(); - container.addChild(listContainer); - 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))); - - let filteredItems = items; - let selectList: SelectList | null = null; - - const updateList = () => { - listContainer.clear(); - if (filteredItems.length === 0) { - listContainer.addChild( - new Text(theme.fg("warning", " No matching branches")), - ); - selectList = null; - return; - } - - selectList = new SelectList( - filteredItems, - Math.min(filteredItems.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); - listContainer.addChild(selectList); - }; - - const applyFilter = () => { - const query = searchInput.getValue(); - filteredItems = query - ? fuzzyFilter( - items, - query, - (item) => - `${item.label} ${item.value} ${item.description ?? ""}`, - ) - : items; - updateList(); - }; - - applyFilter(); - - return { - render(width: number) { - return container.render(width); - }, - invalidate() { - container.invalidate(); - }, - handleInput(data: string) { - if ( - keybindings.matches(data, "tui.select.up") || - keybindings.matches(data, "tui.select.down") || - keybindings.matches(data, "tui.select.confirm") || - keybindings.matches(data, "tui.select.cancel") - ) { - if (selectList) { - selectList.handleInput(data); - } else if (keybindings.matches(data, "tui.select.cancel")) { - done(null); - } - tui.requestRender(); - return; - } - - searchInput.handleInput(data); - applyFilter(); - tui.requestRender(); - }, - }; - }, - ); - - if (!result) return null; - return { type: "baseBranch", branch: result }; - } - - /** - * Show commit selector - */ - async function showCommitSelector( - ctx: ExtensionContext, - ): Promise { - 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, keybindings, 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 searchInput = new Input(); - container.addChild(searchInput); - container.addChild(new Spacer(1)); - - const listContainer = new Container(); - container.addChild(listContainer); - 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))); - - let filteredItems = items; - let selectList: SelectList | null = null; - - const updateList = () => { - listContainer.clear(); - if (filteredItems.length === 0) { - listContainer.addChild( - new Text(theme.fg("warning", " No matching commits")), - ); - selectList = null; - return; - } - - selectList = new SelectList( - filteredItems, - Math.min(filteredItems.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) => { - const commit = commits.find((c) => c.sha === item.value); - if (commit) { - done(commit); - } else { - done(null); - } - }; - selectList.onCancel = () => done(null); - listContainer.addChild(selectList); - }; - - const applyFilter = () => { - const query = searchInput.getValue(); - filteredItems = query - ? fuzzyFilter( - items, - query, - (item) => - `${item.label} ${item.value} ${item.description ?? ""}`, - ) - : items; - updateList(); - }; - - applyFilter(); - - return { - render(width: number) { - return container.render(width); - }, - invalidate() { - container.invalidate(); - }, - handleInput(data: string) { - if ( - keybindings.matches(data, "tui.select.up") || - keybindings.matches(data, "tui.select.down") || - keybindings.matches(data, "tui.select.confirm") || - keybindings.matches(data, "tui.select.cancel") - ) { - if (selectList) { - selectList.handleInput(data); - } else if (keybindings.matches(data, "tui.select.cancel")) { - done(null); - } - tui.requestRender(); - return; - } - - searchInput.handleInput(data); - applyFilter(); - tui.requestRender(); - }, - }; - }, - ); - - if (!result) return null; - return { type: "commit", sha: result.sha, title: result.title }; - } - - 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 { - 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 PR input and handle checkout - */ - async function showPrInput( - ctx: ExtensionContext, - ): Promise { - // First check for pending changes that would prevent branch switching - if (await hasPendingChanges(pi)) { - ctx.ui.notify( - "Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.", - "error", - ); - return null; - } - - // Get PR reference from user - const prRef = await ctx.ui.editor( - "Enter PR number or URL (e.g. 123 or https://github.com/owner/repo/pull/123):", - "", - ); - - if (!prRef?.trim()) return null; - - const prNumber = parsePrReference(prRef); - if (!prNumber) { - ctx.ui.notify( - "Invalid PR reference. Enter a number or GitHub PR URL.", - "error", - ); - return null; - } - - // Get PR info from GitHub - ctx.ui.notify(`Fetching PR #${prNumber} info...`, "info"); - const prInfo = await getPrInfo(pi, prNumber); - - if (!prInfo) { - ctx.ui.notify( - `Could not find PR #${prNumber}. Make sure gh is authenticated and the PR exists.`, - "error", - ); - return null; - } - - // Check again for pending changes (in case something changed) - if (await hasPendingChanges(pi)) { - ctx.ui.notify( - "Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.", - "error", - ); - return null; - } - - // Checkout the PR - ctx.ui.notify(`Checking out PR #${prNumber}...`, "info"); - const checkoutResult = await checkoutPr(pi, prNumber); - - if (!checkoutResult.success) { - ctx.ui.notify(`Failed to checkout PR: ${checkoutResult.error}`, "error"); - return null; - } - - ctx.ui.notify(`Checked out PR #${prNumber} (${prInfo.headBranch})`, "info"); - - return { - type: "pullRequest", - prNumber, - baseBranch: prInfo.baseBranch, - title: prInfo.title, - }; - } - - /** - * Execute the review - */ - async function executeReview( - ctx: ExtensionCommandContext, - target: ReviewTarget, - useFreshSession: boolean, - options?: { includeLocalChanges?: boolean; extraInstruction?: string }, - ): Promise { - // 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 false; - } - - // Handle fresh session mode - if (useFreshSession) { - // Store current position (where we'll return to). - // In an empty session there is no leaf yet, so create a lightweight anchor first. - let originId = ctx.sessionManager.getLeafId() ?? undefined; - if (!originId) { - pi.appendEntry(REVIEW_ANCHOR_TYPE, { - createdAt: new Date().toISOString(), - }); - originId = ctx.sessionManager.getLeafId() ?? undefined; - } - if (!originId) { - ctx.ui.notify("Failed to determine review origin.", "error"); - return false; - } - 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. - // If none exists (e.g. brand-new session), we'll stay on the current leaf. - const entries = ctx.sessionManager.getEntries(); - const firstUserMessage = entries.find( - (e) => e.type === "message" && e.message.role === "user", - ); - - if (firstUserMessage) { - // 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; - return false; - } - } catch (error) { - // Clean up state if navigation fails - reviewOriginId = undefined; - ctx.ui.notify( - `Failed to start review: ${error instanceof Error ? error.message : String(error)}`, - "error", - ); - return false; - } - - // Clear the editor (navigating to user message fills it with the message text) - ctx.ui.setEditorText(""); - } - - // Restore origin after navigation events (session_tree can reset it) - reviewOriginId = lockedOriginId; - - // 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, - }); - } - - const prompt = await buildReviewPrompt(pi, target, { - includeLocalChanges: options?.includeLocalChanges === true, - }); - const hint = getUserFacingHint(target); - const projectGuidelines = await loadProjectReviewGuidelines(ctx.cwd); - - // Combine the review rubric with the specific prompt - let fullPrompt = `${REVIEW_RUBRIC}\n\n---\n\nPlease perform a code review with the following focus:\n\n${prompt}`; - - if (reviewCustomInstructions) { - fullPrompt += `\n\nShared custom review instructions (applies to all reviews):\n\n${reviewCustomInstructions}`; - } - - if (options?.extraInstruction?.trim()) { - fullPrompt += `\n\nAdditional user-provided review instruction:\n\n${options.extraInstruction.trim()}`; - } - - if (projectGuidelines) { - fullPrompt += `\n\nThis project has additional instructions for code reviews:\n\n${projectGuidelines}`; - } - - const modeHint = useFreshSession ? " (fresh session)" : ""; - ctx.ui.notify(`Starting review: ${hint}${modeHint}`, "info"); - - // Send as a user message that triggers a turn - pi.sendUserMessage(fullPrompt); - return true; - } - - /** - * Parse command arguments for direct invocation - * Returns the target or a special marker for PR that needs async handling - */ - type ParsedReviewArgs = { - target: ReviewTarget | { type: "pr"; ref: string } | null; - extraInstruction?: string; - error?: string; - }; - - function tokenizeArgs(value: string): string[] { - const tokens: string[] = []; - let current = ""; - let quote: '"' | "'" | null = null; - - for (let i = 0; i < value.length; i++) { - const char = value[i]; - - if (quote) { - if (char === "\\" && i + 1 < value.length) { - current += value[i + 1]; - i += 1; - continue; - } - if (char === quote) { - quote = null; - continue; - } - current += char; - continue; - } - - if (char === '"' || char === "'") { - quote = char; - continue; - } - - if (/\s/.test(char)) { - if (current.length > 0) { - tokens.push(current); - current = ""; - } - continue; - } - - current += char; - } - - if (current.length > 0) { - tokens.push(current); - } - - return tokens; - } - - function parseArgs(args: string | undefined): ParsedReviewArgs { - if (!args?.trim()) return { target: null }; - - const rawParts = tokenizeArgs(args.trim()); - const parts: string[] = []; - let extraInstruction: string | undefined; - - for (let i = 0; i < rawParts.length; i++) { - const part = rawParts[i]; - if (part === "--extra") { - const next = rawParts[i + 1]; - if (!next) { - return { target: null, error: "Missing value for --extra" }; - } - extraInstruction = next; - i += 1; - continue; - } - - if (part.startsWith("--extra=")) { - extraInstruction = part.slice("--extra=".length); - continue; - } - - parts.push(part); - } - - if (parts.length === 0) { - return { target: null, extraInstruction }; - } - - const subcommand = parts[0]?.toLowerCase(); - - switch (subcommand) { - case "uncommitted": - return { target: { type: "uncommitted" }, extraInstruction }; - - case "branch": { - const branch = parts[1]; - if (!branch) return { target: null, extraInstruction }; - return { target: { type: "baseBranch", branch }, extraInstruction }; - } - - case "commit": { - const sha = parts[1]; - if (!sha) return { target: null, extraInstruction }; - const title = parts.slice(2).join(" ") || undefined; - return { target: { type: "commit", sha, title }, extraInstruction }; - } - - case "folder": { - const paths = parseReviewPaths(parts.slice(1).join(" ")); - if (paths.length === 0) return { target: null, extraInstruction }; - return { target: { type: "folder", paths }, extraInstruction }; - } - - case "pr": { - const ref = parts[1]; - if (!ref) return { target: null, extraInstruction }; - return { target: { type: "pr", ref }, extraInstruction }; - } - - default: - return { target: null, extraInstruction }; - } - } - - /** - * Handle PR checkout and return a ReviewTarget (or null on failure) - */ - async function handlePrCheckout( - ctx: ExtensionContext, - ref: string, - ): Promise { - // First check for pending changes - if (await hasPendingChanges(pi)) { - ctx.ui.notify( - "Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.", - "error", - ); - return null; - } - - const prNumber = parsePrReference(ref); - if (!prNumber) { - ctx.ui.notify( - "Invalid PR reference. Enter a number or GitHub PR URL.", - "error", - ); - return null; - } - - // Get PR info - ctx.ui.notify(`Fetching PR #${prNumber} info...`, "info"); - const prInfo = await getPrInfo(pi, prNumber); - - if (!prInfo) { - ctx.ui.notify( - `Could not find PR #${prNumber}. Make sure gh is authenticated and the PR exists.`, - "error", - ); - return null; - } - - // Checkout the PR - ctx.ui.notify(`Checking out PR #${prNumber}...`, "info"); - const checkoutResult = await checkoutPr(pi, prNumber); - - if (!checkoutResult.success) { - ctx.ui.notify(`Failed to checkout PR: ${checkoutResult.error}`, "error"); - return null; - } - - ctx.ui.notify(`Checked out PR #${prNumber} (${prInfo.headBranch})`, "info"); - - return { - type: "pullRequest", - prNumber, - baseBranch: prInfo.baseBranch, - title: prInfo.title, - }; - } - - function isLoopCompatibleTarget(target: ReviewTarget): boolean { - if (target.type !== "commit") { - return true; - } - - return false; - } - - async function runLoopFixingReview( - ctx: ExtensionCommandContext, - target: ReviewTarget, - extraInstruction?: string, - ): Promise { - if (reviewLoopInProgress) { - ctx.ui.notify("Loop fixing review is already running.", "warning"); - return; - } - - reviewLoopInProgress = true; - setReviewWidget(ctx, Boolean(reviewOriginId)); - try { - ctx.ui.notify( - "Loop fixing enabled: using Empty branch mode and cycling until no blocking findings remain.", - "info", - ); - - for (let pass = 1; pass <= REVIEW_LOOP_MAX_ITERATIONS; pass++) { - const reviewBaselineAssistantId = getLastAssistantSnapshot(ctx)?.id; - const started = await executeReview(ctx, target, true, { - includeLocalChanges: true, - extraInstruction, - }); - if (!started) { - ctx.ui.notify( - "Loop fixing stopped before starting the review pass.", - "warning", - ); - return; - } - - const reviewTurnStarted = await waitForLoopTurnToStart( - ctx, - reviewBaselineAssistantId, - ); - if (!reviewTurnStarted) { - ctx.ui.notify( - "Loop fixing stopped: review pass did not start in time.", - "error", - ); - return; - } - - await ctx.waitForIdle(); - - const reviewSnapshot = getLastAssistantSnapshot(ctx); - if ( - !reviewSnapshot || - reviewSnapshot.id === reviewBaselineAssistantId - ) { - ctx.ui.notify( - "Loop fixing stopped: could not read the review result.", - "warning", - ); - return; - } - - if (reviewSnapshot.stopReason === "aborted") { - ctx.ui.notify("Loop fixing stopped: review was aborted.", "warning"); - return; - } - - if (reviewSnapshot.stopReason === "error") { - ctx.ui.notify( - "Loop fixing stopped: review failed with an error.", - "error", - ); - return; - } - - if (reviewSnapshot.stopReason === "length") { - ctx.ui.notify( - "Loop fixing stopped: review output was truncated (stopReason=length).", - "warning", - ); - return; - } - - if (!hasBlockingReviewFindings(reviewSnapshot.text)) { - const finalized = await executeEndReviewAction( - ctx, - "returnAndSummarize", - { - showSummaryLoader: true, - notifySuccess: false, - }, - ); - if (finalized !== "ok") { - return; - } - - ctx.ui.notify( - "Loop fixing complete: no blocking findings remain.", - "info", - ); - return; - } - - ctx.ui.notify( - `Loop fixing pass ${pass}: found blocking findings, returning to fix them...`, - "info", - ); - - const fixBaselineAssistantId = getLastAssistantSnapshot(ctx)?.id; - const sentFixPrompt = await executeEndReviewAction( - ctx, - "returnAndFix", - { - showSummaryLoader: true, - notifySuccess: false, - }, - ); - if (sentFixPrompt !== "ok") { - return; - } - - const fixTurnStarted = await waitForLoopTurnToStart( - ctx, - fixBaselineAssistantId, - ); - if (!fixTurnStarted) { - ctx.ui.notify( - "Loop fixing stopped: fix pass did not start in time.", - "error", - ); - return; - } - - await ctx.waitForIdle(); - - const fixSnapshot = getLastAssistantSnapshot(ctx); - if (!fixSnapshot || fixSnapshot.id === fixBaselineAssistantId) { - ctx.ui.notify( - "Loop fixing stopped: could not read the fix pass result.", - "warning", - ); - return; - } - if (fixSnapshot.stopReason === "aborted") { - ctx.ui.notify( - "Loop fixing stopped: fix pass was aborted.", - "warning", - ); - return; - } - if (fixSnapshot.stopReason === "error") { - ctx.ui.notify( - "Loop fixing stopped: fix pass failed with an error.", - "error", - ); - return; - } - if (fixSnapshot.stopReason === "length") { - ctx.ui.notify( - "Loop fixing stopped: fix pass output was truncated (stopReason=length).", - "warning", - ); - return; - } - } - - ctx.ui.notify( - `Loop fixing stopped after ${REVIEW_LOOP_MAX_ITERATIONS} passes (safety limit reached).`, - "warning", - ); - } finally { - reviewLoopInProgress = false; - setReviewWidget(ctx, Boolean(reviewOriginId)); - } - } - - // Register the /review command - pi.registerCommand("review", { - description: - "Review code changes (PR, uncommitted, branch, commit, or folder)", - handler: async (args, ctx) => { - if (!ctx.hasUI) { - ctx.ui.notify("Review requires interactive mode", "error"); - return; - } - - if (reviewLoopInProgress) { - ctx.ui.notify("Loop fixing review is already running.", "warning"); - 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; - let extraInstruction: string | undefined; - const parsed = parseArgs(args); - if (parsed.error) { - ctx.ui.notify(parsed.error, "error"); - return; - } - extraInstruction = parsed.extraInstruction?.trim() || undefined; - - if (parsed.target) { - if (parsed.target.type === "pr") { - // Handle PR checkout (async operation) - target = await handlePrCheckout(ctx, parsed.target.ref); - if (!target) { - ctx.ui.notify( - "PR review failed. Returning to review menu.", - "warning", - ); - } - } else { - target = parsed.target; - } - } - - // 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; - } - - if (reviewLoopFixingEnabled && !isLoopCompatibleTarget(target)) { - ctx.ui.notify("Loop mode does not work with commit review.", "error"); - if (fromSelector) { - target = null; - continue; - } - return; - } - - if (reviewLoopFixingEnabled) { - await runLoopFixingReview(ctx, target, extraInstruction); - 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; - - // In an empty session, default to fresh review mode so /end-review works consistently. - let useFreshSession = messageCount === 0; - - 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"; - } - - await executeReview(ctx, target, useFreshSession, { extraInstruction }); - return; - } - }, - }); - - // Custom prompt for review summaries - focuses on preserving actionable findings - const REVIEW_SUMMARY_PROMPT = `We are leaving a code-review branch and returning to the main coding branch. -Create a structured handoff that can be used immediately to implement fixes. - -You MUST summarize the review that happened in this branch so findings can be acted on. -Do not omit findings: include every actionable issue that was identified. - -Required sections (in order): - -## Review Scope -- What was reviewed (files/paths, changes, and scope) - -## Verdict -- "correct" or "needs attention" - -## Findings -For EACH finding, include: -- Priority tag ([P0]..[P3]) and short title -- File location (\`path/to/file.ext:line\`) -- Why it matters (brief) -- What should change (brief, actionable) - -## Fix Queue -1. Ordered implementation checklist (highest priority first) - -## Constraints & Preferences -- Any constraints or preferences mentioned during review -- Or "(none)" - -## Human Reviewer Callouts (Non-Blocking) -Include only applicable callouts (no yes/no lines): -- **This change adds a database migration:** -- **This change introduces a new dependency:** -- **This change changes a dependency (or the lockfile):** -- **This change modifies auth/permission behavior:** -- **This change introduces backwards-incompatible public schema/API/contract changes:** -- **This change includes irreversible or destructive operations:** - -If none apply, write "- (none)". - -These are informational callouts for humans and are not fix items by themselves. - -Preserve exact file paths, function names, and error messages where available.`; - - const REVIEW_FIX_FINDINGS_PROMPT = `Use the latest review summary in this session and implement the review findings now. - -Instructions: -1. Treat the summary's Findings/Fix Queue as a checklist. -2. Fix in priority order: P0, P1, then P2 (include P3 if quick and safe). -3. If a finding is invalid/already fixed/not possible right now, briefly explain why and continue. -4. Treat "Human Reviewer Callouts (Non-Blocking)" as informational only; do not convert them into fix tasks unless there is a separate explicit finding. -5. Follow fail-fast error handling: do not add local catch/fallback recovery unless this scope is an explicit boundary that can safely translate the failure. -6. If you add or keep a \`try/catch\`, explain the expected failure mode and either rethrow with context or return a boundary-safe error response. -7. JSON parsing/decoding should fail loudly by default; avoid silent fallback parsing. -8. Run relevant tests/checks for touched code where practical. -9. End with: fixed items, deferred/skipped items (with reasons), and verification results.`; - - type EndReviewAction = "returnOnly" | "returnAndFix" | "returnAndSummarize"; - type EndReviewActionResult = "ok" | "cancelled" | "error"; - type EndReviewActionOptions = { - showSummaryLoader?: boolean; - notifySuccess?: boolean; - }; - - function getActiveReviewOrigin(ctx: ExtensionContext): string | undefined { - if (reviewOriginId) { - return reviewOriginId; - } - - const state = getReviewState(ctx); - if (state?.active && state.originId) { - reviewOriginId = state.originId; - return reviewOriginId; - } - - if (state?.active) { - setReviewWidget(ctx, false); - pi.appendEntry(REVIEW_STATE_TYPE, { active: false }); - ctx.ui.notify( - "Review state was missing origin info; cleared review status.", - "warning", - ); - } - - return undefined; - } - - function clearReviewState(ctx: ExtensionContext) { - setReviewWidget(ctx, false); - reviewOriginId = undefined; - pi.appendEntry(REVIEW_STATE_TYPE, { active: false }); - } - - async function navigateWithSummary( - ctx: ExtensionCommandContext, - originId: string, - showLoader: boolean, - ): Promise<{ cancelled: boolean; error?: string } | null> { - if (showLoader && ctx.hasUI) { - return ctx.ui.custom<{ cancelled: boolean; error?: string } | null>( - (tui, theme, _kb, done) => { - const loader = new BorderedLoader( - tui, - theme, - "Returning and 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; - }, - ); - } - - try { - return await ctx.navigateTree(originId, { - summarize: true, - customInstructions: REVIEW_SUMMARY_PROMPT, - replaceInstructions: true, - }); - } catch (error) { - return { - cancelled: false, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async function executeEndReviewAction( - ctx: ExtensionCommandContext, - action: EndReviewAction, - options: EndReviewActionOptions = {}, - ): Promise { - const originId = getActiveReviewOrigin(ctx); - if (!originId) { - if (!getReviewState(ctx)?.active) { - ctx.ui.notify( - "Not in a review branch (use /review first, or review was started in current session mode)", - "info", - ); - } - return "error"; - } - - const notifySuccess = options.notifySuccess ?? true; - - if (action === "returnOnly") { - try { - const result = await ctx.navigateTree(originId, { summarize: false }); - if (result.cancelled) { - ctx.ui.notify( - "Navigation cancelled. Use /end-review to try again.", - "info", - ); - return "cancelled"; - } - } catch (error) { - ctx.ui.notify( - `Failed to return: ${error instanceof Error ? error.message : String(error)}`, - "error", - ); - return "error"; - } - - clearReviewState(ctx); - if (notifySuccess) { - ctx.ui.notify( - "Review complete! Returned to original position.", - "info", - ); - } - return "ok"; - } - - const summaryResult = await navigateWithSummary( - ctx, - originId, - options.showSummaryLoader ?? false, - ); - if (summaryResult === null) { - ctx.ui.notify( - "Summarization cancelled. Use /end-review to try again.", - "info", - ); - return "cancelled"; - } - - if (summaryResult.error) { - ctx.ui.notify(`Summarization failed: ${summaryResult.error}`, "error"); - return "error"; - } - - if (summaryResult.cancelled) { - ctx.ui.notify( - "Navigation cancelled. Use /end-review to try again.", - "info", - ); - return "cancelled"; - } - - clearReviewState(ctx); - - if (action === "returnAndSummarize") { - if (!ctx.ui.getEditorText().trim()) { - ctx.ui.setEditorText("Act on the review findings"); - } - if (notifySuccess) { - ctx.ui.notify("Review complete! Returned and summarized.", "info"); - } - return "ok"; - } - - pi.sendUserMessage(REVIEW_FIX_FINDINGS_PROMPT, { deliverAs: "followUp" }); - if (notifySuccess) { - ctx.ui.notify( - "Review complete! Returned and queued a follow-up to fix findings.", - "info", - ); - } - return "ok"; - } - - async function runEndReview(ctx: ExtensionCommandContext): Promise { - if (!ctx.hasUI) { - ctx.ui.notify("End-review requires interactive mode", "error"); - return; - } - - if (reviewLoopInProgress) { - ctx.ui.notify( - "Loop fixing review is running. Wait for it to finish.", - "info", - ); - return; - } - - if (endReviewInProgress) { - ctx.ui.notify("/end-review is already running", "info"); - return; - } - - endReviewInProgress = true; - try { - const choice = await ctx.ui.select("Finish review:", [ - "Return only", - "Return and fix findings", - "Return and summarize", - ]); - - if (choice === undefined) { - ctx.ui.notify("Cancelled. Use /end-review to try again.", "info"); - return; - } - - const action: EndReviewAction = - choice === "Return and fix findings" - ? "returnAndFix" - : choice === "Return and summarize" - ? "returnAndSummarize" - : "returnOnly"; - - await executeEndReviewAction(ctx, action, { - showSummaryLoader: true, - notifySuccess: true, - }); - } finally { - endReviewInProgress = false; - } - } - - // Register the /end-review command - pi.registerCommand("end-review", { - description: "Complete review and return to original position", - handler: async (_args, ctx) => { - await runEndReview(ctx); - }, - }); -} diff --git a/dot_pi/agent/extensions/session-breakdown/index.ts b/dot_pi/agent/extensions/session-breakdown/index.ts --- a/dot_pi/agent/extensions/session-breakdown/index.ts +++ b/dot_pi/agent/extensions/session-breakdown/index.ts @@ -1,3 +1,5 @@ +// Source: mitsuhiko/agent-stuff (https://github.com/mitsuhiko/agent-stuff) +// Path: extensions/session-breakdown.ts /** * /session-breakdown * diff --git a/dot_pi/agent/extensions/session-name/index.ts b/dot_pi/agent/extensions/session-name/index.ts --- a/dot_pi/agent/extensions/session-name/index.ts +++ b/dot_pi/agent/extensions/session-name/index.ts @@ -1,3 +1,5 @@ +// Source: badlogic/pi-mono (https://github.com/badlogic/pi-mono) +// Path: packages/coding-agent/examples/extensions/session-name.ts /** * Session naming example. * diff --git a/dot_pi/agent/extensions/todos/index.ts b/dot_pi/agent/extensions/todos/index.ts --- a/dot_pi/agent/extensions/todos/index.ts +++ b/dot_pi/agent/extensions/todos/index.ts @@ -1,3 +1,5 @@ +// Source: mitsuhiko/agent-stuff (https://github.com/mitsuhiko/agent-stuff) +// Path: extensions/todos.ts /** * This extension stores todo items as files under (defaults to .pi/todos, * or the path in PI_TODO_PATH). Each todo is a standalone markdown file named @@ -56,7 +58,6 @@ Text, TUI, fuzzyMatch, - getEditorKeybindings, matchesKey, truncateToWidth, visibleWidth, @@ -97,6 +98,10 @@ gc: boolean; gcDays: number; } + +type KeybindingMatcher = { + matches: (keyData: string, keybindingId: string) => boolean; +}; const TodoParams = Type.Object({ action: StringEnum([ @@ -285,6 +290,7 @@ private onCancelCallback: () => void; private tui: TUI; private theme: Theme; + private keybindings: KeybindingMatcher; private headerText: Text; private hintText: Text; private currentSessionId?: string; @@ -301,6 +307,7 @@ constructor( tui: TUI, theme: Theme, + keybindings: KeybindingMatcher, todos: TodoFrontMatter[], onSelect: (todo: TodoFrontMatter) => void, onCancel: () => void, @@ -314,6 +321,7 @@ super(); this.tui = tui; this.theme = theme; + this.keybindings = keybindings; this.currentSessionId = currentSessionId; this.allTodos = todos; this.filteredTodos = todos; @@ -449,7 +457,7 @@ } handleInput(keyData: string): void { - const kb = getEditorKeybindings(); + const kb = this.keybindings; if (kb.matches(keyData, "tui.select.up")) { if (this.filteredTodos.length === 0) return; this.selectedIndex = @@ -636,15 +644,18 @@ private viewHeight = 0; private totalLines = 0; private onAction: (action: TodoOverlayAction) => void; + private keybindings: KeybindingMatcher; constructor( tui: TUI, theme: Theme, + keybindings: KeybindingMatcher, todo: TodoRecord, onAction: (action: TodoOverlayAction) => void, ) { this.tui = tui; this.theme = theme; + this.keybindings = keybindings; this.todo = todo; this.onAction = onAction; this.markdown = new Markdown( @@ -661,7 +672,7 @@ } handleInput(keyData: string): void { - const kb = getEditorKeybindings(); + const kb = this.keybindings; if (kb.matches(keyData, "tui.select.cancel")) { this.onAction("back"); return; @@ -678,11 +689,17 @@ this.scrollBy(1); return; } - if (kb.matches(keyData, "tui.select.pageUp")) { + if ( + kb.matches(keyData, "tui.select.pageUp") || + matchesKey(keyData, Key.left) + ) { this.scrollBy(-this.viewHeight || -1); return; } - if (kb.matches(keyData, "tui.select.pageDown")) { + if ( + kb.matches(keyData, "tui.select.pageDown") || + matchesKey(keyData, Key.right) + ) { this.scrollBy(this.viewHeight || 1); return; } @@ -792,7 +809,8 @@ this.theme.fg("accent", "enter") + this.theme.fg("muted", " work on todo"); const back = this.theme.fg("dim", "esc back"); - const pieces = [work, back]; + const nav = this.theme.fg("dim", "↑/↓: move. ←/→: page."); + const pieces = [work, back, nav]; let line = pieces.join(this.theme.fg("muted", " • ")); if (this.totalLines > this.viewHeight) { @@ -1607,7 +1625,6 @@ "Title is the short summary; body is long-form markdown notes (update replaces, append adds). " + "Todo ids are shown as TODO-; id parameters accept TODO- or the raw hex filename. " + "Claim tasks before working on them to avoid conflicts, and close them when complete.", - promptSnippet: `Manage file-based todos in ${todosDirLabel} (list, list-all, get, create, update, append, delete, claim, release)`, parameters: TodoParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { @@ -2000,21 +2017,6 @@ pi.registerCommand("todos", { description: "List todos from .pi/todos", - getArgumentCompletions: (argumentPrefix: string) => { - const todos = listTodosSync(getTodosDir(process.cwd())); - if (!todos.length) return null; - const matches = filterTodos(todos, argumentPrefix); - if (!matches.length) return null; - return matches.map((todo) => { - const title = todo.title || "(untitled)"; - const tags = todo.tags.length ? ` • ${todo.tags.join(", ")}` : ""; - return { - value: title, - label: `${formatTodoId(todo.id)} ${title}`, - description: `${todo.status || "open"}${tags}`, - }; - }); - }, handler: async (args, ctx) => { const todosDir = getTodosDir(ctx.cwd); const todos = await listTodos(todosDir); @@ -2029,7 +2031,7 @@ let nextPrompt: string | null = null; let rootTui: TUI | null = null; - await ctx.ui.custom((tui, theme, _kb, done) => { + await ctx.ui.custom((tui, theme, keybindings, done) => { rootTui = tui; let selector: TodoSelectorComponent | null = null; let actionMenu: TodoActionMenuComponent | null = null; @@ -2103,10 +2105,11 @@ record: TodoRecord, ): Promise => { const action = await ctx.ui.custom( - (overlayTui, overlayTheme, _overlayKb, overlayDone) => + (overlayTui, overlayTheme, overlayKeybindings, overlayDone) => new TodoDetailOverlayComponent( overlayTui, overlayTheme, + overlayKeybindings, record, overlayDone, ), @@ -2266,6 +2269,7 @@ selector = new TodoSelectorComponent( tui, theme, + keybindings, todos, (todo) => { void handleSelect(todo); diff --git a/dot_pi/agent/extensions/uv/index.ts b/dot_pi/agent/extensions/uv/index.ts --- a/dot_pi/agent/extensions/uv/index.ts +++ b/dot_pi/agent/extensions/uv/index.ts @@ -1,3 +1,5 @@ +// Source: mitsuhiko/agent-stuff (https://github.com/mitsuhiko/agent-stuff) +// Path: extensions/uv.ts /** * UV Extension - Redirects Python tooling to uv equivalents * diff --git a/dot_pi/agent/extensions/whimsical/index.ts b/dot_pi/agent/extensions/whimsical/index.ts --- a/dot_pi/agent/extensions/whimsical/index.ts +++ b/dot_pi/agent/extensions/whimsical/index.ts @@ -1,3 +1,5 @@ +// Source: mitsuhiko/agent-stuff (https://github.com/mitsuhiko/agent-stuff) +// Path: extensions/whimsical.ts /** * Whimsical - Fun loading messages *