@oh-my-pi/pi-coding-agent 15.11.0 → 15.11.2

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.
Files changed (102) hide show
  1. package/CHANGELOG.md +57 -2
  2. package/dist/cli.js +678 -657
  3. package/dist/types/capability/mcp.d.ts +1 -0
  4. package/dist/types/config/settings-schema.d.ts +49 -4
  5. package/dist/types/export/html/template.generated.d.ts +1 -1
  6. package/dist/types/extensibility/custom-commands/types.d.ts +6 -3
  7. package/dist/types/extensibility/custom-tools/loader.d.ts +2 -1
  8. package/dist/types/extensibility/custom-tools/types.d.ts +8 -4
  9. package/dist/types/extensibility/extensions/types.d.ts +2 -2
  10. package/dist/types/extensibility/hooks/types.d.ts +8 -4
  11. package/dist/types/irc/bus.d.ts +15 -2
  12. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  13. package/dist/types/mcp/oauth-flow.d.ts +6 -1
  14. package/dist/types/mcp/transports/stdio.d.ts +1 -0
  15. package/dist/types/mcp/types.d.ts +2 -0
  16. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  17. package/dist/types/modes/components/mcp-add-wizard.d.ts +2 -1
  18. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  19. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  20. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  21. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  22. package/dist/types/modes/controllers/tool-args-reveal.d.ts +43 -0
  23. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  25. package/dist/types/modes/rpc/rpc-types.d.ts +30 -0
  26. package/dist/types/modes/theme/theme.d.ts +3 -2
  27. package/dist/types/session/agent-session.d.ts +17 -3
  28. package/dist/types/slash-commands/available-commands.d.ts +34 -0
  29. package/dist/types/task/index.d.ts +3 -3
  30. package/dist/types/tools/bash.d.ts +1 -1
  31. package/dist/types/tools/browser/attach.d.ts +4 -4
  32. package/dist/types/tools/browser/registry.d.ts +1 -0
  33. package/dist/types/tools/irc.d.ts +3 -2
  34. package/dist/types/tools/path-utils.d.ts +0 -4
  35. package/dist/types/tools/render-utils.d.ts +22 -0
  36. package/package.json +11 -11
  37. package/src/capability/mcp.ts +1 -0
  38. package/src/cli/gallery-cli.ts +5 -4
  39. package/src/config/mcp-schema.json +4 -0
  40. package/src/config/settings-schema.ts +55 -4
  41. package/src/edit/renderer.ts +96 -46
  42. package/src/exec/bash-executor.ts +21 -6
  43. package/src/export/html/template.generated.ts +1 -1
  44. package/src/export/html/template.js +6 -1
  45. package/src/extensibility/custom-commands/loader.ts +3 -1
  46. package/src/extensibility/custom-commands/types.ts +6 -3
  47. package/src/extensibility/custom-tools/loader.ts +4 -7
  48. package/src/extensibility/custom-tools/types.ts +8 -4
  49. package/src/extensibility/extensions/loader.ts +2 -1
  50. package/src/extensibility/extensions/types.ts +2 -2
  51. package/src/extensibility/hooks/loader.ts +3 -1
  52. package/src/extensibility/hooks/types.ts +8 -4
  53. package/src/internal-urls/docs-index.generated.ts +8 -8
  54. package/src/irc/bus.ts +14 -3
  55. package/src/lsp/defaults.json +6 -0
  56. package/src/lsp/render.ts +2 -28
  57. package/src/mcp/manager.ts +3 -0
  58. package/src/mcp/oauth-discovery.ts +27 -2
  59. package/src/mcp/oauth-flow.ts +47 -1
  60. package/src/mcp/transports/stdio.ts +3 -0
  61. package/src/mcp/types.ts +2 -0
  62. package/src/memories/index.ts +2 -0
  63. package/src/modes/acp/acp-agent.ts +4 -67
  64. package/src/modes/components/assistant-message.ts +15 -0
  65. package/src/modes/components/btw-panel.ts +5 -1
  66. package/src/modes/components/mcp-add-wizard.ts +13 -0
  67. package/src/modes/components/plan-review-overlay.ts +32 -3
  68. package/src/modes/components/settings-selector.ts +2 -0
  69. package/src/modes/components/status-line/component.ts +22 -12
  70. package/src/modes/components/status-line/types.ts +3 -0
  71. package/src/modes/components/transcript-container.ts +99 -18
  72. package/src/modes/components/tree-selector.ts +6 -1
  73. package/src/modes/controllers/event-controller.ts +28 -4
  74. package/src/modes/controllers/mcp-command-controller.ts +34 -2
  75. package/src/modes/controllers/selector-controller.ts +4 -0
  76. package/src/modes/controllers/streaming-reveal.ts +16 -8
  77. package/src/modes/controllers/tool-args-reveal.ts +174 -0
  78. package/src/modes/interactive-mode.ts +41 -2
  79. package/src/modes/rpc/rpc-client.ts +32 -0
  80. package/src/modes/rpc/rpc-mode.ts +82 -7
  81. package/src/modes/rpc/rpc-types.ts +23 -0
  82. package/src/modes/theme/theme.ts +13 -7
  83. package/src/modes/utils/ui-helpers.ts +13 -4
  84. package/src/prompts/memories/consolidation_system.md +4 -0
  85. package/src/prompts/system/irc-autoreply.md +6 -0
  86. package/src/prompts/system/irc-incoming.md +1 -1
  87. package/src/prompts/tools/bash.md +1 -0
  88. package/src/prompts/tools/irc.md +1 -1
  89. package/src/prompts/tools/task.md +7 -2
  90. package/src/session/agent-session.ts +120 -10
  91. package/src/slash-commands/available-commands.ts +105 -0
  92. package/src/task/index.ts +15 -10
  93. package/src/task/render.ts +10 -4
  94. package/src/tools/bash.ts +5 -1
  95. package/src/tools/browser/attach.ts +26 -7
  96. package/src/tools/browser/registry.ts +11 -1
  97. package/src/tools/irc.ts +16 -4
  98. package/src/tools/job.ts +7 -3
  99. package/src/tools/path-utils.ts +22 -15
  100. package/src/tools/render-utils.ts +56 -0
  101. package/src/tools/write.ts +65 -47
  102. package/src/web/search/providers/anthropic.ts +29 -4
