pi-repoprompt-mcp 0.4.0 → 0.5.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/README.md +43 -19
- package/extensions/repoprompt-mcp/src/binding.ts +273 -49
- package/extensions/repoprompt-mcp/src/config.ts +21 -2
- package/extensions/repoprompt-mcp/src/diff-presentation.ts +79 -0
- package/extensions/repoprompt-mcp/src/diff-renderer.ts +1421 -0
- package/extensions/repoprompt-mcp/src/file-action-normalization.ts +96 -0
- package/extensions/repoprompt-mcp/src/index.ts +167 -54
- package/extensions/repoprompt-mcp/src/language-detection.ts +33 -0
- package/extensions/repoprompt-mcp/src/presentation-summary.ts +302 -0
- package/extensions/repoprompt-mcp/src/render.ts +323 -433
- package/extensions/repoprompt-mcp/src/result-normalization.ts +69 -0
- package/extensions/repoprompt-mcp/src/tool-forwarding-policy.ts +17 -0
- package/extensions/repoprompt-mcp/src/types.ts +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# RepoPrompt MCP for Pi (`pi-repoprompt-mcp`)
|
|
2
2
|
|
|
3
|
-
This
|
|
3
|
+
This extension provides a single tool (`rp`) that exposes RepoPrompt MCP tools to Pi, includes branch-safe window and tab binding (auto-detect and bind to window by `cwd`, auto-bind to safe tab, persist and restore across sessions and session tree nodes, and interactive selection of windows and tabs) and batches of read files (automatically selected as context in the RepoPrompt desktop app), renders RepoPrompt tool outputs (syntax + diff highlighting), and applies guardrails for destructive operations.
|
|
4
4
|
|
|
5
|
-
The
|
|
5
|
+
The extension's window- and tab-related management features allow a workflow where new Pi sessions automatically attach to the required workspace and tab without clobbering your, or other agents', parallel usage of RepoPrompt. Because it recovers the window, tab, and auto-selected read-files context when you rewind via `/tree` or restore a session, all the context the agent has built up (and automatically selected in the RepoPrompt app) by reading files and slices up to that point always remains available in the app for RP Chat (see `/rp oracle` below) or external "oracle" (e.g. GPT-x Pro) use cases. Recovery is based on the required root(s) of the saved selection state, so it can reattach to any open workspace that already contains those roots rather than requiring the original workspace name; if multiple open workspaces satisfy that requirement and `cwd` does not disambiguate them, then you should re-bind with `/rp bind`.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -37,19 +37,34 @@ Add to `~/.pi/agent/settings.json` (or replace an existing unfiltered `git:githu
|
|
|
37
37
|
- Auto-binds to the RepoPrompt window that matches `process.cwd()` (by workspace roots, resolving symlinks to their real paths before matching)
|
|
38
38
|
- If multiple windows match, you're prompted to pick one
|
|
39
39
|
- Window binding is (optionally) persisted across session reloads and session tree nodes
|
|
40
|
-
- If a bound window has
|
|
40
|
+
- If a bound window has an existing tab with zero selected files and no chats, the extension binds to that tab; otherwise it provisions a new tab and binds to that
|
|
41
41
|
- Deterministically reconciles the session tree node's bound tab, and can restore the tab already associated with that node or provision a new safe tab when needed
|
|
42
42
|
- User-driven binding via `/rp bind` (windows) or `/rp tab` (tabs); agents can use `rp({ bind: ... })`
|
|
43
43
|
- In addition to window bindings, tab bindings and auto-selected read-files context is stored and automatically recovered across node rewinds via `/tree`, different sessions (e.g., created via `/fork`), and resumed sessions
|
|
44
44
|
|
|
45
|
-
Forked sessions inherit the parent session-plus-node's window, tab, and auto-selected context snapshot at the fork point (unless you rewind in the forked session and switch window/tab/etc.), then can diverge independently as later reads or manual tab switches are performed in the child session. Binding is non-invasive, in that it doesn't change RepoPrompt's globally active window, and automatic tab provisioning uses background tabs (`focus=false`) without stealing UI focus. This is to prevent interference when multiple agents (or your manual usage of RepoPrompt in parallel to a Pi session) are using this
|
|
45
|
+
Forked sessions inherit the parent session-plus-node's window, tab, and auto-selected context snapshot at the fork point (unless you rewind in the forked session and switch window/tab/etc.), then can diverge independently as later reads or manual tab switches are performed in the child session. Binding is non-invasive, in that it doesn't change RepoPrompt's globally active window, and automatic tab provisioning uses background tabs (`focus=false`) without stealing UI focus. This is to prevent interference when multiple agents (or your manual usage of RepoPrompt in parallel to a Pi session) are using this extension and need to target different windows or tabs simultaneously.
|
|
46
46
|
|
|
47
47
|
### Output rendering
|
|
48
48
|
|
|
49
|
-
- Syntax highlighting for
|
|
50
|
-
-
|
|
49
|
+
- Syntax highlighting for code blocks and codemaps in `read_file`, and for code blocks in outputs of `apply_edits`, `file_actions create/delete`, and `git`
|
|
50
|
+
- Common non-mutating RepoPrompt actions (`read_file`, `file_search`, `get_file_tree`, `get_code_structure`, `workspace_context`, routing helpers like `manage_workspaces`, and control/discovery actions like `windows`/`bind`/`status`/`search`/`describe`) get concise request-driven call/result summaries in collapsed mode. The call line carries intent while the result line carries outcome, so the transcript stays compact without echoing the same label twice. These summaries are derived from the arguments Pi sent, not by parsing RepoPrompt's prose output, and unknown tools fall back to normal collapsed rendering
|
|
51
|
+
|
|
52
|
+
<p align="center">
|
|
53
|
+
<img width="270" height="936" alt="Image" src="https://github.com/user-attachments/assets/142ca6c2-c1cf-4f0b-b41b-3d52d623c78c" />
|
|
54
|
+
</p>
|
|
55
|
+
|
|
56
|
+
- RepoPrompt `apply_edits` calls are forwarded with `verbose: true` by default (unless `raw: true`), while the returned diff is normalized into `details.diff` and presented to the agent as a terse summary. The same is done for `file_actions create/delete` outputs, so you see all edited/created/deleted LOC with rich rendering but the extension prevents the context window from getting bloated by round-tripping tool I/O tokens
|
|
57
|
+
- Adaptive diff rendering for RepoPrompt `git` and `apply_edits` outputs by default (`diffViewMode: "auto"` picks split, unified, compact, or summary at render time based on pane width). This uses the active Pi theme's `toolDiffAdded`, `toolDiffRemoved`, and `toolDiffContext` colors (typically mapped to chosen hues for green and red), and its visual design and rendering logic are indebted to [MasuRii/pi-tool-display](https://github.com/MasuRii/pi-tool-display). Two different examples at different pane widths:
|
|
58
|
+
|
|
59
|
+
<p align="center">
|
|
60
|
+
<img width="1027" height="256" alt="horizontal" src="https://github.com/user-attachments/assets/31943d5b-475c-4254-813b-18bf9bd79d60" />
|
|
61
|
+
</p>
|
|
62
|
+
<p align="center">
|
|
63
|
+
<img width="629" height="302" alt="vertical" src="https://github.com/user-attachments/assets/fe4fc253-6bda-49e3-a37e-918244eb9e05" />
|
|
64
|
+
</p>
|
|
65
|
+
|
|
66
|
+
- Generic fenced diff blocks, and adaptive-diff parse failures, fall back to a simpler diff renderer, which uses `delta` if installed or otherwise the built-in highlighter
|
|
51
67
|
- Markdown-aware styling for headings and lists
|
|
52
|
-
- Collapsed output by default (expand using Pi's standard UI controls)
|
|
53
68
|
|
|
54
69
|
### Safety checks
|
|
55
70
|
|
|
@@ -60,31 +75,36 @@ Forked sessions inherit the parent session-plus-node's window, tab, and auto-sel
|
|
|
60
75
|
## Requirements
|
|
61
76
|
|
|
62
77
|
- RepoPrompt MCP server configured and reachable (stdio transport)
|
|
63
|
-
- If the server is not configured/auto-detected, the
|
|
78
|
+
- If the server is not configured/auto-detected, the extension will still load, but `rp(...)` will error until you configure it
|
|
64
79
|
- `rp-cli` available in `PATH` is recommended (used as a fallback for window discovery)
|
|
65
80
|
|
|
66
81
|
### Compatibility notes
|
|
67
82
|
|
|
68
|
-
This
|
|
83
|
+
This extension tries to be tolerant of **tool name prefixing** (e.g. `RepoPrompt_list_windows` vs `list_windows`), but it is still dependent on a small set of capabilities and their semantics remaining reasonably stable across RepoPrompt versions:
|
|
69
84
|
|
|
70
85
|
- **Window discovery**: `list_windows`
|
|
71
|
-
- If `list_windows` is not exposed by the MCP server, the
|
|
86
|
+
- If `list_windows` is not exposed by the MCP server, the extension falls back to `rp-cli -e 'windows'`
|
|
72
87
|
- If neither is available, window listing/binding features will be limited
|
|
73
88
|
- **Workspace root discovery (auto-bind by cwd)**: `get_file_tree` with `{ type: "roots" }` (scoped by `_windowID`)
|
|
74
89
|
- If unavailable (or if parameters/semantics change), auto-binding may be disabled or less accurate
|
|
75
90
|
- Selection summary: `manage_selection` with `{ op: "get", view: "files" }` and `{ op: "get", view: "summary" }`
|
|
76
91
|
- If these are unavailable (or if parameters/semantics change), the status output may omit file/token counts
|
|
77
92
|
|
|
78
|
-
If RepoPrompt renames/removes these tools or changes their required parameters/output formats, this
|
|
93
|
+
If RepoPrompt renames/removes these tools or changes their required parameters/output formats, this extension may need updates
|
|
79
94
|
|
|
80
95
|
## Usage
|
|
81
96
|
|
|
82
97
|
### Commands
|
|
83
98
|
|
|
84
99
|
- `/rp status` — show status (connection + binding), including the currently bound tab name and a label like `[bound, in-focus]` or `[bound, out-of-focus]`, plus current selected file counts and estimated token counts
|
|
100
|
+
|
|
101
|
+
<p align="center">
|
|
102
|
+
<img width="210" alt="status" src="https://github.com/user-attachments/assets/bd59af9e-7df1-4572-8baf-edb6f8f7a0df" />
|
|
103
|
+
</p>
|
|
104
|
+
|
|
85
105
|
- `/rp windows` — list available RepoPrompt windows
|
|
86
106
|
- `/rp bind` — interactive workflow for choosing the RepoPrompt window
|
|
87
|
-
- `/rp bind <id> [tab]` — direct option if you already know the target window id (and optionally an exact tab name or tab id); when `[tab]` is omitted, the
|
|
107
|
+
- `/rp bind <id> [tab]` — direct option if you already know the target window id (and optionally an exact tab name or tab id); when `[tab]` is omitted, the extension restores the branch's tab for that window or provisions a fresh background tab once
|
|
88
108
|
- `/rp tab` — interactive tab picker for the current bound window, with `Create new tab` as the first option followed by existing tab names
|
|
89
109
|
- `/rp tab new` — create and bind a fresh tab on the current bound window
|
|
90
110
|
- `/rp tab <name-or-id>` — bind an existing tab on the current bound window by name or id
|
|
@@ -150,11 +170,13 @@ Create `~/.pi/agent/extensions/repoprompt-mcp.json`:
|
|
|
150
170
|
"oracleDefaultMode": "chat",
|
|
151
171
|
|
|
152
172
|
"collapsedMaxLines": 3,
|
|
173
|
+
"diffViewMode": "auto",
|
|
174
|
+
"diffSplitMinWidth": 120,
|
|
153
175
|
"suppressHostDisconnectedLog": true
|
|
154
176
|
}
|
|
155
177
|
```
|
|
156
178
|
|
|
157
|
-
`collapsedMaxLines` controls how many lines of RepoPrompt tool output Pi shows before the result is expanded
|
|
179
|
+
`collapsedMaxLines` controls how many rendered lines of RepoPrompt tool output Pi shows before the result is expanded for the generic fallback path. In addition, the extension now emits hand-authored one-line or two-line collapsed summaries for common non-mutating actions like `read_file`, `file_search`, `get_file_tree`, `get_code_structure`, `workspace_context`, `windows`, `bind`, and `status`; these are derived from Pi's own request metadata rather than RepoPrompt's returned prose. Unknown or unsupported tools still fall back to the normal `collapsedMaxLines` behavior. LOC-changing operations are the other exception: verbose RepoPrompt `apply_edits` and rendered `file_actions create/delete` results ignore `collapsedMaxLines` once normalized into `details.diff`, so the full rendered code changes remain visible.
|
|
158
180
|
|
|
159
181
|
Options:
|
|
160
182
|
|
|
@@ -168,14 +190,16 @@ Options:
|
|
|
168
190
|
| `confirmDeletes` | `true` | Block delete operations unless `allowDelete: true` |
|
|
169
191
|
| `confirmEdits` | `false` | Block edit-like operations unless `confirmEdits: true` |
|
|
170
192
|
| `readcacheReadFile` | `false` | Enable [pi-readcache](https://github.com/Gurpartap/pi-readcache)-like caching for RepoPrompt `read_file` calls (returns unchanged markers/diffs on repeat reads to save on tokens and prevent context bloat) |
|
|
171
|
-
| `autoSelectReadSlices` | `true` | Automatically track `read_file` calls by adding slices/full-file selection via `manage_selection`, so `chat_send` (or a manually created chat in the RP app) uses everything the agent has read as context; these file/slice selections are **branch-safe** across `/tree` rewinds and `/fork`ed session branches via
|
|
193
|
+
| `autoSelectReadSlices` | `true` | Automatically track `read_file` calls by adding slices/full-file selection via `manage_selection`, so `chat_send` (or a manually created chat in the RP app) uses everything the agent has read as context; these file/slice selections are **branch-safe** across `/tree` rewinds and `/fork`ed session branches via extension-owned snapshot replay |
|
|
172
194
|
| `oracleDefaultMode` | `"chat"` | Default mode for `/rp oracle` when `--mode` is omitted (`chat`, `plan`, `edit`, or `review`) |
|
|
173
|
-
| `collapsedMaxLines` | `
|
|
195
|
+
| `collapsedMaxLines` | `3` | Lines shown in collapsed view |
|
|
196
|
+
| `diffViewMode` | `"auto"` | Diff layout for RepoPrompt `git` / `apply_edits` fenced diff output (`auto`, `split`, `unified`) |
|
|
197
|
+
| `diffSplitMinWidth` | `120` | Minimum render width before `diffViewMode: "auto"` uses split diff layout |
|
|
174
198
|
| `suppressHostDisconnectedLog` | `true` | Filter noisy stderr from macOS `repoprompt-mcp` (disconnect/retry bootstrap logs) |
|
|
175
199
|
|
|
176
|
-
Automatic tab restoration and provisioning is driven by `autoBindOnStart` and `persistBinding`; there is no separate tab-only configuration surface.
|
|
200
|
+
Automatic tab restoration and provisioning is driven by `autoBindOnStart` and `persistBinding`; there is no separate tab-only configuration surface. Adaptive diff layout applies only to RepoPrompt `git` and `apply_edits` outputs that arrive as fenced `diff` blocks; other rendered output stays on the existing text-based path.
|
|
177
201
|
|
|
178
|
-
Note: when `readcacheReadFile` is enabled, the
|
|
202
|
+
Note: when `readcacheReadFile` is enabled, the extension may persist UTF-8 file snapshots to an on-disk content-addressed store under
|
|
179
203
|
`<repo-root>/.pi/readcache/objects` to compute diffs/unchanged markers across calls. Common secret filenames (e.g. `.env*`, `*.pem`) are excluded,
|
|
180
204
|
but this is best-effort
|
|
181
205
|
|
|
@@ -193,7 +217,7 @@ but this is best-effort
|
|
|
193
217
|
- Run `/rp reconnect`
|
|
194
218
|
|
|
195
219
|
### Pi becomes unresponsive after closing/restarting RepoPrompt
|
|
196
|
-
If the RepoPrompt MCP server stops responding (for example, if the RepoPrompt app is closed while Pi stays open), tool calls may time out. When that happens, the
|
|
220
|
+
If the RepoPrompt MCP server stops responding (for example, if the RepoPrompt app is closed while Pi stays open), tool calls may time out. When that happens, the extension will drop the connection and you can recover with `/rp reconnect`.
|
|
197
221
|
|
|
198
222
|
### "No matching window found"
|
|
199
223
|
- Your `cwd` may not match any RepoPrompt workspace root
|
|
@@ -201,7 +225,7 @@ If the RepoPrompt MCP server stops responding (for example, if the RepoPrompt ap
|
|
|
201
225
|
- Use `/rp bind` to pick one
|
|
202
226
|
|
|
203
227
|
### Window listing doesn't work
|
|
204
|
-
- If the MCP server does not expose a `list_windows` tool, this
|
|
228
|
+
- If the MCP server does not expose a `list_windows` tool, this extension uses `rp-cli -e 'windows'`
|
|
205
229
|
- Make sure `rp-cli` is installed and on your `PATH`
|
|
206
230
|
- If RepoPrompt is in single-window mode, `rp-cli -e 'windows'` may report single-window mode
|
|
207
231
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { realpathSync } from "node:fs";
|
|
6
|
+
import { access } from "node:fs/promises";
|
|
6
7
|
import { execFile } from "node:child_process";
|
|
7
8
|
import { promisify } from "node:util";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -523,6 +524,141 @@ function canonicalizePathForMatching(inputPath: string): string {
|
|
|
523
524
|
}
|
|
524
525
|
}
|
|
525
526
|
|
|
527
|
+
async function pathExists(absolutePath: string): Promise<boolean> {
|
|
528
|
+
try {
|
|
529
|
+
await access(absolutePath);
|
|
530
|
+
return true;
|
|
531
|
+
} catch {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function parseSelectionRootPath(rawPath: string): { rootHint: string; relPath: string } | null {
|
|
537
|
+
const colonIdx = rawPath.indexOf(":");
|
|
538
|
+
if (colonIdx > 0) {
|
|
539
|
+
const rootHint = rawPath.slice(0, colonIdx).trim();
|
|
540
|
+
const relPath = rawPath.slice(colonIdx + 1).replace(/^\/+/, "");
|
|
541
|
+
if (rootHint && relPath) {
|
|
542
|
+
return { rootHint, relPath };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const parts = rawPath.split(/[\\/]+/).filter(Boolean);
|
|
547
|
+
if (parts.length >= 2) {
|
|
548
|
+
return {
|
|
549
|
+
rootHint: parts[0],
|
|
550
|
+
relPath: parts.slice(1).join("/"),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function windowContainsSelectionPath(window: RpWindow, selectionPath: string, cwd: string): Promise<boolean> {
|
|
558
|
+
const normalizedPath = selectionPath.trim();
|
|
559
|
+
if (!normalizedPath) {
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (path.isAbsolute(normalizedPath)) {
|
|
564
|
+
return window.roots.some((root) => isPathWithinRoot(normalizedPath, root));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const rootScoped = parseSelectionRootPath(normalizedPath);
|
|
568
|
+
if (rootScoped) {
|
|
569
|
+
const matchingRoots = window.roots.filter((root) => path.basename(root) === rootScoped.rootHint);
|
|
570
|
+
for (const root of matchingRoots) {
|
|
571
|
+
if (await pathExists(path.join(root, rootScoped.relPath))) {
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const cwdRelativePath = path.resolve(cwd, normalizedPath);
|
|
578
|
+
if (await pathExists(cwdRelativePath)) {
|
|
579
|
+
return window.roots.some((root) => isPathWithinRoot(cwdRelativePath, root));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
for (const root of window.roots) {
|
|
583
|
+
if (await pathExists(path.join(root, normalizedPath))) {
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export interface FindRecoveryWindowBySelectionPathsResult {
|
|
592
|
+
window: RpWindow | null;
|
|
593
|
+
ambiguous: boolean;
|
|
594
|
+
matches: RpWindow[];
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export async function findRecoveryWindowBySelectionPaths(
|
|
598
|
+
windows: RpWindow[],
|
|
599
|
+
selectionPaths: string[],
|
|
600
|
+
cwd: string
|
|
601
|
+
): Promise<FindRecoveryWindowBySelectionPathsResult> {
|
|
602
|
+
const requiredPaths = [...new Set(selectionPaths.map((item) => item.trim()).filter(Boolean))];
|
|
603
|
+
if (requiredPaths.length === 0) {
|
|
604
|
+
return {
|
|
605
|
+
window: null,
|
|
606
|
+
ambiguous: false,
|
|
607
|
+
matches: [],
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
await Promise.all(
|
|
612
|
+
windows.map(async (window) => {
|
|
613
|
+
if (window.roots.length === 0) {
|
|
614
|
+
window.roots = await fetchWindowRoots(window.id);
|
|
615
|
+
}
|
|
616
|
+
})
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
const matches: RpWindow[] = [];
|
|
620
|
+
for (const window of windows) {
|
|
621
|
+
const compatibility = await Promise.all(
|
|
622
|
+
requiredPaths.map((selectionPath) => windowContainsSelectionPath(window, selectionPath, cwd))
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
if (compatibility.every(Boolean)) {
|
|
626
|
+
matches.push(window);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (matches.length === 0) {
|
|
631
|
+
return {
|
|
632
|
+
window: null,
|
|
633
|
+
ambiguous: false,
|
|
634
|
+
matches: [],
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (matches.length === 1) {
|
|
639
|
+
return {
|
|
640
|
+
window: matches[0],
|
|
641
|
+
ambiguous: false,
|
|
642
|
+
matches,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const cwdMatch = findMatchingWindow(matches, cwd);
|
|
647
|
+
if (cwdMatch.window && !cwdMatch.ambiguous) {
|
|
648
|
+
return {
|
|
649
|
+
window: cwdMatch.window,
|
|
650
|
+
ambiguous: false,
|
|
651
|
+
matches,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
window: null,
|
|
657
|
+
ambiguous: true,
|
|
658
|
+
matches,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
526
662
|
/**
|
|
527
663
|
* Get workspace roots for a specific window
|
|
528
664
|
*/
|
|
@@ -598,7 +734,7 @@ function stripTrailingTabStateAnnotations(name: string): string {
|
|
|
598
734
|
}
|
|
599
735
|
}
|
|
600
736
|
|
|
601
|
-
function
|
|
737
|
+
function parseCountMaybe(value: unknown): number | undefined {
|
|
602
738
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
603
739
|
return value;
|
|
604
740
|
}
|
|
@@ -634,7 +770,7 @@ function parseTabFromJson(raw: unknown): RpTab | null {
|
|
|
634
770
|
name: name || idRaw.trim(),
|
|
635
771
|
isActive: parseBooleanMaybe(obj.isActive ?? obj.active ?? obj.selected ?? obj.is_active ?? obj.inFocus ?? obj.in_focus),
|
|
636
772
|
isBound: parseBooleanMaybe(obj.isBound ?? obj.bound ?? obj.pinned ?? obj.is_bound),
|
|
637
|
-
selectedFileCount:
|
|
773
|
+
selectedFileCount: parseCountMaybe(
|
|
638
774
|
obj.selectedFileCount ?? obj.selected_file_count ?? obj.fileCount ?? obj.file_count
|
|
639
775
|
),
|
|
640
776
|
};
|
|
@@ -751,7 +887,7 @@ export function parseTabList(text: string): RpTab[] {
|
|
|
751
887
|
|
|
752
888
|
const fileCountMatch = line.match(/•\s*([\d,]+)\s+files\b/i);
|
|
753
889
|
if (fileCountMatch?.[1]) {
|
|
754
|
-
lastTab.selectedFileCount =
|
|
890
|
+
lastTab.selectedFileCount = parseCountMaybe(fileCountMatch[1]);
|
|
755
891
|
}
|
|
756
892
|
}
|
|
757
893
|
|
|
@@ -763,6 +899,87 @@ function parseTabsFromJson(value: unknown): RpTab[] | null {
|
|
|
763
899
|
return tabs.length > 0 ? dedupeTabs(tabs) : null;
|
|
764
900
|
}
|
|
765
901
|
|
|
902
|
+
function parseChatCountFromJson(value: unknown): number | undefined {
|
|
903
|
+
if (!value) {
|
|
904
|
+
return undefined;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (Array.isArray(value)) {
|
|
908
|
+
return value.length;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (typeof value !== "object") {
|
|
912
|
+
return undefined;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const obj = value as Record<string, unknown>;
|
|
916
|
+
const directCount = parseCountMaybe(
|
|
917
|
+
obj.count ?? obj.chatCount ?? obj.chat_count ?? obj.total ?? obj.totalCount ?? obj.total_count
|
|
918
|
+
);
|
|
919
|
+
if (directCount !== undefined) {
|
|
920
|
+
return directCount;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
for (const key of ["chats", "sessions", "items", "results"]) {
|
|
924
|
+
const candidate = obj[key];
|
|
925
|
+
if (Array.isArray(candidate)) {
|
|
926
|
+
return candidate.length;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return undefined;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function parseChatCountFromText(text: string): number | undefined {
|
|
934
|
+
const countMatch = text.match(/\bCount\b[^\d]*([\d,]+)/i);
|
|
935
|
+
if (countMatch?.[1]) {
|
|
936
|
+
return parseCountMaybe(countMatch[1]);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (/\bNo chats\b/i.test(text)) {
|
|
940
|
+
return 0;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const sessionCount = text
|
|
944
|
+
.split("\n")
|
|
945
|
+
.map((line) => line.trim())
|
|
946
|
+
.filter((line) => /^•\s*\[[^\]]+\]/.test(line)).length;
|
|
947
|
+
|
|
948
|
+
return sessionCount > 0 ? sessionCount : undefined;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function fetchTabChatCount(
|
|
952
|
+
tabId: string,
|
|
953
|
+
client: ReturnType<typeof getRpClient> = getRpClient()
|
|
954
|
+
): Promise<number | undefined> {
|
|
955
|
+
if (!client.isConnected) {
|
|
956
|
+
return undefined;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const chatsToolName = resolveToolName(client.tools, "chats");
|
|
960
|
+
if (!chatsToolName) {
|
|
961
|
+
return undefined;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const result = await client.callTool(chatsToolName, {
|
|
965
|
+
action: "list",
|
|
966
|
+
scope: "tab",
|
|
967
|
+
tab_id: tabId,
|
|
968
|
+
limit: 1,
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
if (result.isError) {
|
|
972
|
+
return undefined;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const countFromJson = parseChatCountFromJson(extractJsonContent(result.content));
|
|
976
|
+
if (countFromJson !== undefined) {
|
|
977
|
+
return countFromJson;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return parseChatCountFromText(extractTextContent(result.content));
|
|
981
|
+
}
|
|
982
|
+
|
|
766
983
|
function findLiveTab(tabs: RpTab[], reference: string | undefined): RpTab | null {
|
|
767
984
|
if (!reference) {
|
|
768
985
|
return null;
|
|
@@ -771,34 +988,48 @@ function findLiveTab(tabs: RpTab[], reference: string | undefined): RpTab | null
|
|
|
771
988
|
return tabs.find((tab) => tab.id === reference || tab.name === reference) ?? null;
|
|
772
989
|
}
|
|
773
990
|
|
|
774
|
-
function
|
|
775
|
-
|
|
776
|
-
return null;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
const [tab] = tabs;
|
|
780
|
-
return tab.selectedFileCount === 0 ? tab : null;
|
|
991
|
+
function isExplicitlyEmptyTab(tab: RpTab): boolean {
|
|
992
|
+
return tab.selectedFileCount === 0;
|
|
781
993
|
}
|
|
782
994
|
|
|
783
|
-
function
|
|
784
|
-
|
|
995
|
+
async function isSafeReusableTab(
|
|
996
|
+
tab: RpTab,
|
|
997
|
+
client: ReturnType<typeof getRpClient> = getRpClient()
|
|
998
|
+
): Promise<boolean> {
|
|
999
|
+
if (!isExplicitlyEmptyTab(tab)) {
|
|
1000
|
+
return false;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const chatCount = await fetchTabChatCount(tab.id, client);
|
|
1004
|
+
return chatCount === 0;
|
|
785
1005
|
}
|
|
786
1006
|
|
|
787
|
-
function
|
|
788
|
-
const emptyTabs = tabs.filter(
|
|
1007
|
+
function orderReusableEmptyTabs(tabs: RpTab[]): RpTab[] {
|
|
1008
|
+
const emptyTabs = tabs.filter(isExplicitlyEmptyTab);
|
|
789
1009
|
if (emptyTabs.length === 0) {
|
|
790
|
-
return
|
|
1010
|
+
return [];
|
|
791
1011
|
}
|
|
792
1012
|
|
|
793
|
-
|
|
794
|
-
emptyTabs.
|
|
795
|
-
emptyTabs.
|
|
796
|
-
emptyTabs
|
|
797
|
-
|
|
1013
|
+
const ordered = [
|
|
1014
|
+
...emptyTabs.filter((tab) => tab.isBound === true),
|
|
1015
|
+
...emptyTabs.filter((tab) => tab.isBound !== true && tab.isActive === true),
|
|
1016
|
+
...emptyTabs.filter((tab) => tab.isBound !== true && tab.isActive !== true),
|
|
1017
|
+
];
|
|
1018
|
+
|
|
1019
|
+
return ordered.filter((tab, index) => ordered.findIndex((candidate) => candidate.id === tab.id) === index);
|
|
798
1020
|
}
|
|
799
1021
|
|
|
800
|
-
function
|
|
801
|
-
|
|
1022
|
+
async function findReusableSafeTab(
|
|
1023
|
+
tabs: RpTab[],
|
|
1024
|
+
client: ReturnType<typeof getRpClient> = getRpClient()
|
|
1025
|
+
): Promise<RpTab | null> {
|
|
1026
|
+
for (const tab of orderReusableEmptyTabs(tabs)) {
|
|
1027
|
+
if (await isSafeReusableTab(tab, client)) {
|
|
1028
|
+
return tab;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return null;
|
|
802
1033
|
}
|
|
803
1034
|
|
|
804
1035
|
function bindingWindowArgs(windowId: number): Record<string, unknown> {
|
|
@@ -1039,37 +1270,30 @@ export async function ensureBindingHasTab(
|
|
|
1039
1270
|
return await adoptTab(unknownCurrentTab, false);
|
|
1040
1271
|
}
|
|
1041
1272
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1273
|
+
const allowHistoricalTabReuse = recoverIfMissing || Boolean(binding.tab);
|
|
1274
|
+
if (allowHistoricalTabReuse) {
|
|
1275
|
+
const branchTabBinding =
|
|
1276
|
+
findMostRecentBindingWithTabForWindow(ctx, binding.windowId) ??
|
|
1277
|
+
findMostRecentAutoSelectionBindingWithTab(ctx.sessionManager.getBranch(), binding.windowId, binding.workspace);
|
|
1278
|
+
const branchTab = findLiveTab(liveTabs, branchTabBinding?.tab);
|
|
1279
|
+
if (branchTab) {
|
|
1280
|
+
return await adoptTab(branchTab, true);
|
|
1046
1281
|
}
|
|
1047
|
-
}
|
|
1048
1282
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
if (liveTabs.length === 0 && branchTabBinding?.tab) {
|
|
1058
|
-
const unknownBranchTab: RpTab = {
|
|
1059
|
-
id: branchTabBinding.tab,
|
|
1060
|
-
name: branchTabBinding.tab,
|
|
1061
|
-
};
|
|
1062
|
-
return await adoptTab(unknownBranchTab, true);
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
const soleEmptyTab = findSoleEmptyTab(liveTabs);
|
|
1066
|
-
if (soleEmptyTab && (reuseSoleEmptyTab || (recoverIfMissing && Boolean(binding.tab)))) {
|
|
1067
|
-
return await adoptTab(soleEmptyTab, true);
|
|
1283
|
+
if (liveTabs.length === 0 && branchTabBinding?.tab) {
|
|
1284
|
+
const unknownBranchTab: RpTab = {
|
|
1285
|
+
id: branchTabBinding.tab,
|
|
1286
|
+
name: branchTabBinding.tab,
|
|
1287
|
+
};
|
|
1288
|
+
return await adoptTab(unknownBranchTab, true);
|
|
1289
|
+
}
|
|
1068
1290
|
}
|
|
1069
1291
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1292
|
+
if (!binding.tab || reuseSoleEmptyTab || recoverIfMissing) {
|
|
1293
|
+
const reusableSafeTab = await findReusableSafeTab(liveTabs, client);
|
|
1294
|
+
if (reusableSafeTab) {
|
|
1295
|
+
return await adoptTab(reusableSafeTab, true);
|
|
1296
|
+
}
|
|
1073
1297
|
}
|
|
1074
1298
|
|
|
1075
1299
|
if (!createIfMissing && !(recoverIfMissing && binding.tab)) {
|
|
@@ -4,7 +4,7 @@ import * as fs from "node:fs";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
|
-
import type
|
|
7
|
+
import { DIFF_VIEW_MODES, type DiffViewMode, type RpConfig } from "./types.js";
|
|
8
8
|
|
|
9
9
|
// Default configuration
|
|
10
10
|
const DEFAULT_CONFIG: RpConfig = {
|
|
@@ -12,7 +12,9 @@ const DEFAULT_CONFIG: RpConfig = {
|
|
|
12
12
|
persistBinding: true,
|
|
13
13
|
confirmDeletes: true,
|
|
14
14
|
confirmEdits: false,
|
|
15
|
-
collapsedMaxLines:
|
|
15
|
+
collapsedMaxLines: 3,
|
|
16
|
+
diffViewMode: "auto",
|
|
17
|
+
diffSplitMinWidth: 120,
|
|
16
18
|
suppressHostDisconnectedLog: true,
|
|
17
19
|
|
|
18
20
|
// Off by default: preserves RepoPrompt's default read_file behavior unless explicitly enabled
|
|
@@ -133,6 +135,20 @@ function findRepoPromptServer(): { command: string; args: string[] } | null {
|
|
|
133
135
|
return null;
|
|
134
136
|
}
|
|
135
137
|
|
|
138
|
+
function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
|
|
139
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
140
|
+
return fallback;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function toDiffViewMode(value: unknown): DiffViewMode {
|
|
147
|
+
return DIFF_VIEW_MODES.includes(value as DiffViewMode)
|
|
148
|
+
? (value as DiffViewMode)
|
|
149
|
+
: (DEFAULT_CONFIG.diffViewMode as DiffViewMode);
|
|
150
|
+
}
|
|
151
|
+
|
|
136
152
|
/**
|
|
137
153
|
* Load extension configuration
|
|
138
154
|
*/
|
|
@@ -181,6 +197,9 @@ export function loadConfig(overrides?: Partial<RpConfig>): RpConfig {
|
|
|
181
197
|
config = { ...config, ...overrides };
|
|
182
198
|
}
|
|
183
199
|
|
|
200
|
+
config.diffViewMode = toDiffViewMode(config.diffViewMode);
|
|
201
|
+
config.diffSplitMinWidth = clampNumber(config.diffSplitMinWidth, 70, 240, DEFAULT_CONFIG.diffSplitMinWidth ?? 120);
|
|
202
|
+
|
|
184
203
|
return config;
|
|
185
204
|
}
|
|
186
205
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
2
|
+
|
|
3
|
+
import type { RpConfig } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export interface DiffSummaryStats {
|
|
6
|
+
added: number;
|
|
7
|
+
removed: number;
|
|
8
|
+
hunks: number;
|
|
9
|
+
files: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type DiffPresentationMode = "split" | "unified" | "compact" | "summary";
|
|
13
|
+
|
|
14
|
+
const MIN_COMPACT_DIFF_WIDTH = 8;
|
|
15
|
+
const MIN_UNIFIED_DIFF_WIDTH = 18;
|
|
16
|
+
|
|
17
|
+
function pluralize(count: number, singular: string): string {
|
|
18
|
+
return count === 1 ? singular : `${singular}s`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeDiffRenderWidth(width: number): number {
|
|
22
|
+
if (!Number.isFinite(width)) {
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return Math.max(0, Math.floor(width));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveDiffPresentationMode(
|
|
30
|
+
config: Pick<RpConfig, "diffViewMode" | "diffSplitMinWidth">,
|
|
31
|
+
width: number,
|
|
32
|
+
canRenderSplitLayout: boolean
|
|
33
|
+
): DiffPresentationMode {
|
|
34
|
+
const safeWidth = normalizeDiffRenderWidth(width);
|
|
35
|
+
|
|
36
|
+
if (safeWidth < MIN_COMPACT_DIFF_WIDTH) {
|
|
37
|
+
return "summary";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (safeWidth < MIN_UNIFIED_DIFF_WIDTH) {
|
|
41
|
+
return "compact";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
switch (config.diffViewMode) {
|
|
45
|
+
case "split":
|
|
46
|
+
return canRenderSplitLayout ? "split" : "unified";
|
|
47
|
+
case "unified":
|
|
48
|
+
return "unified";
|
|
49
|
+
case "auto":
|
|
50
|
+
default:
|
|
51
|
+
return safeWidth >= (config.diffSplitMinWidth ?? 120) && canRenderSplitLayout
|
|
52
|
+
? "split"
|
|
53
|
+
: "unified";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildDiffSummaryText(stats: DiffSummaryStats, width: number): string {
|
|
58
|
+
const safeWidth = normalizeDiffRenderWidth(width);
|
|
59
|
+
if (safeWidth === 0) {
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const summaryCandidates = [
|
|
64
|
+
`↳ diff +${stats.added} -${stats.removed} • ${stats.hunks} ${pluralize(stats.hunks, "hunk")} • ${stats.files} ${pluralize(stats.files, "file")}`,
|
|
65
|
+
`↳ diff +${stats.added} -${stats.removed} • ${stats.hunks}h • ${stats.files}f`,
|
|
66
|
+
`↳ diff +${stats.added} -${stats.removed}`,
|
|
67
|
+
`+${stats.added} -${stats.removed}`,
|
|
68
|
+
"diff",
|
|
69
|
+
"…",
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
for (const candidate of summaryCandidates) {
|
|
73
|
+
if (visibleWidth(candidate) <= safeWidth) {
|
|
74
|
+
return candidate;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return truncateToWidth(summaryCandidates[summaryCandidates.length - 1] ?? "", safeWidth, "");
|
|
79
|
+
}
|