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

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 (53) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/cli.js +73 -67
  3. package/dist/types/capability/mcp.d.ts +1 -0
  4. package/dist/types/config/settings-schema.d.ts +13 -4
  5. package/dist/types/export/html/template.generated.d.ts +1 -1
  6. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  7. package/dist/types/mcp/oauth-flow.d.ts +6 -1
  8. package/dist/types/mcp/transports/stdio.d.ts +1 -0
  9. package/dist/types/mcp/types.d.ts +2 -0
  10. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  11. package/dist/types/modes/components/mcp-add-wizard.d.ts +2 -1
  12. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  13. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  14. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  15. package/dist/types/modes/controllers/tool-args-reveal.d.ts +43 -0
  16. package/dist/types/modes/theme/theme.d.ts +2 -1
  17. package/dist/types/task/index.d.ts +3 -3
  18. package/dist/types/tools/render-utils.d.ts +22 -0
  19. package/package.json +11 -11
  20. package/src/capability/mcp.ts +1 -0
  21. package/src/cli/gallery-cli.ts +5 -4
  22. package/src/config/mcp-schema.json +4 -0
  23. package/src/config/settings-schema.ts +15 -4
  24. package/src/edit/renderer.ts +96 -46
  25. package/src/export/html/template.generated.ts +1 -1
  26. package/src/export/html/template.js +6 -1
  27. package/src/internal-urls/docs-index.generated.ts +4 -4
  28. package/src/mcp/manager.ts +3 -0
  29. package/src/mcp/oauth-discovery.ts +27 -2
  30. package/src/mcp/oauth-flow.ts +47 -1
  31. package/src/mcp/transports/stdio.ts +3 -0
  32. package/src/mcp/types.ts +2 -0
  33. package/src/modes/components/assistant-message.ts +15 -0
  34. package/src/modes/components/btw-panel.ts +5 -1
  35. package/src/modes/components/mcp-add-wizard.ts +13 -0
  36. package/src/modes/components/settings-selector.ts +2 -0
  37. package/src/modes/components/status-line/component.ts +22 -12
  38. package/src/modes/components/status-line/types.ts +3 -0
  39. package/src/modes/components/transcript-container.ts +99 -18
  40. package/src/modes/components/tree-selector.ts +6 -1
  41. package/src/modes/controllers/event-controller.ts +28 -4
  42. package/src/modes/controllers/mcp-command-controller.ts +34 -2
  43. package/src/modes/controllers/selector-controller.ts +4 -0
  44. package/src/modes/controllers/tool-args-reveal.ts +174 -0
  45. package/src/modes/interactive-mode.ts +9 -2
  46. package/src/modes/theme/theme.ts +6 -0
  47. package/src/prompts/tools/task.md +7 -2
  48. package/src/session/agent-session.ts +25 -4
  49. package/src/task/index.ts +15 -10
  50. package/src/task/render.ts +10 -4
  51. package/src/tools/render-utils.ts +56 -0
  52. package/src/tools/write.ts +65 -47
  53. package/src/web/search/providers/anthropic.ts +29 -4
@@ -108,6 +108,7 @@ export type SymbolKey =
108
108
  | "icon.time"
109
109
  | "icon.pi"
110
110
  | "icon.agents"
111
+ | "icon.job"
111
112
  | "icon.cache"
112
113
  | "icon.input"
113
114
  | "icon.output"
@@ -304,6 +305,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
304
305
  "icon.time": "⏱",
305
306
  "icon.pi": "π",
306
307
  "icon.agents": "👥",
308
+ "icon.job": "⚙",
307
309
  "icon.cache": "💾",
308
310
  "icon.input": "⤵",
309
311
  "icon.output": "⤴",
