@oh-my-pi/pi-coding-agent 14.2.1 → 14.3.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/package.json +19 -19
  3. package/src/cli/args.ts +10 -1
  4. package/src/cli/shell-cli.ts +15 -3
  5. package/src/config/settings-schema.ts +60 -1
  6. package/src/debug/system-info.ts +6 -2
  7. package/src/discovery/claude.ts +58 -36
  8. package/src/discovery/opencode.ts +20 -2
  9. package/src/edit/index.ts +2 -1
  10. package/src/edit/modes/chunk.ts +132 -56
  11. package/src/edit/modes/hashline.ts +36 -11
  12. package/src/edit/renderer.ts +98 -133
  13. package/src/edit/streaming.ts +351 -0
  14. package/src/exec/bash-executor.ts +60 -5
  15. package/src/internal-urls/docs-index.generated.ts +5 -5
  16. package/src/internal-urls/pi-protocol.ts +0 -2
  17. package/src/lsp/client.ts +8 -1
  18. package/src/lsp/defaults.json +2 -1
  19. package/src/modes/acp/acp-agent.ts +76 -2
  20. package/src/modes/components/assistant-message.ts +1 -34
  21. package/src/modes/components/hook-editor.ts +1 -1
  22. package/src/modes/components/tool-execution.ts +111 -101
  23. package/src/modes/controllers/input-controller.ts +1 -1
  24. package/src/modes/interactive-mode.ts +0 -2
  25. package/src/modes/theme/mermaid-cache.ts +13 -52
  26. package/src/modes/theme/theme.ts +2 -2
  27. package/src/prompts/system/system-prompt.md +1 -1
  28. package/src/prompts/tools/browser.md +1 -0
  29. package/src/prompts/tools/chunk-edit.md +25 -22
  30. package/src/prompts/tools/gh-pr-push.md +2 -1
  31. package/src/prompts/tools/grep.md +4 -3
  32. package/src/prompts/tools/lsp.md +6 -0
  33. package/src/prompts/tools/read-chunk.md +46 -7
  34. package/src/prompts/tools/read.md +7 -4
  35. package/src/sdk.ts +8 -5
  36. package/src/session/agent-session.ts +36 -20
  37. package/src/session/session-manager.ts +228 -57
  38. package/src/session/streaming-output.ts +11 -0
  39. package/src/system-prompt.ts +7 -2
  40. package/src/task/executor.ts +1 -0
  41. package/src/tools/bash.ts +13 -0
  42. package/src/tools/gh.ts +6 -16
  43. package/src/tools/sqlite-reader.ts +116 -3
  44. package/src/web/search/providers/codex.ts +129 -6
@@ -45,7 +45,6 @@ export class PiProtocolHandler implements ProtocolHandler {
45
45
  content,
46
46
  contentType: "text/markdown",
47
47
  size: Buffer.byteLength(content, "utf-8"),
48
- sourcePath: "pi://",
49
48
  };
50
49
  }
51
50
 
@@ -78,7 +77,6 @@ export class PiProtocolHandler implements ProtocolHandler {
78
77
  content,
79
78
  contentType: "text/markdown",
80
79
  size: Buffer.byteLength(content, "utf-8"),
81
- sourcePath: `pi://${normalized}`,
82
80
  };
83
81
  }
84
82
  }
package/src/lsp/client.ts CHANGED
@@ -484,7 +484,14 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
484
484
 
485
485
  // Reject any pending requests — the server is gone, they will never complete.
486
486
  if (client.pendingRequests.size > 0) {
487
- const stderr = proc.peekStderr().trim();
487
+ // Strip informational log lines (e.g. marksman's [INF]/[DBG] prefix)
488
+ // — they are startup noise, not actionable errors.
489
+ const rawStderr = proc.peekStderr().trim();
490
+ const stderr = rawStderr
491
+ .split("\n")
492
+ .filter(line => !/^\[\d{2}:\d{2}:\d{2} (?:INF|DBG|VRB)\]/.test(line))
493
+ .join("\n")
494
+ .trim();
488
495
  const code = proc.exitCode;
489
496
  const err = new Error(
490
497
  stderr ? `LSP server exited (code ${code}): ${stderr}` : `LSP server exited unexpectedly (code ${code})`,
@@ -857,7 +857,8 @@
857
857
  "rootMarkers": [
858
858
  ".marksman.toml",
859
859
  ".git"
860
- ]
860
+ ],
861
+ "warmupTimeoutMs": 15000
861
862
  },
