pi-repoprompt-cli 0.2.0 → 0.2.6
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 +44 -2
- package/extensions/repoprompt-cli/README.md +94 -0
- package/extensions/repoprompt-cli/config.json.example +3 -0
- package/extensions/repoprompt-cli/config.ts +47 -0
- package/extensions/repoprompt-cli/index.ts +1674 -0
- package/extensions/repoprompt-cli/readcache/LICENSE +23 -0
- package/extensions/repoprompt-cli/readcache/constants.ts +38 -0
- package/extensions/repoprompt-cli/readcache/diff.ts +129 -0
- package/extensions/repoprompt-cli/readcache/meta.ts +241 -0
- package/extensions/repoprompt-cli/readcache/object-store.ts +184 -0
- package/extensions/repoprompt-cli/readcache/read-file.ts +438 -0
- package/extensions/repoprompt-cli/readcache/replay.ts +366 -0
- package/extensions/repoprompt-cli/readcache/resolve.ts +235 -0
- package/extensions/repoprompt-cli/readcache/text.ts +43 -0
- package/extensions/repoprompt-cli/readcache/types.ts +73 -0
- package/extensions/repoprompt-cli/types.ts +6 -0
- package/package.json +26 -3
|
@@ -0,0 +1,1674 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { highlightCode, Theme } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
4
|
+
import { Type } from "@sinclair/typebox";
|
|
5
|
+
import * as Diff from "diff";
|
|
6
|
+
|
|
7
|
+
import { loadConfig } from "./config.js";
|
|
8
|
+
import { RP_READCACHE_CUSTOM_TYPE, SCOPE_FULL, scopeRange } from "./readcache/constants.js";
|
|
9
|
+
import { buildInvalidationV1 } from "./readcache/meta.js";
|
|
10
|
+
import { getStoreStats, pruneObjectsOlderThan } from "./readcache/object-store.js";
|
|
11
|
+
import { readFileWithCache } from "./readcache/read-file.js";
|
|
12
|
+
import { clearReplayRuntimeState, createReplayRuntimeState } from "./readcache/replay.js";
|
|
13
|
+
import { resolveReadFilePath } from "./readcache/resolve.js";
|
|
14
|
+
import type { RpReadcacheMetaV1, ScopeKey } from "./readcache/types.js";
|
|
15
|
+
|
|
16
|
+
let parseBash: ((input: string) => any) | null = null;
|
|
17
|
+
let justBashLoadPromise: Promise<void> | null = null;
|
|
18
|
+
let justBashLoadDone = false;
|
|
19
|
+
|
|
20
|
+
async function ensureJustBashLoaded(): Promise<void> {
|
|
21
|
+
if (justBashLoadDone) return;
|
|
22
|
+
|
|
23
|
+
if (!justBashLoadPromise) {
|
|
24
|
+
justBashLoadPromise = import("just-bash")
|
|
25
|
+
.then((mod: any) => {
|
|
26
|
+
parseBash = typeof mod?.parse === "function" ? mod.parse : null;
|
|
27
|
+
})
|
|
28
|
+
.catch(() => {
|
|
29
|
+
parseBash = null;
|
|
30
|
+
})
|
|
31
|
+
.finally(() => {
|
|
32
|
+
justBashLoadDone = true;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await justBashLoadPromise;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let warnedAstUnavailable = false;
|
|
40
|
+
function maybeWarnAstUnavailable(ctx: any): void {
|
|
41
|
+
if (warnedAstUnavailable) return;
|
|
42
|
+
if (parseBash) return;
|
|
43
|
+
if (!ctx?.hasUI) return;
|
|
44
|
+
|
|
45
|
+
warnedAstUnavailable = true;
|
|
46
|
+
ctx.ui.notify(
|
|
47
|
+
"repoprompt-cli: just-bash >= 2 is not available; falling back to best-effort command parsing",
|
|
48
|
+
"warning",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type BashInvocation = {
|
|
53
|
+
statementIndex: number;
|
|
54
|
+
pipelineIndex: number;
|
|
55
|
+
pipelineLength: number;
|
|
56
|
+
commandNameRaw: string;
|
|
57
|
+
commandName: string;
|
|
58
|
+
args: string[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function commandBaseName(value: string): string {
|
|
62
|
+
const normalized = value.replace(/\\+/g, "/");
|
|
63
|
+
const idx = normalized.lastIndexOf("/");
|
|
64
|
+
const base = idx >= 0 ? normalized.slice(idx + 1) : normalized;
|
|
65
|
+
return base.toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function partToText(part: any): string {
|
|
69
|
+
if (!part || typeof part !== "object") return "";
|
|
70
|
+
|
|
71
|
+
switch (part.type) {
|
|
72
|
+
case "Literal":
|
|
73
|
+
case "SingleQuoted":
|
|
74
|
+
case "Escaped":
|
|
75
|
+
return typeof part.value === "string" ? part.value : "";
|
|
76
|
+
case "DoubleQuoted":
|
|
77
|
+
return Array.isArray(part.parts) ? part.parts.map(partToText).join("") : "";
|
|
78
|
+
case "Glob":
|
|
79
|
+
return typeof part.pattern === "string" ? part.pattern : "";
|
|
80
|
+
case "TildeExpansion":
|
|
81
|
+
return typeof part.user === "string" && part.user.length > 0 ? `~${part.user}` : "~";
|
|
82
|
+
case "ParameterExpansion":
|
|
83
|
+
return typeof part.parameter === "string" && part.parameter.length > 0
|
|
84
|
+
? "${" + part.parameter + "}"
|
|
85
|
+
: "${}";
|
|
86
|
+
case "CommandSubstitution":
|
|
87
|
+
return "$(...)";
|
|
88
|
+
case "ProcessSubstitution":
|
|
89
|
+
return part.direction === "output" ? ">(...)" : "<(...)";
|
|
90
|
+
case "ArithmeticExpansion":
|
|
91
|
+
return "$((...))";
|
|
92
|
+
default:
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function wordToText(word: any): string {
|
|
98
|
+
if (!word || typeof word !== "object" || !Array.isArray(word.parts)) return "";
|
|
99
|
+
return word.parts.map(partToText).join("");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function analyzeTopLevelBashScript(command: string): { parseError?: string; topLevelInvocations: BashInvocation[] } {
|
|
103
|
+
try {
|
|
104
|
+
if (!parseBash) {
|
|
105
|
+
return { parseError: "just-bash parse unavailable", topLevelInvocations: [] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ast: any = parseBash(command);
|
|
109
|
+
const topLevelInvocations: BashInvocation[] = [];
|
|
110
|
+
|
|
111
|
+
if (!ast || typeof ast !== "object" || !Array.isArray(ast.statements)) {
|
|
112
|
+
return { topLevelInvocations };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
ast.statements.forEach((statement: any, statementIndex: number) => {
|
|
116
|
+
if (!statement || typeof statement !== "object" || !Array.isArray(statement.pipelines)) return;
|
|
117
|
+
|
|
118
|
+
statement.pipelines.forEach((pipeline: any, pipelineIndex: number) => {
|
|
119
|
+
if (!pipeline || typeof pipeline !== "object" || !Array.isArray(pipeline.commands)) return;
|
|
120
|
+
|
|
121
|
+
const pipelineLength = pipeline.commands.length;
|
|
122
|
+
pipeline.commands.forEach((commandNode: any) => {
|
|
123
|
+
if (!commandNode || commandNode.type !== "SimpleCommand") return;
|
|
124
|
+
|
|
125
|
+
const commandNameRaw = wordToText(commandNode.name).trim();
|
|
126
|
+
if (!commandNameRaw) return;
|
|
127
|
+
|
|
128
|
+
const args = Array.isArray(commandNode.args)
|
|
129
|
+
? commandNode.args.map((arg: any) => wordToText(arg)).filter(Boolean)
|
|
130
|
+
: [];
|
|
131
|
+
|
|
132
|
+
topLevelInvocations.push({
|
|
133
|
+
statementIndex,
|
|
134
|
+
pipelineIndex,
|
|
135
|
+
pipelineLength,
|
|
136
|
+
commandNameRaw,
|
|
137
|
+
commandName: commandBaseName(commandNameRaw),
|
|
138
|
+
args,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return { topLevelInvocations };
|
|
145
|
+
} catch (error: any) {
|
|
146
|
+
return {
|
|
147
|
+
parseError: error?.message ?? String(error),
|
|
148
|
+
topLevelInvocations: [],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function hasSemicolonOutsideQuotes(script: string): boolean {
|
|
154
|
+
let inSingleQuote = false;
|
|
155
|
+
let inDoubleQuote = false;
|
|
156
|
+
let escaped = false;
|
|
157
|
+
|
|
158
|
+
for (let i = 0; i < script.length; i += 1) {
|
|
159
|
+
const ch = script[i];
|
|
160
|
+
|
|
161
|
+
if (escaped) {
|
|
162
|
+
escaped = false;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (ch === "\\") {
|
|
167
|
+
escaped = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!inDoubleQuote && ch === "'") {
|
|
172
|
+
inSingleQuote = !inSingleQuote;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!inSingleQuote && ch === '"') {
|
|
177
|
+
inDoubleQuote = !inDoubleQuote;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!inSingleQuote && !inDoubleQuote && ch === ";") {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hasPipeOutsideQuotes(script: string): boolean {
|
|
190
|
+
let inSingleQuote = false;
|
|
191
|
+
let inDoubleQuote = false;
|
|
192
|
+
let escaped = false;
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < script.length; i += 1) {
|
|
195
|
+
const ch = script[i];
|
|
196
|
+
|
|
197
|
+
if (escaped) {
|
|
198
|
+
escaped = false;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (ch === "\\") {
|
|
203
|
+
escaped = true;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!inDoubleQuote && ch === "'") {
|
|
208
|
+
inSingleQuote = !inSingleQuote;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!inSingleQuote && ch === '"') {
|
|
213
|
+
inDoubleQuote = !inDoubleQuote;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!inSingleQuote && !inDoubleQuote && ch === "|") {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* RepoPrompt CLI ↔ Pi integration extension
|
|
227
|
+
*
|
|
228
|
+
* Registers two Pi tools:
|
|
229
|
+
* - `rp_bind`: binds a RepoPrompt window + compose tab (routing)
|
|
230
|
+
* - `rp_exec`: runs `rp-cli -e <cmd>` against that binding (quiet defaults, output truncation)
|
|
231
|
+
*
|
|
232
|
+
* Safety goals:
|
|
233
|
+
* - Prevent "unbound" rp_exec calls from operating on an unintended window/workspace
|
|
234
|
+
* - Prevent in-place workspace switches by default (they can clobber selection/prompt/context)
|
|
235
|
+
* - Block delete-like commands unless explicitly allowed
|
|
236
|
+
*
|
|
237
|
+
* UX goals:
|
|
238
|
+
* - Persist binding across session reloads via `pi.appendEntry()` (does not enter LLM context)
|
|
239
|
+
* - Provide actionable error messages when blocked
|
|
240
|
+
* - For best command parsing (AST-based), install `just-bash` >= 2; otherwise it falls back to a legacy splitter
|
|
241
|
+
* - Syntax-highlight fenced code blocks in output (read, structure, etc.)
|
|
242
|
+
* - Word-level diff highlighting for edit output
|
|
243
|
+
*/
|
|
244
|
+
|
|
245
|
+
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
246
|
+
const DEFAULT_MAX_OUTPUT_CHARS = 12000;
|
|
247
|
+
const BINDING_CUSTOM_TYPE = "repoprompt-binding";
|
|
248
|
+
|
|
249
|
+
const BindParams = Type.Object({
|
|
250
|
+
windowId: Type.Number({ description: "RepoPrompt window id (from `rp-cli -e windows`)" }),
|
|
251
|
+
tab: Type.String({ description: "RepoPrompt compose tab name or UUID" }),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const ExecParams = Type.Object({
|
|
255
|
+
cmd: Type.String({ description: "rp-cli exec string (e.g. `tree`, `select set src/ && context`)" }),
|
|
256
|
+
rawJson: Type.Optional(Type.Boolean({ description: "Pass --raw-json to rp-cli" })),
|
|
257
|
+
quiet: Type.Optional(Type.Boolean({ description: "Pass -q/--quiet to rp-cli (default: true)" })),
|
|
258
|
+
failFast: Type.Optional(Type.Boolean({ description: "Pass --fail-fast to rp-cli (default: true)" })),
|
|
259
|
+
timeoutMs: Type.Optional(Type.Number({ description: "Timeout in ms (default: 15 minutes)" })),
|
|
260
|
+
maxOutputChars: Type.Optional(Type.Number({ description: "Truncate output to this many chars (default: 12000)" })),
|
|
261
|
+
windowId: Type.Optional(Type.Number({ description: "Override bound window id for this call" })),
|
|
262
|
+
tab: Type.Optional(Type.String({ description: "Override bound tab for this call" })),
|
|
263
|
+
allowDelete: Type.Optional(
|
|
264
|
+
Type.Boolean({ description: "Allow delete commands like `file delete ...` or `workspace delete ...` (default: false)" }),
|
|
265
|
+
),
|
|
266
|
+
allowWorkspaceSwitchInPlace: Type.Optional(
|
|
267
|
+
Type.Boolean({
|
|
268
|
+
description:
|
|
269
|
+
"Allow in-place workspace changes (e.g. `workspace switch <name>` or `workspace create ... --switch`) without --new-window (default: false). In-place switching can disrupt other sessions",
|
|
270
|
+
}),
|
|
271
|
+
),
|
|
272
|
+
failOnNoopEdits: Type.Optional(
|
|
273
|
+
Type.Boolean({
|
|
274
|
+
description: "Treat edit commands that apply 0 changes (or produce empty output) as errors (default: true)",
|
|
275
|
+
}),
|
|
276
|
+
),
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
function truncateText(text: string, maxChars: number): { text: string; truncated: boolean } {
|
|
280
|
+
if (maxChars <= 0) return { text: "", truncated: text.length > 0 };
|
|
281
|
+
if (text.length <= maxChars) return { text, truncated: false };
|
|
282
|
+
return {
|
|
283
|
+
text: `${text.slice(0, maxChars)}\n… [truncated; redirect output to a file if needed]`,
|
|
284
|
+
truncated: true,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
type ParsedCommandChain = {
|
|
289
|
+
commands: string[];
|
|
290
|
+
invocations: BashInvocation[];
|
|
291
|
+
hasSemicolonOutsideQuotes: boolean;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
function parseCommandChainLegacy(cmd: string): { commands: string[]; hasSemicolonOutsideQuotes: boolean } {
|
|
295
|
+
const commands: string[] = [];
|
|
296
|
+
let current = "";
|
|
297
|
+
let inSingleQuote = false;
|
|
298
|
+
let inDoubleQuote = false;
|
|
299
|
+
let escaped = false;
|
|
300
|
+
let hasSemicolonOutsideQuotes = false;
|
|
301
|
+
|
|
302
|
+
const pushCurrent = () => {
|
|
303
|
+
const trimmed = current.trim();
|
|
304
|
+
if (trimmed.length > 0) commands.push(trimmed);
|
|
305
|
+
current = "";
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
for (let i = 0; i < cmd.length; i += 1) {
|
|
309
|
+
const ch = cmd[i];
|
|
310
|
+
|
|
311
|
+
if (escaped) {
|
|
312
|
+
current += ch;
|
|
313
|
+
escaped = false;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (ch === "\\") {
|
|
318
|
+
current += ch;
|
|
319
|
+
escaped = true;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!inDoubleQuote && ch === "'") {
|
|
324
|
+
inSingleQuote = !inSingleQuote;
|
|
325
|
+
current += ch;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!inSingleQuote && ch === '"') {
|
|
330
|
+
inDoubleQuote = !inDoubleQuote;
|
|
331
|
+
current += ch;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!inSingleQuote && !inDoubleQuote) {
|
|
336
|
+
if (ch === "&" && cmd[i + 1] === "&") {
|
|
337
|
+
pushCurrent();
|
|
338
|
+
i += 1;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (ch === ";") {
|
|
343
|
+
hasSemicolonOutsideQuotes = true;
|
|
344
|
+
pushCurrent();
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
current += ch;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
pushCurrent();
|
|
353
|
+
return { commands, hasSemicolonOutsideQuotes };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function renderInvocation(invocation: BashInvocation): string {
|
|
357
|
+
return [invocation.commandNameRaw, ...invocation.args].filter(Boolean).join(" ").trim();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function parseCommandChain(cmd: string): ParsedCommandChain {
|
|
361
|
+
const semicolonOutsideQuotes = hasSemicolonOutsideQuotes(cmd);
|
|
362
|
+
const analysis = analyzeTopLevelBashScript(cmd);
|
|
363
|
+
|
|
364
|
+
if (!analysis.parseError && analysis.topLevelInvocations.length > 0) {
|
|
365
|
+
const commands = analysis.topLevelInvocations
|
|
366
|
+
.map(renderInvocation)
|
|
367
|
+
.filter((command) => command.length > 0);
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
commands,
|
|
371
|
+
invocations: analysis.topLevelInvocations,
|
|
372
|
+
hasSemicolonOutsideQuotes: semicolonOutsideQuotes,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const legacy = parseCommandChainLegacy(cmd);
|
|
377
|
+
return {
|
|
378
|
+
commands: legacy.commands,
|
|
379
|
+
invocations: [],
|
|
380
|
+
hasSemicolonOutsideQuotes: legacy.hasSemicolonOutsideQuotes || semicolonOutsideQuotes,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function looksLikeDeleteCommand(cmd: string): boolean {
|
|
385
|
+
const parsed = parseCommandChain(cmd);
|
|
386
|
+
|
|
387
|
+
if (parsed.invocations.length > 0) {
|
|
388
|
+
for (const invocation of parsed.invocations) {
|
|
389
|
+
const commandName = invocation.commandName;
|
|
390
|
+
const args = invocation.args.map((arg) => arg.toLowerCase());
|
|
391
|
+
|
|
392
|
+
if (commandName === "file" && args[0] === "delete") return true;
|
|
393
|
+
if (commandName === "workspace" && args[0] === "delete") return true;
|
|
394
|
+
|
|
395
|
+
if (commandName === "call") {
|
|
396
|
+
const normalized = args.join(" ");
|
|
397
|
+
if (
|
|
398
|
+
/\baction\s*=\s*delete\b/.test(normalized)
|
|
399
|
+
|| /"action"\s*:\s*"delete"/.test(normalized)
|
|
400
|
+
|| /'action'\s*:\s*'delete'/.test(normalized)
|
|
401
|
+
) {
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Fallback when parsing fails
|
|
410
|
+
for (const command of parsed.commands) {
|
|
411
|
+
const normalized = command.trim().toLowerCase();
|
|
412
|
+
if (normalized === "file delete" || normalized.startsWith("file delete ")) return true;
|
|
413
|
+
if (normalized === "workspace delete" || normalized.startsWith("workspace delete ")) return true;
|
|
414
|
+
|
|
415
|
+
if (
|
|
416
|
+
normalized.startsWith("call ")
|
|
417
|
+
&& (
|
|
418
|
+
/\baction\s*=\s*delete\b/.test(normalized)
|
|
419
|
+
|| /"action"\s*:\s*"delete"/.test(normalized)
|
|
420
|
+
|| /'action'\s*:\s*'delete'/.test(normalized)
|
|
421
|
+
)
|
|
422
|
+
) {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function looksLikeWorkspaceSwitchInPlace(cmd: string): boolean {
|
|
431
|
+
const parsed = parseCommandChain(cmd);
|
|
432
|
+
|
|
433
|
+
if (parsed.invocations.length > 0) {
|
|
434
|
+
for (const invocation of parsed.invocations) {
|
|
435
|
+
if (invocation.commandName !== "workspace") continue;
|
|
436
|
+
|
|
437
|
+
const args = invocation.args.map((arg) => arg.toLowerCase());
|
|
438
|
+
const action = args[0] ?? "";
|
|
439
|
+
const hasNewWindow = args.includes("--new-window");
|
|
440
|
+
const hasSwitchFlag = args.includes("--switch");
|
|
441
|
+
|
|
442
|
+
if (action === "switch" && !hasNewWindow) return true;
|
|
443
|
+
if (action === "create" && hasSwitchFlag && !hasNewWindow) return true;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Fallback when parsing fails
|
|
450
|
+
for (const command of parsed.commands) {
|
|
451
|
+
const normalized = command.toLowerCase();
|
|
452
|
+
|
|
453
|
+
if (normalized.startsWith("workspace switch ") && !normalized.includes("--new-window")) return true;
|
|
454
|
+
|
|
455
|
+
const isCreate = normalized.startsWith("workspace create ");
|
|
456
|
+
const requestsSwitch = /\B--switch\b/.test(normalized);
|
|
457
|
+
if (isCreate && requestsSwitch && !normalized.includes("--new-window")) return true;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function looksLikeEditCommand(cmd: string): boolean {
|
|
464
|
+
const parsed = parseCommandChain(cmd);
|
|
465
|
+
|
|
466
|
+
if (parsed.invocations.length > 0) {
|
|
467
|
+
return parsed.invocations.some((invocation) => {
|
|
468
|
+
if (invocation.commandName === "edit") return true;
|
|
469
|
+
if (invocation.commandName !== "call") return false;
|
|
470
|
+
|
|
471
|
+
return invocation.args.some((arg) => arg.toLowerCase().includes("apply_edits"));
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return parsed.commands.some((command) => {
|
|
476
|
+
const normalized = command.trim().toLowerCase();
|
|
477
|
+
if (normalized === "edit" || normalized.startsWith("edit ")) return true;
|
|
478
|
+
return normalized.startsWith("call ") && normalized.includes("apply_edits");
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
type ParsedReadFileRequest = {
|
|
483
|
+
cmdToRun: string;
|
|
484
|
+
path: string;
|
|
485
|
+
startLine?: number;
|
|
486
|
+
limit?: number;
|
|
487
|
+
bypassCache: boolean;
|
|
488
|
+
|
|
489
|
+
// Whether it is safe to apply readcache substitution (marker/diff) for this request
|
|
490
|
+
// When false, we may still rewrite cmdToRun to strip wrapper-only args like bypass_cache=true
|
|
491
|
+
cacheable: boolean;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
function parseReadFileRequest(cmd: string): ParsedReadFileRequest | null {
|
|
495
|
+
const parsed = parseCommandChain(cmd);
|
|
496
|
+
|
|
497
|
+
// Only handle simple, single-invocation commands to avoid surprising behavior
|
|
498
|
+
if (parsed.hasSemicolonOutsideQuotes) return null;
|
|
499
|
+
|
|
500
|
+
let commandNameRaw: string;
|
|
501
|
+
let commandName: string;
|
|
502
|
+
let rawArgs: string[];
|
|
503
|
+
|
|
504
|
+
if (parsed.invocations.length === 1) {
|
|
505
|
+
const invocation = parsed.invocations[0];
|
|
506
|
+
if (!invocation) return null;
|
|
507
|
+
if (invocation.pipelineLength !== 1) return null;
|
|
508
|
+
|
|
509
|
+
commandNameRaw = invocation.commandNameRaw;
|
|
510
|
+
commandName = invocation.commandName;
|
|
511
|
+
rawArgs = invocation.args;
|
|
512
|
+
} else if (parsed.invocations.length === 0 && parsed.commands.length === 1) {
|
|
513
|
+
const commandText = parsed.commands[0]?.trim() ?? "";
|
|
514
|
+
if (!commandText) return null;
|
|
515
|
+
|
|
516
|
+
// Legacy parsing fallback (just-bash unavailable): only attempt for trivially-tokenizable, single commands
|
|
517
|
+
if (hasPipeOutsideQuotes(commandText)) return null;
|
|
518
|
+
if (commandText.includes("\\")) return null;
|
|
519
|
+
if (commandText.includes("\"") || commandText.includes("'") || commandText.includes("`")) return null;
|
|
520
|
+
|
|
521
|
+
const parts = commandText.split(/\s+/).filter(Boolean);
|
|
522
|
+
if (parts.length === 0) return null;
|
|
523
|
+
|
|
524
|
+
commandNameRaw = parts[0] ?? "";
|
|
525
|
+
commandName = commandBaseName(commandNameRaw);
|
|
526
|
+
rawArgs = parts.slice(1);
|
|
527
|
+
} else {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (commandName !== "read" && commandName !== "cat" && commandName !== "read_file") {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
let inputPath: string | undefined;
|
|
536
|
+
let startLine: number | undefined;
|
|
537
|
+
let limit: number | undefined;
|
|
538
|
+
let bypassCache = false;
|
|
539
|
+
let sawUnknownArg = false;
|
|
540
|
+
|
|
541
|
+
const getNumber = (value: string): number | undefined => {
|
|
542
|
+
if (!/^-?\d+$/.test(value.trim())) {
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const parsedInt = Number.parseInt(value, 10);
|
|
547
|
+
return Number.isFinite(parsedInt) ? parsedInt : undefined;
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const normalizeKey = (raw: string): string => {
|
|
551
|
+
const trimmed = raw.trim().toLowerCase();
|
|
552
|
+
const withoutDashes = trimmed.replace(/^--+/, "");
|
|
553
|
+
return withoutDashes.replace(/-/g, "_");
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const parseSliceSuffix = (value: string): { basePath: string; startLine: number; limit?: number } | null => {
|
|
557
|
+
// Slice notation: path:start-end OR path:start
|
|
558
|
+
// Example: file.swift:10-50
|
|
559
|
+
const match = /^(.*?):(\d+)(?:-(\d+))?$/.exec(value);
|
|
560
|
+
if (!match) return null;
|
|
561
|
+
|
|
562
|
+
const basePath = match[1];
|
|
563
|
+
const start = Number.parseInt(match[2] ?? "", 10);
|
|
564
|
+
const end = match[3] ? Number.parseInt(match[3], 10) : undefined;
|
|
565
|
+
|
|
566
|
+
if (!basePath || !Number.isFinite(start) || start <= 0) {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (end === undefined) {
|
|
571
|
+
return { basePath, startLine: start };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (!Number.isFinite(end) || end < start) {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return { basePath, startLine: start, limit: end - start + 1 };
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const filteredArgs: string[] = [];
|
|
582
|
+
|
|
583
|
+
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
584
|
+
const arg = rawArgs[i] ?? "";
|
|
585
|
+
|
|
586
|
+
// Flags: --start-line 10, --limit 50, also support --start-line=10
|
|
587
|
+
if (arg.startsWith("--")) {
|
|
588
|
+
const eqIdx = arg.indexOf("=");
|
|
589
|
+
if (eqIdx > 0) {
|
|
590
|
+
const rawKey = arg.slice(0, eqIdx);
|
|
591
|
+
const key = normalizeKey(rawKey);
|
|
592
|
+
const value = arg.slice(eqIdx + 1).trim();
|
|
593
|
+
|
|
594
|
+
if (key === "start_line") {
|
|
595
|
+
const parsedNumber = getNumber(value);
|
|
596
|
+
if (parsedNumber === undefined) {
|
|
597
|
+
sawUnknownArg = true;
|
|
598
|
+
} else {
|
|
599
|
+
startLine = parsedNumber;
|
|
600
|
+
}
|
|
601
|
+
filteredArgs.push(arg);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (key === "limit") {
|
|
606
|
+
const parsedNumber = getNumber(value);
|
|
607
|
+
if (parsedNumber === undefined) {
|
|
608
|
+
sawUnknownArg = true;
|
|
609
|
+
} else {
|
|
610
|
+
limit = parsedNumber;
|
|
611
|
+
}
|
|
612
|
+
filteredArgs.push(arg);
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
sawUnknownArg = true;
|
|
617
|
+
filteredArgs.push(arg);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const key = normalizeKey(arg);
|
|
622
|
+
if (key === "start_line") {
|
|
623
|
+
const value = rawArgs[i + 1];
|
|
624
|
+
if (typeof value === "string") {
|
|
625
|
+
const parsedNumber = getNumber(value);
|
|
626
|
+
if (parsedNumber === undefined) {
|
|
627
|
+
sawUnknownArg = true;
|
|
628
|
+
} else {
|
|
629
|
+
startLine = parsedNumber;
|
|
630
|
+
}
|
|
631
|
+
i += 1;
|
|
632
|
+
filteredArgs.push(arg, value);
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (key === "limit") {
|
|
638
|
+
const value = rawArgs[i + 1];
|
|
639
|
+
if (typeof value === "string") {
|
|
640
|
+
const parsedNumber = getNumber(value);
|
|
641
|
+
if (parsedNumber === undefined) {
|
|
642
|
+
sawUnknownArg = true;
|
|
643
|
+
} else {
|
|
644
|
+
limit = parsedNumber;
|
|
645
|
+
}
|
|
646
|
+
i += 1;
|
|
647
|
+
filteredArgs.push(arg, value);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Unknown flag: keep it
|
|
653
|
+
sawUnknownArg = true;
|
|
654
|
+
filteredArgs.push(arg);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// key=value pairs (rp-cli supports key=value and also dash->underscore)
|
|
659
|
+
const eqIdx = arg.indexOf("=");
|
|
660
|
+
if (eqIdx > 0) {
|
|
661
|
+
const key = normalizeKey(arg.slice(0, eqIdx));
|
|
662
|
+
const value = arg.slice(eqIdx + 1).trim();
|
|
663
|
+
|
|
664
|
+
// wrapper-only knob (do not forward)
|
|
665
|
+
if (key === "bypass_cache") {
|
|
666
|
+
bypassCache = value === "true" || value === "1";
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (key === "path") {
|
|
671
|
+
const slice = parseSliceSuffix(value);
|
|
672
|
+
if (slice) {
|
|
673
|
+
inputPath = slice.basePath;
|
|
674
|
+
if (startLine === undefined) startLine = slice.startLine;
|
|
675
|
+
if (limit === undefined && slice.limit !== undefined) limit = slice.limit;
|
|
676
|
+
} else {
|
|
677
|
+
inputPath = value;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
filteredArgs.push(arg);
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (key === "start_line") {
|
|
685
|
+
const parsedNumber = getNumber(value);
|
|
686
|
+
if (parsedNumber === undefined) {
|
|
687
|
+
sawUnknownArg = true;
|
|
688
|
+
} else {
|
|
689
|
+
startLine = parsedNumber;
|
|
690
|
+
}
|
|
691
|
+
filteredArgs.push(arg);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (key === "limit") {
|
|
696
|
+
const parsedNumber = getNumber(value);
|
|
697
|
+
if (parsedNumber === undefined) {
|
|
698
|
+
sawUnknownArg = true;
|
|
699
|
+
} else {
|
|
700
|
+
limit = parsedNumber;
|
|
701
|
+
}
|
|
702
|
+
filteredArgs.push(arg);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
sawUnknownArg = true;
|
|
707
|
+
filteredArgs.push(arg);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// positional path
|
|
712
|
+
if (!inputPath && !arg.startsWith("-")) {
|
|
713
|
+
const slice = parseSliceSuffix(arg);
|
|
714
|
+
if (slice) {
|
|
715
|
+
inputPath = slice.basePath;
|
|
716
|
+
if (startLine === undefined) startLine = slice.startLine;
|
|
717
|
+
if (limit === undefined && slice.limit !== undefined) limit = slice.limit;
|
|
718
|
+
} else {
|
|
719
|
+
inputPath = arg;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
filteredArgs.push(arg);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// positional start/limit (shorthand: read <path> [start] [limit])
|
|
727
|
+
if (inputPath && startLine === undefined) {
|
|
728
|
+
const startCandidate = getNumber(arg);
|
|
729
|
+
if (typeof startCandidate === "number") {
|
|
730
|
+
startLine = startCandidate;
|
|
731
|
+
filteredArgs.push(arg);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (inputPath && startLine !== undefined && limit === undefined) {
|
|
737
|
+
const limitCandidate = getNumber(arg);
|
|
738
|
+
if (typeof limitCandidate === "number") {
|
|
739
|
+
limit = limitCandidate;
|
|
740
|
+
filteredArgs.push(arg);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
sawUnknownArg = true;
|
|
746
|
+
filteredArgs.push(arg);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (!inputPath) {
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
let cmdToRun = [commandNameRaw, ...filteredArgs].filter(Boolean).join(" ");
|
|
754
|
+
|
|
755
|
+
// Canonicalize into rp-cli's documented read shorthand syntax so that equivalent forms behave consistently
|
|
756
|
+
// (especially for bypass_cache=true tests)
|
|
757
|
+
const safePathForRewrite = /^\S+$/.test(inputPath);
|
|
758
|
+
if (!sawUnknownArg && safePathForRewrite) {
|
|
759
|
+
if (commandName === "read_file") {
|
|
760
|
+
const parts: string[] = [commandNameRaw, `path=${inputPath}`];
|
|
761
|
+
if (typeof startLine === "number") parts.push(`start_line=${startLine}`);
|
|
762
|
+
if (typeof limit === "number") parts.push(`limit=${limit}`);
|
|
763
|
+
cmdToRun = parts.join(" ");
|
|
764
|
+
} else {
|
|
765
|
+
const parts: string[] = [commandNameRaw, inputPath];
|
|
766
|
+
if (typeof startLine === "number") parts.push(String(startLine));
|
|
767
|
+
if (typeof limit === "number") parts.push(String(limit));
|
|
768
|
+
cmdToRun = parts.join(" ");
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
cmdToRun,
|
|
774
|
+
path: inputPath,
|
|
775
|
+
...(typeof startLine === "number" ? { startLine } : {}),
|
|
776
|
+
...(typeof limit === "number" ? { limit } : {}),
|
|
777
|
+
bypassCache,
|
|
778
|
+
cacheable: !sawUnknownArg,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function parseLeadingInt(text: string): number | undefined {
|
|
783
|
+
const trimmed = text.trimStart();
|
|
784
|
+
let digits = '';
|
|
785
|
+
|
|
786
|
+
for (const ch of trimmed) {
|
|
787
|
+
if (ch >= '0' && ch <= '9') {
|
|
788
|
+
digits += ch;
|
|
789
|
+
} else {
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return digits.length > 0 ? Number.parseInt(digits, 10) : undefined;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function looksLikeNoopEditOutput(output: string): boolean {
|
|
798
|
+
const trimmed = output.trim();
|
|
799
|
+
if (trimmed.length === 0) return true;
|
|
800
|
+
|
|
801
|
+
const lower = trimmed.toLowerCase();
|
|
802
|
+
|
|
803
|
+
if (lower.includes('search block not found')) return true;
|
|
804
|
+
|
|
805
|
+
const appliedIndex = lower.indexOf('applied');
|
|
806
|
+
if (appliedIndex !== -1) {
|
|
807
|
+
const afterLabel = trimmed.slice(appliedIndex + 'applied'.length);
|
|
808
|
+
const colonIndex = afterLabel.indexOf(':');
|
|
809
|
+
|
|
810
|
+
if (colonIndex !== -1 && colonIndex < 10) {
|
|
811
|
+
const appliedCount = parseLeadingInt(afterLabel.slice(colonIndex + 1));
|
|
812
|
+
if (appliedCount !== undefined) return appliedCount === 0;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Fallback heuristics when the output format doesn't include an explicit applied count
|
|
817
|
+
if (lower.includes('lines changed: 0')) return true;
|
|
818
|
+
if (lower.includes('lines_changed') && lower.includes(': 0')) return true;
|
|
819
|
+
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function isSafeSingleCommandToRunUnbound(cmd: string): boolean {
|
|
824
|
+
const parsed = parseCommandChain(cmd);
|
|
825
|
+
|
|
826
|
+
if (parsed.invocations.length > 0) {
|
|
827
|
+
if (parsed.invocations.length !== 1) return false;
|
|
828
|
+
const invocation = parsed.invocations[0];
|
|
829
|
+
const commandName = invocation.commandName;
|
|
830
|
+
const args = invocation.args.map((arg) => arg.toLowerCase());
|
|
831
|
+
|
|
832
|
+
if (commandName === "windows") return true;
|
|
833
|
+
if (commandName === "help") return true;
|
|
834
|
+
if (commandName === "refresh" && args.length === 0) return true;
|
|
835
|
+
if (commandName === "tabs" && args.length === 0) return true;
|
|
836
|
+
|
|
837
|
+
if (commandName === "workspace") {
|
|
838
|
+
const action = args[0] ?? "";
|
|
839
|
+
if (action === "list") return true;
|
|
840
|
+
if (action === "tabs") return true;
|
|
841
|
+
if (action === "switch" && args.includes("--new-window")) return true;
|
|
842
|
+
if (action === "create" && args.includes("--new-window")) return true;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Fallback when parsing fails
|
|
849
|
+
const normalized = cmd.trim().toLowerCase();
|
|
850
|
+
|
|
851
|
+
if (normalized === "windows" || normalized.startsWith("windows ")) return true;
|
|
852
|
+
if (normalized === "help" || normalized.startsWith("help ")) return true;
|
|
853
|
+
if (normalized === "refresh") return true;
|
|
854
|
+
|
|
855
|
+
if (normalized === "workspace list") return true;
|
|
856
|
+
if (normalized === "workspace tabs") return true;
|
|
857
|
+
if (normalized === "tabs") return true;
|
|
858
|
+
|
|
859
|
+
if (normalized.startsWith("workspace switch ") && normalized.includes("--new-window")) return true;
|
|
860
|
+
if (normalized.startsWith("workspace create ") && normalized.includes("--new-window")) return true;
|
|
861
|
+
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function isSafeToRunUnbound(cmd: string): boolean {
|
|
866
|
+
// Allow `&&` chains, but only if *every* sub-command is safe before binding
|
|
867
|
+
const parsed = parseCommandChain(cmd);
|
|
868
|
+
if (parsed.hasSemicolonOutsideQuotes) return false;
|
|
869
|
+
|
|
870
|
+
if (parsed.invocations.length > 0) {
|
|
871
|
+
return parsed.invocations.every((invocation) => {
|
|
872
|
+
const commandText = renderInvocation(invocation);
|
|
873
|
+
return isSafeSingleCommandToRunUnbound(commandText);
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (parsed.commands.length === 0) return false;
|
|
878
|
+
return parsed.commands.every((command) => isSafeSingleCommandToRunUnbound(command));
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function parseRpbindArgs(args: unknown): { windowId: number; tab: string } | { error: string } {
|
|
882
|
+
const parts = Array.isArray(args) ? args : [];
|
|
883
|
+
if (parts.length < 2) return { error: "Usage: /rpbind <window_id> <tab_name_or_uuid>" };
|
|
884
|
+
|
|
885
|
+
const rawWindowId = String(parts[0]).trim();
|
|
886
|
+
const windowId = Number.parseInt(rawWindowId, 10);
|
|
887
|
+
if (!Number.isFinite(windowId)) return { error: `Invalid window_id: ${rawWindowId}` };
|
|
888
|
+
|
|
889
|
+
const tab = parts.slice(1).join(" ").trim();
|
|
890
|
+
if (!tab) return { error: "Tab cannot be empty" };
|
|
891
|
+
|
|
892
|
+
return { windowId, tab };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
896
|
+
// Rendering utilities for rp_exec output
|
|
897
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
898
|
+
|
|
899
|
+
interface FencedBlock {
|
|
900
|
+
lang: string | undefined;
|
|
901
|
+
code: string;
|
|
902
|
+
startIndex: number;
|
|
903
|
+
endIndex: number;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Parse fenced code blocks from text. Handles:
|
|
908
|
+
* - Multiple blocks
|
|
909
|
+
* - Various language identifiers (typescript, diff, shell, etc.)
|
|
910
|
+
* - Empty/missing language
|
|
911
|
+
* - Unclosed fences (treated as extending to end of text)
|
|
912
|
+
*/
|
|
913
|
+
function parseFencedBlocks(text: string): FencedBlock[] {
|
|
914
|
+
const blocks: FencedBlock[] = [];
|
|
915
|
+
const lines = text.split("\n");
|
|
916
|
+
let i = 0;
|
|
917
|
+
|
|
918
|
+
while (i < lines.length) {
|
|
919
|
+
const line = lines[i];
|
|
920
|
+
const fenceMatch = line.match(/^\s*```(\S*)\s*$/);
|
|
921
|
+
|
|
922
|
+
if (fenceMatch) {
|
|
923
|
+
const lang = fenceMatch[1] || undefined;
|
|
924
|
+
const startLine = i;
|
|
925
|
+
const codeLines: string[] = [];
|
|
926
|
+
i++;
|
|
927
|
+
|
|
928
|
+
// Find closing fence (```)
|
|
929
|
+
while (i < lines.length) {
|
|
930
|
+
const closingMatch = lines[i].match(/^\s*```\s*$/);
|
|
931
|
+
if (closingMatch) {
|
|
932
|
+
i++;
|
|
933
|
+
break;
|
|
934
|
+
}
|
|
935
|
+
codeLines.push(lines[i]);
|
|
936
|
+
i++;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Calculate character indices
|
|
940
|
+
const startIndex = lines.slice(0, startLine).join("\n").length + (startLine > 0 ? 1 : 0);
|
|
941
|
+
const endIndex = lines.slice(0, i).join("\n").length;
|
|
942
|
+
|
|
943
|
+
blocks.push({
|
|
944
|
+
lang,
|
|
945
|
+
code: codeLines.join("\n"),
|
|
946
|
+
startIndex,
|
|
947
|
+
endIndex,
|
|
948
|
+
});
|
|
949
|
+
} else {
|
|
950
|
+
i++;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return blocks;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Compute word-level diff with inverse highlighting on changed parts
|
|
959
|
+
*/
|
|
960
|
+
function renderIntraLineDiff(
|
|
961
|
+
oldContent: string,
|
|
962
|
+
newContent: string,
|
|
963
|
+
theme: Theme
|
|
964
|
+
): { removedLine: string; addedLine: string } {
|
|
965
|
+
const wordDiff = Diff.diffWords(oldContent, newContent);
|
|
966
|
+
|
|
967
|
+
let removedLine = "";
|
|
968
|
+
let addedLine = "";
|
|
969
|
+
let isFirstRemoved = true;
|
|
970
|
+
let isFirstAdded = true;
|
|
971
|
+
|
|
972
|
+
for (const part of wordDiff) {
|
|
973
|
+
if (part.removed) {
|
|
974
|
+
let value = part.value;
|
|
975
|
+
if (isFirstRemoved) {
|
|
976
|
+
const leadingWs = value.match(/^(\s*)/)?.[1] || "";
|
|
977
|
+
value = value.slice(leadingWs.length);
|
|
978
|
+
removedLine += leadingWs;
|
|
979
|
+
isFirstRemoved = false;
|
|
980
|
+
}
|
|
981
|
+
if (value) {
|
|
982
|
+
removedLine += theme.inverse(value);
|
|
983
|
+
}
|
|
984
|
+
} else if (part.added) {
|
|
985
|
+
let value = part.value;
|
|
986
|
+
if (isFirstAdded) {
|
|
987
|
+
const leadingWs = value.match(/^(\s*)/)?.[1] || "";
|
|
988
|
+
value = value.slice(leadingWs.length);
|
|
989
|
+
addedLine += leadingWs;
|
|
990
|
+
isFirstAdded = false;
|
|
991
|
+
}
|
|
992
|
+
if (value) {
|
|
993
|
+
addedLine += theme.inverse(value);
|
|
994
|
+
}
|
|
995
|
+
} else {
|
|
996
|
+
removedLine += part.value;
|
|
997
|
+
addedLine += part.value;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
return { removedLine, addedLine };
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Render diff lines with syntax highlighting (red/green, word-level inverse)
|
|
1006
|
+
*/
|
|
1007
|
+
function renderDiffBlock(code: string, theme: Theme): string {
|
|
1008
|
+
const lines = code.split("\n");
|
|
1009
|
+
const result: string[] = [];
|
|
1010
|
+
|
|
1011
|
+
let i = 0;
|
|
1012
|
+
while (i < lines.length) {
|
|
1013
|
+
const line = lines[i];
|
|
1014
|
+
const trimmed = line.trimStart();
|
|
1015
|
+
const indent = line.slice(0, line.length - trimmed.length);
|
|
1016
|
+
|
|
1017
|
+
// File headers: --- a/file or +++ b/file
|
|
1018
|
+
if (trimmed.match(/^---\s+\S/) || trimmed.match(/^\+\+\+\s+\S/)) {
|
|
1019
|
+
result.push(indent + theme.fg("accent", trimmed));
|
|
1020
|
+
i++;
|
|
1021
|
+
}
|
|
1022
|
+
// Hunk headers: @@ -1,5 +1,6 @@
|
|
1023
|
+
else if (trimmed.match(/^@@\s+-\d+/)) {
|
|
1024
|
+
result.push(indent + theme.fg("muted", trimmed));
|
|
1025
|
+
i++;
|
|
1026
|
+
}
|
|
1027
|
+
// Removed lines (not file headers)
|
|
1028
|
+
else if (trimmed.startsWith("-") && !trimmed.match(/^---\s/)) {
|
|
1029
|
+
// Collect consecutive removed lines
|
|
1030
|
+
const removedLines: Array<{ indent: string; content: string }> = [];
|
|
1031
|
+
while (i < lines.length) {
|
|
1032
|
+
const l = lines[i];
|
|
1033
|
+
const t = l.trimStart();
|
|
1034
|
+
const ind = l.slice(0, l.length - t.length);
|
|
1035
|
+
if (t.startsWith("-") && !t.match(/^---\s/)) {
|
|
1036
|
+
removedLines.push({ indent: ind, content: t.slice(1) });
|
|
1037
|
+
i++;
|
|
1038
|
+
} else {
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Collect consecutive added lines
|
|
1044
|
+
const addedLines: Array<{ indent: string; content: string }> = [];
|
|
1045
|
+
while (i < lines.length) {
|
|
1046
|
+
const l = lines[i];
|
|
1047
|
+
const t = l.trimStart();
|
|
1048
|
+
const ind = l.slice(0, l.length - t.length);
|
|
1049
|
+
if (t.startsWith("+") && !t.match(/^\+\+\+\s/)) {
|
|
1050
|
+
addedLines.push({ indent: ind, content: t.slice(1) });
|
|
1051
|
+
i++;
|
|
1052
|
+
} else {
|
|
1053
|
+
break;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Word-level highlighting for 1:1 line changes
|
|
1058
|
+
if (removedLines.length === 1 && addedLines.length === 1) {
|
|
1059
|
+
const { removedLine, addedLine } = renderIntraLineDiff(
|
|
1060
|
+
removedLines[0].content,
|
|
1061
|
+
addedLines[0].content,
|
|
1062
|
+
theme
|
|
1063
|
+
);
|
|
1064
|
+
result.push(removedLines[0].indent + theme.fg("toolDiffRemoved", "-" + removedLine));
|
|
1065
|
+
result.push(addedLines[0].indent + theme.fg("toolDiffAdded", "+" + addedLine));
|
|
1066
|
+
} else {
|
|
1067
|
+
for (const r of removedLines) {
|
|
1068
|
+
result.push(r.indent + theme.fg("toolDiffRemoved", "-" + r.content));
|
|
1069
|
+
}
|
|
1070
|
+
for (const a of addedLines) {
|
|
1071
|
+
result.push(a.indent + theme.fg("toolDiffAdded", "+" + a.content));
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
// Added lines (not file headers)
|
|
1076
|
+
else if (trimmed.startsWith("+") && !trimmed.match(/^\+\+\+\s/)) {
|
|
1077
|
+
result.push(indent + theme.fg("toolDiffAdded", trimmed));
|
|
1078
|
+
i++;
|
|
1079
|
+
}
|
|
1080
|
+
// Context lines (start with space in unified diff)
|
|
1081
|
+
else if (line.startsWith(" ")) {
|
|
1082
|
+
result.push(theme.fg("toolDiffContext", line));
|
|
1083
|
+
i++;
|
|
1084
|
+
}
|
|
1085
|
+
// Empty or other lines
|
|
1086
|
+
else {
|
|
1087
|
+
result.push(indent + theme.fg("dim", trimmed));
|
|
1088
|
+
i++;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return result.join("\n");
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Render rp_exec output with syntax highlighting for fenced code blocks.
|
|
1097
|
+
* - ```diff blocks get word-level diff highlighting
|
|
1098
|
+
* - Other fenced blocks get syntax highlighting via Pi's highlightCode
|
|
1099
|
+
* - Non-fenced content is rendered dim (no markdown parsing)
|
|
1100
|
+
*/
|
|
1101
|
+
function renderRpExecOutput(text: string, theme: Theme): string {
|
|
1102
|
+
const blocks = parseFencedBlocks(text);
|
|
1103
|
+
|
|
1104
|
+
if (blocks.length === 0) {
|
|
1105
|
+
// No code fences - render everything dim
|
|
1106
|
+
return text.split("\n").map(line => theme.fg("dim", line)).join("\n");
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const result: string[] = [];
|
|
1110
|
+
let lastEnd = 0;
|
|
1111
|
+
|
|
1112
|
+
for (const block of blocks) {
|
|
1113
|
+
// Render text before this block (dim)
|
|
1114
|
+
if (block.startIndex > lastEnd) {
|
|
1115
|
+
const before = text.slice(lastEnd, block.startIndex);
|
|
1116
|
+
result.push(before.split("\n").map(line => theme.fg("dim", line)).join("\n"));
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Render the fenced block
|
|
1120
|
+
if (block.lang?.toLowerCase() === "diff") {
|
|
1121
|
+
// Diff block: use word-level diff highlighting
|
|
1122
|
+
result.push(theme.fg("muted", "```diff"));
|
|
1123
|
+
result.push(renderDiffBlock(block.code, theme));
|
|
1124
|
+
result.push(theme.fg("muted", "```"));
|
|
1125
|
+
} else if (block.lang) {
|
|
1126
|
+
// Other language: use Pi's syntax highlighting
|
|
1127
|
+
result.push(theme.fg("muted", "```" + block.lang));
|
|
1128
|
+
const highlighted = highlightCode(block.code, block.lang);
|
|
1129
|
+
result.push(highlighted.join("\n"));
|
|
1130
|
+
result.push(theme.fg("muted", "```"));
|
|
1131
|
+
} else {
|
|
1132
|
+
// No language specified: render as dim
|
|
1133
|
+
result.push(theme.fg("muted", "```"));
|
|
1134
|
+
result.push(theme.fg("dim", block.code));
|
|
1135
|
+
result.push(theme.fg("muted", "```"));
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
lastEnd = block.endIndex;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Render text after last block (dim)
|
|
1142
|
+
if (lastEnd < text.length) {
|
|
1143
|
+
const after = text.slice(lastEnd);
|
|
1144
|
+
result.push(after.split("\n").map(line => theme.fg("dim", line)).join("\n"));
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
return result.join("\n");
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Collapsed output settings
|
|
1151
|
+
const COLLAPSED_MAX_LINES = 15;
|
|
1152
|
+
const COLLAPSED_MAX_CHARS = 2000;
|
|
1153
|
+
|
|
1154
|
+
export default function (pi: ExtensionAPI) {
|
|
1155
|
+
let config = loadConfig();
|
|
1156
|
+
|
|
1157
|
+
// Replay-aware read_file caching state (optional; guarded by config.readcacheReadFile)
|
|
1158
|
+
const readcacheRuntimeState = createReplayRuntimeState();
|
|
1159
|
+
|
|
1160
|
+
const clearReadcacheCaches = (): void => {
|
|
1161
|
+
clearReplayRuntimeState(readcacheRuntimeState);
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
let boundWindowId: number | undefined;
|
|
1165
|
+
let boundTab: string | undefined;
|
|
1166
|
+
|
|
1167
|
+
const setBinding = (windowId: number, tab: string) => {
|
|
1168
|
+
boundWindowId = windowId;
|
|
1169
|
+
boundTab = tab;
|
|
1170
|
+
};
|
|
1171
|
+
|
|
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;
|
|
1175
|
+
|
|
1176
|
+
setBinding(windowId, tab);
|
|
1177
|
+
pi.appendEntry(BINDING_CUSTOM_TYPE, { windowId, tab });
|
|
1178
|
+
};
|
|
1179
|
+
|
|
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;
|
|
1185
|
+
|
|
1186
|
+
let reconstructedWindowId: number | undefined;
|
|
1187
|
+
let reconstructedTab: string | undefined;
|
|
1188
|
+
|
|
1189
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
1190
|
+
if (entry.type !== "custom" || entry.customType !== BINDING_CUSTOM_TYPE) continue;
|
|
1191
|
+
|
|
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
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (reconstructedWindowId !== undefined && reconstructedTab !== undefined) {
|
|
1202
|
+
setBinding(reconstructedWindowId, reconstructedTab);
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
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;
|
|
1210
|
+
|
|
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
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
|
|
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
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
reconstructBinding(ctx);
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
pi.on("session_switch", async (_event, ctx) => {
|
|
1230
|
+
config = loadConfig();
|
|
1231
|
+
clearReadcacheCaches();
|
|
1232
|
+
reconstructBinding(ctx);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
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
|
+
});
|
|
1241
|
+
|
|
1242
|
+
pi.on("session_branch", async (_event, ctx) => {
|
|
1243
|
+
config = loadConfig();
|
|
1244
|
+
clearReadcacheCaches();
|
|
1245
|
+
reconstructBinding(ctx);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
1249
|
+
config = loadConfig();
|
|
1250
|
+
clearReadcacheCaches();
|
|
1251
|
+
reconstructBinding(ctx);
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
pi.on("session_compact", async () => {
|
|
1255
|
+
clearReadcacheCaches();
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
pi.on("session_shutdown", async () => {
|
|
1259
|
+
clearReadcacheCaches();
|
|
1260
|
+
});
|
|
1261
|
+
|
|
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
|
+
}
|
|
1270
|
+
|
|
1271
|
+
persistBinding(parsed.windowId, parsed.tab);
|
|
1272
|
+
ctx.ui.notify(`Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"`, "success");
|
|
1273
|
+
},
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
pi.registerCommand("rpcli-readcache-status", {
|
|
1277
|
+
description: "Show repoprompt-cli read_file cache status",
|
|
1278
|
+
handler: async (_args, ctx) => {
|
|
1279
|
+
config = loadConfig();
|
|
1280
|
+
|
|
1281
|
+
let msg = "repoprompt-cli read_file cache\n";
|
|
1282
|
+
msg += "──────────────────────────\n";
|
|
1283
|
+
msg += `Enabled: ${config.readcacheReadFile === true ? "✓" : "✗"}\n`;
|
|
1284
|
+
|
|
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;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
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";
|
|
1299
|
+
}
|
|
1300
|
+
|
|
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";
|
|
1304
|
+
|
|
1305
|
+
ctx.ui.notify(msg, "info");
|
|
1306
|
+
},
|
|
1307
|
+
});
|
|
1308
|
+
|
|
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();
|
|
1313
|
+
|
|
1314
|
+
if (config.readcacheReadFile !== true) {
|
|
1315
|
+
ctx.ui.notify("readcacheReadFile is disabled in config", "error");
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const trimmed = args.trim();
|
|
1320
|
+
if (!trimmed) {
|
|
1321
|
+
ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const parts = trimmed.split(/\s+/);
|
|
1326
|
+
const pathInput = parts[0];
|
|
1327
|
+
const rangeInput = parts[1];
|
|
1328
|
+
|
|
1329
|
+
if (!pathInput) {
|
|
1330
|
+
ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const windowId = boundWindowId;
|
|
1335
|
+
const tab = boundTab;
|
|
1336
|
+
|
|
1337
|
+
if (windowId === undefined) {
|
|
1338
|
+
ctx.ui.notify("rp_exec is not bound. Bind first via /rpbind or rp_bind", "error");
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
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
|
+
}
|
|
1349
|
+
|
|
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
|
+
}
|
|
1356
|
+
|
|
1357
|
+
scopeKey = scopeRange(start, end);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
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;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
pi.appendEntry(RP_READCACHE_CUSTOM_TYPE, buildInvalidationV1(resolved.absolutePath, scopeKey));
|
|
1367
|
+
clearReadcacheCaches();
|
|
1368
|
+
|
|
1369
|
+
ctx.ui.notify(
|
|
1370
|
+
`Invalidated readcache for ${resolved.absolutePath}` + (scopeKey === SCOPE_FULL ? "" : ` (${scopeKey})`),
|
|
1371
|
+
"info"
|
|
1372
|
+
);
|
|
1373
|
+
},
|
|
1374
|
+
});
|
|
1375
|
+
|
|
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,
|
|
1381
|
+
|
|
1382
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1383
|
+
await ensureJustBashLoaded();
|
|
1384
|
+
maybeWarnAstUnavailable(ctx);
|
|
1385
|
+
persistBinding(params.windowId, params.tab);
|
|
1386
|
+
|
|
1387
|
+
return {
|
|
1388
|
+
content: [{ type: "text", text: `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` }],
|
|
1389
|
+
details: { windowId: boundWindowId, tab: boundTab },
|
|
1390
|
+
};
|
|
1391
|
+
},
|
|
1392
|
+
});
|
|
1393
|
+
|
|
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,
|
|
1399
|
+
|
|
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);
|
|
1404
|
+
|
|
1405
|
+
const windowId = params.windowId ?? boundWindowId;
|
|
1406
|
+
const tab = params.tab ?? boundTab;
|
|
1407
|
+
const rawJson = params.rawJson ?? false;
|
|
1408
|
+
const quiet = params.quiet ?? true;
|
|
1409
|
+
const failFast = params.failFast ?? true;
|
|
1410
|
+
const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1411
|
+
const maxOutputChars = params.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
1412
|
+
const allowDelete = params.allowDelete ?? false;
|
|
1413
|
+
const allowWorkspaceSwitchInPlace = params.allowWorkspaceSwitchInPlace ?? false;
|
|
1414
|
+
const failOnNoopEdits = params.failOnNoopEdits ?? true;
|
|
1415
|
+
|
|
1416
|
+
if (!allowDelete && looksLikeDeleteCommand(params.cmd)) {
|
|
1417
|
+
return {
|
|
1418
|
+
isError: true,
|
|
1419
|
+
content: [
|
|
1420
|
+
{
|
|
1421
|
+
type: "text",
|
|
1422
|
+
text: "Blocked potential delete command. If deletion is explicitly requested, rerun with allowDelete=true",
|
|
1423
|
+
},
|
|
1424
|
+
],
|
|
1425
|
+
details: { blocked: true, reason: "delete", cmd: params.cmd, windowId, tab },
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
if (!allowWorkspaceSwitchInPlace && looksLikeWorkspaceSwitchInPlace(params.cmd)) {
|
|
1430
|
+
return {
|
|
1431
|
+
isError: true,
|
|
1432
|
+
content: [
|
|
1433
|
+
{
|
|
1434
|
+
type: "text",
|
|
1435
|
+
text:
|
|
1436
|
+
"Blocked in-place workspace change (it can clobber selection/prompt/context and disrupt other sessions). " +
|
|
1437
|
+
"Add `--new-window`, or rerun with allowWorkspaceSwitchInPlace=true if explicitly safe",
|
|
1438
|
+
},
|
|
1439
|
+
],
|
|
1440
|
+
details: { blocked: true, reason: "workspace_switch_in_place", cmd: params.cmd, windowId, tab },
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const isBound = windowId !== undefined && tab !== undefined;
|
|
1445
|
+
if (!isBound && !isSafeToRunUnbound(params.cmd)) {
|
|
1446
|
+
return {
|
|
1447
|
+
content: [
|
|
1448
|
+
{
|
|
1449
|
+
type: "text",
|
|
1450
|
+
text:
|
|
1451
|
+
"Blocked rp_exec because it is not bound to a window+tab. " +
|
|
1452
|
+
"Do not fall back to native Pi tools—bind first. " +
|
|
1453
|
+
"Run `windows` and `workspace tabs`, then bind with rp_bind(windowId, tab). " +
|
|
1454
|
+
"If RepoPrompt is in single-window mode, windowId is usually 1",
|
|
1455
|
+
},
|
|
1456
|
+
],
|
|
1457
|
+
details: { blocked: true, reason: "unbound", cmd: params.cmd, windowId, tab },
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Parse read-like commands to:
|
|
1462
|
+
// - detect cacheable reads (when enabled)
|
|
1463
|
+
// - strip wrapper-only args like bypass_cache=true even when caching is disabled
|
|
1464
|
+
// (so agents can safely use bypass_cache in instructions regardless of config)
|
|
1465
|
+
const readRequest = parseReadFileRequest(params.cmd);
|
|
1466
|
+
|
|
1467
|
+
const cmdToRun = readRequest ? readRequest.cmdToRun : params.cmd;
|
|
1468
|
+
|
|
1469
|
+
const rpArgs: string[] = [];
|
|
1470
|
+
if (windowId !== undefined) rpArgs.push("-w", String(windowId));
|
|
1471
|
+
if (tab !== undefined) rpArgs.push("-t", tab);
|
|
1472
|
+
if (quiet) rpArgs.push("-q");
|
|
1473
|
+
if (rawJson) rpArgs.push("--raw-json");
|
|
1474
|
+
if (failFast) rpArgs.push("--fail-fast");
|
|
1475
|
+
rpArgs.push("-e", cmdToRun);
|
|
1476
|
+
|
|
1477
|
+
if (windowId === undefined || tab === undefined) {
|
|
1478
|
+
onUpdate({
|
|
1479
|
+
status:
|
|
1480
|
+
"Running rp-cli without a bound window/tab (non-deterministic). Bind first with rp_bind(windowId, tab)",
|
|
1481
|
+
});
|
|
1482
|
+
} else {
|
|
1483
|
+
onUpdate({ status: `Running rp-cli in window ${windowId}, tab "${tab}"…` });
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
let stdout = "";
|
|
1487
|
+
let stderr = "";
|
|
1488
|
+
let exitCode = -1;
|
|
1489
|
+
let execError: string | undefined;
|
|
1490
|
+
|
|
1491
|
+
try {
|
|
1492
|
+
const result = await pi.exec("rp-cli", rpArgs, { signal, timeout: timeoutMs });
|
|
1493
|
+
stdout = result.stdout ?? "";
|
|
1494
|
+
stderr = result.stderr ?? "";
|
|
1495
|
+
exitCode = result.code ?? 0;
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
execError = error instanceof Error ? error.message : String(error);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
const combinedOutput = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
1501
|
+
|
|
1502
|
+
let rawOutput = execError ? `rp-cli execution failed: ${execError}` : combinedOutput;
|
|
1503
|
+
|
|
1504
|
+
let rpReadcache: RpReadcacheMetaV1 | null = null;
|
|
1505
|
+
|
|
1506
|
+
if (
|
|
1507
|
+
config.readcacheReadFile === true &&
|
|
1508
|
+
readRequest !== null &&
|
|
1509
|
+
readRequest.cacheable === true &&
|
|
1510
|
+
!execError &&
|
|
1511
|
+
exitCode === 0 &&
|
|
1512
|
+
windowId !== undefined &&
|
|
1513
|
+
tab !== undefined
|
|
1514
|
+
) {
|
|
1515
|
+
try {
|
|
1516
|
+
const cached = await readFileWithCache(
|
|
1517
|
+
pi,
|
|
1518
|
+
{
|
|
1519
|
+
path: readRequest.path,
|
|
1520
|
+
...(typeof readRequest.startLine === "number" ? { start_line: readRequest.startLine } : {}),
|
|
1521
|
+
...(typeof readRequest.limit === "number" ? { limit: readRequest.limit } : {}),
|
|
1522
|
+
...(readRequest.bypassCache ? { bypass_cache: true } : {}),
|
|
1523
|
+
},
|
|
1524
|
+
ctx,
|
|
1525
|
+
readcacheRuntimeState,
|
|
1526
|
+
windowId,
|
|
1527
|
+
tab,
|
|
1528
|
+
signal
|
|
1529
|
+
);
|
|
1530
|
+
|
|
1531
|
+
rpReadcache = cached.meta;
|
|
1532
|
+
|
|
1533
|
+
if (typeof cached.outputText === "string" && cached.outputText.length > 0) {
|
|
1534
|
+
rawOutput = cached.outputText;
|
|
1535
|
+
}
|
|
1536
|
+
} catch {
|
|
1537
|
+
// Fail-open: caching must never break the baseline command output
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const editNoop =
|
|
1542
|
+
!execError &&
|
|
1543
|
+
exitCode === 0 &&
|
|
1544
|
+
looksLikeEditCommand(params.cmd) &&
|
|
1545
|
+
looksLikeNoopEditOutput(rawOutput);
|
|
1546
|
+
|
|
1547
|
+
const shouldFailNoopEdit = editNoop && failOnNoopEdits;
|
|
1548
|
+
const commandFailed = Boolean(execError) || exitCode !== 0;
|
|
1549
|
+
const shouldError = commandFailed || shouldFailNoopEdit;
|
|
1550
|
+
|
|
1551
|
+
let outputForUser = rawOutput;
|
|
1552
|
+
if (editNoop) {
|
|
1553
|
+
const rpCliOutput = rawOutput.length > 0 ? `\n--- rp-cli output ---\n${rawOutput}` : "";
|
|
1554
|
+
|
|
1555
|
+
if (shouldFailNoopEdit) {
|
|
1556
|
+
outputForUser =
|
|
1557
|
+
"RepoPrompt edit made no changes (0 edits applied). This usually means the search string was not found.\n" +
|
|
1558
|
+
"If this was expected, rerun with failOnNoopEdits=false. Otherwise, verify the search text or rerun with rawJson=true / quiet=false.\n" +
|
|
1559
|
+
"Tip: for tricky edits with multiline content, use rp-cli directly: rp-cli -c apply_edits -j '{...}'" +
|
|
1560
|
+
rpCliOutput;
|
|
1561
|
+
} else {
|
|
1562
|
+
outputForUser =
|
|
1563
|
+
"RepoPrompt edit made no changes (0 edits applied).\n" +
|
|
1564
|
+
"RepoPrompt may report this as an error (e.g. 'search block not found'), but failOnNoopEdits=false is treating it as non-fatal.\n" +
|
|
1565
|
+
"Tip: for tricky edits with multiline content, use rp-cli directly: rp-cli -c apply_edits -j '{...}'" +
|
|
1566
|
+
rpCliOutput;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
const outputWithBindingWarning =
|
|
1571
|
+
windowId === undefined || tab === undefined
|
|
1572
|
+
? `WARNING: rp_exec is not bound to a RepoPrompt window/tab. Bind with rp_bind(windowId, tab).\n\n${outputForUser}`
|
|
1573
|
+
: outputForUser;
|
|
1574
|
+
|
|
1575
|
+
const { text: truncatedOutput, truncated } = truncateText(outputWithBindingWarning.trim(), maxOutputChars);
|
|
1576
|
+
const finalText = truncatedOutput.length > 0 ? truncatedOutput : "(no output)";
|
|
1577
|
+
|
|
1578
|
+
return {
|
|
1579
|
+
isError: shouldError,
|
|
1580
|
+
content: [{ type: "text", text: finalText }],
|
|
1581
|
+
details: {
|
|
1582
|
+
cmd: params.cmd,
|
|
1583
|
+
windowId,
|
|
1584
|
+
tab,
|
|
1585
|
+
rawJson,
|
|
1586
|
+
quiet,
|
|
1587
|
+
failOnNoopEdits,
|
|
1588
|
+
failFast,
|
|
1589
|
+
timeoutMs,
|
|
1590
|
+
maxOutputChars,
|
|
1591
|
+
exitCode,
|
|
1592
|
+
truncated,
|
|
1593
|
+
stderrIncluded: stderr.trim().length > 0,
|
|
1594
|
+
execError,
|
|
1595
|
+
editNoop,
|
|
1596
|
+
shouldFailNoopEdit,
|
|
1597
|
+
rpReadcache: rpReadcache ?? undefined,
|
|
1598
|
+
},
|
|
1599
|
+
};
|
|
1600
|
+
},
|
|
1601
|
+
|
|
1602
|
+
renderCall(args: Record<string, unknown>, theme: Theme) {
|
|
1603
|
+
const cmd = (args.cmd as string) || "...";
|
|
1604
|
+
const windowId = args.windowId ?? boundWindowId;
|
|
1605
|
+
const tab = args.tab ?? boundTab;
|
|
1606
|
+
|
|
1607
|
+
let text = theme.fg("toolTitle", theme.bold("rp_exec"));
|
|
1608
|
+
text += " " + theme.fg("accent", cmd);
|
|
1609
|
+
|
|
1610
|
+
if (windowId !== undefined && tab !== undefined) {
|
|
1611
|
+
text += theme.fg("muted", ` (window ${windowId}, tab "${tab}")`);
|
|
1612
|
+
} else {
|
|
1613
|
+
text += theme.fg("warning", " (unbound)");
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
return new Text(text, 0, 0);
|
|
1617
|
+
},
|
|
1618
|
+
|
|
1619
|
+
renderResult(
|
|
1620
|
+
result: { content: Array<{ type: string; text?: string }>; details?: Record<string, unknown>; isError?: boolean },
|
|
1621
|
+
options: ToolRenderResultOptions,
|
|
1622
|
+
theme: Theme
|
|
1623
|
+
) {
|
|
1624
|
+
const details = result.details || {};
|
|
1625
|
+
const exitCode = details.exitCode as number | undefined;
|
|
1626
|
+
const truncated = details.truncated as boolean | undefined;
|
|
1627
|
+
const blocked = details.blocked as boolean | undefined;
|
|
1628
|
+
|
|
1629
|
+
// Get text content
|
|
1630
|
+
const textContent = result.content
|
|
1631
|
+
.filter((c) => c.type === "text")
|
|
1632
|
+
.map((c) => c.text || "")
|
|
1633
|
+
.join("\n");
|
|
1634
|
+
|
|
1635
|
+
// Handle partial/streaming state
|
|
1636
|
+
if (options.isPartial) {
|
|
1637
|
+
return new Text(theme.fg("warning", "Running…"), 0, 0);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// Handle blocked commands
|
|
1641
|
+
if (blocked) {
|
|
1642
|
+
return new Text(theme.fg("error", "✗ " + textContent), 0, 0);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// Handle errors
|
|
1646
|
+
if (result.isError || (exitCode !== undefined && exitCode !== 0)) {
|
|
1647
|
+
const exitInfo = exitCode !== undefined ? ` (exit ${exitCode})` : "";
|
|
1648
|
+
return new Text(theme.fg("error", `✗${exitInfo}\n${textContent}`), 0, 0);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// Success case
|
|
1652
|
+
const truncatedNote = truncated ? theme.fg("warning", " (truncated)") : "";
|
|
1653
|
+
const successPrefix = theme.fg("success", "✓");
|
|
1654
|
+
|
|
1655
|
+
// Collapsed view: show line count
|
|
1656
|
+
if (!options.expanded) {
|
|
1657
|
+
const lines = textContent.split("\n");
|
|
1658
|
+
if (lines.length > COLLAPSED_MAX_LINES || textContent.length > COLLAPSED_MAX_CHARS) {
|
|
1659
|
+
const preview = renderRpExecOutput(
|
|
1660
|
+
lines.slice(0, COLLAPSED_MAX_LINES).join("\n"),
|
|
1661
|
+
theme
|
|
1662
|
+
);
|
|
1663
|
+
const remaining = lines.length - COLLAPSED_MAX_LINES;
|
|
1664
|
+
const moreText = remaining > 0 ? theme.fg("muted", `\n… (${remaining} more lines)`) : "";
|
|
1665
|
+
return new Text(`${successPrefix}${truncatedNote}\n${preview}${moreText}`, 0, 0);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// Expanded view or short output: render with syntax highlighting
|
|
1670
|
+
const highlighted = renderRpExecOutput(textContent, theme);
|
|
1671
|
+
return new Text(`${successPrefix}${truncatedNote}\n${highlighted}`, 0, 0);
|
|
1672
|
+
},
|
|
1673
|
+
});
|
|
1674
|
+
}
|