@@ -1,4 +1,4 @@
1
- import type { Browser, Page } from "puppeteer-core";
1
+ import { type Browser, type Page } from "puppeteer-core";
2
2
  /**
3
3
  * Allocate an unused TCP port on 127.0.0.1 by binding to port 0 and reading
4
4
  * back the kernel-assigned port. There's a small race between close and the
@@ -17,9 +17,9 @@ export declare function findReusableCdp(exe: string, signal?: AbortSignal): Prom
17
17
  pid: number;
18
18
  } | null>;
19
19
  /**
20
- * Pick the best page target on an attached browser. Without a matcher, prefer
21
- * a page that doesn't look like a helper window (devtools, request handler,
22
- * background pages); with a matcher, return the first url+title substring hit.
20
+ * Pick the best page target on an attached browser. Prefer discoverable page
21
+ * targets first so Chromium/Edge attach flows that hide pages from
22
+ * `browser.pages()` can still return a usable tab.
23
23
  */
24
24
  export declare function pickElectronTarget(browser: Browser, matcher?: string): Promise<Page>;
25
25
  /**
@@ -36,6 +36,7 @@ export interface AcquireBrowserOptions {
36
36
  signal?: AbortSignal;
37
37
  }
38
38
  export declare function acquireBrowser(kind: BrowserKind, opts: AcquireBrowserOptions): Promise<BrowserHandle>;
39
+ export declare function normalizeConnectedCdpUrl(rawCdpUrl: string): string;
39
40
  export declare function holdBrowser(handle: BrowserHandle): void;
40
41
  export declare function releaseBrowser(handle: BrowserHandle, opts: {
41
42
  kill: boolean;
@@ -90,12 +90,13 @@ export declare class IrcTool implements AgentTool<typeof ircSchema, IrcDetails>
90
90
  type IrcRenderArgs = Partial<IrcParams>;
91
91
  /**
92
92
  * Display-only transcript card for live IRC traffic: `irc:incoming` DMs
93
- * delivered to this session and `irc:relay` observations of agent↔agent
93
+ * delivered to this session, `irc:autoreply` side-channel replies sent on
94
+ * this session's behalf, and `irc:relay` observations of agent↔agent
94
95
  * traffic. Shares the tool renderer's glyph + quote-border conventions so
95
96
  * cards and `irc` tool output look identical in the transcript.
96
97
  */
