index.ts
1 import { spawnSync } from "node:child_process"; 2 import * as path from "node:path"; 3 4 import type { ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent"; 5 import { highlightCode, Theme } from "@mariozechner/pi-coding-agent"; 6 import { Text } from "@mariozechner/pi-tui"; 7 import { Type } from "@sinclair/typebox"; 8 import * as Diff from "diff"; 9 10 import { loadConfig } from "./config.js"; 11 import { 12 computeSliceRangeFromReadArgs, 13 countFileLines, 14 inferSelectionStatus, 15 toPosixPath, 16 } from "./auto-select.js"; 17 import { RP_READCACHE_CUSTOM_TYPE, SCOPE_FULL, scopeRange } from "./readcache/constants.js"; 18 import { buildInvalidationV1 } from "./readcache/meta.js"; 19 import { getStoreStats, pruneObjectsOlderThan } from "./readcache/object-store.js"; 20 import { readFileWithCache } from "./readcache/read-file.js"; 21 import { clearReplayRuntimeState, createReplayRuntimeState } from "./readcache/replay.js"; 22 import { clearRootsCache, resolveReadFilePath } from "./readcache/resolve.js"; 23 import type { RpReadcacheMetaV1, ScopeKey } from "./readcache/types.js"; 24 import type { 25 AutoSelectionEntryData, 26 AutoSelectionEntryRangeData, 27 AutoSelectionEntrySliceData, 28 RpCliBindingEntryData, 29 } from "./types.js"; 30 31 let parseBash: ((input: string) => any) | null = null; 32 let justBashLoadPromise: Promise<void> | null = null; 33 let justBashLoadDone = false; 34 35 async function ensureJustBashLoaded(): Promise<void> { 36 if (justBashLoadDone) return; 37 38 if (!justBashLoadPromise) { 39 justBashLoadPromise = import("just-bash") 40 .then((mod: any) => { 41 parseBash = typeof mod?.parse === "function" ? mod.parse : null; 42 }) 43 .catch(() => { 44 parseBash = null; 45 }) 46 .finally(() => { 47 justBashLoadDone = true; 48 }); 49 } 50 51 await justBashLoadPromise; 52 } 53 54 let warnedAstUnavailable = false; 55 function maybeWarnAstUnavailable(ctx: any): void { 56 if (warnedAstUnavailable) return; 57 if (parseBash) return; 58 if (!ctx?.hasUI) return; 59 60 warnedAstUnavailable = true; 61 ctx.ui.notify( 62 "repoprompt-cli: just-bash >= 2 is not available; falling back to best-effort command parsing", 63 "warning", 64 ); 65 } 66 67 type BashInvocation = { 68 statementIndex: number; 69 pipelineIndex: number; 70 pipelineLength: number; 71 commandNameRaw: string; 72 commandName: string; 73 args: string[]; 74 }; 75 76 function commandBaseName(value: string): string { 77 const normalized = value.replace(/\\+/g, "/"); 78 const idx = normalized.lastIndexOf("/"); 79 const base = idx >= 0 ? normalized.slice(idx + 1) : normalized; 80 return base.toLowerCase(); 81 } 82 83 function partToText(part: any): string { 84 if (!part || typeof part !== "object") return ""; 85 86 switch (part.type) { 87 case "Literal": 88 case "SingleQuoted": 89 case "Escaped": 90 return typeof part.value === "string" ? part.value : ""; 91 case "DoubleQuoted": 92 return Array.isArray(part.parts) ? part.parts.map(partToText).join("") : ""; 93 case "Glob": 94 return typeof part.pattern === "string" ? part.pattern : ""; 95 case "TildeExpansion": 96 return typeof part.user === "string" && part.user.length > 0 ? `~${part.user}` : "~"; 97 case "ParameterExpansion": 98 return typeof part.parameter === "string" && part.parameter.length > 0 99 ? "${" + part.parameter + "}" 100 : "${}"; 101 case "CommandSubstitution": 102 return "$(...)"; 103 case "ProcessSubstitution": 104 return part.direction === "output" ? ">(...)" : "<(...)"; 105 case "ArithmeticExpansion": 106 return "$((...))"; 107 default: 108 return ""; 109 } 110 } 111 112 function wordToText(word: any): string { 113 if (!word || typeof word !== "object" || !Array.isArray(word.parts)) return ""; 114 return word.parts.map(partToText).join(""); 115 } 116 117 function analyzeTopLevelBashScript(command: string): { parseError?: string; topLevelInvocations: BashInvocation[] } { 118 try { 119 if (!parseBash) { 120 return { parseError: "just-bash parse unavailable", topLevelInvocations: [] }; 121 } 122 123 const ast: any = parseBash(command); 124 const topLevelInvocations: BashInvocation[] = []; 125 126 if (!ast || typeof ast !== "object" || !Array.isArray(ast.statements)) { 127 return { topLevelInvocations }; 128 } 129 130 ast.statements.forEach((statement: any, statementIndex: number) => { 131 if (!statement || typeof statement !== "object" || !Array.isArray(statement.pipelines)) return; 132 133 statement.pipelines.forEach((pipeline: any, pipelineIndex: number) => { 134 if (!pipeline || typeof pipeline !== "object" || !Array.isArray(pipeline.commands)) return; 135 136 const pipelineLength = pipeline.commands.length; 137 pipeline.commands.forEach((commandNode: any) => { 138 if (!commandNode || commandNode.type !== "SimpleCommand") return; 139 140 const commandNameRaw = wordToText(commandNode.name).trim(); 141 if (!commandNameRaw) return; 142 143 const args = Array.isArray(commandNode.args) 144 ? commandNode.args.map((arg: any) => wordToText(arg)).filter(Boolean) 145 : []; 146 147 topLevelInvocations.push({ 148 statementIndex, 149 pipelineIndex, 150 pipelineLength, 151 commandNameRaw, 152 commandName: commandBaseName(commandNameRaw), 153 args, 154 }); 155 }); 156 }); 157 }); 158 159 return { topLevelInvocations }; 160 } catch (error: any) { 161 return { 162 parseError: error?.message ?? String(error), 163 topLevelInvocations: [], 164 }; 165 } 166 } 167 168 function hasSemicolonOutsideQuotes(script: string): boolean { 169 let inSingleQuote = false; 170 let inDoubleQuote = false; 171 let escaped = false; 172 173 for (let i = 0; i < script.length; i += 1) { 174 const ch = script[i]; 175 176 if (escaped) { 177 escaped = false; 178 continue; 179 } 180 181 if (ch === "\\") { 182 escaped = true; 183 continue; 184 } 185 186 if (!inDoubleQuote && ch === "'") { 187 inSingleQuote = !inSingleQuote; 188 continue; 189 } 190 191 if (!inSingleQuote && ch === '"') { 192 inDoubleQuote = !inDoubleQuote; 193 continue; 194 } 195 196 if (!inSingleQuote && !inDoubleQuote && ch === ";") { 197 return true; 198 } 199 } 200 201 return false; 202 } 203 204 function hasPipeOutsideQuotes(script: string): boolean { 205 let inSingleQuote = false; 206 let inDoubleQuote = false; 207 let escaped = false; 208 209 for (let i = 0; i < script.length; i += 1) { 210 const ch = script[i]; 211 212 if (escaped) { 213 escaped = false; 214 continue; 215 } 216 217 if (ch === "\\") { 218 escaped = true; 219 continue; 220 } 221 222 if (!inDoubleQuote && ch === "'") { 223 inSingleQuote = !inSingleQuote; 224 continue; 225 } 226 227 if (!inSingleQuote && ch === '"') { 228 inDoubleQuote = !inDoubleQuote; 229 continue; 230 } 231 232 if (!inSingleQuote && !inDoubleQuote && ch === "|") { 233 return true; 234 } 235 } 236 237 return false; 238 } 239 240 /** 241 * RepoPrompt CLI ↔ Pi integration extension 242 * 243 * Registers two Pi tools: 244 * - `rp_bind`: binds a RepoPrompt window + compose tab (routing) 245 * - `rp_exec`: runs `rp-cli -e <cmd>` against that binding (quiet defaults, output truncation) 246 * 247 * Safety goals: 248 * - Prevent "unbound" rp_exec calls from operating on an unintended window/workspace 249 * - Prevent in-place workspace switches by default (they can clobber selection/prompt/context) 250 * - Block delete-like commands unless explicitly allowed 251 * 252 * UX goals: 253 * - Persist binding across session reloads via `pi.appendEntry()` (does not enter LLM context) 254 * - Provide actionable error messages when blocked 255 * - For best command parsing (AST-based), install `just-bash` >= 2; otherwise it falls back to a legacy splitter 256 * - Syntax-highlight fenced code blocks in output (read, structure, etc.) 257 * - Delta-powered diff highlighting (with graceful fallback when delta is unavailable) 258 */ 259 260 const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; 261 const DEFAULT_MAX_OUTPUT_CHARS = 12000; 262 const BINDING_CUSTOM_TYPE = "repoprompt-binding"; 263 const AUTO_SELECTION_CUSTOM_TYPE = "repoprompt-cli-auto-selection"; 264 const WINDOWS_CACHE_TTL_MS = 5000; 265 const BINDING_VALIDATION_TTL_MS = 5000; 266 267 interface RpCliWindow { 268 windowId: number; 269 workspaceId?: string; 270 workspaceName?: string; 271 rootFolderPaths?: string[]; 272 } 273 274 const BindParams = Type.Object({ 275 windowId: Type.Number({ description: "RepoPrompt window id (from `rp-cli -e windows`)" }), 276 tab: Type.String({ description: "RepoPrompt compose tab name or UUID" }), 277 }); 278 279 const ExecParams = Type.Object({ 280 cmd: Type.String({ description: "rp-cli exec string (e.g. `tree`, `select set src/ && context`)" }), 281 rawJson: Type.Optional(Type.Boolean({ description: "Pass --raw-json to rp-cli" })), 282 quiet: Type.Optional(Type.Boolean({ description: "Pass -q/--quiet to rp-cli (default: true)" })), 283 failFast: Type.Optional(Type.Boolean({ description: "Pass --fail-fast to rp-cli (default: true)" })), 284 timeoutMs: Type.Optional(Type.Number({ description: "Timeout in ms (default: 15 minutes)" })), 285 maxOutputChars: Type.Optional(Type.Number({ description: "Truncate output to this many chars (default: 12000)" })), 286 windowId: Type.Optional(Type.Number({ description: "Override bound window id for this call" })), 287 tab: Type.Optional(Type.String({ description: "Override bound tab for this call" })), 288 allowDelete: Type.Optional( 289 Type.Boolean({ description: "Allow delete commands like `file delete ...` or `workspace delete ...` (default: false)" }), 290 ), 291 allowWorkspaceSwitchInPlace: Type.Optional( 292 Type.Boolean({ 293 description: 294 "Allow in-place workspace changes (e.g. `workspace switch <name>` or `workspace create ... --switch`) without --new-window (default: false). In-place switching can disrupt other sessions", 295 }), 296 ), 297 failOnNoopEdits: Type.Optional( 298 Type.Boolean({ 299 description: "Treat edit commands that apply 0 changes (or produce empty output) as errors (default: true)", 300 }), 301 ), 302 }); 303 304 function truncateText(text: string, maxChars: number): { text: string; truncated: boolean } { 305 if (maxChars <= 0) return { text: "", truncated: text.length > 0 }; 306 if (text.length <= maxChars) return { text, truncated: false }; 307 return { 308 text: `${text.slice(0, maxChars)}\n… [truncated; redirect output to a file if needed]`, 309 truncated: true, 310 }; 311 } 312 313 type ParsedCommandChain = { 314 commands: string[]; 315 invocations: BashInvocation[]; 316 hasSemicolonOutsideQuotes: boolean; 317 }; 318 319 function parseCommandChainLegacy(cmd: string): { commands: string[]; hasSemicolonOutsideQuotes: boolean } { 320 const commands: string[] = []; 321 let current = ""; 322 let inSingleQuote = false; 323 let inDoubleQuote = false; 324 let escaped = false; 325 let hasSemicolonOutsideQuotes = false; 326 327 const pushCurrent = () => { 328 const trimmed = current.trim(); 329 if (trimmed.length > 0) commands.push(trimmed); 330 current = ""; 331 }; 332 333 for (let i = 0; i < cmd.length; i += 1) { 334 const ch = cmd[i]; 335 336 if (escaped) { 337 current += ch; 338 escaped = false; 339 continue; 340 } 341 342 if (ch === "\\") { 343 current += ch; 344 escaped = true; 345 continue; 346 } 347 348 if (!inDoubleQuote && ch === "'") { 349 inSingleQuote = !inSingleQuote; 350 current += ch; 351 continue; 352 } 353 354 if (!inSingleQuote && ch === '"') { 355 inDoubleQuote = !inDoubleQuote; 356 current += ch; 357 continue; 358 } 359 360 if (!inSingleQuote && !inDoubleQuote) { 361 if (ch === "&" && cmd[i + 1] === "&") { 362 pushCurrent(); 363 i += 1; 364 continue; 365 } 366 367 if (ch === ";") { 368 hasSemicolonOutsideQuotes = true; 369 pushCurrent(); 370 continue; 371 } 372 } 373 374 current += ch; 375 } 376 377 pushCurrent(); 378 return { commands, hasSemicolonOutsideQuotes }; 379 } 380 381 function renderInvocation(invocation: BashInvocation): string { 382 return [invocation.commandNameRaw, ...invocation.args].filter(Boolean).join(" ").trim(); 383 } 384 385 function parseCommandChain(cmd: string): ParsedCommandChain { 386 const semicolonOutsideQuotes = hasSemicolonOutsideQuotes(cmd); 387 const analysis = analyzeTopLevelBashScript(cmd); 388 389 if (!analysis.parseError && analysis.topLevelInvocations.length > 0) { 390 const commands = analysis.topLevelInvocations 391 .map(renderInvocation) 392 .filter((command) => command.length > 0); 393 394 return { 395 commands, 396 invocations: analysis.topLevelInvocations, 397 hasSemicolonOutsideQuotes: semicolonOutsideQuotes, 398 }; 399 } 400 401 const legacy = parseCommandChainLegacy(cmd); 402 return { 403 commands: legacy.commands, 404 invocations: [], 405 hasSemicolonOutsideQuotes: legacy.hasSemicolonOutsideQuotes || semicolonOutsideQuotes, 406 }; 407 } 408 409 function looksLikeDeleteCommand(cmd: string): boolean { 410 const parsed = parseCommandChain(cmd); 411 412 if (parsed.invocations.length > 0) { 413 for (const invocation of parsed.invocations) { 414 const commandName = invocation.commandName; 415 const args = invocation.args.map((arg) => arg.toLowerCase()); 416 417 if (commandName === "file" && args[0] === "delete") return true; 418 if (commandName === "workspace" && args[0] === "delete") return true; 419 420 if (commandName === "call") { 421 const normalized = args.join(" "); 422 if ( 423 /\baction\s*=\s*delete\b/.test(normalized) 424 || /"action"\s*:\s*"delete"/.test(normalized) 425 || /'action'\s*:\s*'delete'/.test(normalized) 426 ) { 427 return true; 428 } 429 } 430 } 431 return false; 432 } 433 434 // Fallback when parsing fails 435 for (const command of parsed.commands) { 436 const normalized = command.trim().toLowerCase(); 437 if (normalized === "file delete" || normalized.startsWith("file delete ")) return true; 438 if (normalized === "workspace delete" || normalized.startsWith("workspace delete ")) return true; 439 440 if ( 441 normalized.startsWith("call ") 442 && ( 443 /\baction\s*=\s*delete\b/.test(normalized) 444 || /"action"\s*:\s*"delete"/.test(normalized) 445 || /'action'\s*:\s*'delete'/.test(normalized) 446 ) 447 ) { 448 return true; 449 } 450 } 451 452 return false; 453 } 454 455 function looksLikeWorkspaceSwitchInPlace(cmd: string): boolean { 456 const parsed = parseCommandChain(cmd); 457 458 if (parsed.invocations.length > 0) { 459 for (const invocation of parsed.invocations) { 460 if (invocation.commandName !== "workspace") continue; 461 462 const args = invocation.args.map((arg) => arg.toLowerCase()); 463 const action = args[0] ?? ""; 464 const hasNewWindow = args.includes("--new-window"); 465 const hasSwitchFlag = args.includes("--switch"); 466 467 if (action === "switch" && !hasNewWindow) return true; 468 if (action === "create" && hasSwitchFlag && !hasNewWindow) return true; 469 } 470 471 return false; 472 } 473 474 // Fallback when parsing fails 475 for (const command of parsed.commands) { 476 const normalized = command.toLowerCase(); 477 478 if (normalized.startsWith("workspace switch ") && !normalized.includes("--new-window")) return true; 479 480 const isCreate = normalized.startsWith("workspace create "); 481 const requestsSwitch = /\B--switch\b/.test(normalized); 482 if (isCreate && requestsSwitch && !normalized.includes("--new-window")) return true; 483 } 484 485 return false; 486 } 487 488 function looksLikeEditCommand(cmd: string): boolean { 489 const parsed = parseCommandChain(cmd); 490 491 if (parsed.invocations.length > 0) { 492 return parsed.invocations.some((invocation) => { 493 if (invocation.commandName === "edit") return true; 494 if (invocation.commandName !== "call") return false; 495 496 return invocation.args.some((arg) => arg.toLowerCase().includes("apply_edits")); 497 }); 498 } 499 500 return parsed.commands.some((command) => { 501 const normalized = command.trim().toLowerCase(); 502 if (normalized === "edit" || normalized.startsWith("edit ")) return true; 503 return normalized.startsWith("call ") && normalized.includes("apply_edits"); 504 }); 505 } 506 507 type ParsedReadFileRequest = { 508 cmdToRun: string; 509 path: string; 510 startLine?: number; 511 limit?: number; 512 bypassCache: boolean; 513 514 // Whether it is safe to apply readcache substitution (marker/diff) for this request 515 // When false, we may still rewrite cmdToRun to strip wrapper-only args like bypass_cache=true 516 cacheable: boolean; 517 }; 518 519 function parseReadFileRequest(cmd: string): ParsedReadFileRequest | null { 520 const parsed = parseCommandChain(cmd); 521 522 // Only handle simple, single-invocation commands to avoid surprising behavior 523 if (parsed.hasSemicolonOutsideQuotes) return null; 524 525 let commandNameRaw: string; 526 let commandName: string; 527 let rawArgs: string[]; 528 529 if (parsed.invocations.length === 1) { 530 const invocation = parsed.invocations[0]; 531 if (!invocation) return null; 532 if (invocation.pipelineLength !== 1) return null; 533 534 commandNameRaw = invocation.commandNameRaw; 535 commandName = invocation.commandName; 536 rawArgs = invocation.args; 537 } else if (parsed.invocations.length === 0 && parsed.commands.length === 1) { 538 const commandText = parsed.commands[0]?.trim() ?? ""; 539 if (!commandText) return null; 540 541 // Legacy parsing fallback (just-bash unavailable): only attempt for trivially-tokenizable, single commands 542 if (hasPipeOutsideQuotes(commandText)) return null; 543 if (commandText.includes("\\")) return null; 544 if (commandText.includes("\"") || commandText.includes("'") || commandText.includes("`")) return null; 545 546 const parts = commandText.split(/\s+/).filter(Boolean); 547 if (parts.length === 0) return null; 548 549 commandNameRaw = parts[0] ?? ""; 550 commandName = commandBaseName(commandNameRaw); 551 rawArgs = parts.slice(1); 552 } else { 553 return null; 554 } 555 556 if (commandName !== "read" && commandName !== "cat" && commandName !== "read_file") { 557 return null; 558 } 559 560 let inputPath: string | undefined; 561 let startLine: number | undefined; 562 let limit: number | undefined; 563 let bypassCache = false; 564 let sawUnknownArg = false; 565 566 const getNumber = (value: string): number | undefined => { 567 if (!/^-?\d+$/.test(value.trim())) { 568 return undefined; 569 } 570 571 const parsedInt = Number.parseInt(value, 10); 572 return Number.isFinite(parsedInt) ? parsedInt : undefined; 573 }; 574 575 const normalizeKey = (raw: string): string => { 576 const trimmed = raw.trim().toLowerCase(); 577 const withoutDashes = trimmed.replace(/^--+/, ""); 578 return withoutDashes.replace(/-/g, "_"); 579 }; 580 581 const parseSliceSuffix = (value: string): { basePath: string; startLine: number; limit?: number } | null => { 582 // Slice notation: path:start-end OR path:start 583 // Example: file.swift:10-50 584 const match = /^(.*?):(\d+)(?:-(\d+))?$/.exec(value); 585 if (!match) return null; 586 587 const basePath = match[1]; 588 const start = Number.parseInt(match[2] ?? "", 10); 589 const end = match[3] ? Number.parseInt(match[3], 10) : undefined; 590 591 if (!basePath || !Number.isFinite(start) || start <= 0) { 592 return null; 593 } 594 595 if (end === undefined) { 596 return { basePath, startLine: start }; 597 } 598 599 if (!Number.isFinite(end) || end < start) { 600 return null; 601 } 602 603 return { basePath, startLine: start, limit: end - start + 1 }; 604 }; 605 606 const filteredArgs: string[] = []; 607 608 for (let i = 0; i < rawArgs.length; i += 1) { 609 const arg = rawArgs[i] ?? ""; 610 611 // Flags: --start-line 10, --limit 50, also support --start-line=10 612 if (arg.startsWith("--")) { 613 const eqIdx = arg.indexOf("="); 614 if (eqIdx > 0) { 615 const rawKey = arg.slice(0, eqIdx); 616 const key = normalizeKey(rawKey); 617 const value = arg.slice(eqIdx + 1).trim(); 618 619 if (key === "start_line") { 620 const parsedNumber = getNumber(value); 621 if (parsedNumber === undefined) { 622 sawUnknownArg = true; 623 } else { 624 startLine = parsedNumber; 625 } 626 filteredArgs.push(arg); 627 continue; 628 } 629 630 if (key === "limit") { 631 const parsedNumber = getNumber(value); 632 if (parsedNumber === undefined) { 633 sawUnknownArg = true; 634 } else { 635 limit = parsedNumber; 636 } 637 filteredArgs.push(arg); 638 continue; 639 } 640 641 sawUnknownArg = true; 642 filteredArgs.push(arg); 643 continue; 644 } 645 646 const key = normalizeKey(arg); 647 if (key === "start_line") { 648 const value = rawArgs[i + 1]; 649 if (typeof value === "string") { 650 const parsedNumber = getNumber(value); 651 if (parsedNumber === undefined) { 652 sawUnknownArg = true; 653 } else { 654 startLine = parsedNumber; 655 } 656 i += 1; 657 filteredArgs.push(arg, value); 658 continue; 659 } 660 } 661 662 if (key === "limit") { 663 const value = rawArgs[i + 1]; 664 if (typeof value === "string") { 665 const parsedNumber = getNumber(value); 666 if (parsedNumber === undefined) { 667 sawUnknownArg = true; 668 } else { 669 limit = parsedNumber; 670 } 671 i += 1; 672 filteredArgs.push(arg, value); 673 continue; 674 } 675 } 676 677 // Unknown flag: keep it 678 sawUnknownArg = true; 679 filteredArgs.push(arg); 680 continue; 681 } 682 683 // key=value pairs (rp-cli supports key=value and also dash->underscore) 684 const eqIdx = arg.indexOf("="); 685 if (eqIdx > 0) { 686 const key = normalizeKey(arg.slice(0, eqIdx)); 687 const value = arg.slice(eqIdx + 1).trim(); 688 689 // wrapper-only knob (do not forward) 690 if (key === "bypass_cache") { 691 bypassCache = value === "true" || value === "1"; 692 continue; 693 } 694 695 if (key === "path") { 696 const slice = parseSliceSuffix(value); 697 if (slice) { 698 inputPath = slice.basePath; 699 if (startLine === undefined) startLine = slice.startLine; 700 if (limit === undefined && slice.limit !== undefined) limit = slice.limit; 701 } else { 702 inputPath = value; 703 } 704 705 filteredArgs.push(arg); 706 continue; 707 } 708 709 if (key === "start_line") { 710 const parsedNumber = getNumber(value); 711 if (parsedNumber === undefined) { 712 sawUnknownArg = true; 713 } else { 714 startLine = parsedNumber; 715 } 716 filteredArgs.push(arg); 717 continue; 718 } 719 720 if (key === "limit") { 721 const parsedNumber = getNumber(value); 722 if (parsedNumber === undefined) { 723 sawUnknownArg = true; 724 } else { 725 limit = parsedNumber; 726 } 727 filteredArgs.push(arg); 728 continue; 729 } 730 731 sawUnknownArg = true; 732 filteredArgs.push(arg); 733 continue; 734 } 735 736 // positional path 737 if (!inputPath && !arg.startsWith("-")) { 738 const slice = parseSliceSuffix(arg); 739 if (slice) { 740 inputPath = slice.basePath; 741 if (startLine === undefined) startLine = slice.startLine; 742 if (limit === undefined && slice.limit !== undefined) limit = slice.limit; 743 } else { 744 inputPath = arg; 745 } 746 747 filteredArgs.push(arg); 748 continue; 749 } 750 751 // positional start/limit (shorthand: read <path> [start] [limit]) 752 if (inputPath && startLine === undefined) { 753 const startCandidate = getNumber(arg); 754 if (typeof startCandidate === "number") { 755 startLine = startCandidate; 756 filteredArgs.push(arg); 757 continue; 758 } 759 } 760 761 if (inputPath && startLine !== undefined && limit === undefined) { 762 const limitCandidate = getNumber(arg); 763 if (typeof limitCandidate === "number") { 764 limit = limitCandidate; 765 filteredArgs.push(arg); 766 continue; 767 } 768 } 769 770 sawUnknownArg = true; 771 filteredArgs.push(arg); 772 } 773 774 if (!inputPath) { 775 return null; 776 } 777 778 let cmdToRun = [commandNameRaw, ...filteredArgs].filter(Boolean).join(" "); 779 780 // Canonicalize into rp-cli's documented read shorthand syntax so that equivalent forms behave consistently 781 // (especially for bypass_cache=true tests) 782 const safePathForRewrite = /^\S+$/.test(inputPath); 783 if (!sawUnknownArg && safePathForRewrite) { 784 if (commandName === "read_file") { 785 const parts: string[] = [commandNameRaw, `path=${inputPath}`]; 786 if (typeof startLine === "number") parts.push(`start_line=${startLine}`); 787 if (typeof limit === "number") parts.push(`limit=${limit}`); 788 cmdToRun = parts.join(" "); 789 } else { 790 const parts: string[] = [commandNameRaw, inputPath]; 791 if (typeof startLine === "number") parts.push(String(startLine)); 792 if (typeof limit === "number") parts.push(String(limit)); 793 cmdToRun = parts.join(" "); 794 } 795 } 796 797 return { 798 cmdToRun, 799 path: inputPath, 800 ...(typeof startLine === "number" ? { startLine } : {}), 801 ...(typeof limit === "number" ? { limit } : {}), 802 bypassCache, 803 cacheable: !sawUnknownArg, 804 }; 805 } 806 807 function parseLeadingInt(text: string): number | undefined { 808 const trimmed = text.trimStart(); 809 let digits = ''; 810 811 for (const ch of trimmed) { 812 if (ch >= '0' && ch <= '9') { 813 digits += ch; 814 } else { 815 break; 816 } 817 } 818 819 return digits.length > 0 ? Number.parseInt(digits, 10) : undefined; 820 } 821 822 function looksLikeNoopEditOutput(output: string): boolean { 823 const trimmed = output.trim(); 824 if (trimmed.length === 0) return true; 825 826 const lower = trimmed.toLowerCase(); 827 828 if (lower.includes('search block not found')) return true; 829 830 const appliedIndex = lower.indexOf('applied'); 831 if (appliedIndex !== -1) { 832 const afterLabel = trimmed.slice(appliedIndex + 'applied'.length); 833 const colonIndex = afterLabel.indexOf(':'); 834 835 if (colonIndex !== -1 && colonIndex < 10) { 836 const appliedCount = parseLeadingInt(afterLabel.slice(colonIndex + 1)); 837 if (appliedCount !== undefined) return appliedCount === 0; 838 } 839 } 840 841 // Fallback heuristics when the output format doesn't include an explicit applied count 842 if (lower.includes('lines changed: 0')) return true; 843 if (lower.includes('lines_changed') && lower.includes(': 0')) return true; 844 845 return false; 846 } 847 848 function isSafeSingleCommandToRunUnbound(cmd: string): boolean { 849 const parsed = parseCommandChain(cmd); 850 851 if (parsed.invocations.length > 0) { 852 if (parsed.invocations.length !== 1) return false; 853 const invocation = parsed.invocations[0]; 854 const commandName = invocation.commandName; 855 const args = invocation.args.map((arg) => arg.toLowerCase()); 856 857 if (commandName === "windows") return true; 858 if (commandName === "help") return true; 859 if (commandName === "refresh" && args.length === 0) return true; 860 if (commandName === "tabs" && (args.length === 0 || args[0] === "list")) return true; 861 862 if (commandName === "workspace") { 863 const action = args[0] ?? ""; 864 if (action === "list") return true; 865 if (action === "tabs") return true; 866 if (action === "switch" && args.includes("--new-window")) return true; 867 if (action === "create" && args.includes("--new-window")) return true; 868 } 869 870 return false; 871 } 872 873 // Fallback when parsing fails 874 const normalized = cmd.trim().toLowerCase(); 875 876 if (normalized === "windows" || normalized.startsWith("windows ")) return true; 877 if (normalized === "help" || normalized.startsWith("help ")) return true; 878 if (normalized === "refresh") return true; 879 880 if (normalized === "workspace list") return true; 881 if (normalized === "workspace tabs") return true; 882 if (normalized === "tabs" || normalized === "tabs list") return true; 883 884 if (normalized.startsWith("workspace switch ") && normalized.includes("--new-window")) return true; 885 if (normalized.startsWith("workspace create ") && normalized.includes("--new-window")) return true; 886 887 return false; 888 } 889 890 function isSafeToRunUnbound(cmd: string): boolean { 891 // Allow `&&` chains, but only if *every* sub-command is safe before binding 892 const parsed = parseCommandChain(cmd); 893 if (parsed.hasSemicolonOutsideQuotes) return false; 894 895 if (parsed.invocations.length > 0) { 896 return parsed.invocations.every((invocation) => { 897 const commandText = renderInvocation(invocation); 898 return isSafeSingleCommandToRunUnbound(commandText); 899 }); 900 } 901 902 if (parsed.commands.length === 0) return false; 903 return parsed.commands.every((command) => isSafeSingleCommandToRunUnbound(command)); 904 } 905 906 function parseRpbindArgs(args: unknown): { windowId: number; tab: string } | { error: string } { 907 const parts = Array.isArray(args) ? args : []; 908 if (parts.length < 2) return { error: "Usage: /rpbind <window_id> <tab_name_or_uuid>" }; 909 910 const rawWindowId = String(parts[0]).trim(); 911 const windowId = Number.parseInt(rawWindowId, 10); 912 if (!Number.isFinite(windowId)) return { error: `Invalid window_id: ${rawWindowId}` }; 913 914 const tab = parts.slice(1).join(" ").trim(); 915 if (!tab) return { error: "Tab cannot be empty" }; 916 917 return { windowId, tab }; 918 } 919 920 // ───────────────────────────────────────────────────────────────────────────── 921 // Rendering utilities for rp_exec output 922 // ───────────────────────────────────────────────────────────────────────────── 923 924 interface FencedBlock { 925 lang: string | undefined; 926 code: string; 927 startIndex: number; 928 endIndex: number; 929 } 930 931 /** 932 * Parse fenced code blocks from text. Handles: 933 * - Multiple blocks 934 * - Various language identifiers (typescript, diff, shell, etc.) 935 * - Empty/missing language 936 * - Unclosed fences (treated as extending to end of text) 937 */ 938 function parseFencedBlocks(text: string): FencedBlock[] { 939 const blocks: FencedBlock[] = []; 940 const lines = text.split("\n"); 941 let i = 0; 942 943 while (i < lines.length) { 944 const line = lines[i]; 945 const fenceMatch = line.match(/^\s*```(\S*)\s*$/); 946 947 if (fenceMatch) { 948 const lang = fenceMatch[1] || undefined; 949 const startLine = i; 950 const codeLines: string[] = []; 951 i++; 952 953 // Find closing fence (```) 954 while (i < lines.length) { 955 const closingMatch = lines[i].match(/^\s*```\s*$/); 956 if (closingMatch) { 957 i++; 958 break; 959 } 960 codeLines.push(lines[i]); 961 i++; 962 } 963 964 // Calculate character indices 965 const startIndex = lines.slice(0, startLine).join("\n").length + (startLine > 0 ? 1 : 0); 966 const endIndex = lines.slice(0, i).join("\n").length; 967 968 blocks.push({ 969 lang, 970 code: codeLines.join("\n"), 971 startIndex, 972 endIndex, 973 }); 974 } else { 975 i++; 976 } 977 } 978 979 return blocks; 980 } 981 982 const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*m/g; 983 const DELTA_TIMEOUT_MS = 5000; 984 const DELTA_MAX_BUFFER = 8 * 1024 * 1024; 985 const DELTA_CACHE_MAX_ENTRIES = 200; 986 987 let deltaAvailable: boolean | null = null; 988 const deltaDiffCache = new Map<string, string | null>(); 989 990 function isDeltaInstalled(): boolean { 991 if (deltaAvailable !== null) { 992 return deltaAvailable; 993 } 994 995 const check = spawnSync("delta", ["--version"], { 996 stdio: "ignore", 997 timeout: 1000, 998 }); 999 1000 deltaAvailable = !check.error && check.status === 0; 1001 return deltaAvailable; 1002 } 1003 1004 function runDelta(diffText: string): string | null { 1005 const result = spawnSync("delta", ["--color-only", "--paging=never"], { 1006 encoding: "utf-8", 1007 input: diffText, 1008 timeout: DELTA_TIMEOUT_MS, 1009 maxBuffer: DELTA_MAX_BUFFER, 1010 }); 1011 1012 if (result.error || result.status !== 0) { 1013 return null; 1014 } 1015 1016 return typeof result.stdout === "string" ? result.stdout : null; 1017 } 1018 1019 function stripSyntheticHeader(deltaOutput: string): string { 1020 const outputLines = deltaOutput.split("\n"); 1021 const bodyStart = outputLines.findIndex((line) => line.replace(ANSI_ESCAPE_RE, "").startsWith("@@")); 1022 1023 if (bodyStart >= 0) { 1024 return outputLines.slice(bodyStart + 1).join("\n"); 1025 } 1026 1027 return deltaOutput; 1028 } 1029 1030 function renderDiffBlockWithDelta(code: string): string | null { 1031 if (!isDeltaInstalled()) { 1032 return null; 1033 } 1034 1035 const cached = deltaDiffCache.get(code); 1036 if (cached !== undefined) { 1037 return cached; 1038 } 1039 1040 let rendered = runDelta(code); 1041 1042 if (!rendered) { 1043 const syntheticDiff = [ 1044 "--- a/file", 1045 "+++ b/file", 1046 "@@ -1,1 +1,1 @@", 1047 code, 1048 ].join("\n"); 1049 1050 const syntheticRendered = runDelta(syntheticDiff); 1051 if (syntheticRendered) { 1052 rendered = stripSyntheticHeader(syntheticRendered); 1053 } 1054 } 1055 1056 if (deltaDiffCache.size >= DELTA_CACHE_MAX_ENTRIES) { 1057 deltaDiffCache.clear(); 1058 } 1059 1060 deltaDiffCache.set(code, rendered); 1061 return rendered; 1062 } 1063 1064 /** 1065 * Compute word-level diff with inverse highlighting on changed parts 1066 */ 1067 function renderIntraLineDiff( 1068 oldContent: string, 1069 newContent: string, 1070 theme: Theme 1071 ): { removedLine: string; addedLine: string } { 1072 const wordDiff = Diff.diffWords(oldContent, newContent); 1073 1074 let removedLine = ""; 1075 let addedLine = ""; 1076 let isFirstRemoved = true; 1077 let isFirstAdded = true; 1078 1079 for (const part of wordDiff) { 1080 if (part.removed) { 1081 let value = part.value; 1082 if (isFirstRemoved) { 1083 const leadingWs = value.match(/^(\s*)/)?.[1] || ""; 1084 value = value.slice(leadingWs.length); 1085 removedLine += leadingWs; 1086 isFirstRemoved = false; 1087 } 1088 if (value) { 1089 removedLine += theme.inverse(value); 1090 } 1091 } else if (part.added) { 1092 let value = part.value; 1093 if (isFirstAdded) { 1094 const leadingWs = value.match(/^(\s*)/)?.[1] || ""; 1095 value = value.slice(leadingWs.length); 1096 addedLine += leadingWs; 1097 isFirstAdded = false; 1098 } 1099 if (value) { 1100 addedLine += theme.inverse(value); 1101 } 1102 } else { 1103 removedLine += part.value; 1104 addedLine += part.value; 1105 } 1106 } 1107 1108 return { removedLine, addedLine }; 1109 } 1110 1111 /** 1112 * Render diff lines with syntax highlighting (red/green, word-level inverse) 1113 */ 1114 function renderDiffBlock(code: string, theme: Theme): string { 1115 const deltaRendered = renderDiffBlockWithDelta(code); 1116 if (deltaRendered !== null) { 1117 return deltaRendered; 1118 } 1119 1120 const lines = code.split("\n"); 1121 const result: string[] = []; 1122 1123 let i = 0; 1124 while (i < lines.length) { 1125 const line = lines[i]; 1126 const trimmed = line.trimStart(); 1127 const indent = line.slice(0, line.length - trimmed.length); 1128 1129 // File headers: --- a/file or +++ b/file 1130 if (trimmed.match(/^---\s+\S/) || trimmed.match(/^\+\+\+\s+\S/)) { 1131 result.push(indent + theme.fg("accent", trimmed)); 1132 i++; 1133 } 1134 // Hunk headers: @@ -1,5 +1,6 @@ 1135 else if (trimmed.match(/^@@\s+-\d+/)) { 1136 result.push(indent + theme.fg("muted", trimmed)); 1137 i++; 1138 } 1139 // Removed lines (not file headers) 1140 else if (trimmed.startsWith("-") && !trimmed.match(/^---\s/)) { 1141 // Collect consecutive removed lines 1142 const removedLines: Array<{ indent: string; content: string }> = []; 1143 while (i < lines.length) { 1144 const l = lines[i]; 1145 const t = l.trimStart(); 1146 const ind = l.slice(0, l.length - t.length); 1147 if (t.startsWith("-") && !t.match(/^---\s/)) { 1148 removedLines.push({ indent: ind, content: t.slice(1) }); 1149 i++; 1150 } else { 1151 break; 1152 } 1153 } 1154 1155 // Collect consecutive added lines 1156 const addedLines: Array<{ indent: string; content: string }> = []; 1157 while (i < lines.length) { 1158 const l = lines[i]; 1159 const t = l.trimStart(); 1160 const ind = l.slice(0, l.length - t.length); 1161 if (t.startsWith("+") && !t.match(/^\+\+\+\s/)) { 1162 addedLines.push({ indent: ind, content: t.slice(1) }); 1163 i++; 1164 } else { 1165 break; 1166 } 1167 } 1168 1169 // Word-level highlighting for 1:1 line changes 1170 if (removedLines.length === 1 && addedLines.length === 1) { 1171 const { removedLine, addedLine } = renderIntraLineDiff( 1172 removedLines[0].content, 1173 addedLines[0].content, 1174 theme 1175 ); 1176 result.push(removedLines[0].indent + theme.fg("toolDiffRemoved", "-" + removedLine)); 1177 result.push(addedLines[0].indent + theme.fg("toolDiffAdded", "+" + addedLine)); 1178 } else { 1179 for (const r of removedLines) { 1180 result.push(r.indent + theme.fg("toolDiffRemoved", "-" + r.content)); 1181 } 1182 for (const a of addedLines) { 1183 result.push(a.indent + theme.fg("toolDiffAdded", "+" + a.content)); 1184 } 1185 } 1186 } 1187 // Added lines (not file headers) 1188 else if (trimmed.startsWith("+") && !trimmed.match(/^\+\+\+\s/)) { 1189 result.push(indent + theme.fg("toolDiffAdded", trimmed)); 1190 i++; 1191 } 1192 // Context lines (start with space in unified diff) 1193 else if (line.startsWith(" ")) { 1194 result.push(theme.fg("toolDiffContext", line)); 1195 i++; 1196 } 1197 // Empty or other lines 1198 else { 1199 result.push(indent + theme.fg("dim", trimmed)); 1200 i++; 1201 } 1202 } 1203 1204 return result.join("\n"); 1205 } 1206 1207 /** 1208 * Render rp_exec output with syntax highlighting for fenced code blocks. 1209 * - ```diff blocks use delta when available, with word-level fallback 1210 * - Other fenced blocks get syntax highlighting via Pi's highlightCode 1211 * - Non-fenced content is rendered dim (no markdown parsing) 1212 */ 1213 function renderRpExecOutput(text: string, theme: Theme): string { 1214 const blocks = parseFencedBlocks(text); 1215 1216 if (blocks.length === 0) { 1217 // No code fences - render everything dim 1218 return text.split("\n").map(line => theme.fg("dim", line)).join("\n"); 1219 } 1220 1221 const result: string[] = []; 1222 let lastEnd = 0; 1223 1224 for (const block of blocks) { 1225 // Render text before this block (dim) 1226 if (block.startIndex > lastEnd) { 1227 const before = text.slice(lastEnd, block.startIndex); 1228 result.push(before.split("\n").map(line => theme.fg("dim", line)).join("\n")); 1229 } 1230 1231 // Render the fenced block 1232 if (block.lang?.toLowerCase() === "diff") { 1233 // Diff block: use word-level diff highlighting 1234 result.push(theme.fg("muted", "```diff")); 1235 result.push(renderDiffBlock(block.code, theme)); 1236 result.push(theme.fg("muted", "```")); 1237 } else if (block.lang) { 1238 // Other language: use Pi's syntax highlighting 1239 result.push(theme.fg("muted", "```" + block.lang)); 1240 const highlighted = highlightCode(block.code, block.lang); 1241 result.push(highlighted.join("\n")); 1242 result.push(theme.fg("muted", "```")); 1243 } else { 1244 // No language specified: render as dim 1245 result.push(theme.fg("muted", "```")); 1246 result.push(theme.fg("dim", block.code)); 1247 result.push(theme.fg("muted", "```")); 1248 } 1249 1250 lastEnd = block.endIndex; 1251 } 1252 1253 // Render text after last block (dim) 1254 if (lastEnd < text.length) { 1255 const after = text.slice(lastEnd); 1256 result.push(after.split("\n").map(line => theme.fg("dim", line)).join("\n")); 1257 } 1258 1259 return result.join("\n"); 1260 } 1261 1262 // Collapsed output settings 1263 const DEFAULT_COLLAPSED_MAX_LINES = 15; 1264 const COLLAPSED_MAX_CHARS = 2000; 1265 1266 function stripNoiseForCollapsedView(lines: string[]): string[] { 1267 const filtered: string[] = []; 1268 let consecutiveEmpty = 0; 1269 1270 for (const line of lines) { 1271 const trimmed = line.trim(); 1272 1273 if (trimmed.startsWith("```")) { 1274 continue; 1275 } 1276 1277 if (trimmed.length === 0) { 1278 consecutiveEmpty += 1; 1279 if (consecutiveEmpty > 1) { 1280 continue; 1281 } 1282 filtered.push(""); 1283 continue; 1284 } 1285 1286 consecutiveEmpty = 0; 1287 filtered.push(line); 1288 } 1289 1290 while (filtered.length > 0 && filtered[filtered.length - 1]?.trim().length === 0) { 1291 filtered.pop(); 1292 } 1293 1294 return filtered; 1295 } 1296 1297 function prepareCollapsedView( 1298 text: string, 1299 theme: Theme, 1300 maxLines: number = DEFAULT_COLLAPSED_MAX_LINES 1301 ): { content: string; truncated: boolean; totalLines: number } { 1302 const lines = stripNoiseForCollapsedView(text.split("\n")); 1303 const totalLines = lines.length; 1304 1305 if (maxLines <= 0) { 1306 return { 1307 content: "", 1308 truncated: totalLines > 0, 1309 totalLines, 1310 }; 1311 } 1312 1313 const normalizedText = lines.join("\n"); 1314 1315 if (lines.length <= maxLines && normalizedText.length <= COLLAPSED_MAX_CHARS) { 1316 return { 1317 content: renderRpExecOutput(normalizedText, theme), 1318 truncated: false, 1319 totalLines, 1320 }; 1321 } 1322 1323 return { 1324 content: renderRpExecOutput(lines.slice(0, maxLines).join("\n"), theme), 1325 truncated: true, 1326 totalLines, 1327 }; 1328 } 1329 1330 export default function (pi: ExtensionAPI) { 1331 let config = loadConfig(); 1332 1333 pi.on("before_agent_start", async () => { 1334 config = loadConfig(); 1335 }); 1336 1337 // Replay-aware read_file caching state (optional; guarded by config.readcacheReadFile) 1338 const readcacheRuntimeState = createReplayRuntimeState(); 1339 1340 const clearReadcacheCaches = (): void => { 1341 clearReplayRuntimeState(readcacheRuntimeState); 1342 }; 1343 1344 let activeAutoSelectionState: AutoSelectionEntryData | null = null; 1345 1346 let boundWindowId: number | undefined; 1347 let boundTab: string | undefined; 1348 let boundWorkspaceId: string | undefined; 1349 let boundWorkspaceName: string | undefined; 1350 let boundWorkspaceRoots: string[] | undefined; 1351 1352 let windowsCache: { windows: RpCliWindow[]; fetchedAtMs: number } | null = null; 1353 let lastBindingValidationAtMs = 0; 1354 1355 function sameOptionalText(a?: string, b?: string): boolean { 1356 return (a ?? undefined) === (b ?? undefined); 1357 } 1358 1359 function clearWindowsCache(): void { 1360 windowsCache = null; 1361 } 1362 1363 function markBindingValidationStale(): void { 1364 lastBindingValidationAtMs = 0; 1365 } 1366 1367 function shouldRevalidateBinding(): boolean { 1368 const now = Date.now(); 1369 return (now - lastBindingValidationAtMs) >= BINDING_VALIDATION_TTL_MS; 1370 } 1371 1372 function markBindingValidatedNow(): void { 1373 lastBindingValidationAtMs = Date.now(); 1374 } 1375 1376 function normalizeWorkspaceRoots(roots: string[] | undefined): string[] { 1377 if (!Array.isArray(roots)) { 1378 return []; 1379 } 1380 1381 return [...new Set(roots.map((root) => toPosixPath(String(root).trim())).filter(Boolean))].sort(); 1382 } 1383 1384 function workspaceRootsEqual(left: string[] | undefined, right: string[] | undefined): boolean { 1385 const leftNormalized = normalizeWorkspaceRoots(left); 1386 const rightNormalized = normalizeWorkspaceRoots(right); 1387 return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized); 1388 } 1389 1390 function workspaceIdentityMatches( 1391 left: { workspaceId?: string; workspaceName?: string; workspaceRoots?: string[] }, 1392 right: { workspaceId?: string; workspaceName?: string; workspaceRoots?: string[] } 1393 ): boolean { 1394 if (left.workspaceId && right.workspaceId) { 1395 return left.workspaceId === right.workspaceId; 1396 } 1397 1398 const leftRoots = normalizeWorkspaceRoots(left.workspaceRoots); 1399 const rightRoots = normalizeWorkspaceRoots(right.workspaceRoots); 1400 1401 if (leftRoots.length > 0 && rightRoots.length > 0) { 1402 return JSON.stringify(leftRoots) === JSON.stringify(rightRoots); 1403 } 1404 1405 if (left.workspaceName && right.workspaceName) { 1406 return left.workspaceName === right.workspaceName; 1407 } 1408 1409 return false; 1410 } 1411 1412 function getCurrentBinding(): RpCliBindingEntryData | null { 1413 if (boundWindowId === undefined || !boundTab) { 1414 return null; 1415 } 1416 1417 return { 1418 windowId: boundWindowId, 1419 tab: boundTab, 1420 workspaceId: boundWorkspaceId, 1421 workspaceName: boundWorkspaceName, 1422 workspaceRoots: boundWorkspaceRoots, 1423 }; 1424 } 1425 1426 function normalizeBindingEntry(binding: RpCliBindingEntryData): RpCliBindingEntryData { 1427 return { 1428 windowId: binding.windowId, 1429 tab: binding.tab, 1430 workspaceId: typeof binding.workspaceId === "string" ? binding.workspaceId : undefined, 1431 workspaceName: typeof binding.workspaceName === "string" ? binding.workspaceName : undefined, 1432 workspaceRoots: normalizeWorkspaceRoots(binding.workspaceRoots), 1433 }; 1434 } 1435 1436 function bindingEntriesEqual(a: RpCliBindingEntryData | null, b: RpCliBindingEntryData | null): boolean { 1437 if (!a && !b) { 1438 return true; 1439 } 1440 1441 if (!a || !b) { 1442 return false; 1443 } 1444 1445 const left = normalizeBindingEntry(a); 1446 const right = normalizeBindingEntry(b); 1447 1448 return ( 1449 left.windowId === right.windowId && 1450 left.tab === right.tab && 1451 sameOptionalText(left.workspaceId, right.workspaceId) && 1452 sameOptionalText(left.workspaceName, right.workspaceName) && 1453 workspaceRootsEqual(left.workspaceRoots, right.workspaceRoots) 1454 ); 1455 } 1456 1457 function setBinding(binding: RpCliBindingEntryData | null): void { 1458 const previousWindowId = boundWindowId; 1459 1460 if (!binding) { 1461 boundWindowId = undefined; 1462 boundTab = undefined; 1463 boundWorkspaceId = undefined; 1464 boundWorkspaceName = undefined; 1465 boundWorkspaceRoots = undefined; 1466 } else { 1467 const normalized = normalizeBindingEntry(binding); 1468 boundWindowId = normalized.windowId; 1469 boundTab = normalized.tab; 1470 boundWorkspaceId = normalized.workspaceId; 1471 boundWorkspaceName = normalized.workspaceName; 1472 boundWorkspaceRoots = normalized.workspaceRoots; 1473 } 1474 1475 if (previousWindowId !== boundWindowId) { 1476 if (previousWindowId !== undefined) { 1477 clearRootsCache(previousWindowId); 1478 } 1479 1480 if (boundWindowId !== undefined) { 1481 clearRootsCache(boundWindowId); 1482 } 1483 1484 clearWindowsCache(); 1485 } 1486 1487 markBindingValidationStale(); 1488 } 1489 1490 function parseBindingEntryData(value: unknown): RpCliBindingEntryData | null { 1491 if (!value || typeof value !== "object") { 1492 return null; 1493 } 1494 1495 const obj = value as Record<string, unknown>; 1496 1497 const windowId = typeof obj.windowId === "number" ? obj.windowId : undefined; 1498 const tab = typeof obj.tab === "string" ? obj.tab : undefined; 1499 1500 if (windowId === undefined || !tab) { 1501 return null; 1502 } 1503 1504 const workspaceId = typeof obj.workspaceId === "string" 1505 ? obj.workspaceId 1506 : (typeof obj.workspaceID === "string" ? obj.workspaceID : undefined); 1507 1508 const workspaceName = typeof obj.workspaceName === "string" 1509 ? obj.workspaceName 1510 : (typeof obj.workspace === "string" ? obj.workspace : undefined); 1511 1512 const workspaceRoots = Array.isArray(obj.workspaceRoots) 1513 ? obj.workspaceRoots.filter((root): root is string => typeof root === "string") 1514 : (Array.isArray(obj.rootFolderPaths) 1515 ? obj.rootFolderPaths.filter((root): root is string => typeof root === "string") 1516 : undefined); 1517 1518 return normalizeBindingEntry({ 1519 windowId, 1520 tab, 1521 workspaceId, 1522 workspaceName, 1523 workspaceRoots, 1524 }); 1525 } 1526 1527 function persistBinding(binding: RpCliBindingEntryData): void { 1528 const normalized = normalizeBindingEntry(binding); 1529 const current = getCurrentBinding(); 1530 1531 if (bindingEntriesEqual(current, normalized)) { 1532 return; 1533 } 1534 1535 setBinding(normalized); 1536 pi.appendEntry(BINDING_CUSTOM_TYPE, normalized); 1537 } 1538 1539 function reconstructBinding(ctx: ExtensionContext): void { 1540 // Prefer persisted binding (appendEntry) from the *current branch*, then fall back to prior rp_bind tool results 1541 // Branch semantics: if the current branch has no binding state, stay unbound 1542 setBinding(null); 1543 1544 let reconstructed: RpCliBindingEntryData | null = null; 1545 1546 for (const entry of ctx.sessionManager.getBranch()) { 1547 if (entry.type !== "custom" || entry.customType !== BINDING_CUSTOM_TYPE) { 1548 continue; 1549 } 1550 1551 const parsed = parseBindingEntryData(entry.data); 1552 if (parsed) { 1553 reconstructed = parsed; 1554 } 1555 } 1556 1557 if (reconstructed) { 1558 setBinding(reconstructed); 1559 return; 1560 } 1561 1562 for (const entry of ctx.sessionManager.getBranch()) { 1563 if (entry.type !== "message") { 1564 continue; 1565 } 1566 1567 const msg = entry.message; 1568 if (msg.role !== "toolResult" || msg.toolName !== "rp_bind") { 1569 continue; 1570 } 1571 1572 const parsed = parseBindingEntryData(msg.details); 1573 if (parsed) { 1574 persistBinding(parsed); 1575 } 1576 } 1577 } 1578 1579 function parseWindowsRawJson(raw: string): RpCliWindow[] { 1580 const trimmed = raw.trim(); 1581 if (!trimmed) { 1582 return []; 1583 } 1584 1585 let parsed: unknown; 1586 try { 1587 parsed = JSON.parse(trimmed); 1588 } catch { 1589 return []; 1590 } 1591 1592 const pickRows = (value: unknown): unknown[] => { 1593 if (Array.isArray(value)) { 1594 return value; 1595 } 1596 1597 if (!value || typeof value !== "object") { 1598 return []; 1599 } 1600 1601 const obj = value as Record<string, unknown>; 1602 1603 const directArrayKeys = ["windows", "items", "data", "result"]; 1604 for (const key of directArrayKeys) { 1605 const candidate = obj[key]; 1606 if (Array.isArray(candidate)) { 1607 return candidate; 1608 } 1609 } 1610 1611 const nested = obj.data; 1612 if (nested && typeof nested === "object") { 1613 const nestedObj = nested as Record<string, unknown>; 1614 if (Array.isArray(nestedObj.windows)) { 1615 return nestedObj.windows; 1616 } 1617 } 1618 1619 return []; 1620 }; 1621 1622 const rows = pickRows(parsed); 1623 1624 return rows 1625 .map((row) => { 1626 if (!row || typeof row !== "object") { 1627 return null; 1628 } 1629 1630 const obj = row as Record<string, unknown>; 1631 const windowId = typeof obj.windowID === "number" 1632 ? obj.windowID 1633 : (typeof obj.windowId === "number" ? obj.windowId : undefined); 1634 1635 if (windowId === undefined) { 1636 return null; 1637 } 1638 1639 const workspaceId = typeof obj.workspaceID === "string" 1640 ? obj.workspaceID 1641 : (typeof obj.workspaceId === "string" ? obj.workspaceId : undefined); 1642 1643 const workspaceName = typeof obj.workspaceName === "string" 1644 ? obj.workspaceName 1645 : (typeof obj.workspace === "string" ? obj.workspace : undefined); 1646 1647 const rootFolderPaths = Array.isArray(obj.rootFolderPaths) 1648 ? obj.rootFolderPaths.filter((root): root is string => typeof root === "string") 1649 : (Array.isArray(obj.roots) 1650 ? obj.roots.filter((root): root is string => typeof root === "string") 1651 : undefined); 1652 1653 return { 1654 windowId, 1655 workspaceId, 1656 workspaceName, 1657 rootFolderPaths, 1658 } as RpCliWindow; 1659 }) 1660 .filter((window): window is RpCliWindow => window !== null); 1661 } 1662 1663 async function fetchWindowsFromCli(forceRefresh = false): Promise<RpCliWindow[]> { 1664 const now = Date.now(); 1665 1666 if (!forceRefresh && windowsCache && (now - windowsCache.fetchedAtMs) < WINDOWS_CACHE_TTL_MS) { 1667 return windowsCache.windows; 1668 } 1669 1670 try { 1671 const result = await pi.exec("rp-cli", ["--raw-json", "-q", "-e", "windows"], { timeout: 10_000 }); 1672 1673 if ((result.code ?? 0) !== 0) { 1674 return windowsCache?.windows ?? []; 1675 } 1676 1677 const stdout = result.stdout ?? ""; 1678 const stderr = result.stderr ?? ""; 1679 1680 const fromStdout = parseWindowsRawJson(stdout); 1681 const parsed = fromStdout.length > 0 ? fromStdout : parseWindowsRawJson(stderr); 1682 1683 windowsCache = { 1684 windows: parsed, 1685 fetchedAtMs: now, 1686 }; 1687 1688 return parsed; 1689 } catch { 1690 return windowsCache?.windows ?? []; 1691 } 1692 } 1693 1694 function windowToBinding(window: RpCliWindow, tab: string): RpCliBindingEntryData { 1695 return normalizeBindingEntry({ 1696 windowId: window.windowId, 1697 tab, 1698 workspaceId: window.workspaceId, 1699 workspaceName: window.workspaceName, 1700 workspaceRoots: window.rootFolderPaths, 1701 }); 1702 } 1703 1704 async function enrichBinding(windowId: number, tab: string): Promise<RpCliBindingEntryData> { 1705 const windows = await fetchWindowsFromCli(); 1706 const match = windows.find((window) => window.windowId === windowId); 1707 1708 if (!match) { 1709 return normalizeBindingEntry({ windowId, tab }); 1710 } 1711 1712 return windowToBinding(match, tab); 1713 } 1714 1715 async function ensureBindingTargetsLiveWindow( 1716 ctx: ExtensionContext, 1717 forceRefresh = false 1718 ): Promise<RpCliBindingEntryData | null> { 1719 const binding = getCurrentBinding(); 1720 if (!binding) { 1721 return null; 1722 } 1723 1724 const windows = await fetchWindowsFromCli(forceRefresh); 1725 if (windows.length === 0) { 1726 return binding; 1727 } 1728 1729 const sameWindow = windows.find((window) => window.windowId === binding.windowId); 1730 if (sameWindow) { 1731 const hydrated = windowToBinding(sameWindow, binding.tab); 1732 1733 if (binding.workspaceId && hydrated.workspaceId && binding.workspaceId !== hydrated.workspaceId) { 1734 // Window IDs were recycled to a different workspace. Fall through to workspace-based remap 1735 } else { 1736 if (!bindingEntriesEqual(binding, hydrated)) { 1737 persistBinding(hydrated); 1738 return hydrated; 1739 } 1740 1741 setBinding(hydrated); 1742 return hydrated; 1743 } 1744 } 1745 1746 const workspaceCandidates = windows.filter((window) => workspaceIdentityMatches( 1747 { 1748 workspaceId: binding.workspaceId, 1749 workspaceName: binding.workspaceName, 1750 workspaceRoots: binding.workspaceRoots, 1751 }, 1752 { 1753 workspaceId: window.workspaceId, 1754 workspaceName: window.workspaceName, 1755 workspaceRoots: window.rootFolderPaths, 1756 } 1757 )); 1758 1759 if (workspaceCandidates.length === 1) { 1760 const rebound = windowToBinding(workspaceCandidates[0], binding.tab); 1761 persistBinding(rebound); 1762 return rebound; 1763 } 1764 1765 setBinding(null); 1766 1767 if (ctx.hasUI) { 1768 const workspaceLabel = binding.workspaceName ?? binding.workspaceId ?? `window ${binding.windowId}`; 1769 1770 if (workspaceCandidates.length > 1) { 1771 ctx.ui.notify( 1772 `repoprompt-cli: binding for ${workspaceLabel} is ambiguous after restart. Re-bind with /rpbind`, 1773 "warning" 1774 ); 1775 } else { 1776 ctx.ui.notify( 1777 `repoprompt-cli: ${workspaceLabel} not found after restart. Re-bind with /rpbind`, 1778 "warning" 1779 ); 1780 } 1781 } 1782 1783 return null; 1784 } 1785 1786 async function maybeEnsureBindingTargetsLiveWindow(ctx: ExtensionContext): Promise<RpCliBindingEntryData | null> { 1787 const binding = getCurrentBinding(); 1788 if (!binding) { 1789 return null; 1790 } 1791 1792 if (!shouldRevalidateBinding()) { 1793 return binding; 1794 } 1795 1796 const validated = await ensureBindingTargetsLiveWindow(ctx, true); 1797 1798 if (validated) { 1799 markBindingValidatedNow(); 1800 } 1801 1802 return validated; 1803 } 1804 1805 function sameBindingForAutoSelection( 1806 binding: RpCliBindingEntryData | null, 1807 state: AutoSelectionEntryData | null 1808 ): boolean { 1809 if (!binding || !state) { 1810 return false; 1811 } 1812 1813 if (!sameOptionalText(binding.tab, state.tab)) { 1814 return false; 1815 } 1816 1817 if (binding.windowId === state.windowId) { 1818 return true; 1819 } 1820 1821 return workspaceIdentityMatches( 1822 { 1823 workspaceId: binding.workspaceId, 1824 workspaceName: binding.workspaceName, 1825 workspaceRoots: binding.workspaceRoots, 1826 }, 1827 { 1828 workspaceId: state.workspaceId, 1829 workspaceName: state.workspaceName, 1830 workspaceRoots: state.workspaceRoots, 1831 } 1832 ); 1833 } 1834 1835 function makeEmptyAutoSelectionState(binding: RpCliBindingEntryData): AutoSelectionEntryData { 1836 return { 1837 windowId: binding.windowId, 1838 tab: binding.tab, 1839 workspaceId: binding.workspaceId, 1840 workspaceName: binding.workspaceName, 1841 workspaceRoots: normalizeWorkspaceRoots(binding.workspaceRoots), 1842 fullPaths: [], 1843 slicePaths: [], 1844 }; 1845 } 1846 1847 function normalizeAutoSelectionRanges(ranges: AutoSelectionEntryRangeData[]): AutoSelectionEntryRangeData[] { 1848 const normalized = ranges 1849 .map((range) => ({ 1850 start_line: Number(range.start_line), 1851 end_line: Number(range.end_line), 1852 })) 1853 .filter((range) => Number.isFinite(range.start_line) && Number.isFinite(range.end_line)) 1854 .filter((range) => range.start_line > 0 && range.end_line >= range.start_line) 1855 .sort((a, b) => { 1856 if (a.start_line !== b.start_line) { 1857 return a.start_line - b.start_line; 1858 } 1859 1860 return a.end_line - b.end_line; 1861 }); 1862 1863 const merged: AutoSelectionEntryRangeData[] = []; 1864 for (const range of normalized) { 1865 const last = merged[merged.length - 1]; 1866 if (!last) { 1867 merged.push(range); 1868 continue; 1869 } 1870 1871 if (range.start_line <= last.end_line + 1) { 1872 last.end_line = Math.max(last.end_line, range.end_line); 1873 continue; 1874 } 1875 1876 merged.push(range); 1877 } 1878 1879 return merged; 1880 } 1881 1882 function normalizeAutoSelectionState(state: AutoSelectionEntryData): AutoSelectionEntryData { 1883 const fullPaths = [...new Set(state.fullPaths.map((p) => toPosixPath(String(p).trim())).filter(Boolean))].sort(); 1884 1885 const fullSet = new Set(fullPaths); 1886 1887 const sliceMap = new Map<string, AutoSelectionEntryRangeData[]>(); 1888 for (const item of state.slicePaths) { 1889 const pathKey = toPosixPath(String(item.path ?? "").trim()); 1890 if (!pathKey || fullSet.has(pathKey)) { 1891 continue; 1892 } 1893 1894 const existing = sliceMap.get(pathKey) ?? []; 1895 existing.push(...normalizeAutoSelectionRanges(item.ranges ?? [])); 1896 sliceMap.set(pathKey, existing); 1897 } 1898 1899 const slicePaths: AutoSelectionEntrySliceData[] = [...sliceMap.entries()] 1900 .map(([pathKey, ranges]) => ({ 1901 path: pathKey, 1902 ranges: normalizeAutoSelectionRanges(ranges), 1903 })) 1904 .filter((item) => item.ranges.length > 0) 1905 .sort((a, b) => a.path.localeCompare(b.path)); 1906 1907 return { 1908 windowId: state.windowId, 1909 tab: state.tab, 1910 workspaceId: typeof state.workspaceId === "string" ? state.workspaceId : undefined, 1911 workspaceName: typeof state.workspaceName === "string" ? state.workspaceName : undefined, 1912 workspaceRoots: normalizeWorkspaceRoots(state.workspaceRoots), 1913 fullPaths, 1914 slicePaths, 1915 }; 1916 } 1917 1918 function autoSelectionStatesEqual(a: AutoSelectionEntryData | null, b: AutoSelectionEntryData | null): boolean { 1919 if (!a && !b) { 1920 return true; 1921 } 1922 1923 if (!a || !b) { 1924 return false; 1925 } 1926 1927 const left = normalizeAutoSelectionState(a); 1928 const right = normalizeAutoSelectionState(b); 1929 1930 return JSON.stringify(left) === JSON.stringify(right); 1931 } 1932 1933 function parseAutoSelectionEntryData( 1934 value: unknown, 1935 binding: RpCliBindingEntryData 1936 ): AutoSelectionEntryData | null { 1937 if (!value || typeof value !== "object") { 1938 return null; 1939 } 1940 1941 const obj = value as Record<string, unknown>; 1942 1943 const windowId = typeof obj.windowId === "number" ? obj.windowId : undefined; 1944 const tab = typeof obj.tab === "string" ? obj.tab : undefined; 1945 1946 const workspaceId = typeof obj.workspaceId === "string" 1947 ? obj.workspaceId 1948 : (typeof obj.workspaceID === "string" ? obj.workspaceID : undefined); 1949 1950 const workspaceName = typeof obj.workspaceName === "string" 1951 ? obj.workspaceName 1952 : (typeof obj.workspace === "string" ? obj.workspace : undefined); 1953 1954 const workspaceRoots = Array.isArray(obj.workspaceRoots) 1955 ? obj.workspaceRoots.filter((root): root is string => typeof root === "string") 1956 : (Array.isArray(obj.rootFolderPaths) 1957 ? obj.rootFolderPaths.filter((root): root is string => typeof root === "string") 1958 : undefined); 1959 1960 const tabMatches = sameOptionalText(tab, binding.tab); 1961 const windowMatches = windowId === binding.windowId; 1962 const workspaceMatches = workspaceIdentityMatches( 1963 { 1964 workspaceId: binding.workspaceId, 1965 workspaceName: binding.workspaceName, 1966 workspaceRoots: binding.workspaceRoots, 1967 }, 1968 { 1969 workspaceId, 1970 workspaceName, 1971 workspaceRoots, 1972 } 1973 ); 1974 1975 if (!tabMatches || (!windowMatches && !workspaceMatches)) { 1976 return null; 1977 } 1978 1979 const fullPaths = Array.isArray(obj.fullPaths) 1980 ? obj.fullPaths.filter((item): item is string => typeof item === "string") 1981 : []; 1982 1983 const slicePathsRaw = Array.isArray(obj.slicePaths) ? obj.slicePaths : []; 1984 const slicePaths: AutoSelectionEntrySliceData[] = slicePathsRaw 1985 .map((raw) => { 1986 if (!raw || typeof raw !== "object") { 1987 return null; 1988 } 1989 1990 const row = raw as Record<string, unknown>; 1991 const pathValue = typeof row.path === "string" ? row.path : null; 1992 const rangesRaw = Array.isArray(row.ranges) ? row.ranges : []; 1993 1994 if (!pathValue) { 1995 return null; 1996 } 1997 1998 const ranges: AutoSelectionEntryRangeData[] = rangesRaw 1999 .map((rangeRaw) => { 2000 if (!rangeRaw || typeof rangeRaw !== "object") { 2001 return null; 2002 } 2003 2004 const rangeObj = rangeRaw as Record<string, unknown>; 2005 const start = typeof rangeObj.start_line === "number" ? rangeObj.start_line : NaN; 2006 const end = typeof rangeObj.end_line === "number" ? rangeObj.end_line : NaN; 2007 2008 if (!Number.isFinite(start) || !Number.isFinite(end)) { 2009 return null; 2010 } 2011 2012 return { 2013 start_line: start, 2014 end_line: end, 2015 }; 2016 }) 2017 .filter((range): range is AutoSelectionEntryRangeData => range !== null); 2018 2019 return { 2020 path: pathValue, 2021 ranges, 2022 }; 2023 }) 2024 .filter((item): item is AutoSelectionEntrySliceData => item !== null); 2025 2026 return normalizeAutoSelectionState({ 2027 windowId: binding.windowId, 2028 tab: binding.tab, 2029 workspaceId: binding.workspaceId ?? workspaceId, 2030 workspaceName: binding.workspaceName ?? workspaceName, 2031 workspaceRoots: binding.workspaceRoots ?? workspaceRoots, 2032 fullPaths, 2033 slicePaths, 2034 }); 2035 } 2036 2037 function getAutoSelectionStateFromBranch( 2038 ctx: ExtensionContext, 2039 binding: RpCliBindingEntryData 2040 ): AutoSelectionEntryData { 2041 const entries = ctx.sessionManager.getBranch(); 2042 2043 for (let i = entries.length - 1; i >= 0; i -= 1) { 2044 const entry = entries[i]; 2045 if (entry.type !== "custom" || entry.customType !== AUTO_SELECTION_CUSTOM_TYPE) { 2046 continue; 2047 } 2048 2049 const parsed = parseAutoSelectionEntryData(entry.data, binding); 2050 if (parsed) { 2051 return parsed; 2052 } 2053 } 2054 2055 return makeEmptyAutoSelectionState(binding); 2056 } 2057 2058 function persistAutoSelectionState(state: AutoSelectionEntryData): void { 2059 const normalized = normalizeAutoSelectionState(state); 2060 activeAutoSelectionState = normalized; 2061 pi.appendEntry(AUTO_SELECTION_CUSTOM_TYPE, normalized); 2062 } 2063 2064 function autoSelectionManagedPaths(state: AutoSelectionEntryData): string[] { 2065 const fromSlices = state.slicePaths.map((item) => item.path); 2066 return [...new Set([...state.fullPaths, ...fromSlices])]; 2067 } 2068 2069 function autoSelectionSliceKey(item: AutoSelectionEntrySliceData): string { 2070 return JSON.stringify(normalizeAutoSelectionRanges(item.ranges)); 2071 } 2072 2073 function bindingForAutoSelectionState(state: AutoSelectionEntryData): RpCliBindingEntryData { 2074 return { 2075 windowId: state.windowId, 2076 tab: state.tab ?? "Compose", 2077 workspaceId: state.workspaceId, 2078 workspaceName: state.workspaceName, 2079 workspaceRoots: state.workspaceRoots, 2080 }; 2081 } 2082 2083 async function runRpCliForBinding( 2084 binding: RpCliBindingEntryData, 2085 cmd: string, 2086 rawJson = false, 2087 timeout = 10_000 2088 ): Promise<{ stdout: string; stderr: string; exitCode: number; output: string }> { 2089 const args: string[] = ["-w", String(binding.windowId)]; 2090 2091 if (binding.tab) { 2092 args.push("-t", binding.tab); 2093 } 2094 2095 args.push("-q", "--fail-fast"); 2096 2097 if (rawJson) { 2098 args.push("--raw-json"); 2099 } 2100 2101 args.push("-e", cmd); 2102 2103 const result = await pi.exec("rp-cli", args, { timeout }); 2104 2105 const stdout = result.stdout ?? ""; 2106 const stderr = result.stderr ?? ""; 2107 const exitCode = result.code ?? 0; 2108 const output = [stdout, stderr].filter(Boolean).join("\n").trim(); 2109 2110 if (exitCode !== 0) { 2111 throw new Error(output || `rp-cli exited with status ${exitCode}`); 2112 } 2113 2114 return { stdout, stderr, exitCode, output }; 2115 } 2116 2117 async function callManageSelection(binding: RpCliBindingEntryData, args: Record<string, unknown>): Promise<string> { 2118 const cmd = `call manage_selection ${JSON.stringify(args)}`; 2119 const result = await runRpCliForBinding(binding, cmd, false); 2120 return result.output; 2121 } 2122 2123 async function getSelectionFilesText(binding: RpCliBindingEntryData): Promise<string | null> { 2124 try { 2125 const text = await callManageSelection(binding, { 2126 op: "get", 2127 view: "files", 2128 }); 2129 2130 return text.length > 0 ? text : null; 2131 } catch { 2132 return null; 2133 } 2134 } 2135 2136 function buildSelectionPathFromResolved( 2137 inputPath: string, 2138 resolved: { absolutePath: string | null; repoRoot: string | null } 2139 ): string { 2140 if (!resolved.absolutePath || !resolved.repoRoot) { 2141 return inputPath; 2142 } 2143 2144 const rel = path.relative(resolved.repoRoot, resolved.absolutePath); 2145 if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) { 2146 return inputPath; 2147 } 2148 2149 const rootHint = path.basename(resolved.repoRoot); 2150 const relPosix = rel.split(path.sep).join("/"); 2151 2152 return `${rootHint}/${relPosix}`; 2153 } 2154 2155 function updateAutoSelectionStateAfterFullRead(binding: RpCliBindingEntryData, selectionPath: string): void { 2156 const normalizedPath = toPosixPath(selectionPath); 2157 2158 const baseState = sameBindingForAutoSelection(binding, activeAutoSelectionState) 2159 ? (activeAutoSelectionState as AutoSelectionEntryData) 2160 : makeEmptyAutoSelectionState(binding); 2161 2162 const nextState: AutoSelectionEntryData = { 2163 ...baseState, 2164 fullPaths: [...baseState.fullPaths, normalizedPath], 2165 slicePaths: baseState.slicePaths.filter((entry) => entry.path !== normalizedPath), 2166 }; 2167 2168 const normalizedNext = normalizeAutoSelectionState(nextState); 2169 if (autoSelectionStatesEqual(baseState, normalizedNext)) { 2170 activeAutoSelectionState = normalizedNext; 2171 return; 2172 } 2173 2174 persistAutoSelectionState(normalizedNext); 2175 } 2176 2177 function existingSliceRangesForRead( 2178 binding: RpCliBindingEntryData, 2179 selectionPath: string 2180 ): AutoSelectionEntryRangeData[] | null { 2181 const normalizedPath = toPosixPath(selectionPath); 2182 2183 const baseState = sameBindingForAutoSelection(binding, activeAutoSelectionState) 2184 ? (activeAutoSelectionState as AutoSelectionEntryData) 2185 : makeEmptyAutoSelectionState(binding); 2186 2187 if (baseState.fullPaths.includes(normalizedPath)) { 2188 return null; 2189 } 2190 2191 const existing = baseState.slicePaths.find((entry) => entry.path === normalizedPath); 2192 return normalizeAutoSelectionRanges(existing?.ranges ?? []); 2193 } 2194 2195 function subtractCoveredRanges( 2196 incomingRange: AutoSelectionEntryRangeData, 2197 existingRanges: AutoSelectionEntryRangeData[] 2198 ): AutoSelectionEntryRangeData[] { 2199 let pending: AutoSelectionEntryRangeData[] = [incomingRange]; 2200 2201 for (const existing of normalizeAutoSelectionRanges(existingRanges)) { 2202 const nextPending: AutoSelectionEntryRangeData[] = []; 2203 2204 for (const candidate of pending) { 2205 const overlapStart = Math.max(candidate.start_line, existing.start_line); 2206 const overlapEnd = Math.min(candidate.end_line, existing.end_line); 2207 2208 if (overlapStart > overlapEnd) { 2209 nextPending.push(candidate); 2210 continue; 2211 } 2212 2213 if (candidate.start_line < overlapStart) { 2214 nextPending.push({ 2215 start_line: candidate.start_line, 2216 end_line: overlapStart - 1, 2217 }); 2218 } 2219 2220 if (candidate.end_line > overlapEnd) { 2221 nextPending.push({ 2222 start_line: overlapEnd + 1, 2223 end_line: candidate.end_line, 2224 }); 2225 } 2226 } 2227 2228 pending = nextPending; 2229 if (pending.length === 0) { 2230 return []; 2231 } 2232 } 2233 2234 return normalizeAutoSelectionRanges(pending); 2235 } 2236 2237 function updateAutoSelectionStateAfterSliceRead( 2238 binding: RpCliBindingEntryData, 2239 selectionPath: string, 2240 range: AutoSelectionEntryRangeData 2241 ): void { 2242 const normalizedPath = toPosixPath(selectionPath); 2243 2244 const baseState = sameBindingForAutoSelection(binding, activeAutoSelectionState) 2245 ? (activeAutoSelectionState as AutoSelectionEntryData) 2246 : makeEmptyAutoSelectionState(binding); 2247 2248 if (baseState.fullPaths.includes(normalizedPath)) { 2249 return; 2250 } 2251 2252 const existing = baseState.slicePaths.find((entry) => entry.path === normalizedPath); 2253 2254 const nextSlicePaths = baseState.slicePaths.filter((entry) => entry.path !== normalizedPath); 2255 nextSlicePaths.push({ 2256 path: normalizedPath, 2257 ranges: [...(existing?.ranges ?? []), range], 2258 }); 2259 2260 const nextState: AutoSelectionEntryData = { 2261 ...baseState, 2262 fullPaths: [...baseState.fullPaths], 2263 slicePaths: nextSlicePaths, 2264 }; 2265 2266 const normalizedNext = normalizeAutoSelectionState(nextState); 2267 if (autoSelectionStatesEqual(baseState, normalizedNext)) { 2268 activeAutoSelectionState = normalizedNext; 2269 return; 2270 } 2271 2272 persistAutoSelectionState(normalizedNext); 2273 } 2274 2275 async function removeAutoSelectionPaths(state: AutoSelectionEntryData, paths: string[]): Promise<void> { 2276 if (paths.length === 0) { 2277 return; 2278 } 2279 2280 const binding = bindingForAutoSelectionState(state); 2281 await callManageSelection(binding, { op: "remove", paths }); 2282 } 2283 2284 async function addAutoSelectionFullPaths(state: AutoSelectionEntryData, paths: string[]): Promise<void> { 2285 if (paths.length === 0) { 2286 return; 2287 } 2288 2289 const binding = bindingForAutoSelectionState(state); 2290 await callManageSelection(binding, { 2291 op: "add", 2292 mode: "full", 2293 paths, 2294 }); 2295 } 2296 2297 async function addAutoSelectionSlices( 2298 state: AutoSelectionEntryData, 2299 slices: AutoSelectionEntrySliceData[] 2300 ): Promise<void> { 2301 if (slices.length === 0) { 2302 return; 2303 } 2304 2305 const binding = bindingForAutoSelectionState(state); 2306 await callManageSelection(binding, { 2307 op: "add", 2308 slices, 2309 }); 2310 } 2311 2312 async function reconcileAutoSelectionWithinBinding( 2313 currentState: AutoSelectionEntryData, 2314 desiredState: AutoSelectionEntryData 2315 ): Promise<void> { 2316 const currentModeByPath = new Map<string, "full" | "slices">(); 2317 for (const p of currentState.fullPaths) { 2318 currentModeByPath.set(p, "full"); 2319 } 2320 2321 for (const s of currentState.slicePaths) { 2322 if (!currentModeByPath.has(s.path)) { 2323 currentModeByPath.set(s.path, "slices"); 2324 } 2325 } 2326 2327 const desiredModeByPath = new Map<string, "full" | "slices">(); 2328 for (const p of desiredState.fullPaths) { 2329 desiredModeByPath.set(p, "full"); 2330 } 2331 2332 for (const s of desiredState.slicePaths) { 2333 if (!desiredModeByPath.has(s.path)) { 2334 desiredModeByPath.set(s.path, "slices"); 2335 } 2336 } 2337 2338 const desiredSliceByPath = new Map<string, AutoSelectionEntrySliceData>(); 2339 for (const s of desiredState.slicePaths) { 2340 desiredSliceByPath.set(s.path, s); 2341 } 2342 2343 const currentSliceByPath = new Map<string, AutoSelectionEntrySliceData>(); 2344 for (const s of currentState.slicePaths) { 2345 currentSliceByPath.set(s.path, s); 2346 } 2347 2348 const removePaths = new Set<string>(); 2349 const addFullPaths: string[] = []; 2350 const addSlices: AutoSelectionEntrySliceData[] = []; 2351 2352 for (const [pathKey] of currentModeByPath) { 2353 if (!desiredModeByPath.has(pathKey)) { 2354 removePaths.add(pathKey); 2355 } 2356 } 2357 2358 for (const [pathKey, mode] of desiredModeByPath) { 2359 const currentMode = currentModeByPath.get(pathKey); 2360 2361 if (mode === "full") { 2362 if (currentMode === "full") { 2363 continue; 2364 } 2365 2366 if (currentMode === "slices") { 2367 removePaths.add(pathKey); 2368 } 2369 2370 addFullPaths.push(pathKey); 2371 continue; 2372 } 2373 2374 const desiredSlice = desiredSliceByPath.get(pathKey); 2375 if (!desiredSlice) { 2376 continue; 2377 } 2378 2379 if (currentMode === "full") { 2380 removePaths.add(pathKey); 2381 addSlices.push(desiredSlice); 2382 continue; 2383 } 2384 2385 if (currentMode === "slices") { 2386 const currentSlice = currentSliceByPath.get(pathKey); 2387 if (currentSlice && autoSelectionSliceKey(currentSlice) === autoSelectionSliceKey(desiredSlice)) { 2388 continue; 2389 } 2390 2391 removePaths.add(pathKey); 2392 addSlices.push(desiredSlice); 2393 continue; 2394 } 2395 2396 addSlices.push(desiredSlice); 2397 } 2398 2399 await removeAutoSelectionPaths(currentState, [...removePaths]); 2400 await addAutoSelectionFullPaths(desiredState, addFullPaths); 2401 await addAutoSelectionSlices(desiredState, addSlices); 2402 } 2403 2404 async function reconcileAutoSelectionStates( 2405 currentState: AutoSelectionEntryData | null, 2406 desiredState: AutoSelectionEntryData | null 2407 ): Promise<void> { 2408 if (autoSelectionStatesEqual(currentState, desiredState)) { 2409 return; 2410 } 2411 2412 if (currentState && desiredState) { 2413 const sameBinding = 2414 currentState.windowId === desiredState.windowId && 2415 sameOptionalText(currentState.tab, desiredState.tab); 2416 2417 if (sameBinding) { 2418 await reconcileAutoSelectionWithinBinding(currentState, desiredState); 2419 return; 2420 } 2421 2422 try { 2423 await removeAutoSelectionPaths(currentState, autoSelectionManagedPaths(currentState)); 2424 } catch { 2425 // Old binding/window may no longer exist after RepoPrompt app restart 2426 } 2427 2428 await addAutoSelectionFullPaths(desiredState, desiredState.fullPaths); 2429 await addAutoSelectionSlices(desiredState, desiredState.slicePaths); 2430 return; 2431 } 2432 2433 if (currentState && !desiredState) { 2434 try { 2435 await removeAutoSelectionPaths(currentState, autoSelectionManagedPaths(currentState)); 2436 } catch { 2437 // Old binding/window may no longer exist after RepoPrompt app restart 2438 } 2439 return; 2440 } 2441 2442 if (!currentState && desiredState) { 2443 await addAutoSelectionFullPaths(desiredState, desiredState.fullPaths); 2444 await addAutoSelectionSlices(desiredState, desiredState.slicePaths); 2445 } 2446 } 2447 2448 async function syncAutoSelectionToCurrentBranch(ctx: ExtensionContext): Promise<void> { 2449 if (config.autoSelectReadSlices !== true) { 2450 activeAutoSelectionState = null; 2451 return; 2452 } 2453 2454 const binding = await ensureBindingTargetsLiveWindow(ctx); 2455 const desiredState = binding ? getAutoSelectionStateFromBranch(ctx, binding) : null; 2456 2457 try { 2458 await reconcileAutoSelectionStates(activeAutoSelectionState, desiredState); 2459 } catch { 2460 // Fail-open 2461 } 2462 2463 activeAutoSelectionState = desiredState; 2464 } 2465 2466 function parseReadOutputHints(readOutputText: string | undefined): { 2467 selectionPath: string | null; 2468 totalLines: number | null; 2469 } { 2470 if (!readOutputText) { 2471 return { selectionPath: null, totalLines: null }; 2472 } 2473 2474 const pathMatch = 2475 /\*\*Path\*\*:\s*`([^`]+)`/i.exec(readOutputText) ?? 2476 /\*\*Path\*\*:\s*([^\n]+)$/im.exec(readOutputText); 2477 2478 const selectionPath = pathMatch?.[1]?.trim() ?? null; 2479 2480 const linesRegexes = [ 2481 /\*\*Lines\*\*:\s*(\d+)\s*[–—-]\s*(\d+)\s+of\s+(\d+)/i, 2482 /Lines(?:\s*:)?\s*(\d+)\s*[–—-]\s*(\d+)\s+of\s+(\d+)/i, 2483 ]; 2484 2485 let totalLines: number | null = null; 2486 2487 for (const rx of linesRegexes) { 2488 const match = rx.exec(readOutputText); 2489 if (!match) { 2490 continue; 2491 } 2492 2493 const parsed = Number.parseInt(match[3] ?? "", 10); 2494 if (Number.isFinite(parsed)) { 2495 totalLines = parsed; 2496 break; 2497 } 2498 } 2499 2500 return { selectionPath, totalLines }; 2501 } 2502 2503 async function autoSelectReadFileInRepoPromptSelection( 2504 ctx: ExtensionContext, 2505 inputPath: string, 2506 startLine: number | undefined, 2507 limit: number | undefined, 2508 readOutputText: string | undefined 2509 ): Promise<void> { 2510 if (config.autoSelectReadSlices !== true) { 2511 return; 2512 } 2513 2514 const outputHints = parseReadOutputHints(readOutputText); 2515 2516 const binding = await maybeEnsureBindingTargetsLiveWindow(ctx); 2517 if (!binding) { 2518 return; 2519 } 2520 2521 const selectionText = await getSelectionFilesText(binding); 2522 if (selectionText === null) { 2523 return; 2524 } 2525 2526 const resolved = await resolveReadFilePath(pi, inputPath, ctx.cwd, binding.windowId, binding.tab); 2527 2528 const resolvedSelectionPath = buildSelectionPathFromResolved(inputPath, resolved); 2529 const selectionPath = 2530 outputHints.selectionPath && outputHints.selectionPath.trim().length > 0 2531 ? outputHints.selectionPath 2532 : resolvedSelectionPath; 2533 2534 const candidatePaths = new Set<string>(); 2535 candidatePaths.add(toPosixPath(selectionPath)); 2536 candidatePaths.add(toPosixPath(resolvedSelectionPath)); 2537 candidatePaths.add(toPosixPath(inputPath)); 2538 2539 if (outputHints.selectionPath) { 2540 candidatePaths.add(toPosixPath(outputHints.selectionPath)); 2541 } 2542 2543 if (resolved.absolutePath) { 2544 candidatePaths.add(toPosixPath(resolved.absolutePath)); 2545 } 2546 2547 if (resolved.absolutePath && resolved.repoRoot) { 2548 const rel = path.relative(resolved.repoRoot, resolved.absolutePath); 2549 if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) { 2550 candidatePaths.add(toPosixPath(rel.split(path.sep).join("/"))); 2551 } 2552 } 2553 2554 let selectionStatus: ReturnType<typeof inferSelectionStatus> = null; 2555 2556 for (const candidate of candidatePaths) { 2557 const status = inferSelectionStatus(selectionText, candidate); 2558 if (!status) { 2559 continue; 2560 } 2561 2562 if (status.mode === "full") { 2563 selectionStatus = status; 2564 break; 2565 } 2566 2567 if (status.mode === "codemap_only" && status.codemapManual === true) { 2568 selectionStatus = status; 2569 break; 2570 } 2571 2572 if (selectionStatus === null) { 2573 selectionStatus = status; 2574 continue; 2575 } 2576 2577 if (selectionStatus.mode === "codemap_only" && status.mode === "slices") { 2578 selectionStatus = status; 2579 } 2580 } 2581 2582 if (selectionStatus?.mode === "full") { 2583 return; 2584 } 2585 2586 if (selectionStatus?.mode === "codemap_only" && selectionStatus.codemapManual === true) { 2587 return; 2588 } 2589 2590 let totalLines: number | undefined; 2591 2592 if (typeof startLine === "number" && startLine < 0) { 2593 if (resolved.absolutePath) { 2594 try { 2595 totalLines = await countFileLines(resolved.absolutePath); 2596 } catch { 2597 totalLines = undefined; 2598 } 2599 } 2600 2601 if (totalLines === undefined && typeof outputHints.totalLines === "number") { 2602 totalLines = outputHints.totalLines; 2603 } 2604 } 2605 2606 const sliceRange = computeSliceRangeFromReadArgs(startLine, limit, totalLines); 2607 2608 2609 if (sliceRange) { 2610 const existingRanges = existingSliceRangesForRead(binding, selectionPath); 2611 if (!existingRanges) { 2612 return; 2613 } 2614 2615 const uncoveredRanges = subtractCoveredRanges(sliceRange, existingRanges); 2616 2617 if (uncoveredRanges.length === 0) { 2618 updateAutoSelectionStateAfterSliceRead(binding, selectionPath, sliceRange); 2619 return; 2620 } 2621 2622 // Add only uncovered ranges to avoid touching unrelated selection state 2623 // (and to avoid global set semantics) 2624 const payload = { 2625 op: "add", 2626 slices: [ 2627 { 2628 path: toPosixPath(selectionPath), 2629 ranges: uncoveredRanges, 2630 }, 2631 ], 2632 }; 2633 2634 try { 2635 await callManageSelection(binding, payload); 2636 } catch { 2637 // Fail-open 2638 return; 2639 } 2640 2641 updateAutoSelectionStateAfterSliceRead(binding, selectionPath, sliceRange); 2642 return; 2643 } 2644 2645 const payload = { 2646 op: "add", 2647 mode: "full", 2648 paths: [toPosixPath(selectionPath)], 2649 }; 2650 2651 try { 2652 await callManageSelection(binding, payload); 2653 } catch { 2654 // Fail-open 2655 return; 2656 } 2657 2658 updateAutoSelectionStateAfterFullRead(binding, selectionPath); 2659 } 2660 2661 2662 pi.on("session_start", async (_event, ctx) => { 2663 config = loadConfig(); 2664 clearReadcacheCaches(); 2665 clearWindowsCache(); 2666 markBindingValidationStale(); 2667 reconstructBinding(ctx); 2668 2669 const binding = getCurrentBinding(); 2670 activeAutoSelectionState = 2671 config.autoSelectReadSlices === true && binding 2672 ? getAutoSelectionStateFromBranch(ctx, binding) 2673 : null; 2674 2675 if (config.readcacheReadFile === true) { 2676 void pruneObjectsOlderThan(ctx.cwd).catch(() => { 2677 // Fail-open 2678 }); 2679 } 2680 2681 try { 2682 await syncAutoSelectionToCurrentBranch(ctx); 2683 } catch { 2684 // Fail-open 2685 } 2686 }); 2687 2688 pi.on("session_switch", async (_event, ctx) => { 2689 config = loadConfig(); 2690 clearReadcacheCaches(); 2691 clearWindowsCache(); 2692 markBindingValidationStale(); 2693 reconstructBinding(ctx); 2694 await syncAutoSelectionToCurrentBranch(ctx); 2695 }); 2696 2697 // session_fork is the current event name; keep session_branch for backwards compatibility 2698 pi.on("session_fork", async (_event, ctx) => { 2699 config = loadConfig(); 2700 clearReadcacheCaches(); 2701 clearWindowsCache(); 2702 markBindingValidationStale(); 2703 reconstructBinding(ctx); 2704 await syncAutoSelectionToCurrentBranch(ctx); 2705 }); 2706 2707 pi.on("session_branch", async (_event, ctx) => { 2708 config = loadConfig(); 2709 clearReadcacheCaches(); 2710 clearWindowsCache(); 2711 markBindingValidationStale(); 2712 reconstructBinding(ctx); 2713 await syncAutoSelectionToCurrentBranch(ctx); 2714 }); 2715 2716 pi.on("session_tree", async (_event, ctx) => { 2717 config = loadConfig(); 2718 clearReadcacheCaches(); 2719 clearWindowsCache(); 2720 markBindingValidationStale(); 2721 reconstructBinding(ctx); 2722 await syncAutoSelectionToCurrentBranch(ctx); 2723 }); 2724 2725 pi.on("session_compact", async () => { 2726 clearReadcacheCaches(); 2727 }); 2728 2729 pi.on("session_shutdown", async () => { 2730 clearReadcacheCaches(); 2731 clearWindowsCache(); 2732 activeAutoSelectionState = null; 2733 setBinding(null); 2734 }); 2735 2736 pi.registerCommand("rpbind", { 2737 description: "Bind rp_exec to RepoPrompt: /rpbind <window_id> <tab>", 2738 handler: async (args, ctx) => { 2739 const parsed = parseRpbindArgs(args); 2740 if ("error" in parsed) { 2741 ctx.ui.notify(parsed.error, "error"); 2742 return; 2743 } 2744 2745 const binding = await enrichBinding(parsed.windowId, parsed.tab); 2746 persistBinding(binding); 2747 2748 try { 2749 await syncAutoSelectionToCurrentBranch(ctx); 2750 } catch { 2751 // Fail-open 2752 } 2753 2754 ctx.ui.notify( 2755 `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` + 2756 (boundWorkspaceName ? ` (${boundWorkspaceName})` : ""), 2757 "success" 2758 ); 2759 }, 2760 }); 2761 2762 2763 pi.registerCommand("rpcli-readcache-status", { 2764 description: "Show repoprompt-cli read_file cache status", 2765 handler: async (_args, ctx) => { 2766 config = loadConfig(); 2767 2768 let msg = "repoprompt-cli read_file cache\n"; 2769 msg += "──────────────────────────\n"; 2770 msg += `Enabled: ${config.readcacheReadFile === true ? "✓" : "✗"}\n`; 2771 msg += `Auto-select reads: ${config.autoSelectReadSlices === true ? "✓" : "✗"}\n`; 2772 2773 if (config.readcacheReadFile !== true) { 2774 msg += "\nEnable by creating ~/.pi/agent/extensions/repoprompt-cli/config.json\n"; 2775 msg += "\nwith:\n { \"readcacheReadFile\": true }\n"; 2776 ctx.ui.notify(msg, "info"); 2777 return; 2778 } 2779 2780 try { 2781 const stats = await getStoreStats(ctx.cwd); 2782 msg += `\nObject store (under ${ctx.cwd}/.pi/readcache):\n`; 2783 msg += ` Objects: ${stats.objects}\n`; 2784 msg += ` Bytes: ${stats.bytes}\n`; 2785 } catch { 2786 msg += "\nObject store: unavailable\n"; 2787 } 2788 2789 msg += "\nNotes:\n"; 2790 msg += "- Cache applies only to simple rp_exec reads (read/cat/read_file)\n"; 2791 msg += "- Use bypass_cache=true in the read command to force baseline output\n"; 2792 2793 ctx.ui.notify(msg, "info"); 2794 }, 2795 }); 2796 2797 pi.registerCommand("rpcli-readcache-refresh", { 2798 description: "Invalidate repoprompt-cli read_file cache trust for a path and optional line range", 2799 handler: async (args, ctx) => { 2800 config = loadConfig(); 2801 2802 if (config.readcacheReadFile !== true) { 2803 ctx.ui.notify("readcacheReadFile is disabled in config", "error"); 2804 return; 2805 } 2806 2807 const trimmed = args.trim(); 2808 if (!trimmed) { 2809 ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error"); 2810 return; 2811 } 2812 2813 const parts = trimmed.split(/\s+/); 2814 const pathInput = parts[0]; 2815 const rangeInput = parts[1]; 2816 2817 if (!pathInput) { 2818 ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error"); 2819 return; 2820 } 2821 2822 const binding = await ensureBindingTargetsLiveWindow(ctx); 2823 if (!binding) { 2824 ctx.ui.notify("rp_exec is not bound. Bind first via /rpbind or rp_bind", "error"); 2825 return; 2826 } 2827 2828 let scopeKey: ScopeKey = SCOPE_FULL; 2829 if (rangeInput) { 2830 const match = rangeInput.match(/^(\d+)-(\d+)$/); 2831 if (!match) { 2832 ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error"); 2833 return; 2834 } 2835 2836 const start = parseInt(match[1] ?? "", 10); 2837 const end = parseInt(match[2] ?? "", 10); 2838 if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end < start) { 2839 ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error"); 2840 return; 2841 } 2842 2843 scopeKey = scopeRange(start, end); 2844 } 2845 2846 const resolved = await resolveReadFilePath(pi, pathInput, ctx.cwd, binding.windowId, binding.tab); 2847 if (!resolved.absolutePath) { 2848 ctx.ui.notify(`Could not resolve path: ${pathInput}`, "error"); 2849 return; 2850 } 2851 2852 pi.appendEntry(RP_READCACHE_CUSTOM_TYPE, buildInvalidationV1(resolved.absolutePath, scopeKey)); 2853 clearReadcacheCaches(); 2854 2855 ctx.ui.notify( 2856 `Invalidated readcache for ${resolved.absolutePath}` + (scopeKey === SCOPE_FULL ? "" : ` (${scopeKey})`), 2857 "info" 2858 ); 2859 }, 2860 }); 2861 2862 pi.registerTool({ 2863 name: "rp_bind", 2864 label: "RepoPrompt Bind", 2865 description: "Bind rp_exec to a specific RepoPrompt window and compose tab", 2866 parameters: BindParams, 2867 2868 async execute(_toolCallId, params, _signal, _onUpdate, ctx) { 2869 await ensureJustBashLoaded(); 2870 maybeWarnAstUnavailable(ctx); 2871 2872 const binding = await enrichBinding(params.windowId, params.tab); 2873 persistBinding(binding); 2874 2875 try { 2876 await syncAutoSelectionToCurrentBranch(ctx); 2877 } catch { 2878 // Fail-open 2879 } 2880 2881 return { 2882 content: [{ type: "text", text: `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` }], 2883 details: { 2884 windowId: boundWindowId, 2885 tab: boundTab, 2886 workspaceId: boundWorkspaceId, 2887 workspaceName: boundWorkspaceName, 2888 workspaceRoots: boundWorkspaceRoots, 2889 }, 2890 }; 2891 }, 2892 }); 2893 2894 pi.registerTool({ 2895 name: "rp_exec", 2896 label: "RepoPrompt Exec", 2897 description: "Run rp-cli in the bound RepoPrompt window/tab, with quiet defaults and output truncation", 2898 parameters: ExecParams, 2899 2900 async execute(_toolCallId, params, signal, onUpdate, ctx) { 2901 // Routing: prefer call-time overrides, otherwise fall back to the last persisted binding 2902 await ensureJustBashLoaded(); 2903 maybeWarnAstUnavailable(ctx); 2904 2905 if (params.windowId === undefined && params.tab === undefined) { 2906 try { 2907 await maybeEnsureBindingTargetsLiveWindow(ctx); 2908 } catch { 2909 // Fail-open 2910 } 2911 } 2912 2913 const windowId = params.windowId ?? boundWindowId; 2914 const tab = params.tab ?? boundTab; 2915 const rawJson = params.rawJson ?? false; 2916 const quiet = params.quiet ?? true; 2917 const failFast = params.failFast ?? true; 2918 const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS; 2919 const maxOutputChars = params.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS; 2920 const allowDelete = params.allowDelete ?? false; 2921 const allowWorkspaceSwitchInPlace = params.allowWorkspaceSwitchInPlace ?? false; 2922 const failOnNoopEdits = params.failOnNoopEdits ?? true; 2923 2924 if (!allowDelete && looksLikeDeleteCommand(params.cmd)) { 2925 return { 2926 isError: true, 2927 content: [ 2928 { 2929 type: "text", 2930 text: "Blocked potential delete command. If deletion is explicitly requested, rerun with allowDelete=true", 2931 }, 2932 ], 2933 details: { blocked: true, reason: "delete", cmd: params.cmd, windowId, tab }, 2934 }; 2935 } 2936 2937 if (!allowWorkspaceSwitchInPlace && looksLikeWorkspaceSwitchInPlace(params.cmd)) { 2938 return { 2939 isError: true, 2940 content: [ 2941 { 2942 type: "text", 2943 text: 2944 "Blocked in-place workspace change (it can clobber selection/prompt/context and disrupt other sessions). " + 2945 "Add `--new-window`, or rerun with allowWorkspaceSwitchInPlace=true if explicitly safe", 2946 }, 2947 ], 2948 details: { blocked: true, reason: "workspace_switch_in_place", cmd: params.cmd, windowId, tab }, 2949 }; 2950 } 2951 2952 const isBound = windowId !== undefined && tab !== undefined; 2953 if (!isBound && !isSafeToRunUnbound(params.cmd)) { 2954 return { 2955 content: [ 2956 { 2957 type: "text", 2958 text: 2959 "Blocked rp_exec because it is not bound to a window+tab. " + 2960 "Do not fall back to native Pi tools—bind first. " + 2961 "Run `windows` (it now reports tabs/context IDs) or `tabs list`, then bind with rp_bind(windowId, tab). " + 2962 "If RepoPrompt is in single-window mode, windowId is usually 1", 2963 }, 2964 ], 2965 details: { blocked: true, reason: "unbound", cmd: params.cmd, windowId, tab }, 2966 }; 2967 } 2968 2969 // Parse read-like commands to: 2970 // - detect cacheable reads (when enabled) 2971 // - strip wrapper-only args like bypass_cache=true even when caching is disabled 2972 // (so agents can safely use bypass_cache in instructions regardless of config) 2973 const readRequest = parseReadFileRequest(params.cmd); 2974 2975 const cmdToRun = readRequest ? readRequest.cmdToRun : params.cmd; 2976 2977 const rpArgs: string[] = []; 2978 if (windowId !== undefined) rpArgs.push("-w", String(windowId)); 2979 if (tab !== undefined) rpArgs.push("-t", tab); 2980 if (quiet) rpArgs.push("-q"); 2981 if (rawJson) rpArgs.push("--raw-json"); 2982 if (failFast) rpArgs.push("--fail-fast"); 2983 rpArgs.push("-e", cmdToRun); 2984 2985 if (windowId === undefined || tab === undefined) { 2986 onUpdate({ 2987 status: 2988 "Running rp-cli without a bound window/tab (non-deterministic). Bind first with rp_bind(windowId, tab)", 2989 }); 2990 } else { 2991 onUpdate({ status: `Running rp-cli in window ${windowId}, tab "${tab}"…` }); 2992 } 2993 2994 let stdout = ""; 2995 let stderr = ""; 2996 let exitCode = -1; 2997 let execError: string | undefined; 2998 2999 try { 3000 const result = await pi.exec("rp-cli", rpArgs, { signal, timeout: timeoutMs }); 3001 stdout = result.stdout ?? ""; 3002 stderr = result.stderr ?? ""; 3003 exitCode = result.code ?? 0; 3004 } catch (error) { 3005 execError = error instanceof Error ? error.message : String(error); 3006 } 3007 3008 const combinedOutput = [stdout, stderr].filter(Boolean).join("\n").trim(); 3009 3010 let rawOutput = execError ? `rp-cli execution failed: ${execError}` : combinedOutput; 3011 3012 let rpReadcache: RpReadcacheMetaV1 | null = null; 3013 3014 if ( 3015 config.readcacheReadFile === true && 3016 readRequest !== null && 3017 readRequest.cacheable === true && 3018 !execError && 3019 exitCode === 0 && 3020 windowId !== undefined && 3021 tab !== undefined 3022 ) { 3023 try { 3024 const cached = await readFileWithCache( 3025 pi, 3026 { 3027 path: readRequest.path, 3028 ...(typeof readRequest.startLine === "number" ? { start_line: readRequest.startLine } : {}), 3029 ...(typeof readRequest.limit === "number" ? { limit: readRequest.limit } : {}), 3030 ...(readRequest.bypassCache ? { bypass_cache: true } : {}), 3031 }, 3032 ctx, 3033 readcacheRuntimeState, 3034 windowId, 3035 tab, 3036 signal 3037 ); 3038 3039 rpReadcache = cached.meta; 3040 3041 if (typeof cached.outputText === "string" && cached.outputText.length > 0) { 3042 rawOutput = cached.outputText; 3043 } 3044 } catch { 3045 // Fail-open: caching must never break the baseline command output 3046 } 3047 } 3048 3049 const shouldAutoSelectRead = 3050 config.autoSelectReadSlices === true && 3051 readRequest !== null && 3052 !execError && 3053 exitCode === 0 && 3054 windowId !== undefined && 3055 tab !== undefined && 3056 params.windowId === undefined && 3057 params.tab === undefined; 3058 3059 if (shouldAutoSelectRead) { 3060 try { 3061 await autoSelectReadFileInRepoPromptSelection( 3062 ctx, 3063 readRequest.path, 3064 readRequest.startLine, 3065 readRequest.limit, 3066 combinedOutput, 3067 ); 3068 } catch { 3069 // Fail-open 3070 } 3071 } 3072 3073 const editNoop = 3074 !execError && 3075 exitCode === 0 && 3076 looksLikeEditCommand(params.cmd) && 3077 looksLikeNoopEditOutput(rawOutput); 3078 3079 const shouldFailNoopEdit = editNoop && failOnNoopEdits; 3080 const commandFailed = Boolean(execError) || exitCode !== 0; 3081 const shouldError = commandFailed || shouldFailNoopEdit; 3082 3083 let outputForUser = rawOutput; 3084 if (editNoop) { 3085 const rpCliOutput = rawOutput.length > 0 ? `\n--- rp-cli output ---\n${rawOutput}` : ""; 3086 3087 if (shouldFailNoopEdit) { 3088 outputForUser = 3089 "RepoPrompt edit made no changes (0 edits applied). This usually means the search string was not found.\n" + 3090 "If this was expected, rerun with failOnNoopEdits=false. Otherwise, verify the search text or rerun with rawJson=true / quiet=false.\n" + 3091 "Tip: for tricky edits with multiline content, use rp-cli directly: rp-cli -c apply_edits -j '{...}'" + 3092 rpCliOutput; 3093 } else { 3094 outputForUser = 3095 "RepoPrompt edit made no changes (0 edits applied).\n" + 3096 "RepoPrompt may report this as an error (e.g. 'search block not found'), but failOnNoopEdits=false is treating it as non-fatal.\n" + 3097 "Tip: for tricky edits with multiline content, use rp-cli directly: rp-cli -c apply_edits -j '{...}'" + 3098 rpCliOutput; 3099 } 3100 } 3101 3102 const outputWithBindingWarning = 3103 windowId === undefined || tab === undefined 3104 ? `WARNING: rp_exec is not bound to a RepoPrompt window/tab. Bind with rp_bind(windowId, tab).\n\n${outputForUser}` 3105 : outputForUser; 3106 3107 const { text: truncatedOutput, truncated } = truncateText(outputWithBindingWarning.trim(), maxOutputChars); 3108 const finalText = truncatedOutput.length > 0 ? truncatedOutput : "(no output)"; 3109 3110 return { 3111 isError: shouldError, 3112 content: [{ type: "text", text: finalText }], 3113 details: { 3114 cmd: params.cmd, 3115 windowId, 3116 tab, 3117 rawJson, 3118 quiet, 3119 failOnNoopEdits, 3120 failFast, 3121 timeoutMs, 3122 maxOutputChars, 3123 exitCode, 3124 truncated, 3125 stderrIncluded: stderr.trim().length > 0, 3126 execError, 3127 editNoop, 3128 shouldFailNoopEdit, 3129 rpReadcache: rpReadcache ?? undefined, 3130 }, 3131 }; 3132 }, 3133 3134 renderCall(args: Record<string, unknown>, theme: Theme) { 3135 const cmd = (args.cmd as string) || "..."; 3136 const windowId = args.windowId ?? boundWindowId; 3137 const tab = args.tab ?? boundTab; 3138 3139 let text = theme.fg("toolTitle", theme.bold("rp_exec")); 3140 text += " " + theme.fg("accent", cmd); 3141 3142 if (windowId !== undefined && tab !== undefined) { 3143 text += theme.fg("muted", ` (window ${windowId}, tab "${tab}")`); 3144 } else { 3145 text += theme.fg("warning", " (unbound)"); 3146 } 3147 3148 return new Text(text, 0, 0); 3149 }, 3150 3151 renderResult( 3152 result: { content: Array<{ type: string; text?: string }>; details?: Record<string, unknown>; isError?: boolean }, 3153 options: ToolRenderResultOptions, 3154 theme: Theme 3155 ) { 3156 const details = result.details || {}; 3157 const exitCode = details.exitCode as number | undefined; 3158 const truncated = details.truncated as boolean | undefined; 3159 const blocked = details.blocked as boolean | undefined; 3160 3161 // Get text content 3162 const textContent = result.content 3163 .filter((c) => c.type === "text") 3164 .map((c) => c.text || "") 3165 .join("\n"); 3166 3167 // Handle partial/streaming state 3168 if (options.isPartial) { 3169 return new Text(theme.fg("warning", "Running…"), 0, 0); 3170 } 3171 3172 // Handle blocked commands 3173 if (blocked) { 3174 return new Text(theme.fg("error", "✗ " + textContent), 0, 0); 3175 } 3176 3177 // Handle errors 3178 if (result.isError || (exitCode !== undefined && exitCode !== 0)) { 3179 const exitInfo = exitCode !== undefined ? ` (exit ${exitCode})` : ""; 3180 return new Text(theme.fg("error", `✗${exitInfo}\n${textContent}`), 0, 0); 3181 } 3182 3183 // Success case 3184 const truncatedNote = truncated ? theme.fg("warning", " (truncated)") : ""; 3185 const successPrefix = theme.fg("success", "✓ "); 3186 const prefixFirstLine = (value: string, prefix: string): string => { 3187 if (!value) { 3188 return prefix.trimEnd(); 3189 } 3190 const idx = value.indexOf("\n"); 3191 if (idx < 0) { 3192 return `${prefix}${value}`; 3193 } 3194 return `${prefix}${value.slice(0, idx)}${value.slice(idx)}`; 3195 }; 3196 3197 if (!options.expanded) { 3198 const collapsedMaxLines = config.collapsedMaxLines; 3199 const maxLines = collapsedMaxLines ?? DEFAULT_COLLAPSED_MAX_LINES; 3200 const { content, truncated: collapsedTruncated, totalLines } = prepareCollapsedView( 3201 textContent, 3202 theme, 3203 collapsedMaxLines 3204 ); 3205 3206 if (maxLines === 0) { 3207 const hidden = theme.fg("muted", "(output hidden)"); 3208 const moreText = totalLines > 0 ? theme.fg("muted", `\n… (${totalLines} more lines)`) : ""; 3209 return new Text(`${successPrefix}${truncatedNote}${hidden}${moreText}`, 0, 0); 3210 } 3211 3212 if (collapsedTruncated) { 3213 const remaining = totalLines - maxLines; 3214 const moreText = remaining > 0 ? theme.fg("muted", `\n… (${remaining} more lines)`) : ""; 3215 return new Text(`${prefixFirstLine(content, `${successPrefix}${truncatedNote}`)}${moreText}`, 0, 0); 3216 } 3217 3218 return new Text(prefixFirstLine(content, `${successPrefix}${truncatedNote}`), 0, 0); 3219 } 3220 3221 // Expanded view or short output: render with syntax highlighting 3222 const highlighted = renderRpExecOutput(textContent, theme); 3223 return new Text(prefixFirstLine(highlighted, `${successPrefix}${truncatedNote}`), 0, 0); 3224 }, 3225 }); 3226 }