pi-repoprompt-cli 0.1.0
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/LICENSE +21 -0
- package/README.md +68 -0
- package/extensions/repoprompt-cli.ts +834 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Warren Winter
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# RepoPrompt CLI bridge for Pi (`pi-repoprompt-cli`)
|
|
2
|
+
|
|
3
|
+
Integrates RepoPrompt with Pi via RepoPrompt's `rp-cli` executable.
|
|
4
|
+
|
|
5
|
+
Provides two tools:
|
|
6
|
+
- `rp_bind` — bind a RepoPrompt window + compose tab (routing)
|
|
7
|
+
- `rp_exec` — run `rp-cli -e <cmd>` against that binding (quiet defaults + output truncation)
|
|
8
|
+
|
|
9
|
+
Also provides a convenience command:
|
|
10
|
+
- `/rpbind <window_id> <tab>`
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
From npm:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pi install npm:pi-repoprompt-cli
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
From the dot314 git bundle (filtered install):
|
|
21
|
+
|
|
22
|
+
Add to `~/.pi/agent/settings.json` (or replace an existing unfiltered `git:github.com/w-winter/dot314` entry):
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"packages": [
|
|
27
|
+
{
|
|
28
|
+
"source": "git:github.com/w-winter/dot314",
|
|
29
|
+
"extensions": ["extensions/repoprompt-cli.ts"],
|
|
30
|
+
"skills": [],
|
|
31
|
+
"themes": [],
|
|
32
|
+
"prompts": []
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- `rp-cli` must be installed and available on `PATH`
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
1) Find your RepoPrompt window + tab (from a terminal):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
rp-cli -e windows
|
|
48
|
+
rp-cli -e "workspace tabs"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
2) Bind inside Pi:
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
/rpbind 3 Compose
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
3) Instruct the agent to use RepoPrompt via the `rp_exec` tool, for example:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
Use rp_exec with cmd: "get_file_tree type=files max_depth=4".
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Safety behavior (by default)
|
|
64
|
+
|
|
65
|
+
- Blocks delete-like commands unless `allowDelete: true`
|
|
66
|
+
- Blocks in-place workspace switching unless `allowWorkspaceSwitchInPlace: true`
|
|
67
|
+
- Blocks non-trivial commands when unbound (to avoid operating on the wrong window/tab)
|
|
68
|
+
- Treats "0 edits applied" as an error by default (`failOnNoopEdits: true`)
|
|
@@ -0,0 +1,834 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* RepoPrompt CLI ↔ Pi integration extension
|
|
9
|
+
*
|
|
10
|
+
* Registers two Pi tools:
|
|
11
|
+
* - `rp_bind`: binds a RepoPrompt window + compose tab (routing)
|
|
12
|
+
* - `rp_exec`: runs `rp-cli -e <cmd>` against that binding (quiet defaults, output truncation)
|
|
13
|
+
*
|
|
14
|
+
* Safety goals:
|
|
15
|
+
* - Prevent "unbound" rp_exec calls from operating on an unintended window/workspace
|
|
16
|
+
* - Prevent in-place workspace switches by default (they can clobber selection/prompt/context)
|
|
17
|
+
* - Block delete-like commands unless explicitly allowed
|
|
18
|
+
*
|
|
19
|
+
* UX goals:
|
|
20
|
+
* - Persist binding across session reloads via `pi.appendEntry()` (does not enter LLM context)
|
|
21
|
+
* - Provide actionable error messages when blocked
|
|
22
|
+
* - Syntax-highlight fenced code blocks in output (read, structure, etc.)
|
|
23
|
+
* - Word-level diff highlighting for edit output
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
27
|
+
const DEFAULT_MAX_OUTPUT_CHARS = 12000;
|
|
28
|
+
const BINDING_CUSTOM_TYPE = "repoprompt-binding";
|
|
29
|
+
|
|
30
|
+
const BindParams = Type.Object({
|
|
31
|
+
windowId: Type.Number({ description: "RepoPrompt window id (from `rp-cli -e windows`)" }),
|
|
32
|
+
tab: Type.String({ description: "RepoPrompt compose tab name or UUID" }),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const ExecParams = Type.Object({
|
|
36
|
+
cmd: Type.String({ description: "rp-cli exec string (e.g. `tree`, `select set src/ && context`)" }),
|
|
37
|
+
rawJson: Type.Optional(Type.Boolean({ description: "Pass --raw-json to rp-cli" })),
|
|
38
|
+
quiet: Type.Optional(Type.Boolean({ description: "Pass -q/--quiet to rp-cli (default: true)" })),
|
|
39
|
+
failFast: Type.Optional(Type.Boolean({ description: "Pass --fail-fast to rp-cli (default: true)" })),
|
|
40
|
+
timeoutMs: Type.Optional(Type.Number({ description: "Timeout in ms (default: 15 minutes)" })),
|
|
41
|
+
maxOutputChars: Type.Optional(Type.Number({ description: "Truncate output to this many chars (default: 12000)" })),
|
|
42
|
+
windowId: Type.Optional(Type.Number({ description: "Override bound window id for this call" })),
|
|
43
|
+
tab: Type.Optional(Type.String({ description: "Override bound tab for this call" })),
|
|
44
|
+
allowDelete: Type.Optional(
|
|
45
|
+
Type.Boolean({ description: "Allow delete commands like `file delete ...` or `workspace delete ...` (default: false)" }),
|
|
46
|
+
),
|
|
47
|
+
allowWorkspaceSwitchInPlace: Type.Optional(
|
|
48
|
+
Type.Boolean({
|
|
49
|
+
description:
|
|
50
|
+
"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",
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
failOnNoopEdits: Type.Optional(
|
|
54
|
+
Type.Boolean({
|
|
55
|
+
description: "Treat edit commands that apply 0 changes (or produce empty output) as errors (default: true)",
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function truncateText(text: string, maxChars: number): { text: string; truncated: boolean } {
|
|
61
|
+
if (maxChars <= 0) return { text: "", truncated: text.length > 0 };
|
|
62
|
+
if (text.length <= maxChars) return { text, truncated: false };
|
|
63
|
+
return {
|
|
64
|
+
text: `${text.slice(0, maxChars)}\n… [truncated; redirect output to a file if needed]`,
|
|
65
|
+
truncated: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseCommandChain(cmd: string): { commands: string[]; hasSemicolonOutsideQuotes: boolean } {
|
|
70
|
+
// Lightweight parser to split on `&&` / `;` without breaking quoted JSON or quoted strings
|
|
71
|
+
const commands: string[] = [];
|
|
72
|
+
let current = "";
|
|
73
|
+
let inSingleQuote = false;
|
|
74
|
+
let inDoubleQuote = false;
|
|
75
|
+
let escaped = false;
|
|
76
|
+
let hasSemicolonOutsideQuotes = false;
|
|
77
|
+
|
|
78
|
+
const pushCurrent = () => {
|
|
79
|
+
const trimmed = current.trim();
|
|
80
|
+
if (trimmed.length > 0) commands.push(trimmed);
|
|
81
|
+
current = "";
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
85
|
+
const ch = cmd[i];
|
|
86
|
+
|
|
87
|
+
if (escaped) {
|
|
88
|
+
current += ch;
|
|
89
|
+
escaped = false;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (ch === "\\") {
|
|
94
|
+
current += ch;
|
|
95
|
+
escaped = true;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!inDoubleQuote && ch === "'") {
|
|
100
|
+
inSingleQuote = !inSingleQuote;
|
|
101
|
+
current += ch;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!inSingleQuote && ch === "\"") {
|
|
106
|
+
inDoubleQuote = !inDoubleQuote;
|
|
107
|
+
current += ch;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!inSingleQuote && !inDoubleQuote) {
|
|
112
|
+
if (ch === "&" && cmd[i + 1] === "&") {
|
|
113
|
+
pushCurrent();
|
|
114
|
+
i += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (ch === ";") {
|
|
119
|
+
hasSemicolonOutsideQuotes = true;
|
|
120
|
+
pushCurrent();
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
current += ch;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
pushCurrent();
|
|
129
|
+
return { commands, hasSemicolonOutsideQuotes };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function looksLikeDeleteCommand(cmd: string): boolean {
|
|
133
|
+
// Conservative detection: block obvious deletes and common `call ... {"action":"delete"}` patterns
|
|
134
|
+
for (const command of parseCommandChain(cmd).commands) {
|
|
135
|
+
const normalized = command.trim().toLowerCase();
|
|
136
|
+
if (normalized === "file delete" || normalized.startsWith("file delete ")) return true;
|
|
137
|
+
if (normalized === "workspace delete" || normalized.startsWith("workspace delete ")) return true;
|
|
138
|
+
|
|
139
|
+
if (normalized.startsWith("call ")) {
|
|
140
|
+
if (
|
|
141
|
+
/\baction\s*=\s*delete\b/.test(normalized) ||
|
|
142
|
+
/"action"\s*:\s*"delete"/.test(normalized) ||
|
|
143
|
+
/'action'\s*:\s*'delete'/.test(normalized)
|
|
144
|
+
) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function looksLikeWorkspaceSwitchInPlace(cmd: string): boolean {
|
|
154
|
+
// Prevent clobbering shared state: require `--new-window` for workspace switching/creation by default
|
|
155
|
+
for (const command of parseCommandChain(cmd).commands) {
|
|
156
|
+
const normalized = command.toLowerCase();
|
|
157
|
+
|
|
158
|
+
if (normalized.startsWith("workspace switch ") && !normalized.includes("--new-window")) return true;
|
|
159
|
+
|
|
160
|
+
const isCreate = normalized.startsWith("workspace create ");
|
|
161
|
+
const requestsSwitch = /\B--switch\b/.test(normalized);
|
|
162
|
+
if (isCreate && requestsSwitch && !normalized.includes("--new-window")) return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function looksLikeEditCommand(cmd: string): boolean {
|
|
169
|
+
for (const command of parseCommandChain(cmd).commands) {
|
|
170
|
+
const normalized = command.trim().toLowerCase();
|
|
171
|
+
|
|
172
|
+
if (normalized === 'edit' || normalized.startsWith('edit ')) return true;
|
|
173
|
+
|
|
174
|
+
if (normalized.startsWith('call ') && normalized.includes('apply_edits')) return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseLeadingInt(text: string): number | undefined {
|
|
181
|
+
const trimmed = text.trimStart();
|
|
182
|
+
let digits = '';
|
|
183
|
+
|
|
184
|
+
for (const ch of trimmed) {
|
|
185
|
+
if (ch >= '0' && ch <= '9') {
|
|
186
|
+
digits += ch;
|
|
187
|
+
} else {
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return digits.length > 0 ? Number.parseInt(digits, 10) : undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function looksLikeNoopEditOutput(output: string): boolean {
|
|
196
|
+
const trimmed = output.trim();
|
|
197
|
+
if (trimmed.length === 0) return true;
|
|
198
|
+
|
|
199
|
+
const lower = trimmed.toLowerCase();
|
|
200
|
+
|
|
201
|
+
if (lower.includes('search block not found')) return true;
|
|
202
|
+
|
|
203
|
+
const appliedIndex = lower.indexOf('applied');
|
|
204
|
+
if (appliedIndex !== -1) {
|
|
205
|
+
const afterLabel = trimmed.slice(appliedIndex + 'applied'.length);
|
|
206
|
+
const colonIndex = afterLabel.indexOf(':');
|
|
207
|
+
|
|
208
|
+
if (colonIndex !== -1 && colonIndex < 10) {
|
|
209
|
+
const appliedCount = parseLeadingInt(afterLabel.slice(colonIndex + 1));
|
|
210
|
+
if (appliedCount !== undefined) return appliedCount === 0;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Fallback heuristics when the output format doesn't include an explicit applied count
|
|
215
|
+
if (lower.includes('lines changed: 0')) return true;
|
|
216
|
+
if (lower.includes('lines_changed') && lower.includes(': 0')) return true;
|
|
217
|
+
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isSafeSingleCommandToRunUnbound(cmd: string): boolean {
|
|
222
|
+
// Allow only "bootstrap" commands before binding so agents don't operate on the wrong window/workspace
|
|
223
|
+
const normalized = cmd.trim().toLowerCase();
|
|
224
|
+
|
|
225
|
+
if (normalized === "windows" || normalized.startsWith("windows ")) return true;
|
|
226
|
+
if (normalized === "help" || normalized.startsWith("help ")) return true;
|
|
227
|
+
if (normalized === "refresh") return true;
|
|
228
|
+
|
|
229
|
+
if (normalized === "workspace list") return true;
|
|
230
|
+
if (normalized === "workspace tabs") return true;
|
|
231
|
+
if (normalized === "tabs") return true;
|
|
232
|
+
|
|
233
|
+
if (normalized.startsWith("workspace switch ") && normalized.includes("--new-window")) return true;
|
|
234
|
+
if (normalized.startsWith("workspace create ") && normalized.includes("--new-window")) return true;
|
|
235
|
+
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isSafeToRunUnbound(cmd: string): boolean {
|
|
240
|
+
// Allow `&&` chains, but only if *every* sub-command is safe before binding
|
|
241
|
+
const parsed = parseCommandChain(cmd);
|
|
242
|
+
if (parsed.hasSemicolonOutsideQuotes) return false;
|
|
243
|
+
if (parsed.commands.length === 0) return false;
|
|
244
|
+
|
|
245
|
+
return parsed.commands.every((command) => isSafeSingleCommandToRunUnbound(command));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function parseRpbindArgs(args: unknown): { windowId: number; tab: string } | { error: string } {
|
|
249
|
+
const parts = Array.isArray(args) ? args : [];
|
|
250
|
+
if (parts.length < 2) return { error: "Usage: /rpbind <window_id> <tab_name_or_uuid>" };
|
|
251
|
+
|
|
252
|
+
const rawWindowId = String(parts[0]).trim();
|
|
253
|
+
const windowId = Number.parseInt(rawWindowId, 10);
|
|
254
|
+
if (!Number.isFinite(windowId)) return { error: `Invalid window_id: ${rawWindowId}` };
|
|
255
|
+
|
|
256
|
+
const tab = parts.slice(1).join(" ").trim();
|
|
257
|
+
if (!tab) return { error: "Tab cannot be empty" };
|
|
258
|
+
|
|
259
|
+
return { windowId, tab };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
263
|
+
// Rendering utilities for rp_exec output
|
|
264
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
interface FencedBlock {
|
|
267
|
+
lang: string | undefined;
|
|
268
|
+
code: string;
|
|
269
|
+
startIndex: number;
|
|
270
|
+
endIndex: number;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Parse fenced code blocks from text. Handles:
|
|
275
|
+
* - Multiple blocks
|
|
276
|
+
* - Various language identifiers (typescript, diff, shell, etc.)
|
|
277
|
+
* - Empty/missing language
|
|
278
|
+
* - Unclosed fences (treated as extending to end of text)
|
|
279
|
+
*/
|
|
280
|
+
function parseFencedBlocks(text: string): FencedBlock[] {
|
|
281
|
+
const blocks: FencedBlock[] = [];
|
|
282
|
+
const lines = text.split("\n");
|
|
283
|
+
let i = 0;
|
|
284
|
+
|
|
285
|
+
while (i < lines.length) {
|
|
286
|
+
const line = lines[i];
|
|
287
|
+
const fenceMatch = line.match(/^\s*```(\S*)\s*$/);
|
|
288
|
+
|
|
289
|
+
if (fenceMatch) {
|
|
290
|
+
const lang = fenceMatch[1] || undefined;
|
|
291
|
+
const startLine = i;
|
|
292
|
+
const codeLines: string[] = [];
|
|
293
|
+
i++;
|
|
294
|
+
|
|
295
|
+
// Find closing fence (```)
|
|
296
|
+
while (i < lines.length) {
|
|
297
|
+
const closingMatch = lines[i].match(/^\s*```\s*$/);
|
|
298
|
+
if (closingMatch) {
|
|
299
|
+
i++;
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
codeLines.push(lines[i]);
|
|
303
|
+
i++;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Calculate character indices
|
|
307
|
+
const startIndex = lines.slice(0, startLine).join("\n").length + (startLine > 0 ? 1 : 0);
|
|
308
|
+
const endIndex = lines.slice(0, i).join("\n").length;
|
|
309
|
+
|
|
310
|
+
blocks.push({
|
|
311
|
+
lang,
|
|
312
|
+
code: codeLines.join("\n"),
|
|
313
|
+
startIndex,
|
|
314
|
+
endIndex,
|
|
315
|
+
});
|
|
316
|
+
} else {
|
|
317
|
+
i++;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return blocks;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Compute word-level diff with inverse highlighting on changed parts
|
|
326
|
+
*/
|
|
327
|
+
function renderIntraLineDiff(
|
|
328
|
+
oldContent: string,
|
|
329
|
+
newContent: string,
|
|
330
|
+
theme: Theme
|
|
331
|
+
): { removedLine: string; addedLine: string } {
|
|
332
|
+
const wordDiff = Diff.diffWords(oldContent, newContent);
|
|
333
|
+
|
|
334
|
+
let removedLine = "";
|
|
335
|
+
let addedLine = "";
|
|
336
|
+
let isFirstRemoved = true;
|
|
337
|
+
let isFirstAdded = true;
|
|
338
|
+
|
|
339
|
+
for (const part of wordDiff) {
|
|
340
|
+
if (part.removed) {
|
|
341
|
+
let value = part.value;
|
|
342
|
+
if (isFirstRemoved) {
|
|
343
|
+
const leadingWs = value.match(/^(\s*)/)?.[1] || "";
|
|
344
|
+
value = value.slice(leadingWs.length);
|
|
345
|
+
removedLine += leadingWs;
|
|
346
|
+
isFirstRemoved = false;
|
|
347
|
+
}
|
|
348
|
+
if (value) {
|
|
349
|
+
removedLine += theme.inverse(value);
|
|
350
|
+
}
|
|
351
|
+
} else if (part.added) {
|
|
352
|
+
let value = part.value;
|
|
353
|
+
if (isFirstAdded) {
|
|
354
|
+
const leadingWs = value.match(/^(\s*)/)?.[1] || "";
|
|
355
|
+
value = value.slice(leadingWs.length);
|
|
356
|
+
addedLine += leadingWs;
|
|
357
|
+
isFirstAdded = false;
|
|
358
|
+
}
|
|
359
|
+
if (value) {
|
|
360
|
+
addedLine += theme.inverse(value);
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
removedLine += part.value;
|
|
364
|
+
addedLine += part.value;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { removedLine, addedLine };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Render diff lines with syntax highlighting (red/green, word-level inverse)
|
|
373
|
+
*/
|
|
374
|
+
function renderDiffBlock(code: string, theme: Theme): string {
|
|
375
|
+
const lines = code.split("\n");
|
|
376
|
+
const result: string[] = [];
|
|
377
|
+
|
|
378
|
+
let i = 0;
|
|
379
|
+
while (i < lines.length) {
|
|
380
|
+
const line = lines[i];
|
|
381
|
+
const trimmed = line.trimStart();
|
|
382
|
+
const indent = line.slice(0, line.length - trimmed.length);
|
|
383
|
+
|
|
384
|
+
// File headers: --- a/file or +++ b/file
|
|
385
|
+
if (trimmed.match(/^---\s+\S/) || trimmed.match(/^\+\+\+\s+\S/)) {
|
|
386
|
+
result.push(indent + theme.fg("accent", trimmed));
|
|
387
|
+
i++;
|
|
388
|
+
}
|
|
389
|
+
// Hunk headers: @@ -1,5 +1,6 @@
|
|
390
|
+
else if (trimmed.match(/^@@\s+-\d+/)) {
|
|
391
|
+
result.push(indent + theme.fg("muted", trimmed));
|
|
392
|
+
i++;
|
|
393
|
+
}
|
|
394
|
+
// Removed lines (not file headers)
|
|
395
|
+
else if (trimmed.startsWith("-") && !trimmed.match(/^---\s/)) {
|
|
396
|
+
// Collect consecutive removed lines
|
|
397
|
+
const removedLines: Array<{ indent: string; content: string }> = [];
|
|
398
|
+
while (i < lines.length) {
|
|
399
|
+
const l = lines[i];
|
|
400
|
+
const t = l.trimStart();
|
|
401
|
+
const ind = l.slice(0, l.length - t.length);
|
|
402
|
+
if (t.startsWith("-") && !t.match(/^---\s/)) {
|
|
403
|
+
removedLines.push({ indent: ind, content: t.slice(1) });
|
|
404
|
+
i++;
|
|
405
|
+
} else {
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Collect consecutive added lines
|
|
411
|
+
const addedLines: Array<{ indent: string; content: string }> = [];
|
|
412
|
+
while (i < lines.length) {
|
|
413
|
+
const l = lines[i];
|
|
414
|
+
const t = l.trimStart();
|
|
415
|
+
const ind = l.slice(0, l.length - t.length);
|
|
416
|
+
if (t.startsWith("+") && !t.match(/^\+\+\+\s/)) {
|
|
417
|
+
addedLines.push({ indent: ind, content: t.slice(1) });
|
|
418
|
+
i++;
|
|
419
|
+
} else {
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Word-level highlighting for 1:1 line changes
|
|
425
|
+
if (removedLines.length === 1 && addedLines.length === 1) {
|
|
426
|
+
const { removedLine, addedLine } = renderIntraLineDiff(
|
|
427
|
+
removedLines[0].content,
|
|
428
|
+
addedLines[0].content,
|
|
429
|
+
theme
|
|
430
|
+
);
|
|
431
|
+
result.push(removedLines[0].indent + theme.fg("toolDiffRemoved", "-" + removedLine));
|
|
432
|
+
result.push(addedLines[0].indent + theme.fg("toolDiffAdded", "+" + addedLine));
|
|
433
|
+
} else {
|
|
434
|
+
for (const r of removedLines) {
|
|
435
|
+
result.push(r.indent + theme.fg("toolDiffRemoved", "-" + r.content));
|
|
436
|
+
}
|
|
437
|
+
for (const a of addedLines) {
|
|
438
|
+
result.push(a.indent + theme.fg("toolDiffAdded", "+" + a.content));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Added lines (not file headers)
|
|
443
|
+
else if (trimmed.startsWith("+") && !trimmed.match(/^\+\+\+\s/)) {
|
|
444
|
+
result.push(indent + theme.fg("toolDiffAdded", trimmed));
|
|
445
|
+
i++;
|
|
446
|
+
}
|
|
447
|
+
// Context lines (start with space in unified diff)
|
|
448
|
+
else if (line.startsWith(" ")) {
|
|
449
|
+
result.push(theme.fg("toolDiffContext", line));
|
|
450
|
+
i++;
|
|
451
|
+
}
|
|
452
|
+
// Empty or other lines
|
|
453
|
+
else {
|
|
454
|
+
result.push(indent + theme.fg("dim", trimmed));
|
|
455
|
+
i++;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return result.join("\n");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Render rp_exec output with syntax highlighting for fenced code blocks.
|
|
464
|
+
* - ```diff blocks get word-level diff highlighting
|
|
465
|
+
* - Other fenced blocks get syntax highlighting via Pi's highlightCode
|
|
466
|
+
* - Non-fenced content is rendered dim (no markdown parsing)
|
|
467
|
+
*/
|
|
468
|
+
function renderRpExecOutput(text: string, theme: Theme): string {
|
|
469
|
+
const blocks = parseFencedBlocks(text);
|
|
470
|
+
|
|
471
|
+
if (blocks.length === 0) {
|
|
472
|
+
// No code fences - render everything dim
|
|
473
|
+
return text.split("\n").map(line => theme.fg("dim", line)).join("\n");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const result: string[] = [];
|
|
477
|
+
let lastEnd = 0;
|
|
478
|
+
|
|
479
|
+
for (const block of blocks) {
|
|
480
|
+
// Render text before this block (dim)
|
|
481
|
+
if (block.startIndex > lastEnd) {
|
|
482
|
+
const before = text.slice(lastEnd, block.startIndex);
|
|
483
|
+
result.push(before.split("\n").map(line => theme.fg("dim", line)).join("\n"));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Render the fenced block
|
|
487
|
+
if (block.lang?.toLowerCase() === "diff") {
|
|
488
|
+
// Diff block: use word-level diff highlighting
|
|
489
|
+
result.push(theme.fg("muted", "```diff"));
|
|
490
|
+
result.push(renderDiffBlock(block.code, theme));
|
|
491
|
+
result.push(theme.fg("muted", "```"));
|
|
492
|
+
} else if (block.lang) {
|
|
493
|
+
// Other language: use Pi's syntax highlighting
|
|
494
|
+
result.push(theme.fg("muted", "```" + block.lang));
|
|
495
|
+
const highlighted = highlightCode(block.code, block.lang);
|
|
496
|
+
result.push(highlighted.join("\n"));
|
|
497
|
+
result.push(theme.fg("muted", "```"));
|
|
498
|
+
} else {
|
|
499
|
+
// No language specified: render as dim
|
|
500
|
+
result.push(theme.fg("muted", "```"));
|
|
501
|
+
result.push(theme.fg("dim", block.code));
|
|
502
|
+
result.push(theme.fg("muted", "```"));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
lastEnd = block.endIndex;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Render text after last block (dim)
|
|
509
|
+
if (lastEnd < text.length) {
|
|
510
|
+
const after = text.slice(lastEnd);
|
|
511
|
+
result.push(after.split("\n").map(line => theme.fg("dim", line)).join("\n"));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return result.join("\n");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Collapsed output settings
|
|
518
|
+
const COLLAPSED_MAX_LINES = 15;
|
|
519
|
+
const COLLAPSED_MAX_CHARS = 2000;
|
|
520
|
+
|
|
521
|
+
export default function (pi: ExtensionAPI) {
|
|
522
|
+
let boundWindowId: number | undefined;
|
|
523
|
+
let boundTab: string | undefined;
|
|
524
|
+
|
|
525
|
+
const setBinding = (windowId: number, tab: string) => {
|
|
526
|
+
boundWindowId = windowId;
|
|
527
|
+
boundTab = tab;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const persistBinding = (windowId: number, tab: string) => {
|
|
531
|
+
// Persist binding across session reloads without injecting extra text into the model context
|
|
532
|
+
if (boundWindowId === windowId && boundTab === tab) return;
|
|
533
|
+
|
|
534
|
+
setBinding(windowId, tab);
|
|
535
|
+
pi.appendEntry(BINDING_CUSTOM_TYPE, { windowId, tab });
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const reconstructBinding = (ctx: ExtensionContext) => {
|
|
539
|
+
// Prefer persisted binding (appendEntry), then fall back to prior rp_bind tool results
|
|
540
|
+
let reconstructedWindowId: number | undefined;
|
|
541
|
+
let reconstructedTab: string | undefined;
|
|
542
|
+
|
|
543
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
544
|
+
if (entry.type !== "custom" || entry.customType !== BINDING_CUSTOM_TYPE) continue;
|
|
545
|
+
|
|
546
|
+
const data = entry.data as { windowId?: unknown; tab?: unknown } | undefined;
|
|
547
|
+
const windowId = typeof data?.windowId === "number" ? data.windowId : undefined;
|
|
548
|
+
const tab = typeof data?.tab === "string" ? data.tab : undefined;
|
|
549
|
+
if (windowId !== undefined && tab) {
|
|
550
|
+
reconstructedWindowId = windowId;
|
|
551
|
+
reconstructedTab = tab;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (reconstructedWindowId !== undefined && reconstructedTab !== undefined) {
|
|
556
|
+
setBinding(reconstructedWindowId, reconstructedTab);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
561
|
+
if (entry.type !== "message") continue;
|
|
562
|
+
const msg = entry.message;
|
|
563
|
+
if (msg.role !== "toolResult" || msg.toolName !== "rp_bind") continue;
|
|
564
|
+
|
|
565
|
+
const details = msg.details as { windowId?: number; tab?: string } | undefined;
|
|
566
|
+
if (details?.windowId !== undefined && details?.tab) {
|
|
567
|
+
persistBinding(details.windowId, details.tab);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
pi.on("session_start", async (_event, ctx) => reconstructBinding(ctx));
|
|
573
|
+
pi.on("session_switch", async (_event, ctx) => reconstructBinding(ctx));
|
|
574
|
+
pi.on("session_branch", async (_event, ctx) => reconstructBinding(ctx));
|
|
575
|
+
pi.on("session_tree", async (_event, ctx) => reconstructBinding(ctx));
|
|
576
|
+
|
|
577
|
+
pi.registerCommand("rpbind", {
|
|
578
|
+
description: "Bind rp_exec to RepoPrompt: /rpbind <window_id> <tab>",
|
|
579
|
+
handler: async (args, ctx) => {
|
|
580
|
+
const parsed = parseRpbindArgs(args);
|
|
581
|
+
if ("error" in parsed) {
|
|
582
|
+
ctx.ui.notify(parsed.error, "error");
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
persistBinding(parsed.windowId, parsed.tab);
|
|
587
|
+
ctx.ui.notify(`Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"`, "success");
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
pi.registerTool({
|
|
592
|
+
name: "rp_bind",
|
|
593
|
+
label: "RepoPrompt Bind",
|
|
594
|
+
description: "Bind rp_exec to a specific RepoPrompt window and compose tab",
|
|
595
|
+
parameters: BindParams,
|
|
596
|
+
|
|
597
|
+
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
|
598
|
+
persistBinding(params.windowId, params.tab);
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
content: [{ type: "text", text: `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` }],
|
|
602
|
+
details: { windowId: boundWindowId, tab: boundTab },
|
|
603
|
+
};
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
pi.registerTool({
|
|
608
|
+
name: "rp_exec",
|
|
609
|
+
label: "RepoPrompt Exec",
|
|
610
|
+
description: "Run rp-cli in the bound RepoPrompt window/tab, with quiet defaults and output truncation",
|
|
611
|
+
parameters: ExecParams,
|
|
612
|
+
|
|
613
|
+
async execute(_toolCallId, params, onUpdate, _ctx, signal) {
|
|
614
|
+
// Routing: prefer call-time overrides, otherwise fall back to the last persisted binding
|
|
615
|
+
const windowId = params.windowId ?? boundWindowId;
|
|
616
|
+
const tab = params.tab ?? boundTab;
|
|
617
|
+
const rawJson = params.rawJson ?? false;
|
|
618
|
+
const quiet = params.quiet ?? true;
|
|
619
|
+
const failFast = params.failFast ?? true;
|
|
620
|
+
const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
621
|
+
const maxOutputChars = params.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
622
|
+
const allowDelete = params.allowDelete ?? false;
|
|
623
|
+
const allowWorkspaceSwitchInPlace = params.allowWorkspaceSwitchInPlace ?? false;
|
|
624
|
+
const failOnNoopEdits = params.failOnNoopEdits ?? true;
|
|
625
|
+
|
|
626
|
+
if (!allowDelete && looksLikeDeleteCommand(params.cmd)) {
|
|
627
|
+
return {
|
|
628
|
+
content: [
|
|
629
|
+
{
|
|
630
|
+
type: "text",
|
|
631
|
+
text: "Blocked potential delete command. If deletion is explicitly requested, rerun with allowDelete=true",
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
details: { blocked: true, reason: "delete", cmd: params.cmd, windowId, tab },
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (!allowWorkspaceSwitchInPlace && looksLikeWorkspaceSwitchInPlace(params.cmd)) {
|
|
639
|
+
return {
|
|
640
|
+
content: [
|
|
641
|
+
{
|
|
642
|
+
type: "text",
|
|
643
|
+
text:
|
|
644
|
+
"Blocked in-place workspace change (it can clobber selection/prompt/context and disrupt other sessions). " +
|
|
645
|
+
"Add `--new-window`, or rerun with allowWorkspaceSwitchInPlace=true if explicitly safe",
|
|
646
|
+
},
|
|
647
|
+
],
|
|
648
|
+
details: { blocked: true, reason: "workspace_switch_in_place", cmd: params.cmd, windowId, tab },
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const isBound = windowId !== undefined && tab !== undefined;
|
|
653
|
+
if (!isBound && !isSafeToRunUnbound(params.cmd)) {
|
|
654
|
+
return {
|
|
655
|
+
content: [
|
|
656
|
+
{
|
|
657
|
+
type: "text",
|
|
658
|
+
text:
|
|
659
|
+
"Blocked rp_exec because it is not bound to a window+tab. " +
|
|
660
|
+
"Do not fall back to native Pi tools—bind first. " +
|
|
661
|
+
"Run `windows` and `workspace tabs`, then bind with rp_bind(windowId, tab). " +
|
|
662
|
+
"If RepoPrompt is in single-window mode, windowId is usually 1",
|
|
663
|
+
},
|
|
664
|
+
],
|
|
665
|
+
details: { blocked: true, reason: "unbound", cmd: params.cmd, windowId, tab },
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const rpArgs: string[] = [];
|
|
670
|
+
if (windowId !== undefined) rpArgs.push("-w", String(windowId));
|
|
671
|
+
if (tab !== undefined) rpArgs.push("-t", tab);
|
|
672
|
+
if (quiet) rpArgs.push("-q");
|
|
673
|
+
if (rawJson) rpArgs.push("--raw-json");
|
|
674
|
+
if (failFast) rpArgs.push("--fail-fast");
|
|
675
|
+
rpArgs.push("-e", params.cmd);
|
|
676
|
+
|
|
677
|
+
if (windowId === undefined || tab === undefined) {
|
|
678
|
+
onUpdate({
|
|
679
|
+
status:
|
|
680
|
+
"Running rp-cli without a bound window/tab (non-deterministic). Bind first with rp_bind(windowId, tab)",
|
|
681
|
+
});
|
|
682
|
+
} else {
|
|
683
|
+
onUpdate({ status: `Running rp-cli in window ${windowId}, tab "${tab}"…` });
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
let stdout = "";
|
|
687
|
+
let stderr = "";
|
|
688
|
+
let exitCode = -1;
|
|
689
|
+
let execError: string | undefined;
|
|
690
|
+
|
|
691
|
+
try {
|
|
692
|
+
const result = await pi.exec("rp-cli", rpArgs, { signal, timeout: timeoutMs });
|
|
693
|
+
stdout = result.stdout ?? "";
|
|
694
|
+
stderr = result.stderr ?? "";
|
|
695
|
+
exitCode = result.code ?? 0;
|
|
696
|
+
} catch (error) {
|
|
697
|
+
execError = error instanceof Error ? error.message : String(error);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const combinedOutput = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
701
|
+
|
|
702
|
+
const rawOutput = execError ? `rp-cli execution failed: ${execError}` : combinedOutput;
|
|
703
|
+
|
|
704
|
+
const editNoop =
|
|
705
|
+
!execError &&
|
|
706
|
+
exitCode === 0 &&
|
|
707
|
+
looksLikeEditCommand(params.cmd) &&
|
|
708
|
+
looksLikeNoopEditOutput(rawOutput);
|
|
709
|
+
|
|
710
|
+
const shouldFailNoopEdit = editNoop && failOnNoopEdits;
|
|
711
|
+
|
|
712
|
+
let outputForUser = rawOutput;
|
|
713
|
+
if (editNoop) {
|
|
714
|
+
const rpCliOutput = rawOutput.length > 0 ? `\n--- rp-cli output ---\n${rawOutput}` : "";
|
|
715
|
+
|
|
716
|
+
if (shouldFailNoopEdit) {
|
|
717
|
+
outputForUser =
|
|
718
|
+
"RepoPrompt edit made no changes (0 edits applied). This usually means the search string was not found.\n" +
|
|
719
|
+
"If this was expected, rerun with failOnNoopEdits=false. Otherwise, verify the search text or rerun with rawJson=true / quiet=false.\n" +
|
|
720
|
+
"Tip: for tricky edits with multiline content, use rp-cli directly: rp-cli -c apply_edits -j '{...}'" +
|
|
721
|
+
rpCliOutput;
|
|
722
|
+
} else {
|
|
723
|
+
outputForUser =
|
|
724
|
+
"RepoPrompt edit made no changes (0 edits applied).\n" +
|
|
725
|
+
"RepoPrompt may report this as an error (e.g. 'search block not found'), but failOnNoopEdits=false is treating it as non-fatal.\n" +
|
|
726
|
+
"Tip: for tricky edits with multiline content, use rp-cli directly: rp-cli -c apply_edits -j '{...}'" +
|
|
727
|
+
rpCliOutput;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const outputWithBindingWarning =
|
|
732
|
+
windowId === undefined || tab === undefined
|
|
733
|
+
? `WARNING: rp_exec is not bound to a RepoPrompt window/tab. Bind with rp_bind(windowId, tab).\n\n${outputForUser}`
|
|
734
|
+
: outputForUser;
|
|
735
|
+
|
|
736
|
+
const { text: truncatedOutput, truncated } = truncateText(outputWithBindingWarning.trim(), maxOutputChars);
|
|
737
|
+
const finalText = truncatedOutput.length > 0 ? truncatedOutput : "(no output)";
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
isError: shouldFailNoopEdit,
|
|
741
|
+
content: [{ type: "text", text: finalText }],
|
|
742
|
+
details: {
|
|
743
|
+
cmd: params.cmd,
|
|
744
|
+
windowId,
|
|
745
|
+
tab,
|
|
746
|
+
rawJson,
|
|
747
|
+
quiet,
|
|
748
|
+
failOnNoopEdits,
|
|
749
|
+
failFast,
|
|
750
|
+
timeoutMs,
|
|
751
|
+
maxOutputChars,
|
|
752
|
+
exitCode,
|
|
753
|
+
truncated,
|
|
754
|
+
stderrIncluded: stderr.trim().length > 0,
|
|
755
|
+
execError,
|
|
756
|
+
editNoop,
|
|
757
|
+
shouldFailNoopEdit,
|
|
758
|
+
},
|
|
759
|
+
};
|
|
760
|
+
},
|
|
761
|
+
|
|
762
|
+
renderCall(args: Record<string, unknown>, theme: Theme) {
|
|
763
|
+
const cmd = (args.cmd as string) || "...";
|
|
764
|
+
const windowId = args.windowId ?? boundWindowId;
|
|
765
|
+
const tab = args.tab ?? boundTab;
|
|
766
|
+
|
|
767
|
+
let text = theme.fg("toolTitle", theme.bold("rp_exec"));
|
|
768
|
+
text += " " + theme.fg("accent", cmd);
|
|
769
|
+
|
|
770
|
+
if (windowId !== undefined && tab !== undefined) {
|
|
771
|
+
text += theme.fg("muted", ` (window ${windowId}, tab "${tab}")`);
|
|
772
|
+
} else {
|
|
773
|
+
text += theme.fg("warning", " (unbound)");
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return new Text(text, 0, 0);
|
|
777
|
+
},
|
|
778
|
+
|
|
779
|
+
renderResult(
|
|
780
|
+
result: { content: Array<{ type: string; text?: string }>; details?: Record<string, unknown>; isError?: boolean },
|
|
781
|
+
options: ToolRenderResultOptions,
|
|
782
|
+
theme: Theme
|
|
783
|
+
) {
|
|
784
|
+
const details = result.details || {};
|
|
785
|
+
const exitCode = details.exitCode as number | undefined;
|
|
786
|
+
const truncated = details.truncated as boolean | undefined;
|
|
787
|
+
const blocked = details.blocked as boolean | undefined;
|
|
788
|
+
|
|
789
|
+
// Get text content
|
|
790
|
+
const textContent = result.content
|
|
791
|
+
.filter((c) => c.type === "text")
|
|
792
|
+
.map((c) => c.text || "")
|
|
793
|
+
.join("\n");
|
|
794
|
+
|
|
795
|
+
// Handle partial/streaming state
|
|
796
|
+
if (options.isPartial) {
|
|
797
|
+
return new Text(theme.fg("warning", "Running…"), 0, 0);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Handle blocked commands
|
|
801
|
+
if (blocked) {
|
|
802
|
+
return new Text(theme.fg("error", "✗ " + textContent), 0, 0);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Handle errors
|
|
806
|
+
if (result.isError || (exitCode !== undefined && exitCode !== 0)) {
|
|
807
|
+
const exitInfo = exitCode !== undefined ? ` (exit ${exitCode})` : "";
|
|
808
|
+
return new Text(theme.fg("error", `✗${exitInfo}\n${textContent}`), 0, 0);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Success case
|
|
812
|
+
const truncatedNote = truncated ? theme.fg("warning", " (truncated)") : "";
|
|
813
|
+
const successPrefix = theme.fg("success", "✓");
|
|
814
|
+
|
|
815
|
+
// Collapsed view: show line count
|
|
816
|
+
if (!options.expanded) {
|
|
817
|
+
const lines = textContent.split("\n");
|
|
818
|
+
if (lines.length > COLLAPSED_MAX_LINES || textContent.length > COLLAPSED_MAX_CHARS) {
|
|
819
|
+
const preview = renderRpExecOutput(
|
|
820
|
+
lines.slice(0, COLLAPSED_MAX_LINES).join("\n"),
|
|
821
|
+
theme
|
|
822
|
+
);
|
|
823
|
+
const remaining = lines.length - COLLAPSED_MAX_LINES;
|
|
824
|
+
const moreText = remaining > 0 ? theme.fg("muted", `\n… (${remaining} more lines)`) : "";
|
|
825
|
+
return new Text(`${successPrefix}${truncatedNote}\n${preview}${moreText}`, 0, 0);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Expanded view or short output: render with syntax highlighting
|
|
830
|
+
const highlighted = renderRpExecOutput(textContent, theme);
|
|
831
|
+
return new Text(`${successPrefix}${truncatedNote}\n${highlighted}`, 0, 0);
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-repoprompt-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Integrates RepoPrompt with Pi via RepoPrompt's `rp-cli` executable",
|
|
5
|
+
"keywords": ["pi-package", "pi", "pi-coding-agent", "repoprompt"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/w-winter/dot314.git",
|
|
10
|
+
"directory": "packages/pi-repoprompt-cli"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/w-winter/dot314/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/w-winter/dot314#readme",
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": ["extensions/repoprompt-cli.ts"]
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"diff": "^7.0.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
24
|
+
"@mariozechner/pi-tui": "*",
|
|
25
|
+
"@sinclair/typebox": "*"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"prepack": "node ../../scripts/pi-package-prepack.mjs"
|
|
29
|
+
},
|
|
30
|
+
"files": ["extensions/**", "README.md", "LICENSE", "package.json"],
|
|
31
|
+
"dot314Prepack": {
|
|
32
|
+
"copy": [
|
|
33
|
+
{ "from": "../../extensions/repoprompt-cli.ts", "to": "extensions/repoprompt-cli.ts" },
|
|
34
|
+
{ "from": "../../LICENSE", "to": "LICENSE" }
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|