97
98
  export declare function createIrcMessageCard(card: {
98
- kind: "incoming" | "relay";
99
+ kind: "incoming" | "autoreply" | "relay";
99
100
  from?: string;
100
101
  to?: string;
101
102
  body?: string;
@@ -124,10 +124,6 @@ export interface ResolvedMultiFindPattern {
124
124
  targets: ResolvedFindTarget[];
125
125
  scopePath: string;
126
126
  }
127
- /**
128
- * Split a user path into a base path + glob pattern for tools that delegate to
129
- * APIs accepting separate `path` and `glob` arguments.
130
- */
131
127
  export declare function parseSearchPath(filePath: string): ParsedSearchPath;
132
128
  /**
133
129
  * Async sibling of {@link parseSearchPath} that prefers literal interpretation
@@ -173,6 +173,28 @@ export declare function capParseErrors(errors: string[] | undefined, limit?: num
173
173
  export declare function createCachedComponent(getExpanded: () => boolean, compute: (width: number, expanded: boolean) => string[], options?: {
174
174
  paddingX?: number;
175
175
  }): Component;
176
+ /**
177
+ * Single-slot memo for an expensive rendered string (syntax highlighting, diff
178
+ * coloring) keyed by the exact inputs that shape the bytes: theme instance,
179
+ * expanded state, a caller-chosen salt (path/language), and the source content.
180
+ * Field-wise comparison instead of a concatenated key string: a cache hit costs
181
+ * one string value-compare (engines short-circuit on length) and a miss never
182
+ * allocates a key. Comparing the {@link Theme} by reference is sound because
183
+ * theme switches replace the instance wholesale (`setTheme`/`previewTheme`/
184
+ * `setSymbolPreset` in modes/theme/theme.ts) — themes are never mutated in
185
+ * place.
186
+ */
187
+ export interface RenderedStringCache {
188
+ theme: Theme | null;
189
+ expanded: boolean;
190
+ salt: string;
191
+ content: string;
192
+ value: string;
193
+ }
194
+ export declare function createRenderedStringCache(): RenderedStringCache;
195
+ /** Drop the memo so the next lookup re-renders (e.g. the render function identity changed). */
196
+ export declare function invalidateRenderedStringCache(cache: RenderedStringCache): void;
197
+ export declare function cachedRenderedString(cache: RenderedStringCache | undefined, theme: Theme, expanded: boolean, salt: string, content: string, render: () => string): string;
176
198
  /**
177
199
  * Append the indented bullet list of parse errors (capped at
178
200
  * {@link PARSE_ERRORS_LIMIT}) to `lines`, with an overflow summary line if the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.11.0",
4
+ "version": "15.11.2",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,16 +47,16 @@
47
47
  "@agentclientprotocol/sdk": "0.22.1",
48
48
  "@babel/parser": "^7.29.7",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/hashline": "15.11.0",
51
- "@oh-my-pi/omp-stats": "15.11.0",
52
- "@oh-my-pi/pi-agent-core": "15.11.0",
53
- "@oh-my-pi/pi-ai": "15.11.0",
54
- "@oh-my-pi/pi-catalog": "15.11.0",
55
- "@oh-my-pi/pi-mnemopi": "15.11.0",
56
- "@oh-my-pi/pi-natives": "15.11.0",
57
- "@oh-my-pi/pi-tui": "15.11.0",
58
- "@oh-my-pi/pi-utils": "15.11.0",
59
- "@oh-my-pi/snapcompact": "15.11.0",
50
+ "@oh-my-pi/hashline": "15.11.2",
51
+ "@oh-my-pi/omp-stats": "15.11.2",
52
+ "@oh-my-pi/pi-agent-core": "15.11.2",
53
+ "@oh-my-pi/pi-ai": "15.11.2",
54
+ "@oh-my-pi/pi-catalog": "15.11.2",
55
+ "@oh-my-pi/pi-mnemopi": "15.11.2",
56
+ "@oh-my-pi/pi-natives": "15.11.2",
57
+ "@oh-my-pi/pi-tui": "15.11.2",
58
+ "@oh-my-pi/pi-utils": "15.11.2",
59
+ "@oh-my-pi/snapcompact": "15.11.2",
60
60
  "@opentelemetry/api": "^1.9.1",
61
61
  "@opentelemetry/context-async-hooks": "^2.7.1",
62
62
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -36,6 +36,7 @@ export interface MCPServer {
36
36
  tokenUrl?: string;
37
37
  clientId?: string;
38
38
  clientSecret?: string;
39
+ resource?: string;
39
40
  };
40
41
  /** OAuth configuration (clientId, clientSecret, redirectUri, callbackPort, callbackPath) for servers requiring explicit client credentials */
41
42
  oauth?: {
@@ -111,10 +111,11 @@ export async function renderGalleryState(
111
111
 
112
112
  const tool = fakeToolFor(name, fixture);
113
113
  const streamingArgs = state === "streaming" ? (fixture.streamingArgs ?? fixture.args) : fixture.args;
114
- // The component only calls `requestRender` during a static render;
115
- // `imageBudget` is consulted solely when images render, which the gallery
116
- // disables. A cast avoids constructing a real terminal.
117
- const ui = { requestRender() {} } as unknown as TUI;
114
+ // The component only calls `requestRender`/`requestComponentRender` (via
115
+ // its loader) during a static render; `imageBudget` is consulted solely
116
+ // when images render, which the gallery disables. A cast avoids
117
+ // constructing a real terminal.
118
+ const ui = { requestRender() {}, requestComponentRender() {} } as unknown as TUI;
118
119
  const component = new ToolExecutionComponent(name, streamingArgs, { showImages: false }, tool, ui, getProjectDir());
119
120
  component.setExpanded(expanded);
120
121
 
@@ -62,6 +62,10 @@
62
62
  "clientSecret": {
63
63
  "type": "string",
64
64
  "description": "OAuth client secret persisted for refresh."
65
+ },
66
+ "resource": {
67
+ "type": "string",
68
+ "description": "MCP resource URI persisted for OAuth resource indicators."
65
69
  }
66
70
  }
67
71
  },
@@ -452,6 +452,17 @@ export const SETTINGS_SCHEMA = {
452
452
  description: "Use the session name color for the editor border and status line gap",
453
453
  },
454
454
  },
455
+
456
+ "statusLine.transparent": {
457
+ type: "boolean",
458
+ default: false,
459
+ ui: {
460
+ tab: "appearance",
461
+ label: "Transparent Status Line",
462
+ description:
463
+ "Use the terminal's default background for the status line instead of the theme's `statusLineBg`. Powerline end caps are dropped because they need a contrasting fill to bridge into the surrounding terminal.",
464
+ },
465
+ },
455
466
  "tools.artifactSpillThreshold": {
456
467
  type: "number",
457
468
  default: 50,
@@ -668,7 +679,7 @@ export const SETTINGS_SCHEMA = {
668
679
  ui: {
669
680
  tab: "appearance",
670
681
  label: "Smooth Streaming",
671
- description: "Reveal assistant text smoothly while streamed chunks arrive",
682
+ description: "Reveal assistant text and streamed tool input smoothly while chunks arrive",
672
683
  },
673
684
  },
674
685
 
@@ -1082,6 +1093,46 @@ export const SETTINGS_SCHEMA = {
1082
1093
  ui: { tab: "interaction", label: "Collapse Changelog", description: "Show condensed changelog after updates" },
1083
1094
  },
1084
1095
 
1096
+ "magicKeywords.enabled": {
1097
+ type: "boolean",
1098
+ default: true,
1099
+ ui: {
1100
+ tab: "interaction",
1101
+ label: "Magic Keywords",
1102
+ description: "Enable hidden notices for standalone ultrathink, orchestrate, and workflowz keywords",
1103
+ },
1104
+ },
1105
+
1106
+ "magicKeywords.ultrathink": {
1107
+ type: "boolean",
1108
+ default: true,
1109
+ ui: {
1110
+ tab: "interaction",
1111
+ label: "Ultrathink Keyword",
1112
+ description: "Let standalone ultrathink request maximum automatic thinking and append its hidden notice",
1113
+ },
1114
+ },
1115
+
1116
+ "magicKeywords.orchestrate": {
1117
+ type: "boolean",
1118
+ default: true,
1119
+ ui: {
1120
+ tab: "interaction",
1121
+ label: "Orchestrate Keyword",
1122
+ description: "Let standalone orchestrate append its hidden multi-agent orchestration notice",
1123
+ },
1124
+ },
1125
+
1126
+ "magicKeywords.workflow": {
1127
+ type: "boolean",
1128
+ default: true,
1129
+ ui: {
1130
+ tab: "interaction",
1131
+ label: "Workflow Keyword",
1132
+ description: "Let standalone workflowz append its hidden eval workflow notice",
1133
+ },
1134
+ },
1135
+
1085
1136
  // Notifications
1086
1137
  "completion.notify": {
1087
1138
  type: "enum",
@@ -2501,11 +2552,11 @@ export const SETTINGS_SCHEMA = {
2501
2552
  // Async jobs
2502
2553
  "async.enabled": {
2503
2554
  type: "boolean",
2504
- default: false,
2555
+ default: true,
2505
2556
  ui: {
2506
2557
  tab: "tools",
2507
2558
  label: "Async Execution",
2508
- description: "Enable async bash commands",
2559
+ description: "Enable async bash commands and background task execution",
2509
2560
  },
2510
2561
  },
2511
2562
 
@@ -2758,7 +2809,7 @@ export const SETTINGS_SCHEMA = {
2758
2809
  tab: "tasks",
2759
2810
  label: "Batch Task Calls",
2760
2811
  description:
2761
- "Switch the task tool to its batch shape: one call carries { agent, context, tasks[] } — one subagent per item (with per-item isolation) and a required shared context prepended to every assignment. Each spawn still runs as an independent background agent with the normal idle/parked lifecycle. Disable to restore the flat single-spawn schema.",
2812
+ "Switch the task tool to its batch shape: one call carries { agent, context, tasks[] } — one subagent per item (with per-item isolation) and a required shared context prepended to every assignment. With async.enabled=true, each spawn runs as an independent background agent with the normal idle/parked lifecycle; otherwise the call blocks for merged results. Disable to restore the flat single-spawn schema.",
2762
2813
  },
2763
2814
  },
2764
2815
 
@@ -12,13 +12,17 @@ import { renderDiff as renderDiffColored } from "../modes/components/diff";
12
12
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
13
13
  import type { OutputMeta } from "../tools/output-meta";
14
14
  import {
15
+ cachedRenderedString,
16
+ createRenderedStringCache,
15
17
  formatDiagnostics,
16
18
  formatExpandHint,
17
19
  formatStatusIcon,
18
20
  getDiffStats,
19
21
  getLspBatchRequest,
22
+ invalidateRenderedStringCache,
20
23
  type LspBatchRequest,
21
24
  PREVIEW_LIMITS,
25
+ type RenderedStringCache,
22
26
  replaceTabs,
23
27
  shortenPath,
24
28
  truncateDiffByHunk,
@@ -138,6 +142,26 @@ export interface EditRenderContext {
138
142
  }
139
143
 
140
144
  const EDIT_STREAMING_PREVIEW_LINES = 12;
145
+
146
+ function plainDiffRender(diffText: string): string {
147
+ return diffText;
148
+ }
149
+
150
+ /**
151
+ * Lazily grown per-file preview cache slots: the file count of a streaming
152
+ * multi-file patch is discovered mid-stream, so a fixed-size array would
153
+ * silently bypass caching for late files.
154
+ */
155
+ function previewCacheAt(caches: RenderedStringCache[] | undefined, index: number): RenderedStringCache | undefined {
156
+ if (!caches) return undefined;
157
+ let cache = caches[index];
158
+ if (cache === undefined) {
159
+ cache = createRenderedStringCache();
160
+ caches[index] = cache;
161
+ }
162
+ return cache;
163
+ }
164
+
141
165
  const CALL_TEXT_PREVIEW_LINES = 6;
142
166
  const CALL_TEXT_PREVIEW_WIDTH = 80;
143
167
 
@@ -313,7 +337,6 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string)
313
337
  }
314
338
  return preview.trimEnd();
315
339
  }
316
-
317
340
  function formatStreamingDiff(
318
341
  diff: string,
319
342
  rawPath: string,
@@ -321,26 +344,30 @@ function formatStreamingDiff(
321
344
  expanded: boolean,
322
345
  label = "streaming",
323
346
  spinnerFrame?: number,
347
+ cache?: RenderedStringCache,
324
348
  ): string {
325
349
  if (!diff) return "";
326
- // Collapsed uses a "Cursor" tail window: pin the last
327
- // EDIT_STREAMING_PREVIEW_LINES rows to the bottom so freshly streamed changes
328
- // stay on screen. The whole-file diff is recomputed on every streamed chunk
329
- // and its Myers alignment is not monotonic in payload length, so a hunk-aware
330
- // window stutters as rows move between hunks. Expanded deliberately lifts that
331
- // cap for the approval-time full view.
332
- const allLines = diff.replace(/\n+$/u, "").split("\n");
333
- const hiddenLines = expanded ? 0 : Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES);
334
- const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines;
335
- let text = "\n\n";
336
- if (hiddenLines > 0) {
337
- const hiddenHunks = getDiffStats(allLines.slice(0, hiddenLines).join("\n")).hunks;
338
- const remainder: string[] = [];
339
- if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
340
- remainder.push(`${hiddenLines} more lines`);
341
- text += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
342
- }
343
- text += renderDiffColored(visible.join("\n"), { filePath: rawPath });
350
+ let text = cachedRenderedString(cache, uiTheme, expanded, rawPath, diff, () => {
351
+ // Collapsed uses a "Cursor" tail window: pin the last
352
+ // EDIT_STREAMING_PREVIEW_LINES rows to the bottom so freshly streamed changes
353
+ // stay on screen. The whole-file diff is recomputed on every streamed chunk
354
+ // and its Myers alignment is not monotonic in payload length, so a hunk-aware
355
+ // window stutters as rows move between hunks. Expanded deliberately lifts that
356
+ // cap for the approval-time full view.
357
+ const allLines = diff.replace(/\n+$/u, "").split("\n");
358
+ const hiddenLines = expanded ? 0 : Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES);
359
+ const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines;
360
+ let rendered = "\n\n";
361
+ if (hiddenLines > 0) {
362
+ const hiddenHunks = getDiffStats(allLines.slice(0, hiddenLines).join("\n")).hunks;
363
+ const remainder: string[] = [];
364
+ if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
365
+ remainder.push(`${hiddenLines} more lines`);
366
+ rendered += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
367
+ }
368
+ rendered += renderDiffColored(visible.join("\n"), { filePath: rawPath });
369
+ return rendered;
370
+ });
344
371
  // The animated glyph rides this trailing line — inside the transcript's
345
372
  // volatile-tail holdback — never the block header: an animating head row
346
373
  // pins the native-scrollback commit boundary at the top of the block, so a
@@ -360,9 +387,11 @@ function formatMultiFileStreamingDiff(
360
387
  uiTheme: Theme,
361
388
  expanded: boolean,
362
389
  spinnerFrame?: number,
390
+ caches?: RenderedStringCache[],
363
391
  ): string {
364
392
  const parts: string[] = [];
365
- for (const preview of previews) {
393
+ for (let index = 0; index < previews.length; index++) {
394
+ const preview = previews[index]!;
366
395
  if (!preview.diff && !preview.error) continue;
367
396
  const header = uiTheme.fg("dim", `\n\n── ${shortenPath(preview.path)} ──`);
368
397
  if (preview.error) {
@@ -373,9 +402,10 @@ function formatMultiFileStreamingDiff(
373
402
  // Only the last file's preview carries the animated streaming glyph;
374
403
  // earlier files have settled and must stay byte-stable so their rows
375
404
  // can commit to native scrollback mid-stream.
376
- const isLast = preview === previews[previews.length - 1];
405
+ const isLast = index === previews.length - 1;
406
+ const cache = previewCacheAt(caches, index);
377
407
  parts.push(
378
- `${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, expanded, "preview", isLast ? spinnerFrame : undefined)}`,
408
+ `${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, expanded, "preview", isLast ? spinnerFrame : undefined, cache)}`,
379
409
  );
380
410
  }
381
411
  }
@@ -389,16 +419,18 @@ function getCallPreview(
389
419
  renderContext: EditRenderContext | undefined,
390
420
  expanded: boolean,
391
421
  spinnerFrame?: number,
422
+ caches?: RenderedStringCache[],
392
423
  ): string {
393
424
  const multi = renderContext?.perFileDiffPreview;
394
425
  if (multi && multi.length > 1 && multi.some(p => p.diff || p.error)) {
395
- return formatMultiFileStreamingDiff(multi, uiTheme, expanded, spinnerFrame);
426
+ return formatMultiFileStreamingDiff(multi, uiTheme, expanded, spinnerFrame, caches);
396
427
  }
428
+ const cache = previewCacheAt(caches, 0);
397
429
  if (args.previewDiff) {
398
- return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, expanded, "preview", spinnerFrame);
430
+ return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, expanded, "preview", spinnerFrame, cache);
399
431
  }
400
432
  if (args.diff && args.op) {
401
- return formatStreamingDiff(args.diff, rawPath, uiTheme, expanded, "streaming", spinnerFrame);
433
+ return formatStreamingDiff(args.diff, rawPath, uiTheme, expanded, "streaming", spinnerFrame, cache);
402
434
  }
403
435
  if (args.diff) {
404
436
  return renderPlainTextPreview(args.diff, uiTheme, rawPath);
@@ -492,30 +524,32 @@ function formatDiffStatsSuffix(diff: string, uiTheme: Theme): string {
492
524
  ].filter(value => value !== undefined);
493
525
  return ` ${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${stats.join(uiTheme.fg("dim", "/"))}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
494
526
  }
495
-
496
527
  function renderDiffSection(
497
528
  diff: string,
498
529
  rawPath: string,
499
530
  expanded: boolean,
500
531
  uiTheme: Theme,
501
532
  renderDiffFn: (t: string, o?: { filePath?: string }) => string,
533
+ cache?: RenderedStringCache,
502
534
  ): string {
503
- const {
504
- text: truncatedDiff,
505
- hiddenHunks,
506
- hiddenLines,
507
- } = expanded
508
- ? { text: diff, hiddenHunks: 0, hiddenLines: 0 }
509
- : truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, PREVIEW_LIMITS.DIFF_COLLAPSED_LINES);
510
-
511
- let text = `\n${renderDiffFn(truncatedDiff, { filePath: rawPath })}`;
512
- if (!expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
513
- const remainder: string[] = [];
514
- if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
515
- if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
516
- text += uiTheme.fg("toolOutput", `\n… (${remainder.join(", ")}) ${formatExpandHint(uiTheme)}`);
517
- }
518
- return text;
535
+ return cachedRenderedString(cache, uiTheme, expanded, rawPath, diff, () => {
536
+ const {
537
+ text: truncatedDiff,
538
+ hiddenHunks,
539
+ hiddenLines,
540
+ } = expanded
541
+ ? { text: diff, hiddenHunks: 0, hiddenLines: 0 }
542
+ : truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, PREVIEW_LIMITS.DIFF_COLLAPSED_LINES);
543
+
544
+ let text = `\n${renderDiffFn(truncatedDiff, { filePath: rawPath })}`;
545
+ if (!expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
546
+ const remainder: string[] = [];
547
+ if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
548
+ if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
549
+ text += uiTheme.fg("toolOutput", `\n… (${remainder.join(", ")}) ${formatExpandHint(uiTheme)}`);
550
+ }
551
+ return text;
552
+ });
519
553
  }
520
554
 
521
555
  function wrapEditRendererLine(line: string, width: number): string[] {
@@ -574,6 +608,7 @@ export const editToolRenderer = {
574
608
  if (Array.isArray(editArgs.edits)) {
575
609
  fileCount = countEditFiles(editArgs.edits);
576
610
  }
611
+ const callPreviewCaches: RenderedStringCache[] = [];
577
612
  return framedBlock(uiTheme, width => {
578
613
  // Static pending icon, never the animated glyph: the header is the
579
614
  // head row of the framed block, and native-scrollback commits are
@@ -588,7 +623,15 @@ export const editToolRenderer = {
588
623
  rename,
589
624
  extraSuffix: fileCount > 1 ? uiTheme.fg("dim", ` (+${fileCount - 1} more)`) : undefined,
590
625
  });
591
- let body = getCallPreview(editArgs, rawPath, uiTheme, renderContext, options.expanded, options?.spinnerFrame);
626
+ let body = getCallPreview(
627
+ editArgs,
628
+ rawPath,
629
+ uiTheme,
630
+ renderContext,
631
+ options.expanded,
632
+ options?.spinnerFrame,
633
+ callPreviewCaches,
634
+ );
592
635
  if (applyPatchSummary?.error) {
593
636
  body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), Math.max(1, width - 2)))}`;
594
637
  }
@@ -652,11 +695,18 @@ function renderSingleFileResult(
652
695
  (result.content?.find(c => c.type === "text")?.text ?? "")
653
696
  : "";
654
697
 
698
+ let diffSectionRenderDiffFn: ((t: string, o?: { filePath?: string }) => string) | undefined;
699
+ const diffSectionCache = createRenderedStringCache();
700
+
655
701
  return framedBlock(uiTheme, width => {
656
702
  const { expanded, renderContext } = options;
657
703
  const editDiffPreview = renderContext?.editDiffPreview;
658
- const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
704
+ const renderDiffFn = renderContext?.renderDiff ?? plainDiffRender;
659
705
 
706
+ if (diffSectionRenderDiffFn !== renderDiffFn) {
707
+ diffSectionRenderDiffFn = renderDiffFn;
708
+ invalidateRenderedStringCache(diffSectionCache);
709
+ }
660
710
  const firstChangedLine =
661
711
  (editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
662
712
  (details && !isError ? details.firstChangedLine : undefined);
@@ -681,11 +731,11 @@ function renderSingleFileResult(
681
731
  if (isError) {
682
732
  if (errorText) body = uiTheme.fg("error", replaceTabs(errorText, rawPath));
683
733
  } else if (details?.diff) {
684
- body = renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn);
734
+ body = renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn, diffSectionCache);
685
735
  } else if (editDiffPreview) {
686
736
  if ("error" in editDiffPreview) body = uiTheme.fg("error", replaceTabs(editDiffPreview.error, rawPath));
687
737
  else if (editDiffPreview.diff)
688
- body = renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn);
738
+ body = renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn, diffSectionCache);
689
739
  }
690
740
  if (details?.diagnostics) {
691
741
  body += formatDiagnostics(details.diagnostics, expanded, uiTheme, (fp: string) =>
@@ -56,6 +56,8 @@ export interface BashResult {
56
56
  const shellSessions = new Map<string, Shell>();
57
57
  const brokenShellSessions = new Set<string>();
58
58
  const shellSessionQuarantines = new Map<string, Promise<unknown>>();
59
+ /** Session keys with a command currently in flight on the persistent Shell. */
60
+ const shellSessionsInUse = new Set<string>();
59
61
 
60
62
  function quarantineShellSession(
61
63
  sessionKey: string,
@@ -223,8 +225,14 @@ export async function executeBash(command: string, options?: BashExecutorOptions
223
225
  shellSessions.delete(sessionKey);
224
226
  }
225
227
 
226
- let shellSession = persistentSessionBroken ? undefined : shellSessions.get(sessionKey);
227
- if (!shellSession && !persistentSessionBroken) {
228
+ // A persistent Shell runs one command at a time (the native session is a
229
+ // mutex-guarded queue and `abort()` kills every in-flight run on it). When
230
+ // parallel bash calls overlap on the same key, the first one owns the
231
+ // persistent session; the rest degrade to isolated one-shot shells — the
232
+ // same path quarantined sessions take.
233
+ const sessionBusy = shellSessionsInUse.has(sessionKey);
234
+ let shellSession = persistentSessionBroken || sessionBusy ? undefined : shellSessions.get(sessionKey);
235
+ if (!shellSession && !persistentSessionBroken && !sessionBusy) {
228
236
  shellSession = new Shell({
229
237
  sessionEnv: shellEnv,
230
238
  snapshotPath: snapshotPath ?? undefined,
@@ -232,6 +240,10 @@ export async function executeBash(command: string, options?: BashExecutorOptions
232
240
  });
233
241
  shellSessions.set(sessionKey, shellSession);
234
242
  }
243
+ const ownsPersistentSession = shellSession !== undefined;
244
+ if (ownsPersistentSession) {
245
+ shellSessionsInUse.add(sessionKey);
246
+ }
235
247
  const userSignal = options?.signal;
236
248
  const runAbortController = new AbortController();
237
249
  let abortCleanupPromise: Promise<void> | undefined;
@@ -393,10 +405,13 @@ export async function executeBash(command: string, options?: BashExecutorOptions
393
405
  if (userSignal) {
394
406
  userSignal.removeEventListener("abort", abortHandler);
395
407
  }
396
- if (resetSession || options?.sessionKey?.includes(":async:")) {
397
- // `:async:` keys are per-job (jobId is unique), so the Shell would
398
- // otherwise stay in the process-global map forever after completion.
399
- shellSessions.delete(sessionKey);
408
+ if (ownsPersistentSession) {
409
+ shellSessionsInUse.delete(sessionKey);
410
+ if (resetSession || options?.sessionKey?.includes(":async:")) {
411
+ // `:async:` keys are per-job (jobId is unique), so the Shell would
412
+ // otherwise stay in the process-global map forever after completion.
413
+ shellSessions.delete(sessionKey);
414
+ }
400
415
  }
401
416
  }
402
417
  }