@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,6 +1,6 @@
1
1
  import * as net from "node:net";
2
2
  import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
3
- import type { Browser, Page } from "puppeteer-core";
3
+ import { type Browser, type Page, TargetType } from "puppeteer-core";
4
4
  import { ToolError, throwIfAborted } from "../tool-errors";
5
5
 
6
6
  const ATTACH_TARGET_SKIP_PATTERN =
@@ -119,22 +119,41 @@ export async function findReusableCdp(
119
119
  }
120
120
 
121
121
  /**
122
- * Pick the best page target on an attached browser. Without a matcher, prefer
123
- * a page that doesn't look like a helper window (devtools, request handler,
124
- * background pages); with a matcher, return the first url+title substring hit.
122
+ * Pick the best page target on an attached browser. Prefer discoverable page
123
+ * targets first so Chromium/Edge attach flows that hide pages from
124
+ * `browser.pages()` can still return a usable tab.
125
125
  */
126
126
  export async function pickElectronTarget(browser: Browser, matcher?: string): Promise<Page> {
127
- const pages = await browser.pages();
128
- if (!pages.length) {
127
+ const discoveredPages = await Promise.all(
128
+ browser.targets().map(async target => {
129
+ if (target.type() !== TargetType.PAGE) return null;
130
+ return await target.page().catch(() => null);
131
+ }),
132
+ );
133
+ const usablePages = discoveredPages.filter((page): page is Page => page !== null);
134
+ if (usablePages.length > 0) {
135
+ return pickPageFromList(usablePages, matcher);
136
+ }
137
+
138
+ const fallbackPages = await browser.pages();
139
+ if (!fallbackPages.length) {
129
140
  throw new ToolError("No page targets available on the attached browser");
130
141
  }
131
- const enriched = await Promise.all(
142
+ return pickPageFromList(fallbackPages, matcher);
143
+ }
144
+
145
+ async function enrichPages(pages: Page[]): Promise<Array<{ page: Page; url: string; title: string }>> {
146
+ return await Promise.all(
132
147
  pages.map(async page => ({
133
148
  page,
134
149
  url: page.url(),
135
150
  title: ((await page.title().catch(() => "")) ?? "").trim(),
136
151
  })),
137
152
  );
153
+ }
154
+
155
+ async function pickPageFromList(pages: Page[], matcher?: string): Promise<Page> {
156
+ const enriched = await enrichPages(pages);
138
157
  if (matcher) {
139
158
  const needle = matcher.toLowerCase();
140
159
  const hit = enriched.find(p => p.url.toLowerCase().includes(needle) || p.title.toLowerCase().includes(needle));
@@ -58,6 +58,16 @@ export async function acquireBrowser(kind: BrowserKind, opts: AcquireBrowserOpti
58
58
  return handle;
59
59
  }
60
60
 
61
+ export function normalizeConnectedCdpUrl(rawCdpUrl: string): string {
62
+ const cdpUrl = rawCdpUrl.replace(/\/+$/, "");
63
+ if (/^wss?:\/\//i.test(cdpUrl)) {
64
+ throw new ToolError(
65
+ "browser app.cdp_url must be the HTTP CDP discovery endpoint (for example http://127.0.0.1:9222), not a ws:// browser websocket URL.",
66
+ );
67
+ }
68
+ return cdpUrl;
69
+ }
70
+
61
71
  async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions): Promise<BrowserHandle> {
62
72
  if (kind.kind === "headless") {
63
73
  const browser = await launchHeadlessBrowser({ headless: kind.headless, viewport: opts.viewport });
@@ -70,7 +80,7 @@ async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions)
70
80
  };
71
81
  }
72
82
  if (kind.kind === "connected") {
73
- const cdpUrl = kind.cdpUrl.replace(/\/+$/, "");
83
+ const cdpUrl = normalizeConnectedCdpUrl(kind.cdpUrl);
74
84
  await waitForCdp(cdpUrl, 5_000, opts.signal);
75
85
  const puppeteer = await loadPuppeteer();
76
86
  const browser = await puppeteer.connect({
package/src/tools/irc.ts CHANGED
@@ -234,7 +234,15 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
234
234
  // through the bus unfiltered so parked recipients are revived.
235
235
  const targets = isBroadcast ? registry.listVisibleTo(senderId).map(ref => ref.id) : [to];
236
236
  const receipts = await Promise.all(
237
- targets.map(target => bus.send({ from: senderId, to: target, body: message, replyTo: params.replyTo })),
237
+ targets.map(target =>
238
+ bus.send(
239
+ { from: senderId, to: target, body: message, replyTo: params.replyTo },
240
+ // Awaited sends mark the sender as blocked on an answer so a
241
+ // busy recipient that cannot reach a step boundary (async
242
+ // disabled) auto-replies instead of stranding the sender.
243
+ params.await ? { expectsReply: true } : undefined,
244
+ ),
245
+ ),
238
246
  );
239
247
 
240
248
  const lines: string[] = [];
@@ -457,13 +465,14 @@ function callMeta(args: IrcRenderArgs | undefined): string[] {
457
465
 
458
466
  /**
459
467
  * Display-only transcript card for live IRC traffic: `irc:incoming` DMs
460
- * delivered to this session and `irc:relay` observations of agent↔agent
468
+ * delivered to this session, `irc:autoreply` side-channel replies sent on
469
+ * this session's behalf, and `irc:relay` observations of agent↔agent
461
470
  * traffic. Shares the tool renderer's glyph + quote-border conventions so
462
471
  * cards and `irc` tool output look identical in the transcript.
463
472
  */
464
473
  export function createIrcMessageCard(
465
474
  card: {
466
- kind: "incoming" | "relay";
475
+ kind: "incoming" | "autoreply" | "relay";
467
476
  from?: string;
468
477
  to?: string;
469
478
  body?: string;
@@ -477,9 +486,12 @@ export function createIrcMessageCard(
477
486
  const title =
478
487
  card.kind === "incoming"
479
488
  ? `IRC ${uiTheme.nav.back} ${from}`
480
- : `IRC ${from} ${uiTheme.nav.selected} ${card.to?.trim() || "?"}`;
489
+ : card.kind === "autoreply"
490
+ ? `IRC ${uiTheme.nav.selected} ${card.to?.trim() || "?"}`
491
+ : `IRC ${from} ${uiTheme.nav.selected} ${card.to?.trim() || "?"}`;
481
492
  const body = card.body ?? "";
482
493
  const meta: string[] = [];
494
+ if (card.kind === "autoreply") meta.push("auto");
483
495
  if (card.replyTo) meta.push("reply");
484
496
  const age = messageAge(card.timestamp);
485
497
  if (age) meta.push(age);
package/src/tools/job.ts CHANGED
@@ -449,17 +449,21 @@ export const jobToolRenderer = {
449
449
  const counts = { completed: 0, failed: 0, cancelled: 0, running: 0 };
450
450
  for (const job of jobs) counts[job.status]++;
451
451
 
452
+ // The title already carries the running count, so meta lists only the
453
+ // settled categories — "waiting on 19 of 19 · 19 running" read awkward.
452
454
  const meta: string[] = [];
453
455
  if (counts.completed > 0) meta.push(uiTheme.fg("success", `${counts.completed} done`));
454
456
  if (counts.failed > 0) meta.push(uiTheme.fg("error", `${counts.failed} failed`));
455
457
  if (counts.cancelled > 0) meta.push(uiTheme.fg("warning", `${counts.cancelled} cancelled`));
456
- if (counts.running > 0) meta.push(uiTheme.fg("accent", `${counts.running} running`));
457
458
 
458
459
  const headerIcon: ToolUIStatus = counts.failed > 0 ? "warning" : counts.running > 0 ? "info" : "success";
460
+ const jobsNoun = jobs.length === 1 ? "job" : "jobs";
459
461
  const description =
460
462
  counts.running > 0
461
- ? `waiting on ${counts.running} of ${jobs.length}`
462
- : `${jobs.length} ${jobs.length === 1 ? "job" : "jobs"} settled`;
463
+ ? counts.running === jobs.length
464
+ ? `waiting on ${jobs.length} ${jobsNoun}`
465
+ : `waiting on ${counts.running} of ${jobs.length} ${jobsNoun}`
466
+ : `${jobs.length} ${jobsNoun} settled`;
463
467
 
464
468
  const header = renderStatusLine(
465
469
  {
@@ -398,6 +398,11 @@ export function formatPathRelativeToCwd(
398
398
  export function stripOuterDoubleQuotes(input: string): string {
399
399
  return input.startsWith('"') && input.endsWith('"') && input.length > 1 ? input.slice(1, -1) : input;
400
400
  }
401
+ function normalizePathSeparators(input: string): string {
402
+ if (isInternalUrlPath(input)) return input;
403
+ if (!input.includes("\\")) return input;
404
+ return input.replace(/\\/g, "/");
405
+ }
401
406
 
402
407
  export function normalizePathLikeInput(input: string): string {
403
408
  return stripOuterDoubleQuotes(input.trim());
@@ -582,19 +587,20 @@ export interface ResolvedMultiFindPattern {
582
587
  targets: ResolvedFindTarget[];
583
588
  scopePath: string;
584
589
  }
585
-
586
- /**
587
- * Split a user path into a base path + glob pattern for tools that delegate to
588
- * APIs accepting separate `path` and `glob` arguments.
589
- */
590
590
  export function parseSearchPath(filePath: string): ParsedSearchPath {
591
- const normalizedPath = filePath.replace(/\\/g, "/");
592
- if (!hasGlobPathChars(normalizedPath)) {
593
- return { basePath: filePath };
591
+ const normalizedPath = normalizePathSeparators(filePath);
592
+ const segments = normalizedPath.split("/");
593
+ let firstGlobIndex = -1;
594
+ for (let i = 0; i < segments.length; i++) {
595
+ if (hasGlobPathChars(segments[i])) {
596
+ firstGlobIndex = i;
597
+ break;
598
+ }
594
599
  }
595
600
 
596
- const segments = normalizedPath.split("/");
597
- const firstGlobIndex = segments.findIndex(segment => hasGlobPathChars(segment));
601
+ if (firstGlobIndex === -1) {
602
+ return { basePath: normalizedPath };
603
+ }
598
604
 
599
605
  if (firstGlobIndex <= 0) {
600
606
  return { basePath: ".", glob: normalizedPath };
@@ -617,7 +623,7 @@ export async function parseSearchPathPreferringLiteral(filePath: string, cwd: st
617
623
  if (!hasGlobPathChars(filePath) || isInternalUrlPath(filePath)) return parseSearchPath(filePath);
618
624
  try {
619
625
  await fs.promises.stat(resolveToCwd(filePath, cwd));
620
- return { basePath: filePath };
626
+ return { basePath: normalizePathSeparators(filePath) };
621
627
  } catch {
622
628
  return parseSearchPath(filePath);
623
629
  }
@@ -632,7 +638,8 @@ export async function parseSearchPathPreferringLiteral(filePath: string, cwd: st
632
638
  // /abs/path/**/\*.ts -> { basePath: "/abs/path", globPattern: "**/*.ts", hasGlob: true }
633
639
  // src/app -> { basePath: "src/app", globPattern: "**/*", hasGlob: false }
634
640
  export function parseFindPattern(pattern: string): ParsedFindPattern {
635
- const segments = pattern.split("/");
641
+ const normalizedPattern = normalizePathSeparators(pattern);
642
+ const segments = normalizedPattern.split("/");
636
643
  let firstGlobIndex = -1;
637
644
  for (let i = 0; i < segments.length; i++) {
638
645
  if (hasGlobPathChars(segments[i])) {
@@ -642,14 +649,14 @@ export function parseFindPattern(pattern: string): ParsedFindPattern {
642
649
  }
643
650
 
644
651
  if (firstGlobIndex === -1) {
645
- return { basePath: pattern, globPattern: "**/*", hasGlob: false };
652
+ return { basePath: normalizedPattern, globPattern: "**/*", hasGlob: false };
646
653
  }
647
654
 
648
655
  if (firstGlobIndex === 0) {
649
- const needsRecursive = !pattern.startsWith("**/");
656
+ const needsRecursive = !normalizedPattern.startsWith("**/");
650
657
  return {
651
658
  basePath: ".",
652
- globPattern: needsRecursive ? `**/${pattern}` : pattern,
659
+ globPattern: needsRecursive ? `**/${normalizedPattern}` : normalizedPattern,
653
660
  hasGlob: true,
654
661
  };
655
662
  }
@@ -779,6 +779,62 @@ export function createCachedComponent(
779
779
  };
780
780
  }
781
781
 
782
+ /**
783
+ * Single-slot memo for an expensive rendered string (syntax highlighting, diff
784
+ * coloring) keyed by the exact inputs that shape the bytes: theme instance,
785
+ * expanded state, a caller-chosen salt (path/language), and the source content.
786
+ * Field-wise comparison instead of a concatenated key string: a cache hit costs
787
+ * one string value-compare (engines short-circuit on length) and a miss never
788
+ * allocates a key. Comparing the {@link Theme} by reference is sound because
789
+ * theme switches replace the instance wholesale (`setTheme`/`previewTheme`/
790
+ * `setSymbolPreset` in modes/theme/theme.ts) — themes are never mutated in
791
+ * place.
792
+ */
793
+ export interface RenderedStringCache {
794
+ theme: Theme | null;
795
+ expanded: boolean;
796
+ salt: string;
797
+ content: string;
798
+ value: string;
799
+ }
800
+
801
+ export function createRenderedStringCache(): RenderedStringCache {
802
+ return { theme: null, expanded: false, salt: "", content: "", value: "" };
803
+ }
804
+
805
+ /** Drop the memo so the next lookup re-renders (e.g. the render function identity changed). */
806
+ export function invalidateRenderedStringCache(cache: RenderedStringCache): void {
807
+ cache.theme = null;
808
+ }
809
+
810
+ export function cachedRenderedString(
811
+ cache: RenderedStringCache | undefined,
812
+ theme: Theme,
813
+ expanded: boolean,
814
+ salt: string,
815
+ content: string,
816
+ render: () => string,
817
+ ): string {
818
+ if (
819
+ cache !== undefined &&
820
+ cache.theme === theme &&
821
+ cache.expanded === expanded &&
822
+ cache.salt === salt &&
823
+ cache.content === content
824
+ ) {
825
+ return cache.value;
826
+ }
827
+ const value = render();
828
+ if (cache !== undefined) {
829
+ cache.theme = theme;
830
+ cache.expanded = expanded;
831
+ cache.salt = salt;
832
+ cache.content = content;
833
+ cache.value = value;
834
+ }
835
+ return value;
836
+ }
837
+
782
838
  /**
783
839
  * Append the indented bullet list of parse errors (capped at
784
840
  * {@link PARSE_ERRORS_LIMIT}) to `lines`, with an overflow summary line if the
@@ -37,12 +37,15 @@ import { type OutputMeta, outputMeta } from "./output-meta";
37
37
  import { formatPathRelativeToCwd, isInternalUrlPath } from "./path-utils";
38
38
  import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard";
39
39
  import {
40
+ cachedRenderedString,
41
+ createRenderedStringCache,
40
42
  formatDiagnostics,
41
43
  formatErrorDetail,
42
44
  formatExpandHint,
43
45
  formatMoreItems,
44
46
  formatStatusIcon,
45
47
  getLspBatchRequest,
48
+ type RenderedStringCache,
46
49
  replaceTabs,
47
50
  shortenPath,
48
51
  } from "./render-utils";
@@ -1042,37 +1045,40 @@ function formatStreamingContent(
1042
1045
  language: string | undefined,
1043
1046
  uiTheme: Theme,
1044
1047
  spinnerFrame?: number,
1048
+ cache?: RenderedStringCache,
1045
1049
  ): string {
1046
1050
  if (!content) return "";
1047
- const lines = normalizeDisplayText(content).split("\n");
1048
- const totalLines = lines.length;
1049
- // Collapsed: follow the streaming edge with a bounded tail window so the box
1050
- // stays short enough not to strand its scrolled-off head above the viewport
1051
- // while the block is volatile. `Ctrl+O` (expanded) lifts the cap for a
1052
- // deliberate full view matching the eval streaming preview.
1053
- const startIndex = expanded ? 0 : Math.max(0, totalLines - WRITE_STREAMING_PREVIEW_LINES);
1054
- const visibleLines = lines.slice(startIndex);
1055
- const hidden = startIndex;
1056
- const highlighted = highlightCode(visibleLines.join("\n"), language);
1057
- const lineNumberWidth = Math.max(WRITE_GUTTER_MIN_WIDTH, String(totalLines).length);
1058
-
1059
- let text = "\n\n";
1060
- if (hidden > 0) {
1061
- text += `${uiTheme.fg("dim", `… (${hidden} earlier line${hidden === 1 ? "" : "s"})`)}\n`;
1062
- }
1063
- for (let i = 0; i < highlighted.length; i++) {
1064
- const lineNum = startIndex + i + 1;
1065
- const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")} `);
1066
- const body = replaceTabs(highlighted[i] ?? "");
1067
- text += `${gutter}${body}\n`;
1068
- }
1051
+ const bodyText = cachedRenderedString(cache, uiTheme, expanded, language ?? "", content, () => {
1052
+ const lines = normalizeDisplayText(content).split("\n");
1053
+ const totalLines = lines.length;
1054
+ // Collapsed: follow the streaming edge with a bounded tail window so the box
1055
+ // stays short enough not to strand its scrolled-off head above the viewport
1056
+ // while the block is volatile. `Ctrl+O` (expanded) lifts the cap for a
1057
+ // deliberate full view matching the eval streaming preview.
1058
+ const startIndex = expanded ? 0 : Math.max(0, totalLines - WRITE_STREAMING_PREVIEW_LINES);
1059
+ const visibleLines = lines.slice(startIndex);
1060
+ const hidden = startIndex;
1061
+ const highlighted = highlightCode(visibleLines.join("\n"), language);
1062
+ const lineNumberWidth = Math.max(WRITE_GUTTER_MIN_WIDTH, String(totalLines).length);
1063
+
1064
+ let text = "\n\n";
1065
+ if (hidden > 0) {
1066
+ text += `${uiTheme.fg("dim", `… (${hidden} earlier line${hidden === 1 ? "" : "s"})`)}\n`;
1067
+ }
1068
+ for (let i = 0; i < highlighted.length; i++) {
1069
+ const lineNum = startIndex + i + 1;
1070
+ const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")} `);
1071
+ const body = replaceTabs(highlighted[i] ?? "");
1072
+ text += `${gutter}${body}\n`;
1073
+ }
1074
+ return text;
1075
+ });
1069
1076
  // The animated glyph lives on this trailing line — inside the transcript's
1070
1077
  // volatile-tail holdback — never in the header: an animating head row pins
1071
1078
  // the native-scrollback commit boundary at the top of the block, so a long
1072
1079
  // expanded preview could never scroll-append mid-stream.
1073
1080
  const spinner = spinnerFrame !== undefined ? `${formatStatusIcon("running", uiTheme, spinnerFrame)} ` : "";
1074
- text += `${spinner}${uiTheme.fg("dim", `… (streaming)`)}`;
1075
- return text;
1081
+ return `${bodyText}${spinner}${uiTheme.fg("dim", `… (streaming)`)}`;
1076
1082
  }
1077
1083
 
1078
1084
  function renderContentPreview(
@@ -1080,29 +1086,32 @@ function renderContentPreview(
1080
1086
  expanded: boolean,
1081
1087
  language: string | undefined,
1082
1088
  uiTheme: Theme,
1089
+ cache?: RenderedStringCache,
1083
1090
  ): string {
1084
1091
  if (!content) return "";
1085
- const rawLines = normalizeDisplayText(content).split("\n");
1086
- const totalLines = rawLines.length;
1087
- const maxLines = expanded ? totalLines : Math.min(totalLines, WRITE_PREVIEW_LINES);
1088
- const visibleLines = rawLines.slice(0, maxLines);
1089
- const highlighted = highlightCode(visibleLines.join("\n"), language);
1090
- const lineNumberWidth = Math.max(WRITE_GUTTER_MIN_WIDTH, String(totalLines).length);
1091
- const hidden = totalLines - maxLines;
1092
-
1093
- let text = "\n\n";
1094
- for (let i = 0; i < highlighted.length; i++) {
1095
- const lineNum = i + 1;
1096
- const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")} `);
1097
- const body = replaceTabs(highlighted[i] ?? "");
1098
- text += `${gutter}${body}\n`;
1099
- }
1100
- if (!expanded && hidden > 0) {
1101
- const hint = formatExpandHint(uiTheme, expanded, hidden > 0);
1102
- const moreLine = `${formatMoreItems(hidden, "line")}${hint ? ` ${hint}` : ""}`;
1103
- text += uiTheme.fg("dim", moreLine);
1104
- }
1105
- return text.trimEnd();
1092
+ return cachedRenderedString(cache, uiTheme, expanded, language ?? "", content, () => {
1093
+ const rawLines = normalizeDisplayText(content).split("\n");
1094
+ const totalLines = rawLines.length;
1095
+ const maxLines = expanded ? totalLines : Math.min(totalLines, WRITE_PREVIEW_LINES);
1096
+ const visibleLines = rawLines.slice(0, maxLines);
1097
+ const highlighted = highlightCode(visibleLines.join("\n"), language);
1098
+ const lineNumberWidth = Math.max(WRITE_GUTTER_MIN_WIDTH, String(totalLines).length);
1099
+ const hidden = totalLines - maxLines;
1100
+
1101
+ let text = "\n\n";
1102
+ for (let i = 0; i < highlighted.length; i++) {
1103
+ const lineNum = i + 1;
1104
+ const gutter = uiTheme.fg("dim", `${String(lineNum).padStart(lineNumberWidth, " ")} `);
1105
+ const body = replaceTabs(highlighted[i] ?? "");
1106
+ text += `${gutter}${body}\n`;
1107
+ }
1108
+ if (!expanded && hidden > 0) {
1109
+ const hint = formatExpandHint(uiTheme, expanded, hidden > 0);
1110
+ const moreLine = `${formatMoreItems(hidden, "line")}${hint ? ` ${hint}` : ""}`;
1111
+ text += uiTheme.fg("dim", moreLine);
1112
+ }
1113
+ return text.trimEnd();
1114
+ });
1106
1115
  }
1107
1116
 
1108
1117
  export const writeToolRenderer = {
@@ -1125,9 +1134,17 @@ export const writeToolRenderer = {
1125
1134
  },
1126
1135
  uiTheme,
1127
1136
  );
1137
+ const streamingCache = createRenderedStringCache();
1128
1138
  return framedBlock(uiTheme, width => {
1129
1139
  const body = args.content
1130
- ? formatStreamingContent(args.content, Boolean(options?.expanded), lang, uiTheme, options?.spinnerFrame)
1140
+ ? formatStreamingContent(
1141
+ args.content,
1142
+ Boolean(options?.expanded),
1143
+ lang,
1144
+ uiTheme,
1145
+ options?.spinnerFrame,
1146
+ streamingCache,
1147
+ )
1131
1148
  : "";
1132
1149
  const bodyLines = body ? body.split("\n") : [];
1133
1150
  while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
@@ -1189,9 +1206,10 @@ export const writeToolRenderer = {
1189
1206
  );
1190
1207
  const diagnostics = result.details?.diagnostics;
1191
1208
 
1209
+ const previewCache = createRenderedStringCache();
1192
1210
  return framedBlock(uiTheme, width => {
1193
1211
  const { expanded } = options;
1194
- let body = renderContentPreview(fileContent, expanded, lang, uiTheme);
1212
+ let body = renderContentPreview(fileContent, expanded, lang, uiTheme, previewCache);
1195
1213
  if (diagnostics) {
1196
1214
  const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp =>
1197
1215
  uiTheme.getLangIcon(getLanguageFromPath(fp)),
@@ -14,6 +14,7 @@ import {
14
14
  buildAnthropicSystemBlocks,
15
15
  buildAnthropicUrl,
16
16
  type FetchImpl,
17
+ resolveAnthropicMetadataUserId,
17
18
  stripClaudeToolPrefix,
18
19
  withAuth,
19
20
  wrapFetchForCch,
@@ -82,6 +83,7 @@ function buildSystemBlocks(
82
83
  * @param auth - Authentication configuration (API key or OAuth)
83
84
  * @param model - Model identifier to use
84
85
  * @param query - Search query from the user
86
+ * @param metadataUserId - Optional Anthropic Messages metadata.user_id (already shaped for OAuth)
85
87
  * @param systemPrompt - Optional system prompt for guiding response style
86
88
  * @returns Raw API response from Anthropic
87
89
  * @throws {SearchProviderError} If the API request fails
@@ -90,6 +92,7 @@ async function callSearch(
90
92
  auth: AnthropicAuthConfig,
91
93
  model: string,
92
94
  query: string,
95
+ metadataUserId?: string,
93
96
  systemPrompt?: string,
94
97
  maxTokens?: number,
95
98
  temperature?: number,
@@ -113,6 +116,10 @@ async function callSearch(
113
116
  ],
114
117
  };
115
118
 
119
+ if (metadataUserId) {
120
+ body.metadata = { user_id: metadataUserId };
121
+ }
122
+
116
123
  if (temperature !== undefined) {
117
124
  body.temperature = temperature;
118
125
  }
@@ -273,19 +280,37 @@ export async function searchAnthropic(
273
280
  const model = getModel();
274
281
  const systemPrompt = "authStorage" in params ? params.systemPrompt : params.system_prompt;
275
282
  const maxTokens = "authStorage" in params ? params.maxOutputTokens : params.max_tokens;
283
+ const callerSessionId = "authStorage" in params ? params.sessionId : undefined;
284
+ const accountId =
285
+ "authStorage" in params ? params.authStorage.getOAuthAccountId("anthropic", params.sessionId) : undefined;
276
286
  const response = await withAuth(
277
287
  keyOrResolver,
278
- key =>
279
- callSearch(
280
- buildAnthropicAuthConfig(key, searchBaseUrl),
288
+ key => {
289
+ const auth = buildAnthropicAuthConfig(key, searchBaseUrl);
290
+ // Mirror the main Messages path: OAuth requests need a Claude-Code-shaped
291
+ // metadata.user_id (`{session_id, account_uuid?, device_id}`) so the
292
+ // CC billing header + system fingerprint installed by
293
+ // `buildAnthropicSearchHeaders`/`buildSystemBlocks` line up with the
294
+ // attribution Anthropic and enterprise gateways expect. API-key tokens
295
+ // forward the raw session id verbatim.
296
+ const metadataUserId = resolveAnthropicMetadataUserId(
297
+ callerSessionId,
298
+ auth.isOAuth,
299
+ callerSessionId,
300
+ accountId,
301
+ );
302
+ return callSearch(
303
+ auth,
281
304
  model,
282
305
  params.query,
306
+ metadataUserId,
283
307
  systemPrompt,
284
308
  maxTokens,
285
309
  params.temperature,
286
310
  params.signal,
287
311
  params.fetch,
288
- ),
312
+ );
313
+ },
289
314
  {
290
315
  signal: params.signal,
291
316
  missingKeyMessage: