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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # RepoPrompt MCP for Pi (`pi-repoprompt-mcp`)
2
2
 
3
- This package provides a single tool (`rp`) that exposes the RepoPrompt MCP tools to Pi, includes branch-safe window and tab binding (automatically detecting the right window by `cwd`, provisioning a safe tab, persisting both across sessions and session tree nodes, and letting you pick windows and tabs interactively) 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.
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 package'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. **Note: this recovery currently requires the original workspace (but not necessarily its original tabs) to be open, not just any workspace containing the same required root(s).**
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 a completely blank tab, the package binds to that tab; if the tab is dirty, then it provisions a new tab and binds to that
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 package and need to target different windows or tabs simultaneously.
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 read files' code blocks and for codemaps
50
- - Diff highlighting for diff blocks (`delta` when installed, honoring the user's global git/delta color config, graceful fallback otherwise)
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 package will still load, but `rp(...)` will error until you configure it
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 package 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:
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 package falls back to `rp-cli -e 'windows'`
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 package may need updates
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 package restores the branch's tab for that window or provisions a fresh background tab once
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. This applies to the collapsed preview for all `rp(...)` calls, including commands like window listings and file reads. **Recommended setting for maximally compressed** but still informative output: `3`.
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 package-owned snapshot replay |
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` | `15` | Lines shown in collapsed view |
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 package may persist UTF-8 file snapshots to an on-disk content-addressed store under
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 package will drop the connection and you can recover with `/rp reconnect`.
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 package uses `rp-cli -e 'windows'`
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 parseFileCountMaybe(value: unknown): number | undefined {
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: parseFileCountMaybe(
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 = parseFileCountMaybe(fileCountMatch[1]);
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 findSoleEmptyTab(tabs: RpTab[]): RpTab | null {
775
- if (tabs.length !== 1) {
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 findEmptyBoundTab(tabs: RpTab[]): RpTab | null {
784
- return tabs.find((tab) => tab.isBound === true && tab.selectedFileCount === 0) ?? null;
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 findReusableEmptyTab(tabs: RpTab[]): RpTab | null {
788
- const emptyTabs = tabs.filter((tab) => tab.selectedFileCount === 0);
1007
+ function orderReusableEmptyTabs(tabs: RpTab[]): RpTab[] {
1008
+ const emptyTabs = tabs.filter(isExplicitlyEmptyTab);
789
1009
  if (emptyTabs.length === 0) {
790
- return null;
1010
+ return [];
791
1011
  }
792
1012
 
793
- return (
794
- emptyTabs.find((tab) => tab.isBound === true) ??
795
- emptyTabs.find((tab) => tab.isActive === true) ??
796
- emptyTabs[0]
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 findPreferredActiveTab(tabs: RpTab[]): RpTab | null {
801
- return tabs.find((tab) => tab.isActive === true && (tab.selectedFileCount ?? 0) > 0) ?? null;
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
- if (!binding.tab) {
1043
- const preferredActiveTab = findPreferredActiveTab(liveTabs);
1044
- if (preferredActiveTab) {
1045
- return await adoptTab(preferredActiveTab, true);
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
- const branchTabBinding =
1050
- findMostRecentBindingWithTabForWindow(ctx, binding.windowId) ??
1051
- findMostRecentAutoSelectionBindingWithTab(ctx.sessionManager.getBranch(), binding.windowId, binding.workspace);
1052
- const branchTab = findLiveTab(liveTabs, branchTabBinding?.tab);
1053
- if (branchTab) {
1054
- return await adoptTab(branchTab, true);
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
- const reusableEmptyTab = findReusableEmptyTab(liveTabs);
1071
- if (reusableEmptyTab && (!binding.tab || reuseSoleEmptyTab || recoverIfMissing)) {
1072
- return await adoptTab(reusableEmptyTab, true);
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 { RpConfig } from "./types.js";
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,
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
+ }