pi-repoprompt-cli 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,6 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import * as path from "node:path";
3
+
1
4
  import type { ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
2
5
  import { highlightCode, Theme } from "@mariozechner/pi-coding-agent";
3
6
  import { Text } from "@mariozechner/pi-tui";
@@ -5,13 +8,25 @@ import { Type } from "@sinclair/typebox";
5
8
  import * as Diff from "diff";
6
9
 
7
10
  import { loadConfig } from "./config.js";
11
+ import {
12
+ computeSliceRangeFromReadArgs,
13
+ countFileLines,
14
+ inferSelectionStatus,
15
+ toPosixPath,
16
+ } from "./auto-select.js";
8
17
  import { RP_READCACHE_CUSTOM_TYPE, SCOPE_FULL, scopeRange } from "./readcache/constants.js";
9
18
  import { buildInvalidationV1 } from "./readcache/meta.js";
10
19
  import { getStoreStats, pruneObjectsOlderThan } from "./readcache/object-store.js";
11
20
  import { readFileWithCache } from "./readcache/read-file.js";
12
21
  import { clearReplayRuntimeState, createReplayRuntimeState } from "./readcache/replay.js";
13
- import { resolveReadFilePath } from "./readcache/resolve.js";
22
+ import { clearRootsCache, resolveReadFilePath } from "./readcache/resolve.js";
14
23
  import type { RpReadcacheMetaV1, ScopeKey } from "./readcache/types.js";
24
+ import type {
25
+ AutoSelectionEntryData,
26
+ AutoSelectionEntryRangeData,
27
+ AutoSelectionEntrySliceData,
28
+ RpCliBindingEntryData,
29
+ } from "./types.js";
15
30
 
16
31
  let parseBash: ((input: string) => any) | null = null;
17
32
  let justBashLoadPromise: Promise<void> | null = null;
@@ -239,12 +254,22 @@ function hasPipeOutsideQuotes(script: string): boolean {
239
254
  * - Provide actionable error messages when blocked
240
255
  * - For best command parsing (AST-based), install `just-bash` >= 2; otherwise it falls back to a legacy splitter
241
256
  * - Syntax-highlight fenced code blocks in output (read, structure, etc.)
242
- * - Word-level diff highlighting for edit output
257
+ * - Delta-powered diff highlighting (with graceful fallback when delta is unavailable)
243
258
  */
244
259
 
245
260
  const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
246
261
  const DEFAULT_MAX_OUTPUT_CHARS = 12000;
247
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
+ }
248
273
 
249
274
  const BindParams = Type.Object({
250
275
  windowId: Type.Number({ description: "RepoPrompt window id (from `rp-cli -e windows`)" }),
@@ -954,6 +979,88 @@ function parseFencedBlocks(text: string): FencedBlock[] {
954
979
  return blocks;
955
980
  }
956
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
+
957
1064
  /**
958
1065
  * Compute word-level diff with inverse highlighting on changed parts
959
1066
  */
@@ -1005,6 +1112,11 @@ function renderIntraLineDiff(
1005
1112
  * Render diff lines with syntax highlighting (red/green, word-level inverse)
1006
1113
  */
1007
1114
  function renderDiffBlock(code: string, theme: Theme): string {
1115
+ const deltaRendered = renderDiffBlockWithDelta(code);
1116
+ if (deltaRendered !== null) {
1117
+ return deltaRendered;
1118
+ }
1119
+
1008
1120
  const lines = code.split("\n");
1009
1121
  const result: string[] = [];
1010
1122
 
@@ -1094,7 +1206,7 @@ function renderDiffBlock(code: string, theme: Theme): string {
1094
1206
 
1095
1207
  /**
1096
1208
  * Render rp_exec output with syntax highlighting for fenced code blocks.
1097
- * - ```diff blocks get word-level diff highlighting
1209
+ * - ```diff blocks use delta when available, with word-level fallback
1098
1210
  * - Other fenced blocks get syntax highlighting via Pi's highlightCode
1099
1211
  * - Non-fenced content is rendered dim (no markdown parsing)
1100
1212
  */
@@ -1161,249 +1273,1577 @@ export default function (pi: ExtensionAPI) {
1161
1273
  clearReplayRuntimeState(readcacheRuntimeState);
1162
1274
  };
1163
1275
 
1276
+ let activeAutoSelectionState: AutoSelectionEntryData | null = null;
1277
+
1164
1278
  let boundWindowId: number | undefined;
1165
1279
  let boundTab: string | undefined;
1280
+ let boundWorkspaceId: string | undefined;
1281
+ let boundWorkspaceName: string | undefined;
1282
+ let boundWorkspaceRoots: string[] | undefined;
1166
1283
 
1167
- const setBinding = (windowId: number, tab: string) => {
1168
- boundWindowId = windowId;
1169
- boundTab = tab;
1170
- };
1284
+ let windowsCache: { windows: RpCliWindow[]; fetchedAtMs: number } | null = null;
1285
+ let lastBindingValidationAtMs = 0;
1171
1286
 
1172
- const persistBinding = (windowId: number, tab: string) => {
1173
- // Persist binding across session reloads without injecting extra text into the model context
1174
- if (boundWindowId === windowId && boundTab === tab) return;
1287
+ function sameOptionalText(a?: string, b?: string): boolean {
1288
+ return (a ?? undefined) === (b ?? undefined);
1289
+ }
1175
1290
 
1176
- setBinding(windowId, tab);
1177
- pi.appendEntry(BINDING_CUSTOM_TYPE, { windowId, tab });
1178
- };
1291
+ function clearWindowsCache(): void {
1292
+ windowsCache = null;
1293
+ }
1179
1294
 
1180
- const reconstructBinding = (ctx: ExtensionContext) => {
1181
- // Prefer persisted binding (appendEntry) from the *current branch*, then fall back to prior rp_bind tool results
1182
- // Branch semantics: if the current branch has no binding state, stay unbound
1183
- boundWindowId = undefined;
1184
- boundTab = undefined;
1295
+ function markBindingValidationStale(): void {
1296
+ lastBindingValidationAtMs = 0;
1297
+ }
1185
1298
 
1186
- let reconstructedWindowId: number | undefined;
1187
- let reconstructedTab: string | undefined;
1299
+ function shouldRevalidateBinding(): boolean {
1300
+ const now = Date.now();
1301
+ return (now - lastBindingValidationAtMs) >= BINDING_VALIDATION_TTL_MS;
1302
+ }
1188
1303
 
1189
- for (const entry of ctx.sessionManager.getBranch()) {
1190
- if (entry.type !== "custom" || entry.customType !== BINDING_CUSTOM_TYPE) continue;
1304
+ function markBindingValidatedNow(): void {
1305
+ lastBindingValidationAtMs = Date.now();
1306
+ }
1191
1307
 
1192
- const data = entry.data as { windowId?: unknown; tab?: unknown } | undefined;
1193
- const windowId = typeof data?.windowId === "number" ? data.windowId : undefined;
1194
- const tab = typeof data?.tab === "string" ? data.tab : undefined;
1195
- if (windowId !== undefined && tab) {
1196
- reconstructedWindowId = windowId;
1197
- reconstructedTab = tab;
1198
- }
1308
+ function normalizeWorkspaceRoots(roots: string[] | undefined): string[] {
1309
+ if (!Array.isArray(roots)) {
1310
+ return [];
1199
1311
  }
1200
1312
 
1201
- if (reconstructedWindowId !== undefined && reconstructedTab !== undefined) {
1202
- setBinding(reconstructedWindowId, reconstructedTab);
1203
- return;
1313
+ return [...new Set(roots.map((root) => toPosixPath(String(root).trim())).filter(Boolean))].sort();
1314
+ }
1315
+
1316
+ function workspaceRootsEqual(left: string[] | undefined, right: string[] | undefined): boolean {
1317
+ const leftNormalized = normalizeWorkspaceRoots(left);
1318
+ const rightNormalized = normalizeWorkspaceRoots(right);
1319
+ return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized);
1320
+ }
1321
+
1322
+ function workspaceIdentityMatches(
1323
+ left: { workspaceId?: string; workspaceName?: string; workspaceRoots?: string[] },
1324
+ right: { workspaceId?: string; workspaceName?: string; workspaceRoots?: string[] }
1325
+ ): boolean {
1326
+ if (left.workspaceId && right.workspaceId) {
1327
+ return left.workspaceId === right.workspaceId;
1204
1328
  }
1205
1329
 
1206
- for (const entry of ctx.sessionManager.getBranch()) {
1207
- if (entry.type !== "message") continue;
1208
- const msg = entry.message;
1209
- if (msg.role !== "toolResult" || msg.toolName !== "rp_bind") continue;
1330
+ const leftRoots = normalizeWorkspaceRoots(left.workspaceRoots);
1331
+ const rightRoots = normalizeWorkspaceRoots(right.workspaceRoots);
1210
1332
 
1211
- const details = msg.details as { windowId?: number; tab?: string } | undefined;
1212
- if (details?.windowId !== undefined && details?.tab) {
1213
- persistBinding(details.windowId, details.tab);
1214
- }
1333
+ if (leftRoots.length > 0 && rightRoots.length > 0) {
1334
+ return JSON.stringify(leftRoots) === JSON.stringify(rightRoots);
1215
1335
  }
1216
- };
1217
1336
 
1218
- pi.on("session_start", async (_event, ctx) => {
1219
- config = loadConfig();
1220
- clearReadcacheCaches();
1221
- if (config.readcacheReadFile === true) {
1222
- void pruneObjectsOlderThan(ctx.cwd).catch(() => {
1223
- // Fail-open
1224
- });
1337
+ if (left.workspaceName && right.workspaceName) {
1338
+ return left.workspaceName === right.workspaceName;
1225
1339
  }
1226
- reconstructBinding(ctx);
1227
- });
1228
1340
 
1229
- pi.on("session_switch", async (_event, ctx) => {
1230
- config = loadConfig();
1231
- clearReadcacheCaches();
1232
- reconstructBinding(ctx);
1233
- });
1341
+ return false;
1342
+ }
1234
1343
 
1235
- // session_fork is the current event name; keep session_branch for backwards compatibility
1236
- pi.on("session_fork", async (_event, ctx) => {
1237
- config = loadConfig();
1238
- clearReadcacheCaches();
1239
- reconstructBinding(ctx);
1240
- });
1344
+ function getCurrentBinding(): RpCliBindingEntryData | null {
1345
+ if (boundWindowId === undefined || !boundTab) {
1346
+ return null;
1347
+ }
1241
1348
 
1242
- pi.on("session_branch", async (_event, ctx) => {
1243
- config = loadConfig();
1244
- clearReadcacheCaches();
1245
- reconstructBinding(ctx);
1246
- });
1349
+ return {
1350
+ windowId: boundWindowId,
1351
+ tab: boundTab,
1352
+ workspaceId: boundWorkspaceId,
1353
+ workspaceName: boundWorkspaceName,
1354
+ workspaceRoots: boundWorkspaceRoots,
1355
+ };
1356
+ }
1247
1357
 
1248
- pi.on("session_tree", async (_event, ctx) => {
1249
- config = loadConfig();
1250
- clearReadcacheCaches();
1251
- reconstructBinding(ctx);
1252
- });
1358
+ function normalizeBindingEntry(binding: RpCliBindingEntryData): RpCliBindingEntryData {
1359
+ return {
1360
+ windowId: binding.windowId,
1361
+ tab: binding.tab,
1362
+ workspaceId: typeof binding.workspaceId === "string" ? binding.workspaceId : undefined,
1363
+ workspaceName: typeof binding.workspaceName === "string" ? binding.workspaceName : undefined,
1364
+ workspaceRoots: normalizeWorkspaceRoots(binding.workspaceRoots),
1365
+ };
1366
+ }
1253
1367
 
1254
- pi.on("session_compact", async () => {
1255
- clearReadcacheCaches();
1256
- });
1368
+ function bindingEntriesEqual(a: RpCliBindingEntryData | null, b: RpCliBindingEntryData | null): boolean {
1369
+ if (!a && !b) {
1370
+ return true;
1371
+ }
1257
1372
 
1258
- pi.on("session_shutdown", async () => {
1259
- clearReadcacheCaches();
1260
- });
1373
+ if (!a || !b) {
1374
+ return false;
1375
+ }
1261
1376
 
1262
- pi.registerCommand("rpbind", {
1263
- description: "Bind rp_exec to RepoPrompt: /rpbind <window_id> <tab>",
1264
- handler: async (args, ctx) => {
1265
- const parsed = parseRpbindArgs(args);
1266
- if ("error" in parsed) {
1267
- ctx.ui.notify(parsed.error, "error");
1268
- return;
1269
- }
1377
+ const left = normalizeBindingEntry(a);
1378
+ const right = normalizeBindingEntry(b);
1270
1379
 
1271
- persistBinding(parsed.windowId, parsed.tab);
1272
- ctx.ui.notify(`Bound rp_exec window ${boundWindowId}, tab "${boundTab}"`, "success");
1273
- },
1274
- });
1380
+ return (
1381
+ left.windowId === right.windowId &&
1382
+ left.tab === right.tab &&
1383
+ sameOptionalText(left.workspaceId, right.workspaceId) &&
1384
+ sameOptionalText(left.workspaceName, right.workspaceName) &&
1385
+ workspaceRootsEqual(left.workspaceRoots, right.workspaceRoots)
1386
+ );
1387
+ }
1275
1388
 
1276
- pi.registerCommand("rpcli-readcache-status", {
1277
- description: "Show repoprompt-cli read_file cache status",
1278
- handler: async (_args, ctx) => {
1279
- config = loadConfig();
1389
+ function setBinding(binding: RpCliBindingEntryData | null): void {
1390
+ const previousWindowId = boundWindowId;
1280
1391
 
1281
- let msg = "repoprompt-cli read_file cache\n";
1282
- msg += "──────────────────────────\n";
1283
- msg += `Enabled: ${config.readcacheReadFile === true ? "✓" : "✗"}\n`;
1392
+ if (!binding) {
1393
+ boundWindowId = undefined;
1394
+ boundTab = undefined;
1395
+ boundWorkspaceId = undefined;
1396
+ boundWorkspaceName = undefined;
1397
+ boundWorkspaceRoots = undefined;
1398
+ } else {
1399
+ const normalized = normalizeBindingEntry(binding);
1400
+ boundWindowId = normalized.windowId;
1401
+ boundTab = normalized.tab;
1402
+ boundWorkspaceId = normalized.workspaceId;
1403
+ boundWorkspaceName = normalized.workspaceName;
1404
+ boundWorkspaceRoots = normalized.workspaceRoots;
1405
+ }
1284
1406
 
1285
- if (config.readcacheReadFile !== true) {
1286
- msg += "\nEnable by creating ~/.pi/agent/extensions/repoprompt-cli/config.json\n";
1287
- msg += "\nwith:\n { \"readcacheReadFile\": true }\n";
1288
- ctx.ui.notify(msg, "info");
1289
- return;
1407
+ if (previousWindowId !== boundWindowId) {
1408
+ if (previousWindowId !== undefined) {
1409
+ clearRootsCache(previousWindowId);
1290
1410
  }
1291
1411
 
1292
- try {
1293
- const stats = await getStoreStats(ctx.cwd);
1294
- msg += `\nObject store (under ${ctx.cwd}/.pi/readcache):\n`;
1295
- msg += ` Objects: ${stats.objects}\n`;
1296
- msg += ` Bytes: ${stats.bytes}\n`;
1297
- } catch {
1298
- msg += "\nObject store: unavailable\n";
1412
+ if (boundWindowId !== undefined) {
1413
+ clearRootsCache(boundWindowId);
1299
1414
  }
1300
1415
 
1301
- msg += "\nNotes:\n";
1302
- msg += "- Cache applies only to simple rp_exec reads (read/cat/read_file)\n";
1303
- msg += "- Use bypass_cache=true in the read command to force baseline output\n";
1416
+ clearWindowsCache();
1417
+ }
1304
1418
 
1305
- ctx.ui.notify(msg, "info");
1306
- },
1307
- });
1419
+ markBindingValidationStale();
1420
+ }
1308
1421
 
1309
- pi.registerCommand("rpcli-readcache-refresh", {
1310
- description: "Invalidate repoprompt-cli read_file cache trust for a path and optional line range",
1311
- handler: async (args, ctx) => {
1312
- config = loadConfig();
1422
+ function parseBindingEntryData(value: unknown): RpCliBindingEntryData | null {
1423
+ if (!value || typeof value !== "object") {
1424
+ return null;
1425
+ }
1313
1426
 
1314
- if (config.readcacheReadFile !== true) {
1315
- ctx.ui.notify("readcacheReadFile is disabled in config", "error");
1316
- return;
1427
+ const obj = value as Record<string, unknown>;
1428
+
1429
+ const windowId = typeof obj.windowId === "number" ? obj.windowId : undefined;
1430
+ const tab = typeof obj.tab === "string" ? obj.tab : undefined;
1431
+
1432
+ if (windowId === undefined || !tab) {
1433
+ return null;
1434
+ }
1435
+
1436
+ const workspaceId = typeof obj.workspaceId === "string"
1437
+ ? obj.workspaceId
1438
+ : (typeof obj.workspaceID === "string" ? obj.workspaceID : undefined);
1439
+
1440
+ const workspaceName = typeof obj.workspaceName === "string"
1441
+ ? obj.workspaceName
1442
+ : (typeof obj.workspace === "string" ? obj.workspace : undefined);
1443
+
1444
+ const workspaceRoots = Array.isArray(obj.workspaceRoots)
1445
+ ? obj.workspaceRoots.filter((root): root is string => typeof root === "string")
1446
+ : (Array.isArray(obj.rootFolderPaths)
1447
+ ? obj.rootFolderPaths.filter((root): root is string => typeof root === "string")
1448
+ : undefined);
1449
+
1450
+ return normalizeBindingEntry({
1451
+ windowId,
1452
+ tab,
1453
+ workspaceId,
1454
+ workspaceName,
1455
+ workspaceRoots,
1456
+ });
1457
+ }
1458
+
1459
+ function persistBinding(binding: RpCliBindingEntryData): void {
1460
+ const normalized = normalizeBindingEntry(binding);
1461
+ const current = getCurrentBinding();
1462
+
1463
+ if (bindingEntriesEqual(current, normalized)) {
1464
+ return;
1465
+ }
1466
+
1467
+ setBinding(normalized);
1468
+ pi.appendEntry(BINDING_CUSTOM_TYPE, normalized);
1469
+ }
1470
+
1471
+ function reconstructBinding(ctx: ExtensionContext): void {
1472
+ // Prefer persisted binding (appendEntry) from the *current branch*, then fall back to prior rp_bind tool results
1473
+ // Branch semantics: if the current branch has no binding state, stay unbound
1474
+ setBinding(null);
1475
+
1476
+ let reconstructed: RpCliBindingEntryData | null = null;
1477
+
1478
+ for (const entry of ctx.sessionManager.getBranch()) {
1479
+ if (entry.type !== "custom" || entry.customType !== BINDING_CUSTOM_TYPE) {
1480
+ continue;
1317
1481
  }
1318
1482
 
1319
- const trimmed = args.trim();
1320
- if (!trimmed) {
1321
- ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
1322
- return;
1483
+ const parsed = parseBindingEntryData(entry.data);
1484
+ if (parsed) {
1485
+ reconstructed = parsed;
1323
1486
  }
1487
+ }
1324
1488
 
1325
- const parts = trimmed.split(/\s+/);
1326
- const pathInput = parts[0];
1327
- const rangeInput = parts[1];
1489
+ if (reconstructed) {
1490
+ setBinding(reconstructed);
1491
+ return;
1492
+ }
1328
1493
 
1329
- if (!pathInput) {
1330
- ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
1331
- return;
1494
+ for (const entry of ctx.sessionManager.getBranch()) {
1495
+ if (entry.type !== "message") {
1496
+ continue;
1332
1497
  }
1333
1498
 
1334
- const windowId = boundWindowId;
1335
- const tab = boundTab;
1499
+ const msg = entry.message;
1500
+ if (msg.role !== "toolResult" || msg.toolName !== "rp_bind") {
1501
+ continue;
1502
+ }
1336
1503
 
1337
- if (windowId === undefined) {
1338
- ctx.ui.notify("rp_exec is not bound. Bind first via /rpbind or rp_bind", "error");
1339
- return;
1504
+ const parsed = parseBindingEntryData(msg.details);
1505
+ if (parsed) {
1506
+ persistBinding(parsed);
1340
1507
  }
1508
+ }
1509
+ }
1341
1510
 
1342
- let scopeKey: ScopeKey = SCOPE_FULL;
1343
- if (rangeInput) {
1344
- const match = rangeInput.match(/^(\d+)-(\d+)$/);
1345
- if (!match) {
1346
- ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error");
1347
- return;
1348
- }
1511
+ function parseWindowsRawJson(raw: string): RpCliWindow[] {
1512
+ const trimmed = raw.trim();
1513
+ if (!trimmed) {
1514
+ return [];
1515
+ }
1349
1516
 
1350
- const start = parseInt(match[1] ?? "", 10);
1351
- const end = parseInt(match[2] ?? "", 10);
1352
- if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end < start) {
1353
- ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error");
1354
- return;
1355
- }
1517
+ let parsed: unknown;
1518
+ try {
1519
+ parsed = JSON.parse(trimmed);
1520
+ } catch {
1521
+ return [];
1522
+ }
1356
1523
 
1357
- scopeKey = scopeRange(start, end);
1524
+ const pickRows = (value: unknown): unknown[] => {
1525
+ if (Array.isArray(value)) {
1526
+ return value;
1358
1527
  }
1359
1528
 
1360
- const resolved = await resolveReadFilePath(pi, pathInput, ctx.cwd, windowId, tab);
1361
- if (!resolved.absolutePath) {
1362
- ctx.ui.notify(`Could not resolve path: ${pathInput}`, "error");
1363
- return;
1529
+ if (!value || typeof value !== "object") {
1530
+ return [];
1364
1531
  }
1365
1532
 
1366
- pi.appendEntry(RP_READCACHE_CUSTOM_TYPE, buildInvalidationV1(resolved.absolutePath, scopeKey));
1367
- clearReadcacheCaches();
1533
+ const obj = value as Record<string, unknown>;
1368
1534
 
1369
- ctx.ui.notify(
1370
- `Invalidated readcache for ${resolved.absolutePath}` + (scopeKey === SCOPE_FULL ? "" : ` (${scopeKey})`),
1371
- "info"
1372
- );
1373
- },
1374
- });
1535
+ const directArrayKeys = ["windows", "items", "data", "result"];
1536
+ for (const key of directArrayKeys) {
1537
+ const candidate = obj[key];
1538
+ if (Array.isArray(candidate)) {
1539
+ return candidate;
1540
+ }
1541
+ }
1375
1542
 
1376
- pi.registerTool({
1377
- name: "rp_bind",
1378
- label: "RepoPrompt Bind",
1379
- description: "Bind rp_exec to a specific RepoPrompt window and compose tab",
1380
- parameters: BindParams,
1543
+ const nested = obj.data;
1544
+ if (nested && typeof nested === "object") {
1545
+ const nestedObj = nested as Record<string, unknown>;
1546
+ if (Array.isArray(nestedObj.windows)) {
1547
+ return nestedObj.windows;
1548
+ }
1549
+ }
1381
1550
 
1382
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1383
- await ensureJustBashLoaded();
1384
- maybeWarnAstUnavailable(ctx);
1385
- persistBinding(params.windowId, params.tab);
1551
+ return [];
1552
+ };
1386
1553
 
1387
- return {
1388
- content: [{ type: "text", text: `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` }],
1389
- details: { windowId: boundWindowId, tab: boundTab },
1390
- };
1391
- },
1392
- });
1554
+ const rows = pickRows(parsed);
1393
1555
 
1394
- pi.registerTool({
1395
- name: "rp_exec",
1396
- label: "RepoPrompt Exec",
1397
- description: "Run rp-cli in the bound RepoPrompt window/tab, with quiet defaults and output truncation",
1398
- parameters: ExecParams,
1556
+ return rows
1557
+ .map((row) => {
1558
+ if (!row || typeof row !== "object") {
1559
+ return null;
1560
+ }
1399
1561
 
1400
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
1401
- // Routing: prefer call-time overrides, otherwise fall back to the last persisted binding
1402
- await ensureJustBashLoaded();
1403
- maybeWarnAstUnavailable(ctx);
1562
+ const obj = row as Record<string, unknown>;
1563
+ const windowId = typeof obj.windowID === "number"
1564
+ ? obj.windowID
1565
+ : (typeof obj.windowId === "number" ? obj.windowId : undefined);
1404
1566
 
1405
- const windowId = params.windowId ?? boundWindowId;
1406
- const tab = params.tab ?? boundTab;
1567
+ if (windowId === undefined) {
1568
+ return null;
1569
+ }
1570
+
1571
+ const workspaceId = typeof obj.workspaceID === "string"
1572
+ ? obj.workspaceID
1573
+ : (typeof obj.workspaceId === "string" ? obj.workspaceId : undefined);
1574
+
1575
+ const workspaceName = typeof obj.workspaceName === "string"
1576
+ ? obj.workspaceName
1577
+ : (typeof obj.workspace === "string" ? obj.workspace : undefined);
1578
+
1579
+ const rootFolderPaths = Array.isArray(obj.rootFolderPaths)
1580
+ ? obj.rootFolderPaths.filter((root): root is string => typeof root === "string")
1581
+ : (Array.isArray(obj.roots)
1582
+ ? obj.roots.filter((root): root is string => typeof root === "string")
1583
+ : undefined);
1584
+
1585
+ return {
1586
+ windowId,
1587
+ workspaceId,
1588
+ workspaceName,
1589
+ rootFolderPaths,
1590
+ } as RpCliWindow;
1591
+ })
1592
+ .filter((window): window is RpCliWindow => window !== null);
1593
+ }
1594
+
1595
+ async function fetchWindowsFromCli(forceRefresh = false): Promise<RpCliWindow[]> {
1596
+ const now = Date.now();
1597
+
1598
+ if (!forceRefresh && windowsCache && (now - windowsCache.fetchedAtMs) < WINDOWS_CACHE_TTL_MS) {
1599
+ return windowsCache.windows;
1600
+ }
1601
+
1602
+ try {
1603
+ const result = await pi.exec("rp-cli", ["--raw-json", "-q", "-e", "windows"], { timeout: 10_000 });
1604
+
1605
+ if ((result.code ?? 0) !== 0) {
1606
+ return windowsCache?.windows ?? [];
1607
+ }
1608
+
1609
+ const stdout = result.stdout ?? "";
1610
+ const stderr = result.stderr ?? "";
1611
+
1612
+ const fromStdout = parseWindowsRawJson(stdout);
1613
+ const parsed = fromStdout.length > 0 ? fromStdout : parseWindowsRawJson(stderr);
1614
+
1615
+ windowsCache = {
1616
+ windows: parsed,
1617
+ fetchedAtMs: now,
1618
+ };
1619
+
1620
+ return parsed;
1621
+ } catch {
1622
+ return windowsCache?.windows ?? [];
1623
+ }
1624
+ }
1625
+
1626
+ function windowToBinding(window: RpCliWindow, tab: string): RpCliBindingEntryData {
1627
+ return normalizeBindingEntry({
1628
+ windowId: window.windowId,
1629
+ tab,
1630
+ workspaceId: window.workspaceId,
1631
+ workspaceName: window.workspaceName,
1632
+ workspaceRoots: window.rootFolderPaths,
1633
+ });
1634
+ }
1635
+
1636
+ async function enrichBinding(windowId: number, tab: string): Promise<RpCliBindingEntryData> {
1637
+ const windows = await fetchWindowsFromCli();
1638
+ const match = windows.find((window) => window.windowId === windowId);
1639
+
1640
+ if (!match) {
1641
+ return normalizeBindingEntry({ windowId, tab });
1642
+ }
1643
+
1644
+ return windowToBinding(match, tab);
1645
+ }
1646
+
1647
+ async function ensureBindingTargetsLiveWindow(
1648
+ ctx: ExtensionContext,
1649
+ forceRefresh = false
1650
+ ): Promise<RpCliBindingEntryData | null> {
1651
+ const binding = getCurrentBinding();
1652
+ if (!binding) {
1653
+ return null;
1654
+ }
1655
+
1656
+ const windows = await fetchWindowsFromCli(forceRefresh);
1657
+ if (windows.length === 0) {
1658
+ return binding;
1659
+ }
1660
+
1661
+ const sameWindow = windows.find((window) => window.windowId === binding.windowId);
1662
+ if (sameWindow) {
1663
+ const hydrated = windowToBinding(sameWindow, binding.tab);
1664
+
1665
+ if (binding.workspaceId && hydrated.workspaceId && binding.workspaceId !== hydrated.workspaceId) {
1666
+ // Window IDs were recycled to a different workspace. Fall through to workspace-based remap
1667
+ } else {
1668
+ if (!bindingEntriesEqual(binding, hydrated)) {
1669
+ persistBinding(hydrated);
1670
+ return hydrated;
1671
+ }
1672
+
1673
+ setBinding(hydrated);
1674
+ return hydrated;
1675
+ }
1676
+ }
1677
+
1678
+ const workspaceCandidates = windows.filter((window) => workspaceIdentityMatches(
1679
+ {
1680
+ workspaceId: binding.workspaceId,
1681
+ workspaceName: binding.workspaceName,
1682
+ workspaceRoots: binding.workspaceRoots,
1683
+ },
1684
+ {
1685
+ workspaceId: window.workspaceId,
1686
+ workspaceName: window.workspaceName,
1687
+ workspaceRoots: window.rootFolderPaths,
1688
+ }
1689
+ ));
1690
+
1691
+ if (workspaceCandidates.length === 1) {
1692
+ const rebound = windowToBinding(workspaceCandidates[0], binding.tab);
1693
+ persistBinding(rebound);
1694
+ return rebound;
1695
+ }
1696
+
1697
+ setBinding(null);
1698
+
1699
+ if (ctx.hasUI) {
1700
+ const workspaceLabel = binding.workspaceName ?? binding.workspaceId ?? `window ${binding.windowId}`;
1701
+
1702
+ if (workspaceCandidates.length > 1) {
1703
+ ctx.ui.notify(
1704
+ `repoprompt-cli: binding for ${workspaceLabel} is ambiguous after restart. Re-bind with /rpbind`,
1705
+ "warning"
1706
+ );
1707
+ } else {
1708
+ ctx.ui.notify(
1709
+ `repoprompt-cli: ${workspaceLabel} not found after restart. Re-bind with /rpbind`,
1710
+ "warning"
1711
+ );
1712
+ }
1713
+ }
1714
+
1715
+ return null;
1716
+ }
1717
+
1718
+ async function maybeEnsureBindingTargetsLiveWindow(ctx: ExtensionContext): Promise<RpCliBindingEntryData | null> {
1719
+ const binding = getCurrentBinding();
1720
+ if (!binding) {
1721
+ return null;
1722
+ }
1723
+
1724
+ if (!shouldRevalidateBinding()) {
1725
+ return binding;
1726
+ }
1727
+
1728
+ const validated = await ensureBindingTargetsLiveWindow(ctx, true);
1729
+
1730
+ if (validated) {
1731
+ markBindingValidatedNow();
1732
+ }
1733
+
1734
+ return validated;
1735
+ }
1736
+
1737
+ function sameBindingForAutoSelection(
1738
+ binding: RpCliBindingEntryData | null,
1739
+ state: AutoSelectionEntryData | null
1740
+ ): boolean {
1741
+ if (!binding || !state) {
1742
+ return false;
1743
+ }
1744
+
1745
+ if (!sameOptionalText(binding.tab, state.tab)) {
1746
+ return false;
1747
+ }
1748
+
1749
+ if (binding.windowId === state.windowId) {
1750
+ return true;
1751
+ }
1752
+
1753
+ return workspaceIdentityMatches(
1754
+ {
1755
+ workspaceId: binding.workspaceId,
1756
+ workspaceName: binding.workspaceName,
1757
+ workspaceRoots: binding.workspaceRoots,
1758
+ },
1759
+ {
1760
+ workspaceId: state.workspaceId,
1761
+ workspaceName: state.workspaceName,
1762
+ workspaceRoots: state.workspaceRoots,
1763
+ }
1764
+ );
1765
+ }
1766
+
1767
+ function makeEmptyAutoSelectionState(binding: RpCliBindingEntryData): AutoSelectionEntryData {
1768
+ return {
1769
+ windowId: binding.windowId,
1770
+ tab: binding.tab,
1771
+ workspaceId: binding.workspaceId,
1772
+ workspaceName: binding.workspaceName,
1773
+ workspaceRoots: normalizeWorkspaceRoots(binding.workspaceRoots),
1774
+ fullPaths: [],
1775
+ slicePaths: [],
1776
+ };
1777
+ }
1778
+
1779
+ function normalizeAutoSelectionRanges(ranges: AutoSelectionEntryRangeData[]): AutoSelectionEntryRangeData[] {
1780
+ const normalized = ranges
1781
+ .map((range) => ({
1782
+ start_line: Number(range.start_line),
1783
+ end_line: Number(range.end_line),
1784
+ }))
1785
+ .filter((range) => Number.isFinite(range.start_line) && Number.isFinite(range.end_line))
1786
+ .filter((range) => range.start_line > 0 && range.end_line >= range.start_line)
1787
+ .sort((a, b) => {
1788
+ if (a.start_line !== b.start_line) {
1789
+ return a.start_line - b.start_line;
1790
+ }
1791
+
1792
+ return a.end_line - b.end_line;
1793
+ });
1794
+
1795
+ const merged: AutoSelectionEntryRangeData[] = [];
1796
+ for (const range of normalized) {
1797
+ const last = merged[merged.length - 1];
1798
+ if (!last) {
1799
+ merged.push(range);
1800
+ continue;
1801
+ }
1802
+
1803
+ if (range.start_line <= last.end_line + 1) {
1804
+ last.end_line = Math.max(last.end_line, range.end_line);
1805
+ continue;
1806
+ }
1807
+
1808
+ merged.push(range);
1809
+ }
1810
+
1811
+ return merged;
1812
+ }
1813
+
1814
+ function normalizeAutoSelectionState(state: AutoSelectionEntryData): AutoSelectionEntryData {
1815
+ const fullPaths = [...new Set(state.fullPaths.map((p) => toPosixPath(String(p).trim())).filter(Boolean))].sort();
1816
+
1817
+ const fullSet = new Set(fullPaths);
1818
+
1819
+ const sliceMap = new Map<string, AutoSelectionEntryRangeData[]>();
1820
+ for (const item of state.slicePaths) {
1821
+ const pathKey = toPosixPath(String(item.path ?? "").trim());
1822
+ if (!pathKey || fullSet.has(pathKey)) {
1823
+ continue;
1824
+ }
1825
+
1826
+ const existing = sliceMap.get(pathKey) ?? [];
1827
+ existing.push(...normalizeAutoSelectionRanges(item.ranges ?? []));
1828
+ sliceMap.set(pathKey, existing);
1829
+ }
1830
+
1831
+ const slicePaths: AutoSelectionEntrySliceData[] = [...sliceMap.entries()]
1832
+ .map(([pathKey, ranges]) => ({
1833
+ path: pathKey,
1834
+ ranges: normalizeAutoSelectionRanges(ranges),
1835
+ }))
1836
+ .filter((item) => item.ranges.length > 0)
1837
+ .sort((a, b) => a.path.localeCompare(b.path));
1838
+
1839
+ return {
1840
+ windowId: state.windowId,
1841
+ tab: state.tab,
1842
+ workspaceId: typeof state.workspaceId === "string" ? state.workspaceId : undefined,
1843
+ workspaceName: typeof state.workspaceName === "string" ? state.workspaceName : undefined,
1844
+ workspaceRoots: normalizeWorkspaceRoots(state.workspaceRoots),
1845
+ fullPaths,
1846
+ slicePaths,
1847
+ };
1848
+ }
1849
+
1850
+ function autoSelectionStatesEqual(a: AutoSelectionEntryData | null, b: AutoSelectionEntryData | null): boolean {
1851
+ if (!a && !b) {
1852
+ return true;
1853
+ }
1854
+
1855
+ if (!a || !b) {
1856
+ return false;
1857
+ }
1858
+
1859
+ const left = normalizeAutoSelectionState(a);
1860
+ const right = normalizeAutoSelectionState(b);
1861
+
1862
+ return JSON.stringify(left) === JSON.stringify(right);
1863
+ }
1864
+
1865
+ function parseAutoSelectionEntryData(
1866
+ value: unknown,
1867
+ binding: RpCliBindingEntryData
1868
+ ): AutoSelectionEntryData | null {
1869
+ if (!value || typeof value !== "object") {
1870
+ return null;
1871
+ }
1872
+
1873
+ const obj = value as Record<string, unknown>;
1874
+
1875
+ const windowId = typeof obj.windowId === "number" ? obj.windowId : undefined;
1876
+ const tab = typeof obj.tab === "string" ? obj.tab : undefined;
1877
+
1878
+ const workspaceId = typeof obj.workspaceId === "string"
1879
+ ? obj.workspaceId
1880
+ : (typeof obj.workspaceID === "string" ? obj.workspaceID : undefined);
1881
+
1882
+ const workspaceName = typeof obj.workspaceName === "string"
1883
+ ? obj.workspaceName
1884
+ : (typeof obj.workspace === "string" ? obj.workspace : undefined);
1885
+
1886
+ const workspaceRoots = Array.isArray(obj.workspaceRoots)
1887
+ ? obj.workspaceRoots.filter((root): root is string => typeof root === "string")
1888
+ : (Array.isArray(obj.rootFolderPaths)
1889
+ ? obj.rootFolderPaths.filter((root): root is string => typeof root === "string")
1890
+ : undefined);
1891
+
1892
+ const tabMatches = sameOptionalText(tab, binding.tab);
1893
+ const windowMatches = windowId === binding.windowId;
1894
+ const workspaceMatches = workspaceIdentityMatches(
1895
+ {
1896
+ workspaceId: binding.workspaceId,
1897
+ workspaceName: binding.workspaceName,
1898
+ workspaceRoots: binding.workspaceRoots,
1899
+ },
1900
+ {
1901
+ workspaceId,
1902
+ workspaceName,
1903
+ workspaceRoots,
1904
+ }
1905
+ );
1906
+
1907
+ if (!tabMatches || (!windowMatches && !workspaceMatches)) {
1908
+ return null;
1909
+ }
1910
+
1911
+ const fullPaths = Array.isArray(obj.fullPaths)
1912
+ ? obj.fullPaths.filter((item): item is string => typeof item === "string")
1913
+ : [];
1914
+
1915
+ const slicePathsRaw = Array.isArray(obj.slicePaths) ? obj.slicePaths : [];
1916
+ const slicePaths: AutoSelectionEntrySliceData[] = slicePathsRaw
1917
+ .map((raw) => {
1918
+ if (!raw || typeof raw !== "object") {
1919
+ return null;
1920
+ }
1921
+
1922
+ const row = raw as Record<string, unknown>;
1923
+ const pathValue = typeof row.path === "string" ? row.path : null;
1924
+ const rangesRaw = Array.isArray(row.ranges) ? row.ranges : [];
1925
+
1926
+ if (!pathValue) {
1927
+ return null;
1928
+ }
1929
+
1930
+ const ranges: AutoSelectionEntryRangeData[] = rangesRaw
1931
+ .map((rangeRaw) => {
1932
+ if (!rangeRaw || typeof rangeRaw !== "object") {
1933
+ return null;
1934
+ }
1935
+
1936
+ const rangeObj = rangeRaw as Record<string, unknown>;
1937
+ const start = typeof rangeObj.start_line === "number" ? rangeObj.start_line : NaN;
1938
+ const end = typeof rangeObj.end_line === "number" ? rangeObj.end_line : NaN;
1939
+
1940
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
1941
+ return null;
1942
+ }
1943
+
1944
+ return {
1945
+ start_line: start,
1946
+ end_line: end,
1947
+ };
1948
+ })
1949
+ .filter((range): range is AutoSelectionEntryRangeData => range !== null);
1950
+
1951
+ return {
1952
+ path: pathValue,
1953
+ ranges,
1954
+ };
1955
+ })
1956
+ .filter((item): item is AutoSelectionEntrySliceData => item !== null);
1957
+
1958
+ return normalizeAutoSelectionState({
1959
+ windowId: binding.windowId,
1960
+ tab: binding.tab,
1961
+ workspaceId: binding.workspaceId ?? workspaceId,
1962
+ workspaceName: binding.workspaceName ?? workspaceName,
1963
+ workspaceRoots: binding.workspaceRoots ?? workspaceRoots,
1964
+ fullPaths,
1965
+ slicePaths,
1966
+ });
1967
+ }
1968
+
1969
+ function getAutoSelectionStateFromBranch(
1970
+ ctx: ExtensionContext,
1971
+ binding: RpCliBindingEntryData
1972
+ ): AutoSelectionEntryData {
1973
+ const entries = ctx.sessionManager.getBranch();
1974
+
1975
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
1976
+ const entry = entries[i];
1977
+ if (entry.type !== "custom" || entry.customType !== AUTO_SELECTION_CUSTOM_TYPE) {
1978
+ continue;
1979
+ }
1980
+
1981
+ const parsed = parseAutoSelectionEntryData(entry.data, binding);
1982
+ if (parsed) {
1983
+ return parsed;
1984
+ }
1985
+ }
1986
+
1987
+ return makeEmptyAutoSelectionState(binding);
1988
+ }
1989
+
1990
+ function persistAutoSelectionState(state: AutoSelectionEntryData): void {
1991
+ const normalized = normalizeAutoSelectionState(state);
1992
+ activeAutoSelectionState = normalized;
1993
+ pi.appendEntry(AUTO_SELECTION_CUSTOM_TYPE, normalized);
1994
+ }
1995
+
1996
+ function autoSelectionManagedPaths(state: AutoSelectionEntryData): string[] {
1997
+ const fromSlices = state.slicePaths.map((item) => item.path);
1998
+ return [...new Set([...state.fullPaths, ...fromSlices])];
1999
+ }
2000
+
2001
+ function autoSelectionSliceKey(item: AutoSelectionEntrySliceData): string {
2002
+ return JSON.stringify(normalizeAutoSelectionRanges(item.ranges));
2003
+ }
2004
+
2005
+ function bindingForAutoSelectionState(state: AutoSelectionEntryData): RpCliBindingEntryData {
2006
+ return {
2007
+ windowId: state.windowId,
2008
+ tab: state.tab ?? "Compose",
2009
+ workspaceId: state.workspaceId,
2010
+ workspaceName: state.workspaceName,
2011
+ workspaceRoots: state.workspaceRoots,
2012
+ };
2013
+ }
2014
+
2015
+ async function runRpCliForBinding(
2016
+ binding: RpCliBindingEntryData,
2017
+ cmd: string,
2018
+ rawJson = false,
2019
+ timeout = 10_000
2020
+ ): Promise<{ stdout: string; stderr: string; exitCode: number; output: string }> {
2021
+ const args: string[] = ["-w", String(binding.windowId)];
2022
+
2023
+ if (binding.tab) {
2024
+ args.push("-t", binding.tab);
2025
+ }
2026
+
2027
+ args.push("-q", "--fail-fast");
2028
+
2029
+ if (rawJson) {
2030
+ args.push("--raw-json");
2031
+ }
2032
+
2033
+ args.push("-e", cmd);
2034
+
2035
+ const result = await pi.exec("rp-cli", args, { timeout });
2036
+
2037
+ const stdout = result.stdout ?? "";
2038
+ const stderr = result.stderr ?? "";
2039
+ const exitCode = result.code ?? 0;
2040
+ const output = [stdout, stderr].filter(Boolean).join("\n").trim();
2041
+
2042
+ if (exitCode !== 0) {
2043
+ throw new Error(output || `rp-cli exited with status ${exitCode}`);
2044
+ }
2045
+
2046
+ return { stdout, stderr, exitCode, output };
2047
+ }
2048
+
2049
+ async function callManageSelection(binding: RpCliBindingEntryData, args: Record<string, unknown>): Promise<string> {
2050
+ const cmd = `call manage_selection ${JSON.stringify(args)}`;
2051
+ const result = await runRpCliForBinding(binding, cmd, false);
2052
+ return result.output;
2053
+ }
2054
+
2055
+ async function getSelectionFilesText(binding: RpCliBindingEntryData): Promise<string | null> {
2056
+ try {
2057
+ const text = await callManageSelection(binding, {
2058
+ op: "get",
2059
+ view: "files",
2060
+ });
2061
+
2062
+ return text.length > 0 ? text : null;
2063
+ } catch {
2064
+ return null;
2065
+ }
2066
+ }
2067
+
2068
+ function buildSelectionPathFromResolved(
2069
+ inputPath: string,
2070
+ resolved: { absolutePath: string | null; repoRoot: string | null }
2071
+ ): string {
2072
+ if (!resolved.absolutePath || !resolved.repoRoot) {
2073
+ return inputPath;
2074
+ }
2075
+
2076
+ const rel = path.relative(resolved.repoRoot, resolved.absolutePath);
2077
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
2078
+ return inputPath;
2079
+ }
2080
+
2081
+ const rootHint = path.basename(resolved.repoRoot);
2082
+ const relPosix = rel.split(path.sep).join("/");
2083
+
2084
+ return `${rootHint}/${relPosix}`;
2085
+ }
2086
+
2087
+ function updateAutoSelectionStateAfterFullRead(binding: RpCliBindingEntryData, selectionPath: string): void {
2088
+ const normalizedPath = toPosixPath(selectionPath);
2089
+
2090
+ const baseState = sameBindingForAutoSelection(binding, activeAutoSelectionState)
2091
+ ? (activeAutoSelectionState as AutoSelectionEntryData)
2092
+ : makeEmptyAutoSelectionState(binding);
2093
+
2094
+ const nextState: AutoSelectionEntryData = {
2095
+ ...baseState,
2096
+ fullPaths: [...baseState.fullPaths, normalizedPath],
2097
+ slicePaths: baseState.slicePaths.filter((entry) => entry.path !== normalizedPath),
2098
+ };
2099
+
2100
+ const normalizedNext = normalizeAutoSelectionState(nextState);
2101
+ if (autoSelectionStatesEqual(baseState, normalizedNext)) {
2102
+ activeAutoSelectionState = normalizedNext;
2103
+ return;
2104
+ }
2105
+
2106
+ persistAutoSelectionState(normalizedNext);
2107
+ }
2108
+
2109
+ function existingSliceRangesForRead(
2110
+ binding: RpCliBindingEntryData,
2111
+ selectionPath: string
2112
+ ): AutoSelectionEntryRangeData[] | null {
2113
+ const normalizedPath = toPosixPath(selectionPath);
2114
+
2115
+ const baseState = sameBindingForAutoSelection(binding, activeAutoSelectionState)
2116
+ ? (activeAutoSelectionState as AutoSelectionEntryData)
2117
+ : makeEmptyAutoSelectionState(binding);
2118
+
2119
+ if (baseState.fullPaths.includes(normalizedPath)) {
2120
+ return null;
2121
+ }
2122
+
2123
+ const existing = baseState.slicePaths.find((entry) => entry.path === normalizedPath);
2124
+ return normalizeAutoSelectionRanges(existing?.ranges ?? []);
2125
+ }
2126
+
2127
+ function subtractCoveredRanges(
2128
+ incomingRange: AutoSelectionEntryRangeData,
2129
+ existingRanges: AutoSelectionEntryRangeData[]
2130
+ ): AutoSelectionEntryRangeData[] {
2131
+ let pending: AutoSelectionEntryRangeData[] = [incomingRange];
2132
+
2133
+ for (const existing of normalizeAutoSelectionRanges(existingRanges)) {
2134
+ const nextPending: AutoSelectionEntryRangeData[] = [];
2135
+
2136
+ for (const candidate of pending) {
2137
+ const overlapStart = Math.max(candidate.start_line, existing.start_line);
2138
+ const overlapEnd = Math.min(candidate.end_line, existing.end_line);
2139
+
2140
+ if (overlapStart > overlapEnd) {
2141
+ nextPending.push(candidate);
2142
+ continue;
2143
+ }
2144
+
2145
+ if (candidate.start_line < overlapStart) {
2146
+ nextPending.push({
2147
+ start_line: candidate.start_line,
2148
+ end_line: overlapStart - 1,
2149
+ });
2150
+ }
2151
+
2152
+ if (candidate.end_line > overlapEnd) {
2153
+ nextPending.push({
2154
+ start_line: overlapEnd + 1,
2155
+ end_line: candidate.end_line,
2156
+ });
2157
+ }
2158
+ }
2159
+
2160
+ pending = nextPending;
2161
+ if (pending.length === 0) {
2162
+ return [];
2163
+ }
2164
+ }
2165
+
2166
+ return normalizeAutoSelectionRanges(pending);
2167
+ }
2168
+
2169
+ function updateAutoSelectionStateAfterSliceRead(
2170
+ binding: RpCliBindingEntryData,
2171
+ selectionPath: string,
2172
+ range: AutoSelectionEntryRangeData
2173
+ ): void {
2174
+ const normalizedPath = toPosixPath(selectionPath);
2175
+
2176
+ const baseState = sameBindingForAutoSelection(binding, activeAutoSelectionState)
2177
+ ? (activeAutoSelectionState as AutoSelectionEntryData)
2178
+ : makeEmptyAutoSelectionState(binding);
2179
+
2180
+ if (baseState.fullPaths.includes(normalizedPath)) {
2181
+ return;
2182
+ }
2183
+
2184
+ const existing = baseState.slicePaths.find((entry) => entry.path === normalizedPath);
2185
+
2186
+ const nextSlicePaths = baseState.slicePaths.filter((entry) => entry.path !== normalizedPath);
2187
+ nextSlicePaths.push({
2188
+ path: normalizedPath,
2189
+ ranges: [...(existing?.ranges ?? []), range],
2190
+ });
2191
+
2192
+ const nextState: AutoSelectionEntryData = {
2193
+ ...baseState,
2194
+ fullPaths: [...baseState.fullPaths],
2195
+ slicePaths: nextSlicePaths,
2196
+ };
2197
+
2198
+ const normalizedNext = normalizeAutoSelectionState(nextState);
2199
+ if (autoSelectionStatesEqual(baseState, normalizedNext)) {
2200
+ activeAutoSelectionState = normalizedNext;
2201
+ return;
2202
+ }
2203
+
2204
+ persistAutoSelectionState(normalizedNext);
2205
+ }
2206
+
2207
+ async function removeAutoSelectionPaths(state: AutoSelectionEntryData, paths: string[]): Promise<void> {
2208
+ if (paths.length === 0) {
2209
+ return;
2210
+ }
2211
+
2212
+ const binding = bindingForAutoSelectionState(state);
2213
+ await callManageSelection(binding, { op: "remove", paths });
2214
+ }
2215
+
2216
+ async function addAutoSelectionFullPaths(state: AutoSelectionEntryData, paths: string[]): Promise<void> {
2217
+ if (paths.length === 0) {
2218
+ return;
2219
+ }
2220
+
2221
+ const binding = bindingForAutoSelectionState(state);
2222
+ await callManageSelection(binding, {
2223
+ op: "add",
2224
+ mode: "full",
2225
+ paths,
2226
+ });
2227
+ }
2228
+
2229
+ async function addAutoSelectionSlices(
2230
+ state: AutoSelectionEntryData,
2231
+ slices: AutoSelectionEntrySliceData[]
2232
+ ): Promise<void> {
2233
+ if (slices.length === 0) {
2234
+ return;
2235
+ }
2236
+
2237
+ const binding = bindingForAutoSelectionState(state);
2238
+ await callManageSelection(binding, {
2239
+ op: "add",
2240
+ slices,
2241
+ });
2242
+ }
2243
+
2244
+ async function reconcileAutoSelectionWithinBinding(
2245
+ currentState: AutoSelectionEntryData,
2246
+ desiredState: AutoSelectionEntryData
2247
+ ): Promise<void> {
2248
+ const currentModeByPath = new Map<string, "full" | "slices">();
2249
+ for (const p of currentState.fullPaths) {
2250
+ currentModeByPath.set(p, "full");
2251
+ }
2252
+
2253
+ for (const s of currentState.slicePaths) {
2254
+ if (!currentModeByPath.has(s.path)) {
2255
+ currentModeByPath.set(s.path, "slices");
2256
+ }
2257
+ }
2258
+
2259
+ const desiredModeByPath = new Map<string, "full" | "slices">();
2260
+ for (const p of desiredState.fullPaths) {
2261
+ desiredModeByPath.set(p, "full");
2262
+ }
2263
+
2264
+ for (const s of desiredState.slicePaths) {
2265
+ if (!desiredModeByPath.has(s.path)) {
2266
+ desiredModeByPath.set(s.path, "slices");
2267
+ }
2268
+ }
2269
+
2270
+ const desiredSliceByPath = new Map<string, AutoSelectionEntrySliceData>();
2271
+ for (const s of desiredState.slicePaths) {
2272
+ desiredSliceByPath.set(s.path, s);
2273
+ }
2274
+
2275
+ const currentSliceByPath = new Map<string, AutoSelectionEntrySliceData>();
2276
+ for (const s of currentState.slicePaths) {
2277
+ currentSliceByPath.set(s.path, s);
2278
+ }
2279
+
2280
+ const removePaths = new Set<string>();
2281
+ const addFullPaths: string[] = [];
2282
+ const addSlices: AutoSelectionEntrySliceData[] = [];
2283
+
2284
+ for (const [pathKey] of currentModeByPath) {
2285
+ if (!desiredModeByPath.has(pathKey)) {
2286
+ removePaths.add(pathKey);
2287
+ }
2288
+ }
2289
+
2290
+ for (const [pathKey, mode] of desiredModeByPath) {
2291
+ const currentMode = currentModeByPath.get(pathKey);
2292
+
2293
+ if (mode === "full") {
2294
+ if (currentMode === "full") {
2295
+ continue;
2296
+ }
2297
+
2298
+ if (currentMode === "slices") {
2299
+ removePaths.add(pathKey);
2300
+ }
2301
+
2302
+ addFullPaths.push(pathKey);
2303
+ continue;
2304
+ }
2305
+
2306
+ const desiredSlice = desiredSliceByPath.get(pathKey);
2307
+ if (!desiredSlice) {
2308
+ continue;
2309
+ }
2310
+
2311
+ if (currentMode === "full") {
2312
+ removePaths.add(pathKey);
2313
+ addSlices.push(desiredSlice);
2314
+ continue;
2315
+ }
2316
+
2317
+ if (currentMode === "slices") {
2318
+ const currentSlice = currentSliceByPath.get(pathKey);
2319
+ if (currentSlice && autoSelectionSliceKey(currentSlice) === autoSelectionSliceKey(desiredSlice)) {
2320
+ continue;
2321
+ }
2322
+
2323
+ removePaths.add(pathKey);
2324
+ addSlices.push(desiredSlice);
2325
+ continue;
2326
+ }
2327
+
2328
+ addSlices.push(desiredSlice);
2329
+ }
2330
+
2331
+ await removeAutoSelectionPaths(currentState, [...removePaths]);
2332
+ await addAutoSelectionFullPaths(desiredState, addFullPaths);
2333
+ await addAutoSelectionSlices(desiredState, addSlices);
2334
+ }
2335
+
2336
+ async function reconcileAutoSelectionStates(
2337
+ currentState: AutoSelectionEntryData | null,
2338
+ desiredState: AutoSelectionEntryData | null
2339
+ ): Promise<void> {
2340
+ if (autoSelectionStatesEqual(currentState, desiredState)) {
2341
+ return;
2342
+ }
2343
+
2344
+ if (currentState && desiredState) {
2345
+ const sameBinding =
2346
+ currentState.windowId === desiredState.windowId &&
2347
+ sameOptionalText(currentState.tab, desiredState.tab);
2348
+
2349
+ if (sameBinding) {
2350
+ await reconcileAutoSelectionWithinBinding(currentState, desiredState);
2351
+ return;
2352
+ }
2353
+
2354
+ try {
2355
+ await removeAutoSelectionPaths(currentState, autoSelectionManagedPaths(currentState));
2356
+ } catch {
2357
+ // Old binding/window may no longer exist after RepoPrompt app restart
2358
+ }
2359
+
2360
+ await addAutoSelectionFullPaths(desiredState, desiredState.fullPaths);
2361
+ await addAutoSelectionSlices(desiredState, desiredState.slicePaths);
2362
+ return;
2363
+ }
2364
+
2365
+ if (currentState && !desiredState) {
2366
+ try {
2367
+ await removeAutoSelectionPaths(currentState, autoSelectionManagedPaths(currentState));
2368
+ } catch {
2369
+ // Old binding/window may no longer exist after RepoPrompt app restart
2370
+ }
2371
+ return;
2372
+ }
2373
+
2374
+ if (!currentState && desiredState) {
2375
+ await addAutoSelectionFullPaths(desiredState, desiredState.fullPaths);
2376
+ await addAutoSelectionSlices(desiredState, desiredState.slicePaths);
2377
+ }
2378
+ }
2379
+
2380
+ async function syncAutoSelectionToCurrentBranch(ctx: ExtensionContext): Promise<void> {
2381
+ if (config.autoSelectReadSlices !== true) {
2382
+ activeAutoSelectionState = null;
2383
+ return;
2384
+ }
2385
+
2386
+ const binding = await ensureBindingTargetsLiveWindow(ctx);
2387
+ const desiredState = binding ? getAutoSelectionStateFromBranch(ctx, binding) : null;
2388
+
2389
+ try {
2390
+ await reconcileAutoSelectionStates(activeAutoSelectionState, desiredState);
2391
+ } catch {
2392
+ // Fail-open
2393
+ }
2394
+
2395
+ activeAutoSelectionState = desiredState;
2396
+ }
2397
+
2398
+ function parseReadOutputHints(readOutputText: string | undefined): {
2399
+ selectionPath: string | null;
2400
+ totalLines: number | null;
2401
+ } {
2402
+ if (!readOutputText) {
2403
+ return { selectionPath: null, totalLines: null };
2404
+ }
2405
+
2406
+ const pathMatch =
2407
+ /\*\*Path\*\*:\s*`([^`]+)`/i.exec(readOutputText) ??
2408
+ /\*\*Path\*\*:\s*([^\n]+)$/im.exec(readOutputText);
2409
+
2410
+ const selectionPath = pathMatch?.[1]?.trim() ?? null;
2411
+
2412
+ const linesRegexes = [
2413
+ /\*\*Lines\*\*:\s*(\d+)\s*[–—-]\s*(\d+)\s+of\s+(\d+)/i,
2414
+ /Lines(?:\s*:)?\s*(\d+)\s*[–—-]\s*(\d+)\s+of\s+(\d+)/i,
2415
+ ];
2416
+
2417
+ let totalLines: number | null = null;
2418
+
2419
+ for (const rx of linesRegexes) {
2420
+ const match = rx.exec(readOutputText);
2421
+ if (!match) {
2422
+ continue;
2423
+ }
2424
+
2425
+ const parsed = Number.parseInt(match[3] ?? "", 10);
2426
+ if (Number.isFinite(parsed)) {
2427
+ totalLines = parsed;
2428
+ break;
2429
+ }
2430
+ }
2431
+
2432
+ return { selectionPath, totalLines };
2433
+ }
2434
+
2435
+ async function autoSelectReadFileInRepoPromptSelection(
2436
+ ctx: ExtensionContext,
2437
+ inputPath: string,
2438
+ startLine: number | undefined,
2439
+ limit: number | undefined,
2440
+ readOutputText: string | undefined
2441
+ ): Promise<void> {
2442
+ if (config.autoSelectReadSlices !== true) {
2443
+ return;
2444
+ }
2445
+
2446
+ const outputHints = parseReadOutputHints(readOutputText);
2447
+
2448
+ const binding = await maybeEnsureBindingTargetsLiveWindow(ctx);
2449
+ if (!binding) {
2450
+ return;
2451
+ }
2452
+
2453
+ const selectionText = await getSelectionFilesText(binding);
2454
+ if (selectionText === null) {
2455
+ return;
2456
+ }
2457
+
2458
+ const resolved = await resolveReadFilePath(pi, inputPath, ctx.cwd, binding.windowId, binding.tab);
2459
+
2460
+ const resolvedSelectionPath = buildSelectionPathFromResolved(inputPath, resolved);
2461
+ const selectionPath =
2462
+ outputHints.selectionPath && outputHints.selectionPath.trim().length > 0
2463
+ ? outputHints.selectionPath
2464
+ : resolvedSelectionPath;
2465
+
2466
+ const candidatePaths = new Set<string>();
2467
+ candidatePaths.add(toPosixPath(selectionPath));
2468
+ candidatePaths.add(toPosixPath(resolvedSelectionPath));
2469
+ candidatePaths.add(toPosixPath(inputPath));
2470
+
2471
+ if (outputHints.selectionPath) {
2472
+ candidatePaths.add(toPosixPath(outputHints.selectionPath));
2473
+ }
2474
+
2475
+ if (resolved.absolutePath) {
2476
+ candidatePaths.add(toPosixPath(resolved.absolutePath));
2477
+ }
2478
+
2479
+ if (resolved.absolutePath && resolved.repoRoot) {
2480
+ const rel = path.relative(resolved.repoRoot, resolved.absolutePath);
2481
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
2482
+ candidatePaths.add(toPosixPath(rel.split(path.sep).join("/")));
2483
+ }
2484
+ }
2485
+
2486
+ let selectionStatus: ReturnType<typeof inferSelectionStatus> = null;
2487
+
2488
+ for (const candidate of candidatePaths) {
2489
+ const status = inferSelectionStatus(selectionText, candidate);
2490
+ if (!status) {
2491
+ continue;
2492
+ }
2493
+
2494
+ if (status.mode === "full") {
2495
+ selectionStatus = status;
2496
+ break;
2497
+ }
2498
+
2499
+ if (status.mode === "codemap_only" && status.codemapManual === true) {
2500
+ selectionStatus = status;
2501
+ break;
2502
+ }
2503
+
2504
+ if (selectionStatus === null) {
2505
+ selectionStatus = status;
2506
+ continue;
2507
+ }
2508
+
2509
+ if (selectionStatus.mode === "codemap_only" && status.mode === "slices") {
2510
+ selectionStatus = status;
2511
+ }
2512
+ }
2513
+
2514
+ if (selectionStatus?.mode === "full") {
2515
+ return;
2516
+ }
2517
+
2518
+ if (selectionStatus?.mode === "codemap_only" && selectionStatus.codemapManual === true) {
2519
+ return;
2520
+ }
2521
+
2522
+ let totalLines: number | undefined;
2523
+
2524
+ if (typeof startLine === "number" && startLine < 0) {
2525
+ if (resolved.absolutePath) {
2526
+ try {
2527
+ totalLines = await countFileLines(resolved.absolutePath);
2528
+ } catch {
2529
+ totalLines = undefined;
2530
+ }
2531
+ }
2532
+
2533
+ if (totalLines === undefined && typeof outputHints.totalLines === "number") {
2534
+ totalLines = outputHints.totalLines;
2535
+ }
2536
+ }
2537
+
2538
+ const sliceRange = computeSliceRangeFromReadArgs(startLine, limit, totalLines);
2539
+
2540
+
2541
+ if (sliceRange) {
2542
+ const existingRanges = existingSliceRangesForRead(binding, selectionPath);
2543
+ if (!existingRanges) {
2544
+ return;
2545
+ }
2546
+
2547
+ const uncoveredRanges = subtractCoveredRanges(sliceRange, existingRanges);
2548
+
2549
+ if (uncoveredRanges.length === 0) {
2550
+ updateAutoSelectionStateAfterSliceRead(binding, selectionPath, sliceRange);
2551
+ return;
2552
+ }
2553
+
2554
+ // Add only uncovered ranges to avoid touching unrelated selection state
2555
+ // (and to avoid global set semantics)
2556
+ const payload = {
2557
+ op: "add",
2558
+ slices: [
2559
+ {
2560
+ path: toPosixPath(selectionPath),
2561
+ ranges: uncoveredRanges,
2562
+ },
2563
+ ],
2564
+ };
2565
+
2566
+ try {
2567
+ await callManageSelection(binding, payload);
2568
+ } catch {
2569
+ // Fail-open
2570
+ return;
2571
+ }
2572
+
2573
+ updateAutoSelectionStateAfterSliceRead(binding, selectionPath, sliceRange);
2574
+ return;
2575
+ }
2576
+
2577
+ const payload = {
2578
+ op: "add",
2579
+ mode: "full",
2580
+ paths: [toPosixPath(selectionPath)],
2581
+ };
2582
+
2583
+ try {
2584
+ await callManageSelection(binding, payload);
2585
+ } catch {
2586
+ // Fail-open
2587
+ return;
2588
+ }
2589
+
2590
+ updateAutoSelectionStateAfterFullRead(binding, selectionPath);
2591
+ }
2592
+
2593
+
2594
+ pi.on("session_start", async (_event, ctx) => {
2595
+ config = loadConfig();
2596
+ clearReadcacheCaches();
2597
+ clearWindowsCache();
2598
+ markBindingValidationStale();
2599
+ reconstructBinding(ctx);
2600
+
2601
+ const binding = getCurrentBinding();
2602
+ activeAutoSelectionState =
2603
+ config.autoSelectReadSlices === true && binding
2604
+ ? getAutoSelectionStateFromBranch(ctx, binding)
2605
+ : null;
2606
+
2607
+ if (config.readcacheReadFile === true) {
2608
+ void pruneObjectsOlderThan(ctx.cwd).catch(() => {
2609
+ // Fail-open
2610
+ });
2611
+ }
2612
+
2613
+ try {
2614
+ await syncAutoSelectionToCurrentBranch(ctx);
2615
+ } catch {
2616
+ // Fail-open
2617
+ }
2618
+ });
2619
+
2620
+ pi.on("session_switch", async (_event, ctx) => {
2621
+ config = loadConfig();
2622
+ clearReadcacheCaches();
2623
+ clearWindowsCache();
2624
+ markBindingValidationStale();
2625
+ reconstructBinding(ctx);
2626
+ await syncAutoSelectionToCurrentBranch(ctx);
2627
+ });
2628
+
2629
+ // session_fork is the current event name; keep session_branch for backwards compatibility
2630
+ pi.on("session_fork", async (_event, ctx) => {
2631
+ config = loadConfig();
2632
+ clearReadcacheCaches();
2633
+ clearWindowsCache();
2634
+ markBindingValidationStale();
2635
+ reconstructBinding(ctx);
2636
+ await syncAutoSelectionToCurrentBranch(ctx);
2637
+ });
2638
+
2639
+ pi.on("session_branch", async (_event, ctx) => {
2640
+ config = loadConfig();
2641
+ clearReadcacheCaches();
2642
+ clearWindowsCache();
2643
+ markBindingValidationStale();
2644
+ reconstructBinding(ctx);
2645
+ await syncAutoSelectionToCurrentBranch(ctx);
2646
+ });
2647
+
2648
+ pi.on("session_tree", async (_event, ctx) => {
2649
+ config = loadConfig();
2650
+ clearReadcacheCaches();
2651
+ clearWindowsCache();
2652
+ markBindingValidationStale();
2653
+ reconstructBinding(ctx);
2654
+ await syncAutoSelectionToCurrentBranch(ctx);
2655
+ });
2656
+
2657
+ pi.on("session_compact", async () => {
2658
+ clearReadcacheCaches();
2659
+ });
2660
+
2661
+ pi.on("session_shutdown", async () => {
2662
+ clearReadcacheCaches();
2663
+ clearWindowsCache();
2664
+ activeAutoSelectionState = null;
2665
+ setBinding(null);
2666
+ });
2667
+
2668
+ pi.registerCommand("rpbind", {
2669
+ description: "Bind rp_exec to RepoPrompt: /rpbind <window_id> <tab>",
2670
+ handler: async (args, ctx) => {
2671
+ const parsed = parseRpbindArgs(args);
2672
+ if ("error" in parsed) {
2673
+ ctx.ui.notify(parsed.error, "error");
2674
+ return;
2675
+ }
2676
+
2677
+ const binding = await enrichBinding(parsed.windowId, parsed.tab);
2678
+ persistBinding(binding);
2679
+
2680
+ try {
2681
+ await syncAutoSelectionToCurrentBranch(ctx);
2682
+ } catch {
2683
+ // Fail-open
2684
+ }
2685
+
2686
+ ctx.ui.notify(
2687
+ `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` +
2688
+ (boundWorkspaceName ? ` (${boundWorkspaceName})` : ""),
2689
+ "success"
2690
+ );
2691
+ },
2692
+ });
2693
+
2694
+
2695
+ pi.registerCommand("rpcli-readcache-status", {
2696
+ description: "Show repoprompt-cli read_file cache status",
2697
+ handler: async (_args, ctx) => {
2698
+ config = loadConfig();
2699
+
2700
+ let msg = "repoprompt-cli read_file cache\n";
2701
+ msg += "──────────────────────────\n";
2702
+ msg += `Enabled: ${config.readcacheReadFile === true ? "✓" : "✗"}\n`;
2703
+ msg += `Auto-select reads: ${config.autoSelectReadSlices === true ? "✓" : "✗"}\n`;
2704
+
2705
+ if (config.readcacheReadFile !== true) {
2706
+ msg += "\nEnable by creating ~/.pi/agent/extensions/repoprompt-cli/config.json\n";
2707
+ msg += "\nwith:\n { \"readcacheReadFile\": true }\n";
2708
+ ctx.ui.notify(msg, "info");
2709
+ return;
2710
+ }
2711
+
2712
+ try {
2713
+ const stats = await getStoreStats(ctx.cwd);
2714
+ msg += `\nObject store (under ${ctx.cwd}/.pi/readcache):\n`;
2715
+ msg += ` Objects: ${stats.objects}\n`;
2716
+ msg += ` Bytes: ${stats.bytes}\n`;
2717
+ } catch {
2718
+ msg += "\nObject store: unavailable\n";
2719
+ }
2720
+
2721
+ msg += "\nNotes:\n";
2722
+ msg += "- Cache applies only to simple rp_exec reads (read/cat/read_file)\n";
2723
+ msg += "- Use bypass_cache=true in the read command to force baseline output\n";
2724
+
2725
+ ctx.ui.notify(msg, "info");
2726
+ },
2727
+ });
2728
+
2729
+ pi.registerCommand("rpcli-readcache-refresh", {
2730
+ description: "Invalidate repoprompt-cli read_file cache trust for a path and optional line range",
2731
+ handler: async (args, ctx) => {
2732
+ config = loadConfig();
2733
+
2734
+ if (config.readcacheReadFile !== true) {
2735
+ ctx.ui.notify("readcacheReadFile is disabled in config", "error");
2736
+ return;
2737
+ }
2738
+
2739
+ const trimmed = args.trim();
2740
+ if (!trimmed) {
2741
+ ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
2742
+ return;
2743
+ }
2744
+
2745
+ const parts = trimmed.split(/\s+/);
2746
+ const pathInput = parts[0];
2747
+ const rangeInput = parts[1];
2748
+
2749
+ if (!pathInput) {
2750
+ ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
2751
+ return;
2752
+ }
2753
+
2754
+ const binding = await ensureBindingTargetsLiveWindow(ctx);
2755
+ if (!binding) {
2756
+ ctx.ui.notify("rp_exec is not bound. Bind first via /rpbind or rp_bind", "error");
2757
+ return;
2758
+ }
2759
+
2760
+ let scopeKey: ScopeKey = SCOPE_FULL;
2761
+ if (rangeInput) {
2762
+ const match = rangeInput.match(/^(\d+)-(\d+)$/);
2763
+ if (!match) {
2764
+ ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error");
2765
+ return;
2766
+ }
2767
+
2768
+ const start = parseInt(match[1] ?? "", 10);
2769
+ const end = parseInt(match[2] ?? "", 10);
2770
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end < start) {
2771
+ ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error");
2772
+ return;
2773
+ }
2774
+
2775
+ scopeKey = scopeRange(start, end);
2776
+ }
2777
+
2778
+ const resolved = await resolveReadFilePath(pi, pathInput, ctx.cwd, binding.windowId, binding.tab);
2779
+ if (!resolved.absolutePath) {
2780
+ ctx.ui.notify(`Could not resolve path: ${pathInput}`, "error");
2781
+ return;
2782
+ }
2783
+
2784
+ pi.appendEntry(RP_READCACHE_CUSTOM_TYPE, buildInvalidationV1(resolved.absolutePath, scopeKey));
2785
+ clearReadcacheCaches();
2786
+
2787
+ ctx.ui.notify(
2788
+ `Invalidated readcache for ${resolved.absolutePath}` + (scopeKey === SCOPE_FULL ? "" : ` (${scopeKey})`),
2789
+ "info"
2790
+ );
2791
+ },
2792
+ });
2793
+
2794
+ pi.registerTool({
2795
+ name: "rp_bind",
2796
+ label: "RepoPrompt Bind",
2797
+ description: "Bind rp_exec to a specific RepoPrompt window and compose tab",
2798
+ parameters: BindParams,
2799
+
2800
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2801
+ await ensureJustBashLoaded();
2802
+ maybeWarnAstUnavailable(ctx);
2803
+
2804
+ const binding = await enrichBinding(params.windowId, params.tab);
2805
+ persistBinding(binding);
2806
+
2807
+ try {
2808
+ await syncAutoSelectionToCurrentBranch(ctx);
2809
+ } catch {
2810
+ // Fail-open
2811
+ }
2812
+
2813
+ return {
2814
+ content: [{ type: "text", text: `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` }],
2815
+ details: {
2816
+ windowId: boundWindowId,
2817
+ tab: boundTab,
2818
+ workspaceId: boundWorkspaceId,
2819
+ workspaceName: boundWorkspaceName,
2820
+ workspaceRoots: boundWorkspaceRoots,
2821
+ },
2822
+ };
2823
+ },
2824
+ });
2825
+
2826
+ pi.registerTool({
2827
+ name: "rp_exec",
2828
+ label: "RepoPrompt Exec",
2829
+ description: "Run rp-cli in the bound RepoPrompt window/tab, with quiet defaults and output truncation",
2830
+ parameters: ExecParams,
2831
+
2832
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
2833
+ // Routing: prefer call-time overrides, otherwise fall back to the last persisted binding
2834
+ await ensureJustBashLoaded();
2835
+ maybeWarnAstUnavailable(ctx);
2836
+
2837
+ if (params.windowId === undefined && params.tab === undefined) {
2838
+ try {
2839
+ await maybeEnsureBindingTargetsLiveWindow(ctx);
2840
+ } catch {
2841
+ // Fail-open
2842
+ }
2843
+ }
2844
+
2845
+ const windowId = params.windowId ?? boundWindowId;
2846
+ const tab = params.tab ?? boundTab;
1407
2847
  const rawJson = params.rawJson ?? false;
1408
2848
  const quiet = params.quiet ?? true;
1409
2849
  const failFast = params.failFast ?? true;
@@ -1504,10 +2944,10 @@ export default function (pi: ExtensionAPI) {
1504
2944
  let rpReadcache: RpReadcacheMetaV1 | null = null;
1505
2945
 
1506
2946
  if (
1507
- config.readcacheReadFile === true &&
1508
- readRequest !== null &&
1509
- readRequest.cacheable === true &&
1510
- !execError &&
2947
+ config.readcacheReadFile === true &&
2948
+ readRequest !== null &&
2949
+ readRequest.cacheable === true &&
2950
+ !execError &&
1511
2951
  exitCode === 0 &&
1512
2952
  windowId !== undefined &&
1513
2953
  tab !== undefined
@@ -1538,6 +2978,30 @@ export default function (pi: ExtensionAPI) {
1538
2978
  }
1539
2979
  }
1540
2980
 
2981
+ const shouldAutoSelectRead =
2982
+ config.autoSelectReadSlices === true &&
2983
+ readRequest !== null &&
2984
+ !execError &&
2985
+ exitCode === 0 &&
2986
+ windowId !== undefined &&
2987
+ tab !== undefined &&
2988
+ params.windowId === undefined &&
2989
+ params.tab === undefined;
2990
+
2991
+ if (shouldAutoSelectRead) {
2992
+ try {
2993
+ await autoSelectReadFileInRepoPromptSelection(
2994
+ ctx,
2995
+ readRequest.path,
2996
+ readRequest.startLine,
2997
+ readRequest.limit,
2998
+ combinedOutput,
2999
+ );
3000
+ } catch {
3001
+ // Fail-open
3002
+ }
3003
+ }
3004
+
1541
3005
  const editNoop =
1542
3006
  !execError &&
1543
3007
  exitCode === 0 &&