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.
package/README.md CHANGED
@@ -7,8 +7,10 @@ Provides two tools:
7
7
  - `rp_exec` — run `rp-cli -e <cmd>` against that binding (quiet defaults + output truncation)
8
8
 
9
9
  Optional:
10
+ - Diff blocks in `rp_exec` output use `delta` when installed (honoring the user's global git/delta color config), with graceful fallback otherwise
10
11
  - [Gurpartap/pi-readcache](https://github.com/Gurpartap/pi-readcache)-like caching for `rp_exec` calls that read files (`read` / `cat` / `read_file`) to save on tokens
11
12
  - returns unchanged markers and diffs on repeat reads
13
+ - Auto-selection (in the RP app, e.g. for use in RP Chat) of slices/files the agent has read; these selections are also branch-safe across `/tree` navigation and `/fork`ed session
12
14
 
13
15
  Also provides convenience commands:
14
16
  - `/rpbind <window_id> <tab>`
@@ -53,7 +55,8 @@ Create `~/.pi/agent/extensions/repoprompt-cli/config.json`:
53
55
 
54
56
  ```json
55
57
  {
56
- "readcacheReadFile": true
58
+ "readcacheReadFile": true,
59
+ "autoSelectReadSlices": true
57
60
  }
58
61
  ```
59
62
 
@@ -94,6 +97,8 @@ Notes:
94
97
  - Readcache only triggers for **single-command** reads. Compound commands (`&&`, `;`, `|`) fail open to baseline output
95
98
  - When `just-bash` AST parsing is unavailable, caching only applies to unquoted/unescaped single-command reads; quoted/escaped forms fail open
96
99
  - `rawJson=true` disables caching
100
+ - Read-driven selection replay is enabled by default (`autoSelectReadSlices: true`); set it to `false` to disable
101
+ - Manual selection is always preserved for paths not managed by this feature; for managed paths, branch replay may restore the branch snapshot
97
102
 
98
103
  ## Readcache gotchas
99
104
 
@@ -7,6 +7,10 @@ It provides two Pi tools:
7
7
  - `rp_bind` — bind to a specific RepoPrompt **window id** + **compose tab** (routing)
8
8
  - `rp_exec` — run `rp-cli -e <cmd>` against that binding
9
9
 
10
+ Diff blocks in `rp_exec` output use `delta` when installed (honoring the user's global git/delta color config), with graceful fallback otherwise
11
+
12
+ When enabled (default), `rp_exec` also auto-tracks `read` / `cat` / `read_file` calls and updates RepoPrompt selection with owned file/slice context. The owned selection state is branch-safe across `/tree` and `/fork`, and replays after reconnect/restart using workspace-aware rebinding
13
+
10
14
  ## Optional: readcache for `rp_exec read`
11
15
 
12
16
  If enabled, `rp_exec` will apply [Gurpartap/pi-readcache](https://github.com/Gurpartap/pi-readcache)-like token savings for single-command file reads, returning:
@@ -23,7 +27,8 @@ Create:
23
27
 
24
28
  ```json
25
29
  {
26
- "readcacheReadFile": true
30
+ "readcacheReadFile": true,
31
+ "autoSelectReadSlices": true
27
32
  }
28
33
  ```
29
34
 
@@ -49,6 +54,20 @@ Add `bypass_cache=true` to the `cmd`:
49
54
  rp_exec cmd="read path=src/main.ts start_line=1 limit=120 bypass_cache=true"
50
55
  ```
51
56
 
57
+ ## Auto-selection with branch-safe replay
58
+
59
+ This feature is enabled by default (no config change needed). To disable it, set `"autoSelectReadSlices": false` in your `config.json`.
60
+
61
+ Behavior:
62
+
63
+ - `read_file`/`read`/`cat` with range → tracked as slice selection in the RP app (e.g., for use as context in RP Chat)
64
+ - full reads (no representable range) → tracked as full-file selection in the RP app
65
+ - tail reads (`start_line < 0`) convert to explicit ranges when file line count is available
66
+ - replay is owned-state-only: only paths/slices added by the extension are reconciled (manual selection outside owned state is preserved)
67
+ - for a path already managed by this feature, later branch replay may restore that path to the branch snapshot (including overriding manual tweaks on that same path)
68
+ - branch-local snapshots are restored across `/tree` navigation and `/fork` branch divergence
69
+ - if RepoPrompt restarts and window IDs change, replay remaps by workspace identity when possible
70
+
52
71
  ## Readcache gotchas
53
72
 
54
73
  - `rawJson=true` disables readcache. Don't use unless debugging
@@ -0,0 +1,288 @@
1
+ // auto-select.ts - helpers for automatically selecting read_file context in RepoPrompt
2
+
3
+ import * as fs from "node:fs";
4
+ import { stat } from "node:fs/promises";
5
+
6
+ export type SelectionMode = "full" | "slices" | "codemap_only";
7
+
8
+ export interface SelectionStatus {
9
+ mode: SelectionMode;
10
+
11
+ // Only relevant for codemap-only selection entries
12
+ codemapManual?: boolean;
13
+ }
14
+
15
+ export interface SliceRange {
16
+ start_line: number;
17
+ end_line: number;
18
+ }
19
+
20
+ export function toPosixPath(inputPath: string): string {
21
+ return inputPath.replace(/\\/g, "/");
22
+ }
23
+
24
+ export function inferSelectionStatus(selectionText: string, selectionPath: string): SelectionStatus | null {
25
+ if (!selectionText || !selectionPath) {
26
+ return null;
27
+ }
28
+
29
+ const target = toPosixPath(selectionPath).replace(/\/+$/, "");
30
+
31
+ const normalizeDir = (dir: string): string => {
32
+ const normalized = toPosixPath(dir).trim();
33
+ return normalized.endsWith("/") ? normalized : `${normalized}/`;
34
+ };
35
+
36
+ const joinPath = (dir: string, name: string): string => {
37
+ if (!dir) {
38
+ return name;
39
+ }
40
+ return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
41
+ };
42
+
43
+ const lines = selectionText.split("\n");
44
+
45
+ let section: "selected" | "codemaps" | null = null;
46
+
47
+ // Base directory for the current tree (RepoPrompt prints a root line like `agent/src/`)
48
+ let baseDir: string | null = null;
49
+
50
+ // Prefix stack for tree-indented directory lines
51
+ const prefixes: string[] = [];
52
+
53
+ const stripTrailingSelectionMetadata = (value: string): string => {
54
+ let current = value.trim();
55
+
56
+ // RepoPrompt usually separates metadata with an em dash, but not always
57
+ // We normalize both forms:
58
+ // file.ts — 120 tokens (lines 1-20)
59
+ // file.ts (lines 1-20)
60
+ const dashIndex = current.indexOf(" — ");
61
+ if (dashIndex !== -1) {
62
+ current = current.slice(0, dashIndex).trim();
63
+ }
64
+
65
+ const metadataPatterns = [
66
+ // Slice metadata at end of line
67
+ // Examples:
68
+ // file.ts (lines 1-20)
69
+ // file.ts (lines 1-20, 120-129)
70
+ // file.ts (line 5)
71
+ /\s+\(lines?\s+\d+\s*-\s*\d+(?:\s*,\s*\d+\s*-\s*\d+)*\)\s*$/i,
72
+ /\s+\(line\s+\d+\)\s*$/i,
73
+
74
+ /\s+\((?:manual|auto|full)\)\s*$/i,
75
+
76
+ // Token metadata at end of line
77
+ /\s+\d[\d,]*\s+tokens(?:\s+\((?:manual|auto|full|lines?\s+\d+\s*-\s*\d+(?:\s*,\s*\d+\s*-\s*\d+)*)\))?\s*$/i,
78
+ ];
79
+
80
+ let changed = true;
81
+ while (changed) {
82
+ changed = false;
83
+
84
+ for (const pattern of metadataPatterns) {
85
+ const next = current.replace(pattern, "").trimEnd();
86
+ if (next !== current) {
87
+ current = next;
88
+ changed = true;
89
+ }
90
+ }
91
+ }
92
+
93
+ return current.trim();
94
+ };
95
+
96
+ const considerMatch = (nodePath: string, rest: string): SelectionStatus | null => {
97
+ const normalizedNode = toPosixPath(nodePath).replace(/\/+$/, "");
98
+ if (normalizedNode !== target) {
99
+ return null;
100
+ }
101
+
102
+ if (section === "selected") {
103
+ // RepoPrompt may render multiple ranges as: (lines 1-20, 120-129)
104
+ // We treat anything that looks like a line/lines annotation as slice selection
105
+ if (/\(\s*lines?\s+\d+/i.test(rest) || /\(\s*line\s+\d+/i.test(rest)) {
106
+ return { mode: "slices" };
107
+ }
108
+
109
+ return { mode: "full" };
110
+ }
111
+
112
+ if (section === "codemaps") {
113
+ return {
114
+ mode: "codemap_only",
115
+ codemapManual: /\(manual\)/i.test(rest),
116
+ };
117
+ }
118
+
119
+ return null;
120
+ };
121
+
122
+ for (const rawLine of lines) {
123
+ const line = toPosixPath(rawLine).trimEnd();
124
+
125
+ if (line.includes("### Selected Files")) {
126
+ section = "selected";
127
+ baseDir = null;
128
+ prefixes.length = 0;
129
+ continue;
130
+ }
131
+
132
+ if (line.includes("### Codemaps")) {
133
+ section = "codemaps";
134
+ baseDir = null;
135
+ prefixes.length = 0;
136
+ continue;
137
+ }
138
+
139
+ // Root directory line: `agent/extensions/.../`
140
+ if (line.endsWith("/") && !line.includes("──") && !line.includes(" — ")) {
141
+ baseDir = normalizeDir(line);
142
+ prefixes.length = 0;
143
+ prefixes.push(baseDir);
144
+ continue;
145
+ }
146
+
147
+ const markerMatch = line.match(/(├──|└──)\s+(.*)$/);
148
+ if (!markerMatch) {
149
+ continue;
150
+ }
151
+
152
+ const marker = markerMatch[1] ?? "";
153
+ const markerIdx = line.indexOf(marker);
154
+ const indentPrefix = markerIdx >= 0 ? line.slice(0, markerIdx) : "";
155
+
156
+ // Tree indentation uses 4-char groups: `│ ` or ` `
157
+ const indentDepth = Math.floor(indentPrefix.length / 4);
158
+
159
+ const rest = (markerMatch[2] ?? "").trim();
160
+ const name = stripTrailingSelectionMetadata(rest);
161
+
162
+ if (!name) {
163
+ continue;
164
+ }
165
+
166
+ const parentPrefix = prefixes[indentDepth] ?? baseDir;
167
+ if (!parentPrefix) {
168
+ continue;
169
+ }
170
+
171
+ const nodePath = joinPath(parentPrefix, name);
172
+
173
+ if (name.endsWith("/")) {
174
+ prefixes[indentDepth + 1] = normalizeDir(nodePath);
175
+ prefixes.length = indentDepth + 2;
176
+ continue;
177
+ }
178
+
179
+ const match = considerMatch(nodePath, rest);
180
+ if (match) {
181
+ return match;
182
+ }
183
+ }
184
+
185
+ return null;
186
+ }
187
+
188
+ const LINE_COUNT_CACHE = new Map<string, { mtimeMs: number; size: number; lines: number }>();
189
+ const LINE_COUNT_CACHE_MAX_ENTRIES = 512;
190
+
191
+ async function countFileLinesUncached(absolutePath: string): Promise<number> {
192
+ return await new Promise<number>((resolve, reject) => {
193
+ let newlineCount = 0;
194
+ let sawData = false;
195
+ let lastByte: number | null = null;
196
+
197
+ const stream = fs.createReadStream(absolutePath);
198
+
199
+ stream.on("data", (chunk: Buffer | string) => {
200
+ const buf = (typeof chunk === "string") ? Buffer.from(chunk, "utf8") : chunk;
201
+
202
+ sawData = true;
203
+
204
+ for (let i = 0; i < buf.length; i++) {
205
+ if (buf[i] === 10) {
206
+ newlineCount += 1;
207
+ }
208
+ }
209
+
210
+ if (buf.length > 0) {
211
+ lastByte = buf[buf.length - 1] ?? lastByte;
212
+ }
213
+ });
214
+
215
+ stream.on("error", (err) => reject(err));
216
+
217
+ stream.on("end", () => {
218
+ if (!sawData) {
219
+ resolve(0);
220
+ return;
221
+ }
222
+
223
+ const lines = (lastByte === 10) ? newlineCount : newlineCount + 1;
224
+ resolve(lines);
225
+ });
226
+ });
227
+ }
228
+
229
+ export async function countFileLines(absolutePath: string): Promise<number> {
230
+ const st = await stat(absolutePath);
231
+
232
+ const cached = LINE_COUNT_CACHE.get(absolutePath);
233
+ if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
234
+ return cached.lines;
235
+ }
236
+
237
+ const lines = await countFileLinesUncached(absolutePath);
238
+ LINE_COUNT_CACHE.set(absolutePath, { mtimeMs: st.mtimeMs, size: st.size, lines });
239
+
240
+ while (LINE_COUNT_CACHE.size > LINE_COUNT_CACHE_MAX_ENTRIES) {
241
+ const oldestKey = LINE_COUNT_CACHE.keys().next().value;
242
+ if (oldestKey === undefined) {
243
+ break;
244
+ }
245
+ LINE_COUNT_CACHE.delete(oldestKey);
246
+ }
247
+
248
+ return lines;
249
+ }
250
+
251
+ export function computeSliceRangeFromReadArgs(
252
+ startLine: number | undefined,
253
+ limit: number | undefined,
254
+ totalLines: number | undefined
255
+ ): SliceRange | null {
256
+ if (typeof startLine !== "number") {
257
+ return null;
258
+ }
259
+
260
+ // Positive range reads
261
+ if (startLine > 0) {
262
+ if (typeof limit !== "number" || limit <= 0) {
263
+ return null;
264
+ }
265
+
266
+ return {
267
+ start_line: startLine,
268
+ end_line: startLine + limit - 1,
269
+ };
270
+ }
271
+
272
+ // Tail reads (-N)
273
+ if (startLine < 0) {
274
+ if (typeof totalLines !== "number" || totalLines <= 0) {
275
+ return null;
276
+ }
277
+
278
+ const n = Math.abs(startLine);
279
+ const start = Math.max(1, totalLines - n + 1);
280
+
281
+ return {
282
+ start_line: start,
283
+ end_line: totalLines,
284
+ };
285
+ }
286
+
287
+ return null;
288
+ }
@@ -1,3 +1,4 @@
1
1
  {
2
- "readcacheReadFile": false
2
+ "readcacheReadFile": false,
3
+ "autoSelectReadSlices": true
3
4
  }
@@ -8,6 +8,7 @@ import type { RpCliConfig } from "./types.js";
8
8
 
9
9
  const DEFAULT_CONFIG: Required<RpCliConfig> = {
10
10
  readcacheReadFile: false,
11
+ autoSelectReadSlices: true,
11
12
  };
12
13
 
13
14
  const CONFIG_LOCATIONS = [