pi-repoprompt-cli 0.2.7 → 0.2.9

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,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import * as path from "node:path";
2
3
 
3
4
  import type { ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
4
5
  import { highlightCode, Theme } from "@mariozechner/pi-coding-agent";
@@ -7,13 +8,25 @@ import { Type } from "@sinclair/typebox";
7
8
  import * as Diff from "diff";
8
9
 
9
10
  import { loadConfig } from "./config.js";
11
+ import {
12
+ computeSliceRangeFromReadArgs,
13
+ countFileLines,
14
+ inferSelectionStatus,
15
+ toPosixPath,
16
+ } from "./auto-select.js";
10
17
  import { RP_READCACHE_CUSTOM_TYPE, SCOPE_FULL, scopeRange } from "./readcache/constants.js";
11
18
  import { buildInvalidationV1 } from "./readcache/meta.js";
12
19
  import { getStoreStats, pruneObjectsOlderThan } from "./readcache/object-store.js";
13
20
  import { readFileWithCache } from "./readcache/read-file.js";
14
21
  import { clearReplayRuntimeState, createReplayRuntimeState } from "./readcache/replay.js";
15
- import { resolveReadFilePath } from "./readcache/resolve.js";
22
+ import { clearRootsCache, resolveReadFilePath } from "./readcache/resolve.js";
16
23
  import type { RpReadcacheMetaV1, ScopeKey } from "./readcache/types.js";
24
+ import type {
25
+ AutoSelectionEntryData,
26
+ AutoSelectionEntryRangeData,
27
+ AutoSelectionEntrySliceData,
28
+ RpCliBindingEntryData,
29
+ } from "./types.js";
17
30
 
18
31
  let parseBash: ((input: string) => any) | null = null;
19
32
  let justBashLoadPromise: Promise<void> | null = null;
@@ -247,6 +260,16 @@ function hasPipeOutsideQuotes(script: string): boolean {
247
260
  const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
248
261
  const DEFAULT_MAX_OUTPUT_CHARS = 12000;
249
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
+ }
250
273
 
251
274
  const BindParams = Type.Object({
252
275
  windowId: Type.Number({ description: "RepoPrompt window id (from `rp-cli -e windows`)" }),
@@ -1237,12 +1260,80 @@ function renderRpExecOutput(text: string, theme: Theme): string {
1237
1260
  }
1238
1261
 
1239
1262
  // Collapsed output settings
1240
- const COLLAPSED_MAX_LINES = 15;
1263
+ const DEFAULT_COLLAPSED_MAX_LINES = 15;
1241
1264
  const COLLAPSED_MAX_CHARS = 2000;
1242
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
+
1243
1330
  export default function (pi: ExtensionAPI) {
1244
1331
  let config = loadConfig();
1245
1332
 
1333
+ pi.on("before_agent_start", async () => {
1334
+ config = loadConfig();
1335
+ });
1336
+
1246
1337
  // Replay-aware read_file caching state (optional; guarded by config.readcacheReadFile)
1247
1338
  const readcacheRuntimeState = createReplayRuntimeState();
1248
1339
 
@@ -1250,252 +1341,1580 @@ export default function (pi: ExtensionAPI) {
1250
1341
  clearReplayRuntimeState(readcacheRuntimeState);
1251
1342
  };
1252
1343
 
1344
+ let activeAutoSelectionState: AutoSelectionEntryData | null = null;
1345
+
1253
1346
  let boundWindowId: number | undefined;
1254
1347
  let boundTab: string | undefined;
1348
+ let boundWorkspaceId: string | undefined;
1349
+ let boundWorkspaceName: string | undefined;
1350
+ let boundWorkspaceRoots: string[] | undefined;
1255
1351
 
1256
- const setBinding = (windowId: number, tab: string) => {
1257
- boundWindowId = windowId;
1258
- boundTab = tab;
1259
- };
1352
+ let windowsCache: { windows: RpCliWindow[]; fetchedAtMs: number } | null = null;
1353
+ let lastBindingValidationAtMs = 0;
1260
1354
 
1261
- const persistBinding = (windowId: number, tab: string) => {
1262
- // Persist binding across session reloads without injecting extra text into the model context
1263
- if (boundWindowId === windowId && boundTab === tab) return;
1355
+ function sameOptionalText(a?: string, b?: string): boolean {
1356
+ return (a ?? undefined) === (b ?? undefined);
1357
+ }
1264
1358
 
1265
- setBinding(windowId, tab);
1266
- pi.appendEntry(BINDING_CUSTOM_TYPE, { windowId, tab });
1267
- };
1359
+ function clearWindowsCache(): void {
1360
+ windowsCache = null;
1361
+ }
1268
1362
 
1269
- const reconstructBinding = (ctx: ExtensionContext) => {
1270
- // Prefer persisted binding (appendEntry) from the *current branch*, then fall back to prior rp_bind tool results
1271
- // Branch semantics: if the current branch has no binding state, stay unbound
1272
- boundWindowId = undefined;
1273
- boundTab = undefined;
1363
+ function markBindingValidationStale(): void {
1364
+ lastBindingValidationAtMs = 0;
1365
+ }
1274
1366
 
1275
- let reconstructedWindowId: number | undefined;
1276
- let reconstructedTab: string | undefined;
1367
+ function shouldRevalidateBinding(): boolean {
1368
+ const now = Date.now();
1369
+ return (now - lastBindingValidationAtMs) >= BINDING_VALIDATION_TTL_MS;
1370
+ }
1277
1371
 
1278
- for (const entry of ctx.sessionManager.getBranch()) {
1279
- if (entry.type !== "custom" || entry.customType !== BINDING_CUSTOM_TYPE) continue;
1372
+ function markBindingValidatedNow(): void {
1373
+ lastBindingValidationAtMs = Date.now();
1374
+ }
1280
1375
 
1281
- const data = entry.data as { windowId?: unknown; tab?: unknown } | undefined;
1282
- const windowId = typeof data?.windowId === "number" ? data.windowId : undefined;
1283
- const tab = typeof data?.tab === "string" ? data.tab : undefined;
1284
- if (windowId !== undefined && tab) {
1285
- reconstructedWindowId = windowId;
1286
- reconstructedTab = tab;
1287
- }
1376
+ function normalizeWorkspaceRoots(roots: string[] | undefined): string[] {
1377
+ if (!Array.isArray(roots)) {
1378
+ return [];
1288
1379
  }
1289
1380
 
1290
- if (reconstructedWindowId !== undefined && reconstructedTab !== undefined) {
1291
- setBinding(reconstructedWindowId, reconstructedTab);
1292
- return;
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;
1293
1396
  }
1294
1397
 
1295
- for (const entry of ctx.sessionManager.getBranch()) {
1296
- if (entry.type !== "message") continue;
1297
- const msg = entry.message;
1298
- if (msg.role !== "toolResult" || msg.toolName !== "rp_bind") continue;
1398
+ const leftRoots = normalizeWorkspaceRoots(left.workspaceRoots);
1399
+ const rightRoots = normalizeWorkspaceRoots(right.workspaceRoots);
1299
1400
 
1300
- const details = msg.details as { windowId?: number; tab?: string } | undefined;
1301
- if (details?.windowId !== undefined && details?.tab) {
1302
- persistBinding(details.windowId, details.tab);
1303
- }
1401
+ if (leftRoots.length > 0 && rightRoots.length > 0) {
1402
+ return JSON.stringify(leftRoots) === JSON.stringify(rightRoots);
1304
1403
  }
1305
- };
1306
1404
 
1307
- pi.on("session_start", async (_event, ctx) => {
1308
- config = loadConfig();
1309
- clearReadcacheCaches();
1310
- if (config.readcacheReadFile === true) {
1311
- void pruneObjectsOlderThan(ctx.cwd).catch(() => {
1312
- // Fail-open
1313
- });
1405
+ if (left.workspaceName && right.workspaceName) {
1406
+ return left.workspaceName === right.workspaceName;
1314
1407
  }
1315
- reconstructBinding(ctx);
1316
- });
1317
1408
 
1318
- pi.on("session_switch", async (_event, ctx) => {
1319
- config = loadConfig();
1320
- clearReadcacheCaches();
1321
- reconstructBinding(ctx);
1322
- });
1409
+ return false;
1410
+ }
1323
1411
 
1324
- // session_fork is the current event name; keep session_branch for backwards compatibility
1325
- pi.on("session_fork", async (_event, ctx) => {
1326
- config = loadConfig();
1327
- clearReadcacheCaches();
1328
- reconstructBinding(ctx);
1329
- });
1412
+ function getCurrentBinding(): RpCliBindingEntryData | null {
1413
+ if (boundWindowId === undefined || !boundTab) {
1414
+ return null;
1415
+ }
1330
1416
 
1331
- pi.on("session_branch", async (_event, ctx) => {
1332
- config = loadConfig();
1333
- clearReadcacheCaches();
1334
- reconstructBinding(ctx);
1335
- });
1417
+ return {
1418
+ windowId: boundWindowId,
1419
+ tab: boundTab,
1420
+ workspaceId: boundWorkspaceId,
1421
+ workspaceName: boundWorkspaceName,
1422
+ workspaceRoots: boundWorkspaceRoots,
1423
+ };
1424
+ }
1336
1425
 
1337
- pi.on("session_tree", async (_event, ctx) => {
1338
- config = loadConfig();
1339
- clearReadcacheCaches();
1340
- reconstructBinding(ctx);
1341
- });
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
+ }
1342
1435
 
1343
- pi.on("session_compact", async () => {
1344
- clearReadcacheCaches();
1345
- });
1436
+ function bindingEntriesEqual(a: RpCliBindingEntryData | null, b: RpCliBindingEntryData | null): boolean {
1437
+ if (!a && !b) {
1438
+ return true;
1439
+ }
1346
1440
 
1347
- pi.on("session_shutdown", async () => {
1348
- clearReadcacheCaches();
1349
- });
1441
+ if (!a || !b) {
1442
+ return false;
1443
+ }
1350
1444
 
1351
- pi.registerCommand("rpbind", {
1352
- description: "Bind rp_exec to RepoPrompt: /rpbind <window_id> <tab>",
1353
- handler: async (args, ctx) => {
1354
- const parsed = parseRpbindArgs(args);
1355
- if ("error" in parsed) {
1356
- ctx.ui.notify(parsed.error, "error");
1357
- return;
1358
- }
1445
+ const left = normalizeBindingEntry(a);
1446
+ const right = normalizeBindingEntry(b);
1359
1447
 
1360
- persistBinding(parsed.windowId, parsed.tab);
1361
- ctx.ui.notify(`Bound rp_exec window ${boundWindowId}, tab "${boundTab}"`, "success");
1362
- },
1363
- });
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
+ }
1364
1456
 
1365
- pi.registerCommand("rpcli-readcache-status", {
1366
- description: "Show repoprompt-cli read_file cache status",
1367
- handler: async (_args, ctx) => {
1368
- config = loadConfig();
1457
+ function setBinding(binding: RpCliBindingEntryData | null): void {
1458
+ const previousWindowId = boundWindowId;
1369
1459
 
1370
- let msg = "repoprompt-cli read_file cache\n";
1371
- msg += "──────────────────────────\n";
1372
- msg += `Enabled: ${config.readcacheReadFile === true ? "✓" : "✗"}\n`;
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
+ }
1373
1474
 
1374
- if (config.readcacheReadFile !== true) {
1375
- msg += "\nEnable by creating ~/.pi/agent/extensions/repoprompt-cli/config.json\n";
1376
- msg += "\nwith:\n { \"readcacheReadFile\": true }\n";
1377
- ctx.ui.notify(msg, "info");
1378
- return;
1475
+ if (previousWindowId !== boundWindowId) {
1476
+ if (previousWindowId !== undefined) {
1477
+ clearRootsCache(previousWindowId);
1379
1478
  }
1380
1479
 
1381
- try {
1382
- const stats = await getStoreStats(ctx.cwd);
1383
- msg += `\nObject store (under ${ctx.cwd}/.pi/readcache):\n`;
1384
- msg += ` Objects: ${stats.objects}\n`;
1385
- msg += ` Bytes: ${stats.bytes}\n`;
1386
- } catch {
1387
- msg += "\nObject store: unavailable\n";
1480
+ if (boundWindowId !== undefined) {
1481
+ clearRootsCache(boundWindowId);
1388
1482
  }
1389
1483
 
1390
- msg += "\nNotes:\n";
1391
- msg += "- Cache applies only to simple rp_exec reads (read/cat/read_file)\n";
1392
- msg += "- Use bypass_cache=true in the read command to force baseline output\n";
1484
+ clearWindowsCache();
1485
+ }
1393
1486
 
1394
- ctx.ui.notify(msg, "info");
1395
- },
1396
- });
1487
+ markBindingValidationStale();
1488
+ }
1397
1489
 
1398
- pi.registerCommand("rpcli-readcache-refresh", {
1399
- description: "Invalidate repoprompt-cli read_file cache trust for a path and optional line range",
1400
- handler: async (args, ctx) => {
1401
- config = loadConfig();
1490
+ function parseBindingEntryData(value: unknown): RpCliBindingEntryData | null {
1491
+ if (!value || typeof value !== "object") {
1492
+ return null;
1493
+ }
1402
1494
 
1403
- if (config.readcacheReadFile !== true) {
1404
- ctx.ui.notify("readcacheReadFile is disabled in config", "error");
1405
- return;
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;
1406
1549
  }
1407
1550
 
1408
- const trimmed = args.trim();
1409
- if (!trimmed) {
1410
- ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
1411
- return;
1551
+ const parsed = parseBindingEntryData(entry.data);
1552
+ if (parsed) {
1553
+ reconstructed = parsed;
1412
1554
  }
1555
+ }
1413
1556
 
1414
- const parts = trimmed.split(/\s+/);
1415
- const pathInput = parts[0];
1416
- const rangeInput = parts[1];
1557
+ if (reconstructed) {
1558
+ setBinding(reconstructed);
1559
+ return;
1560
+ }
1417
1561
 
1418
- if (!pathInput) {
1419
- ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
1420
- return;
1562
+ for (const entry of ctx.sessionManager.getBranch()) {
1563
+ if (entry.type !== "message") {
1564
+ continue;
1421
1565
  }
1422
1566
 
1423
- const windowId = boundWindowId;
1424
- const tab = boundTab;
1567
+ const msg = entry.message;
1568
+ if (msg.role !== "toolResult" || msg.toolName !== "rp_bind") {
1569
+ continue;
1570
+ }
1425
1571
 
1426
- if (windowId === undefined) {
1427
- ctx.ui.notify("rp_exec is not bound. Bind first via /rpbind or rp_bind", "error");
1428
- return;
1572
+ const parsed = parseBindingEntryData(msg.details);
1573
+ if (parsed) {
1574
+ persistBinding(parsed);
1429
1575
  }
1576
+ }
1577
+ }
1430
1578
 
1431
- let scopeKey: ScopeKey = SCOPE_FULL;
1432
- if (rangeInput) {
1433
- const match = rangeInput.match(/^(\d+)-(\d+)$/);
1434
- if (!match) {
1435
- ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error");
1436
- return;
1437
- }
1579
+ function parseWindowsRawJson(raw: string): RpCliWindow[] {
1580
+ const trimmed = raw.trim();
1581
+ if (!trimmed) {
1582
+ return [];
1583
+ }
1438
1584
 
1439
- const start = parseInt(match[1] ?? "", 10);
1440
- const end = parseInt(match[2] ?? "", 10);
1441
- if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end < start) {
1442
- ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error");
1443
- return;
1444
- }
1585
+ let parsed: unknown;
1586
+ try {
1587
+ parsed = JSON.parse(trimmed);
1588
+ } catch {
1589
+ return [];
1590
+ }
1445
1591
 
1446
- scopeKey = scopeRange(start, end);
1592
+ const pickRows = (value: unknown): unknown[] => {
1593
+ if (Array.isArray(value)) {
1594
+ return value;
1447
1595
  }
1448
1596
 
1449
- const resolved = await resolveReadFilePath(pi, pathInput, ctx.cwd, windowId, tab);
1450
- if (!resolved.absolutePath) {
1451
- ctx.ui.notify(`Could not resolve path: ${pathInput}`, "error");
1452
- return;
1597
+ if (!value || typeof value !== "object") {
1598
+ return [];
1453
1599
  }
1454
1600
 
1455
- pi.appendEntry(RP_READCACHE_CUSTOM_TYPE, buildInvalidationV1(resolved.absolutePath, scopeKey));
1456
- clearReadcacheCaches();
1601
+ const obj = value as Record<string, unknown>;
1457
1602
 
1458
- ctx.ui.notify(
1459
- `Invalidated readcache for ${resolved.absolutePath}` + (scopeKey === SCOPE_FULL ? "" : ` (${scopeKey})`),
1460
- "info"
1461
- );
1462
- },
1463
- });
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
+ }
1464
1610
 
1465
- pi.registerTool({
1466
- name: "rp_bind",
1467
- label: "RepoPrompt Bind",
1468
- description: "Bind rp_exec to a specific RepoPrompt window and compose tab",
1469
- parameters: BindParams,
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
+ }
1470
1618
 
1471
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1472
- await ensureJustBashLoaded();
1473
- maybeWarnAstUnavailable(ctx);
1474
- persistBinding(params.windowId, params.tab);
1619
+ return [];
1620
+ };
1475
1621
 
1476
- return {
1477
- content: [{ type: "text", text: `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` }],
1478
- details: { windowId: boundWindowId, tab: boundTab },
1479
- };
1480
- },
1481
- });
1622
+ const rows = pickRows(parsed);
1482
1623
 
1483
- pi.registerTool({
1484
- name: "rp_exec",
1485
- label: "RepoPrompt Exec",
1486
- description: "Run rp-cli in the bound RepoPrompt window/tab, with quiet defaults and output truncation",
1487
- parameters: ExecParams,
1624
+ return rows
1625
+ .map((row) => {
1626
+ if (!row || typeof row !== "object") {
1627
+ return null;
1628
+ }
1488
1629
 
1489
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
1490
- // Routing: prefer call-time overrides, otherwise fall back to the last persisted binding
1491
- await ensureJustBashLoaded();
1492
- maybeWarnAstUnavailable(ctx);
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);
1493
1634
 
1494
- const windowId = params.windowId ?? boundWindowId;
1495
- const tab = params.tab ?? boundTab;
1496
- const rawJson = params.rawJson ?? false;
1497
- const quiet = params.quiet ?? true;
1498
- const failFast = params.failFast ?? true;
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;
1499
2918
  const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1500
2919
  const maxOutputChars = params.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
1501
2920
  const allowDelete = params.allowDelete ?? false;
@@ -1593,10 +3012,10 @@ export default function (pi: ExtensionAPI) {
1593
3012
  let rpReadcache: RpReadcacheMetaV1 | null = null;
1594
3013
 
1595
3014
  if (
1596
- config.readcacheReadFile === true &&
1597
- readRequest !== null &&
1598
- readRequest.cacheable === true &&
1599
- !execError &&
3015
+ config.readcacheReadFile === true &&
3016
+ readRequest !== null &&
3017
+ readRequest.cacheable === true &&
3018
+ !execError &&
1600
3019
  exitCode === 0 &&
1601
3020
  windowId !== undefined &&
1602
3021
  tab !== undefined
@@ -1627,6 +3046,30 @@ export default function (pi: ExtensionAPI) {
1627
3046
  }
1628
3047
  }
1629
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
+
1630
3073
  const editNoop =
1631
3074
  !execError &&
1632
3075
  exitCode === 0 &&
@@ -1739,25 +3182,45 @@ export default function (pi: ExtensionAPI) {
1739
3182
 
1740
3183
  // Success case
1741
3184
  const truncatedNote = truncated ? theme.fg("warning", " (truncated)") : "";
1742
- const successPrefix = theme.fg("success", "✓");
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
+ };
1743
3196
 
1744
- // Collapsed view: show line count
1745
3197
  if (!options.expanded) {
1746
- const lines = textContent.split("\n");
1747
- if (lines.length > COLLAPSED_MAX_LINES || textContent.length > COLLAPSED_MAX_CHARS) {
1748
- const preview = renderRpExecOutput(
1749
- lines.slice(0, COLLAPSED_MAX_LINES).join("\n"),
1750
- theme
1751
- );
1752
- const remaining = lines.length - COLLAPSED_MAX_LINES;
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;
1753
3214
  const moreText = remaining > 0 ? theme.fg("muted", `\n… (${remaining} more lines)`) : "";
1754
- return new Text(`${successPrefix}${truncatedNote}\n${preview}${moreText}`, 0, 0);
3215
+ return new Text(`${prefixFirstLine(content, `${successPrefix}${truncatedNote}`)}${moreText}`, 0, 0);
1755
3216
  }
3217
+
3218
+ return new Text(prefixFirstLine(content, `${successPrefix}${truncatedNote}`), 0, 0);
1756
3219
  }
1757
3220
 
1758
3221
  // Expanded view or short output: render with syntax highlighting
1759
3222
  const highlighted = renderRpExecOutput(textContent, theme);
1760
- return new Text(`${successPrefix}${truncatedNote}\n${highlighted}`, 0, 0);
3223
+ return new Text(prefixFirstLine(highlighted, `${successPrefix}${truncatedNote}`), 0, 0);
1761
3224
  },
1762
3225
  });
1763
3226
  }