862
863
  "texlab": {
863
864
  "command": "texlab",
@@ -39,11 +39,14 @@ import {
39
39
  } from "@agentclientprotocol/sdk";
40
40
  import type { Model } from "@oh-my-pi/pi-ai";
41
41
  import { logger, VERSION } from "@oh-my-pi/pi-utils";
42
+ import { disableProvider, enableProvider } from "../../capability";
43
+ import { Settings } from "../../config/settings";
42
44
  import type { ExtensionUIContext } from "../../extensibility/extensions";
43
45
  import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
44
46
  import { loadSlashCommands } from "../../extensibility/slash-commands";
45
47
  import { MCPManager } from "../../mcp/manager";
46
48
  import type { MCPServerConfig } from "../../mcp/types";
49
+ import { loadAllExtensions } from "../../modes/components/extensions/state-manager";
47
50
  import { theme } from "../../modes/theme/theme";
48
51
  import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
49
52
  import {
@@ -379,8 +382,79 @@ export class AcpAgent implements Agent {
379
382
  }
380
383
  }
381
384
 
382
- async extMethod(_method: string, _params: { [key: string]: unknown }): Promise<{ [key: string]: unknown }> {
383
- throw new Error("ACP extension methods are not implemented");
385
+ async extMethod(method: string, params: { [key: string]: unknown }): Promise<{ [key: string]: unknown }> {
386
+ switch (method) {
387
+ case "omp/sessions/listAll": {
388
+ const limit = typeof params.limit === "number" ? Math.max(1, Math.min(5000, params.limit as number)) : 1000;
389
+ const sessions = await SessionManager.listAll();
390
+ const sorted = sessions.sort((l, r) => r.modified.getTime() - l.modified.getTime()).slice(0, limit);
391
+ return {
392
+ sessions: sorted.map(s => this.#toSessionInfo(s)),
393
+ total: sessions.length,
394
+ };
395
+ }
396
+ case "omp/projects/list": {
397
+ const sessions = await SessionManager.listAll();
398
+ const buckets = new Map<
399
+ string,
400
+ { cwd: string; sessionCount: number; lastActivityAt: number; lastTitle: string }
401
+ >();
402
+ for (const s of sessions) {
403
+ if (!s.cwd) continue;
404
+ const ts = s.modified.getTime();
405
+ const existing = buckets.get(s.cwd);
406
+ if (existing) {
407
+ existing.sessionCount += 1;
408
+ if (ts > existing.lastActivityAt) {
409
+ existing.lastActivityAt = ts;
410
+ existing.lastTitle = s.title ?? "";
411
+ }
412
+ } else {
413
+ buckets.set(s.cwd, {
414
+ cwd: s.cwd,
415
+ sessionCount: 1,
416
+ lastActivityAt: ts,
417
+ lastTitle: s.title ?? "",
418
+ });
419
+ }
420
+ }
421
+ const projects = Array.from(buckets.values()).sort((a, b) => b.lastActivityAt - a.lastActivityAt);
422
+ return { projects, totalSessions: sessions.length };
423
+ }
424
+ case "omp/chats/byCwd": {
425
+ const cwd = typeof params.cwd === "string" ? (params.cwd as string) : undefined;
426
+ if (!cwd) throw new Error("cwd required");
427
+ const limit = typeof params.limit === "number" ? Math.max(1, Math.min(500, params.limit as number)) : 100;
428
+ const sessions = await SessionManager.list(cwd);
429
+ const sorted = sessions.sort((l, r) => r.modified.getTime() - l.modified.getTime()).slice(0, limit);
430
+ return { sessions: sorted.map(s => this.#toSessionInfo(s)) };
431
+ }
432
+ case "omp/usage": {
433
+ const [firstRecord] = this.#sessions.values();
434
+ const target = firstRecord?.session ?? this.#initialSession;
435
+ const reports = await target.fetchUsageReports();
436
+ return { reports: reports ?? [] };
437
+ }
438
+ case "omp/extensions": {
439
+ const cwd = typeof params.cwd === "string" ? (params.cwd as string) : undefined;
440
+ const sm = await Settings.init();
441
+ const disabledIds = (sm.get("disabledExtensions") as string[] | undefined) ?? [];
442
+ const extensions = await loadAllExtensions(cwd, disabledIds);
443
+ return { extensions: extensions as unknown as Array<{ [key: string]: unknown }> };
444
+ }
445
+ case "omp/extensions/toggle": {
446
+ const providerId = params.providerId;
447
+ if (typeof providerId !== "string") throw new Error("providerId required");
448
+ if (params.enabled === false) {
449
+ disableProvider(providerId);
450
+ return { enabled: false };
451
+ }
452
+ enableProvider(providerId);
453
+ return { enabled: true };
454
+ }
455
+ default:
456
+ throw new Error(`Unknown ACP ext method: ${method}`);
457
+ }
384
458
  }
385
459
 
386
460
  async extNotification(_method: string, _params: { [key: string]: unknown }): Promise<void> {}
@@ -1,8 +1,7 @@
1
1
  import type { AssistantMessage, ImageContent, Usage } from "@oh-my-pi/pi-ai";
2
2
  import { Container, Image, ImageProtocol, Markdown, Spacer, TERMINAL, Text } from "@oh-my-pi/pi-tui";
3
- import { formatNumber, logger } from "@oh-my-pi/pi-utils";
3
+ import { formatNumber } from "@oh-my-pi/pi-utils";
4
4
  import { settings } from "../../config/settings";
5
- import { hasPendingMermaid, prerenderMermaid } from "../../modes/theme/mermaid-cache";
6
5
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
7
6
  import { resolveImageOptions } from "../../tools/render-utils";
8
7
 
@@ -12,7 +11,6 @@ import { resolveImageOptions } from "../../tools/render-utils";
12
11
  export class AssistantMessageComponent extends Container {
13
12
  #contentContainer: Container;
14
13
  #lastMessage?: AssistantMessage;
15
- #prerenderInFlight = false;
16
14
  #toolImagesByCallId = new Map<string, ImageContent[]>();
17
15
  #usageInfo?: Usage;
18
16
 
@@ -85,34 +83,6 @@ export class AssistantMessageComponent extends Container {
85
83
  this.#contentContainer.addChild(new Text(theme.fg("toolOutput", `[Image: ${image.mimeType}]`), 1, 0));
86
84
  }
87
85
  }
88
- #triggerMermaidPrerender(message: AssistantMessage): void {
89
- if (!TERMINAL.imageProtocol || this.#prerenderInFlight) return;
90
-
91
- // Check if any text content has pending mermaid blocks
92
- const hasPending = message.content.some(c => c.type === "text" && c.text.trim() && hasPendingMermaid(c.text));
93
- if (!hasPending) return;
94
-
95
- this.#prerenderInFlight = true;
96
-
97
- // Fire off background prerender
98
- void (async () => {
99
- try {
100
- for (const content of message.content) {
101
- if (content.type === "text" && content.text.trim() && hasPendingMermaid(content.text)) {
102
- prerenderMermaid(content.text);
103
- }
104
- }
105
- } catch (error) {
106
- logger.warn("Background mermaid prerender failed", {
107
- error: error instanceof Error ? error.message : String(error),
108
- });
109
- } finally {
110
- this.#prerenderInFlight = false;
111
- // Invalidate to re-render with cached images
112
- this.invalidate();
113
- }
114
- })();
115
- }
116
86
 
117
87
  updateContent(message: AssistantMessage): void {
118
88
  this.#lastMessage = message;
@@ -120,9 +90,6 @@ export class AssistantMessageComponent extends Container {
120
90
  // Clear content container
121
91
  this.#contentContainer.clear();
122
92
 
123
- // Trigger background mermaid pre-rendering if needed
124
- this.#triggerMermaidPrerender(message);
125
-
126
93
  const hasVisibleContent = message.content.some(
127
94
  c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
128
95
  );
@@ -136,7 +136,7 @@ export class HookEditorComponent extends Container {
136
136
  const editorCmd = getEditorCommand();
137
137
  if (!editorCmd) return;
138
138
 
139
- const currentText = this.#editor.getText();
139
+ const currentText = this.#editor.getExpandedText();
140
140
  try {
141
141
  this.#tui.stop();
142
142
  const result = await openInEditor(editorCmd, currentText);
@@ -14,14 +14,7 @@ import {
14
14
  type TUI,
15
15
  } from "@oh-my-pi/pi-tui";
16
16
  import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
17
- import {
18
- computeEditDiff,
19
- computeHashlineDiff,
20
- computePatchDiff,
21
- type DiffError,
22
- type DiffResult,
23
- expandApplyPatchToEntries,
24
- } from "../../edit";
17
+ import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "../../edit";
25
18
  import type { Theme } from "../../modes/theme/theme";
26
19
  import { theme } from "../../modes/theme/theme";
27
20
  import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
@@ -65,6 +58,12 @@ function isEditLikeToolName(toolName: string): boolean {
65
58
  return toolName === "edit" || toolName === "apply_patch";
66
59
  }
67
60
 
61
+ function resolveEditModeForTool(toolName: string, tool: AgentTool | undefined): EditMode | undefined {
62
+ if (toolName === "apply_patch") return "apply_patch";
63
+ if (toolName !== "edit") return undefined;
64
+ return (tool as { mode?: EditMode } | undefined)?.mode;
65
+ }
66
+
68
67
  export interface ToolExecutionOptions {
69
68
  showImages?: boolean; // default: true (only used if terminal supports images)
70
69
  editFuzzyThreshold?: number;
@@ -111,9 +110,12 @@ export class ToolExecutionComponent extends Container {
111
110
  isError?: boolean;
112
111
  details?: any;
113
112
  };
114
- // Cached edit diff preview (computed when args arrive, before tool executes)
115
- #editDiffPreview?: DiffResult | DiffError;
116
- #editDiffArgsKey?: string; // Track which args the preview is for
113
+ // Edit preview state (single-file for legacy modes, multi-file for chunk)
114
+ #editMode?: EditMode;
115
+ #editDiffPreview?: PerFileDiffPreview[];
116
+ #editDiffScheduleTimer?: NodeJS.Timeout;
117
+ #editDiffAbort?: AbortController;
118
+ #editDiffLastArgsKey?: string;
117
119
  // Cached converted images for Kitty protocol (which requires PNG), keyed by index
118
120
  #convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
119
121
  // Spinner animation for partial task results
@@ -166,116 +168,98 @@ export class ToolExecutionComponent extends Container {
166
168
  this.addChild(this.#contentText);
167
169
  }
168
170
 
171
+ this.#editMode = resolveEditModeForTool(toolName, tool);
172
+
169
173
  this.#updateDisplay();
174
+ this.#schedulePreviewDiff(0);
170
175
  }
171
176
 
172
177
  updateArgs(args: any, _toolCallId?: string): void {
173
178
  this.#args = cloneToolArgs(args);
174
179
  this.#updateSpinnerAnimation();
180
+ this.#schedulePreviewDiff();
175
181
  this.#updateDisplay();
176
182
  }
177
183
 
178
184
  /**
179
185
  * Signal that args are complete (tool is about to execute).
180
- * This triggers diff computation for edit-like tools.
186
+ * This triggers an immediate final diff computation for edit-like tools.
181
187
  */
182
188
  setArgsComplete(_toolCallId?: string): void {
183
189
  this.#argsComplete = true;
184
190
  this.#updateSpinnerAnimation();
185
- this.#maybeComputeEditDiff();
191
+ this.#schedulePreviewDiff(0);
186
192
  }
187
193
 
188
194
  /**
189
- * Compute edit diff preview when we have complete args.
190
- * This runs async and updates display when done.
195
+ * Schedule a debounced compute of the streaming edit-diff preview.
196
+ * `delayMs === 0` runs immediately (used on construction and on
197
+ * `setArgsComplete`). All other calls coalesce to a trailing-edge timer.
191
198
  */
192
- #maybeComputeEditDiff(): void {
193
- if (!isEditLikeToolName(this.#toolName)) return;
194
-
195
- const edits = this.#args?.edits;
196
- if (!Array.isArray(edits) || edits.length === 0) {
197
- if (this.#toolName !== "apply_patch" || typeof this.#args?.input !== "string") {
198
- return;
199
- }
200
-
201
- const input = this.#args.input;
202
- const argsKey = JSON.stringify({ input });
203
- if (this.#editDiffArgsKey === argsKey) return;
204
- this.#editDiffArgsKey = argsKey;
205
-
206
- try {
207
- const first = expandApplyPatchToEntries({ input })[0];
208
- if (!first?.path) return;
209
- computePatchDiff({ ...first, op: first.op ?? "update" }, this.#cwd, {
210
- fuzzyThreshold: this.#editFuzzyThreshold,
211
- allowFuzzy: this.#editAllowFuzzy,
212
- }).then(result => {
213
- if (this.#editDiffArgsKey === argsKey) {
214
- this.#editDiffPreview = result;
215
- this.#updateDisplay();
216
- this.#ui.requestRender();
217
- }
218
- });
219
- } catch (err) {
220
- this.#editDiffPreview = { error: err instanceof Error ? err.message : String(err) };
221
- this.#updateDisplay();
222
- this.#ui.requestRender();
223
- }
199
+ #schedulePreviewDiff(delayMs = 80): void {
200
+ if (!this.#editMode) return;
201
+ if (this.#editDiffScheduleTimer) {
202
+ clearTimeout(this.#editDiffScheduleTimer);
203
+ this.#editDiffScheduleTimer = undefined;
204
+ }
205
+ if (delayMs === 0) {
206
+ void this.#runPreviewDiff();
224
207
  return;
225
208
  }
209
+ this.#editDiffScheduleTimer = setTimeout(() => {
210
+ this.#editDiffScheduleTimer = undefined;
211
+ void this.#runPreviewDiff();
212
+ }, delayMs);
213
+ }
226
214
 
227
- const first = edits[0];
228
- if (!first || typeof first !== "object") return;
229
-
230
- // Detect mode from first edit entry shape and compute preview for first file
231
- if ("old_text" in first && "new_text" in first) {
232
- // Replace mode
233
- const { path, old_text: oldText, new_text: newText, all } = first;
234
- if (!path || oldText === undefined || newText === undefined) return;
235
-
236
- const argsKey = JSON.stringify({ path, oldText, newText, all });
237
- if (this.#editDiffArgsKey === argsKey) return;
238
- this.#editDiffArgsKey = argsKey;
215
+ async #runPreviewDiff(): Promise<void> {
216
+ const editMode = this.#editMode;
217
+ if (!editMode) return;
218
+ const strategy = EDIT_MODE_STRATEGIES[editMode];
219
+ if (!strategy) return;
220
+
221
+ const args = this.#args;
222
+ if (args == null || typeof args !== "object") return;
223
+
224
+ const partialJson = (args as { __partialJson?: string }).__partialJson;
225
+ let effectiveArgs: unknown;
226
+ try {
227
+ effectiveArgs = strategy.extractCompleteEdits(args, partialJson);
228
+ } catch {
229
+ effectiveArgs = args;
230
+ }
239
231
 
240
- computeEditDiff(path, oldText, newText, this.#cwd, true, all, this.#editFuzzyThreshold).then(result =>
241
- this.#applyEditDiffResult(argsKey, result),
242
- );
243
- } else if ("path" in first && ("diff" in first || ("op" in first && !("content" in first)))) {
244
- // Patch mode (has diff or op without content — chunk edits always have content)
245
- const { path, op, rename, diff } = first;
246
- if (!path) return;
232
+ // Coalesce duplicate computes for identical args.
233
+ let argsKey: string;
234
+ try {
235
+ argsKey = JSON.stringify(effectiveArgs);
236
+ } catch {
237
+ argsKey = String(Date.now());
238
+ }
239
+ if (argsKey === this.#editDiffLastArgsKey) return;
240
+ this.#editDiffLastArgsKey = argsKey;
247
241
 
248
- const argsKey = JSON.stringify({ path, op, rename, diff });
249
- if (this.#editDiffArgsKey === argsKey) return;
250
- this.#editDiffArgsKey = argsKey;
242
+ this.#editDiffAbort?.abort();
243
+ const controller = new AbortController();
244
+ this.#editDiffAbort = controller;
251
245
 
252
- computePatchDiff({ path, op, rename, diff }, this.#cwd, {
246
+ try {
247
+ const previews = await strategy.computeDiffPreview(effectiveArgs, {
248
+ cwd: this.#cwd,
249
+ signal: controller.signal,
253
250
  fuzzyThreshold: this.#editFuzzyThreshold,
254
251
  allowFuzzy: this.#editAllowFuzzy,
255
- }).then(result => this.#applyEditDiffResult(argsKey, result));
256
- } else if ("loc" in first && "path" in first) {
257
- // Hashline mode — group edits by path, preview first file
258
- const path = first.path;
259
- if (!path) return;
260
- const fileEdits = edits.filter((e: any) => e.path === path);
261
- const move = this.#args?.move;
262
-
263
- const argsKey = JSON.stringify({ path, edits: fileEdits, move });
264
- if (this.#editDiffArgsKey === argsKey) return;
265
- this.#editDiffArgsKey = argsKey;
266
-
267
- computeHashlineDiff({ path, edits: fileEdits, move }, this.#cwd).then(result =>
268
- this.#applyEditDiffResult(argsKey, result),
269
- );
252
+ });
253
+ if (controller.signal.aborted) return;
254
+ if (previews) {
255
+ this.#editDiffPreview = previews;
256
+ this.#updateDisplay();
257
+ this.#ui.requestRender();
258
+ }
259
+ } catch (err) {
260
+ if (controller.signal.aborted) return;
261
+ logger.warn("Edit preview diff failed", { tool: this.#toolName, error: String(err) });
270
262
  }
271
- // Chunk mode edits don't have a pre-execution diff preview
272
- }
273
-
274
- #applyEditDiffResult(argsKey: string, result: DiffResult | DiffError): void {
275
- if (this.#editDiffArgsKey !== argsKey) return;
276
- this.#editDiffPreview = result;
277
- this.#updateDisplay();
278
- this.#ui.requestRender();
279
263
  }
280
264
 
281
265
  updateResult(
@@ -378,6 +362,12 @@ export class ToolExecutionComponent extends Container {
378
362
  this.#spinnerInterval = undefined;
379
363
  this.#spinnerFrame = undefined;
380
364
  }
365
+ if (this.#editDiffScheduleTimer) {
366
+ clearTimeout(this.#editDiffScheduleTimer);
367
+ this.#editDiffScheduleTimer = undefined;
368
+ }
369
+ this.#editDiffAbort?.abort();
370
+ this.#editDiffAbort = undefined;
381
371
  }
382
372
 
383
373
  setExpanded(expanded: boolean): void {
@@ -549,6 +539,9 @@ export class ToolExecutionComponent extends Container {
549
539
  this.#contentBox.setBgFn(renderer.inline ? undefined : bgFn);
550
540
  this.#contentBox.clear();
551
541
 
542
+ const renderContext = this.#buildRenderContext();
543
+ this.#renderState.renderContext = renderContext;
544
+
552
545
  const shouldRenderCall = !this.#result || !renderer.mergeCallAndResult;
553
546
  if (shouldRenderCall) {
554
547
  // Render call component
@@ -567,10 +560,6 @@ export class ToolExecutionComponent extends Container {
567
560
  // Render result component if we have a result
568
561
  if (this.#result) {
569
562
  try {
570
- // Build render context for tools that need extra state
571
- const renderContext = this.#buildRenderContext();
572
- this.#renderState.renderContext = renderContext;
573
-
574
563
  const resultComponent = renderer.renderResult(
575
564
  {
576
565
  content: this.#result.content as any,
@@ -646,10 +635,20 @@ export class ToolExecutionComponent extends Container {
646
635
  if (!isEditLikeToolName(this.#toolName)) {
647
636
  return this.#args;
648
637
  }
649
- if (!this.#editDiffPreview || !("diff" in this.#editDiffPreview) || !this.#editDiffPreview.diff) {
638
+ const previews = this.#editDiffPreview;
639
+ if (!previews || previews.length === 0) {
650
640
  return this.#args;
651
641
  }
652
- return { ...(this.#args as Record<string, unknown>), previewDiff: this.#editDiffPreview.diff };
642
+ // Single-file previews feed the existing `previewDiff` channel consumed
643
+ // by `formatStreamingDiff` in the renderer. Multi-file previews are
644
+ // piped via `renderContext.perFileDiffPreview`, so the args we hand to
645
+ // `renderCall` only need the first file's diff to preserve prior
646
+ // single-file behavior.
647
+ const first = previews[0];
648
+ if (!first?.diff) {
649
+ return this.#args;
650
+ }
651
+ return { ...(this.#args as Record<string, unknown>), previewDiff: first.diff };
653
652
  }
654
653
 
655
654
  /**
@@ -680,8 +679,19 @@ export class ToolExecutionComponent extends Container {
680
679
  context.previewLines = PYTHON_DEFAULT_PREVIEW_LINES;
681
680
  context.timeout = normalizeTimeoutSeconds(this.#args?.timeout, 600);
682
681
  } else if (isEditLikeToolName(this.#toolName)) {
683
- // Edit needs diff preview and renderDiff function
684
- context.editDiffPreview = this.#editDiffPreview;
682
+ context.editMode = this.#editMode;
683
+ const previews = this.#editDiffPreview;
684
+ if (previews && previews.length > 0) {
685
+ const first = previews[0];
686
+ if (first?.diff || first?.error) {
687
+ context.editDiffPreview = first.error
688
+ ? { error: first.error }
689
+ : { diff: first.diff ?? "", firstChangedLine: first.firstChangedLine };
690
+ }
691
+ if (previews.length > 1) {
692
+ context.perFileDiffPreview = previews;
693
+ }
694
+ }
685
695
  context.renderDiff = renderDiff;
686
696
  }
687
697
 
@@ -715,7 +715,7 @@ export class InputController {
715
715
  return;
716
716
  }
717
717
 
718
- const currentText = this.ctx.editor.getText();
718
+ const currentText = this.ctx.editor.getExpandedText?.() ?? this.ctx.editor.getText();
719
719
 
720
720
  let ttyHandle: fs.FileHandle | null = null;
721
721
  try {
@@ -63,7 +63,6 @@ import { SelectorController } from "./controllers/selector-controller";
63
63
  import { SSHCommandController } from "./controllers/ssh-command-controller";
64
64
  import { OAuthManualInputManager } from "./oauth-manual-input";
65
65
  import { SessionObserverRegistry } from "./session-observer-registry";
66
- import { setMermaidRenderCallback } from "./theme/mermaid-cache";
67
66
  import type { Theme } from "./theme/theme";
68
67
  import {
69
68
  getEditorTheme,
@@ -220,7 +219,6 @@ export class InteractiveMode implements InteractiveModeContext {
220
219
 
221
220
  this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
222
221
  this.ui.setClearOnShrink(settings.get("clearOnShrink"));
223
- setMermaidRenderCallback(() => this.ui.requestRender());
224
222
  this.chatContainer = new Container();
225
223
  this.pendingMessagesContainer = new Container();
226
224
  this.statusContainer = new Container();
@@ -1,63 +1,24 @@
1
- import { extractMermaidBlocks, logger, renderMermaidAsciiSafe } from "@oh-my-pi/pi-utils";
1
+ import { renderMermaidAsciiSafe } from "@oh-my-pi/pi-utils";
2
2
 
3
- const cache = new Map<bigint | number, string | null>();
3
+ const cache = new Map<string, string | null>();
4
4
 
5
- let onRenderNeeded: (() => void) | null = null;
6
-
7
- /**
8
- * Set callback to trigger TUI re-render when mermaid ASCII renders become available.
9
- */
10
- export function setMermaidRenderCallback(callback: (() => void) | null): void {
11
- onRenderNeeded = callback;
12
- }
13
-
14
- /**
15
- * Get a pre-rendered mermaid ASCII diagram by hash.
16
- * Returns null if not cached or rendering failed.
17
- */
18
- export function getMermaidAscii(hash: bigint | number): string | null {
19
- return cache.get(hash) ?? null;
5
+ function normalizeMermaidSource(source: string): string {
6
+ return source.replace(/\r\n?/g, "\n").trim();
20
7
  }
21
8
 
22
9
  /**
23
- * Render all mermaid blocks in markdown text.
24
- * Caches results and calls render callback when new diagrams are available.
10
+ * Resolve mermaid ASCII from fenced block source text.
11
+ * Returns null when rendering fails, while memoizing failures to avoid repeated work.
25
12
  */
26
- export function prerenderMermaid(markdown: string): void {
27
- const blocks = extractMermaidBlocks(markdown);
28
- if (blocks.length === 0) return;
29
-
30
- let hasNew = false;
31
-
32
- for (const { source, hash } of blocks) {
33
- if (cache.has(hash)) continue;
34
-
35
- const ascii = renderMermaidAsciiSafe(source);
36
- if (ascii) {
37
- cache.set(hash, ascii);
38
- hasNew = true;
39
- } else {
40
- cache.set(hash, null);
41
- }
13
+ export function resolveMermaidAscii(source: string): string | null {
14
+ const normalizedSource = normalizeMermaidSource(source);
15
+ if (cache.has(normalizedSource)) {
16
+ return cache.get(normalizedSource) ?? null;
42
17
  }
43
18
 
44
- if (hasNew && onRenderNeeded) {
45
- try {
46
- onRenderNeeded();
47
- } catch (error) {
48
- logger.warn("Mermaid render callback failed", {
49
- error: error instanceof Error ? error.message : String(error),
50
- });
51
- }
52
- }
53
- }
54
-
55
- /**
56
- * Check if markdown contains mermaid blocks that aren't cached yet.
57
- */
58
- export function hasPendingMermaid(markdown: string): boolean {
59
- const blocks = extractMermaidBlocks(markdown);
60
- return blocks.some(({ hash }) => !cache.has(hash));
19
+ const ascii = normalizedSource ? renderMermaidAsciiSafe(normalizedSource) : null;
20
+ cache.set(normalizedSource, ascii);
21
+ return ascii;
61
22
  }
62
23
 
63
24
  /**
@@ -18,7 +18,7 @@ import chalk from "chalk";
18
18
  import darkThemeJson from "./dark.json" with { type: "json" };
19
19
  import { defaultThemes } from "./defaults";
20
20
  import lightThemeJson from "./light.json" with { type: "json" };
21
- import { getMermaidAscii } from "./mermaid-cache";
21
+ import { resolveMermaidAscii } from "./mermaid-cache";
22
22
 
23
23
  export { getLanguageFromPath } from "../../utils/lang-from-path";
24
24
 
@@ -2339,7 +2339,7 @@ export function getMarkdownTheme(): MarkdownTheme {
2339
2339
  underline: (text: string) => theme.underline(text),
2340
2340
  strikethrough: (text: string) => chalk.strikethrough(text),
2341
2341
  symbols: getSymbolTheme(),
2342
- getMermaidAscii,
2342
+ resolveMermaidAscii,
2343
2343
  highlightCode: (code: string, lang?: string): string[] => {
2344
2344
  const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2345
2345
  try {
@@ -187,7 +187,7 @@ You **MUST NOT** use Python or Bash when a specialized tool exists.
187
187
  {{/ifAny}}
188
188
 
189
189
  {{#ifAny (includes tools "read") (includes tools "write") (includes tools "grep") (includes tools "find") (includes tools "edit")}}
190
- {{#has tools "read"}}- Use `read`, not `cat` or `open`.{{/has}}
190
+ {{#has tools "read"}}- Use `read`, not `cat`.{{/has}}
191
191
  {{#has tools "write"}}- Use `write`, not shell redirection.{{/has}}
192
192
  {{#has tools "grep"}}- Use `grep`, not shell regex search.{{/has}}
193
193
  {{#has tools "find"}}- Use `find`, not shell file globbing.{{/has}}
@@ -1,6 +1,7 @@
1
1
  Navigates, clicks, types, scrolls, drags, queries DOM content, and captures screenshots.
2
2
 
3
3
  <instruction>
4
+ - For fetching static web content (articles, docs, issues/PRs, JSON, PDFs, feeds), prefer the `read` tool with a URL — it returns clean reader-mode text without spinning up a browser. Use this tool only when you need JS execution, authentication, or interactive actions.
4
5
  - `"open"` starts a headless session (or implicitly on first action); `"goto"` navigates to `url`; `"close"` releases the browser
5
6
  - `"observe"` captures a numbered accessibility snapshot — prefer `click_id`/`type_id`/`fill_id` using returned `element_id` values; flags: `include_all`, `viewport_only`
6
7
  - `"click"`, `"type"`, `"fill"`, `"press"`, `"scroll"`, `"drag"` for selector-based interactions — prefer ARIA/text selectors (`p-aria/[name="Sign in"]`, `p-text/Continue`) over brittle CSS