@@ -567,6 +569,8 @@ const NERD_SYMBOLS: SymbolMap = {
567
569
  "icon.pi": "\ue22c",
568
570
  // pick:  | alt: 
569
571
  "icon.agents": "\uf0c0",
572
+ // pick: (nf-fa-gear) | alt: ⚙
573
+ "icon.job": "\uf013",
570
574
  // pick:  | alt:  
571
575
  "icon.cache": "\uf1c0",
572
576
  // pick:  | alt:  →
@@ -796,6 +800,7 @@ const ASCII_SYMBOLS: SymbolMap = {
796
800
  "icon.time": "t:",
797
801
  "icon.pi": "pi",
798
802
  "icon.agents": "AG",
803
+ "icon.job": "bg",
799
804
  "icon.cache": "cache",
800
805
  "icon.input": "in:",
801
806
  "icon.output": "out:",
@@ -1678,6 +1683,7 @@ export class Theme {
1678
1683
  time: this.#symbols["icon.time"],
1679
1684
  pi: this.#symbols["icon.pi"],
1680
1685
  agents: this.#symbols["icon.agents"],
1686
+ job: this.#symbols["icon.job"],
1681
1687
  cache: this.#symbols["icon.cache"],
1682
1688
  input: this.#symbols["icon.input"],
1683
1689
  output: this.#symbols["icon.output"],
@@ -1,10 +1,15 @@
1
- {{#if batchEnabled}}Spawns subagents to work in the background — one per `tasks[]` item; a single spawn is a one-item batch.{{else}}Spawns ONE subagent per call to work in the background.{{/if}}
1
+ {{#if asyncEnabled}}{{#if batchEnabled}}Spawns subagents to work in the background — one per `tasks[]` item; a single spawn is a one-item batch.{{else}}Spawns ONE subagent per call to work in the background.{{/if}}
2
2
 
3
3
  - Spawning is non-blocking: the call returns immediately with the agent id{{#if batchEnabled}}s{{/if}} and job id{{#if batchEnabled}}s{{/if}}; each result is delivered automatically when that agent yields.
4
4
  - Parallelism = {{#if batchEnabled}}`tasks[]` items in one call, and/or multiple `task` calls in one assistant message{{else}}multiple `task` calls in one assistant message{{/if}}. Concurrency is bounded at {{MAX_CONCURRENCY}} running subagents per session.
5
5
  - If genuinely blocked on a result, wait with `job poll`; otherwise keep working. `job cancel` terminates a task and **cannot carry a message** — only for stalled/abandoned work.
6
+ {{else}}{{#if batchEnabled}}Runs subagents synchronously — one per `tasks[]` item; a single spawn is a one-item batch.{{else}}Runs ONE subagent synchronously per call.{{/if}}
7
+
8
+ - Spawning is blocking: the call returns only after the agent{{#if batchEnabled}}s{{/if}} finish; results arrive inline.
9
+ - Parallelism = {{#if batchEnabled}}`tasks[]` items in one call, and/or multiple `task` calls in one assistant message{{else}}multiple `task` calls in one assistant message{{/if}}. Concurrency is bounded at {{MAX_CONCURRENCY}} running subagents per session.
10
+ {{/if}}
6
11
  {{#if ircEnabled}}
7
- - Coordinate with running agents via `irc` using their ids. Agents reach you and their siblings live the same way.
12
+ - Coordinate with agents via `irc` using their ids. Agents reach you and their siblings live the same way.
8
13
  {{/if}}
9
14
 
10
15
  <lifecycle>
@@ -546,6 +546,7 @@ interface ActiveRetryFallbackState {
546
546
  originalSelector: string;
547
547
  originalThinkingLevel: ConfiguredThinkingLevel | undefined;
548
548
  lastAppliedFallbackThinkingLevel: ConfiguredThinkingLevel | undefined;
549
+ pinned: boolean;
549
550
  }
550
551
 
551
552
  function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | undefined {
@@ -8257,10 +8258,18 @@ export class AgentSession {
8257
8258
  const contextWindow = this.model?.contextWindow ?? 0;
8258
8259
  if (isContextOverflow(message, contextWindow)) return false;
8259
8260
 
8261
+ if (this.#isClassifierRefusal(message)) return true;
8262
+
8260
8263
  const err = message.errorMessage;
8261
8264
  return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
8262
8265
  }
8263
8266
 
8267
+ #isClassifierRefusal(message: AssistantMessage): boolean {
8268
+ if (message.stopReason !== "error") return false;
8269
+ const stopType = message.stopDetails?.type;
8270
+ return stopType === "refusal" || stopType === "sensitive";
8271
+ }
8272
+
8264
8273
  #isTransientErrorMessage(errorMessage: string): boolean {
8265
8274
  return (
8266
8275
  this.#isTransientEnvelopeErrorMessage(errorMessage) || this.#isTransientTransportErrorMessage(errorMessage)
@@ -8404,6 +8413,7 @@ export class AgentSession {
8404
8413
  role: string,
8405
8414
  selector: RetryFallbackSelector,
8406
8415
  currentSelector: string,
8416
+ options?: { pinFallback?: boolean },
8407
8417
  ): Promise<void> {
8408
8418
  const candidate = this.#modelRegistry.find(selector.provider, selector.id);
8409
8419
  if (!candidate) {
@@ -8429,9 +8439,11 @@ export class AgentSession {
8429
8439
  originalSelector: currentSelector,
8430
8440
  originalThinkingLevel: currentThinkingLevel,
8431
8441
  lastAppliedFallbackThinkingLevel: nextThinkingLevel,
8442
+ pinned: options?.pinFallback === true,
8432
8443
  };
8433
8444
  } else {
8434
8445
  this.#activeRetryFallback.lastAppliedFallbackThinkingLevel = nextThinkingLevel;
8446
+ this.#activeRetryFallback.pinned = this.#activeRetryFallback.pinned || options?.pinFallback === true;
8435
8447
  }
8436
8448
  await this.#emitSessionEvent({
8437
8449
  type: "retry_fallback_applied",
@@ -8441,7 +8453,7 @@ export class AgentSession {
8441
8453
  });
8442
8454
  }
8443
8455
 
8444
- async #tryRetryModelFallback(currentSelector: string): Promise<boolean> {
8456
+ async #tryRetryModelFallback(currentSelector: string, options?: { pinFallback?: boolean }): Promise<boolean> {
8445
8457
  const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector);
8446
8458
  if (!role) return false;
8447
8459
 
@@ -8451,7 +8463,7 @@ export class AgentSession {
8451
8463
  if (!candidate) continue;
8452
8464
  const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
8453
8465
  if (!apiKey) continue;
8454
- await this.#applyRetryFallbackCandidate(role, selector, currentSelector);
8466
+ await this.#applyRetryFallbackCandidate(role, selector, currentSelector, options);
8455
8467
  return true;
8456
8468
  }
8457
8469
 
@@ -8460,6 +8472,7 @@ export class AgentSession {
8460
8472
 
8461
8473
  async #maybeRestoreRetryFallbackPrimary(): Promise<void> {
8462
8474
  if (!this.#activeRetryFallback) return;
8475
+ if (this.#activeRetryFallback.pinned) return;
8463
8476
  if (this.#getRetryFallbackRevertPolicy() !== "cooldown-expiry") return;
8464
8477
 
8465
8478
  const {
@@ -8557,6 +8570,7 @@ export class AgentSession {
8557
8570
  async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
8558
8571
  const retrySettings = this.settings.getGroup("retry");
8559
8572
  if (!retrySettings.enabled) return false;
8573
+ const classifierRefusal = this.#isClassifierRefusal(message);
8560
8574
 
8561
8575
  const generation = this.#promptGeneration;
8562
8576
  this.#retryAttempt++;
@@ -8630,8 +8644,10 @@ export class AgentSession {
8630
8644
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
8631
8645
  if (!switchedCredential && currentSelector) {
8632
8646
  if (retrySettings.modelFallback) {
8633
- this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
8634
- switchedModel = await this.#tryRetryModelFallback(currentSelector);
8647
+ if (!classifierRefusal) {
8648
+ this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
8649
+ }
8650
+ switchedModel = await this.#tryRetryModelFallback(currentSelector, { pinFallback: classifierRefusal });
8635
8651
  }
8636
8652
  if (switchedModel) {
8637
8653
  delayMs = 0;
@@ -8639,6 +8655,11 @@ export class AgentSession {
8639
8655
  delayMs = parsedRetryAfterMs;
8640
8656
  }
8641
8657
  }
8658
+ if (classifierRefusal && !switchedModel) {
8659
+ this.#retryAttempt = 0;
8660
+ this.#resolveRetry();
8661
+ return false;
8662
+ }
8642
8663
 
8643
8664
  // Fail-fast cap: if the provider asks us to wait longer than
8644
8665
  // retry.maxDelayMs and we have no fallback credential or model to
package/src/task/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * Supports:
10
10
  * - Single agent spawn per call (parallelism = parallel task calls)
11
11
  * - Batch spawning + shared context per call when `task.batch` is enabled
12
- * - Non-blocking execution via the session's AsyncJobManager
12
+ * - Background execution through AsyncJobManager when `async.enabled` is enabled
13
13
  * - Progress tracking via JSON events
14
14
  * - Session artifacts for debugging
15
15
  */
@@ -190,6 +190,7 @@ function renderDescription(
190
190
  isolationEnabled: boolean,
191
191
  disabledAgents: string[],
192
192
  batchEnabled: boolean,
193
+ asyncEnabled: boolean,
193
194
  ircEnabled: boolean,
194
195
  parentSpawns: string,
195
196
  ): string {
@@ -217,6 +218,7 @@ function renderDescription(
217
218
  MAX_CONCURRENCY: maxConcurrency,
218
219
  isolationEnabled,
219
220
  batchEnabled,
221
+ asyncEnabled,
220
222
  ircEnabled,
221
223
  });
222
224
  }
@@ -374,8 +376,8 @@ function discoverAgentsForCreate(cwd: string): Promise<DiscoveryResult> {
374
376
  * Task tool - Delegate tasks to specialized agents.
375
377
  *
376
378
  * Each call spawns one subagent — or, with `task.batch`, one per `tasks[]`
377
- * item. Spawning is non-blocking: the call registers AsyncJobManager jobs and
378
- * returns immediately; each result is delivered when that agent yields.
379
+ * item. When `async.enabled` is on, spawns run as AsyncJobManager jobs; when
380
+ * disabled, the tool blocks until every spawn finishes.
379
381
  */
380
382
  export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetails, Theme> {
381
383
  readonly name = "task";
@@ -411,7 +413,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
411
413
  return lines;
412
414
  };
413
415
  readonly label = "Task";
414
- readonly summary = "Spawn a subagent to complete a task in the background";
416
+ readonly summary = "Spawn subagents to complete delegated tasks";
415
417
  readonly strict = true;
416
418
  readonly loadMode = "discoverable";
417
419
  readonly renderResult = renderResult;
@@ -448,6 +450,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
448
450
  isolationMode !== "none",
449
451
  disabledAgents,
450
452
  this.#isBatchEnabled(),
453
+ this.session.settings.get("async.enabled"),
451
454
  isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0),
452
455
  this.session.getSessionSpawns() ?? "*",
453
456
  );
@@ -492,12 +495,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
492
495
 
493
496
  const spawnItems = resolveSpawnItems(params);
494
497
  const selectedAgent = this.#discoveredAgents.find(agent => agent.name === params.agent);
495
- const manager = this.session.asyncJobManager;
496
- if (!manager || selectedAgent?.blocking === true) {
497
- // Sync fallback: orphaned host that never wired a job manager, or an
498
- // agent definition that declares `blocking: true`. The session-scoped
499
- // semaphore still bounds fan-out across parallel task calls.
500
- if (!manager) {
498
+ const asyncEnabled = this.session.settings.get("async.enabled");
499
+ const manager = asyncEnabled ? this.session.asyncJobManager : undefined;
500
+ if (!asyncEnabled || !manager || selectedAgent?.blocking === true) {
501
+ // Sync fallback: async execution disabled, orphaned host that never
502
+ // wired a job manager, or an agent definition that declares
503
+ // `blocking: true`. The session-scoped semaphore still bounds fan-out
504
+ // across parallel task calls.
505
+ if (asyncEnabled && !manager) {
501
506
  logger.warn("task: no AsyncJobManager registered; falling back to sync execution");
502
507
  }
503
508
  return this.#executeSyncFanout(toolCallId, params, spawnItems, signal, onUpdate);
@@ -631,12 +631,18 @@ export function renderCall(
631
631
  // same agent (and the assignment brief) itself, so showing it here would
632
632
  // repeat what the result frame already shows.
633
633
  if (!options.renderContext?.hasResult) {
634
- sections.push({
635
- separator: true,
636
- lines: renderTaskCallLines(args, theme),
637
- });
634
+ // Mirror renderResult's layout — context, assignment, then the
635
+ // per-agent list — so the agent rows do not jump from above the
636
+ // brief to below it when the first progress snapshot replaces the
637
+ // call view. This also matches the schema's field order (`context`
638
+ // streams before `tasks`), so the streaming preview grows
639
+ // append-only instead of inserting agent rows above the
640
+ // already-rendered markdown and pushing it down on every item.
638
641
  if (contextSection) sections.push(contextSection(width));
639
642
  if (assignmentSection) sections.push(assignmentSection(width));
643
+ const callLines = renderTaskCallLines(args, theme);
644
+ // Guarded: an empty trailing section would still draw its divider.
645
+ if (callLines.length > 0) sections.push({ separator: true, lines: callLines });
640
646
  }
641
647
 
642
648
  return {
@@ -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: