@oh-my-pi/pi-coding-agent 16.2.12 → 16.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 (171) hide show
  1. package/CHANGELOG.md +115 -7
  2. package/dist/cli.js +5996 -5945
  3. package/dist/types/advisor/config.d.ts +4 -2
  4. package/dist/types/collab/host.d.ts +16 -0
  5. package/dist/types/collab/protocol.d.ts +9 -3
  6. package/dist/types/commit/model-selection.d.ts +5 -0
  7. package/dist/types/config/model-resolver.d.ts +12 -10
  8. package/dist/types/config/models-config-schema.d.ts +12 -0
  9. package/dist/types/config/models-config.d.ts +9 -0
  10. package/dist/types/config/settings-schema.d.ts +28 -5
  11. package/dist/types/edit/modes/patch.d.ts +11 -0
  12. package/dist/types/eval/agent-bridge.d.ts +5 -1
  13. package/dist/types/exec/bash-executor.d.ts +1 -0
  14. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +86 -2
  15. package/dist/types/extensibility/skills.d.ts +2 -1
  16. package/dist/types/mnemopi/state.d.ts +12 -0
  17. package/dist/types/modes/components/agent-hub.d.ts +9 -5
  18. package/dist/types/modes/components/status-line/component.test.d.ts +1 -0
  19. package/dist/types/modes/components/usage-row.d.ts +1 -1
  20. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  21. package/dist/types/modes/controllers/extension-ui-controller.d.ts +6 -0
  22. package/dist/types/modes/controllers/streaming-reveal.d.ts +17 -1
  23. package/dist/types/modes/controllers/tool-args-reveal.d.ts +30 -3
  24. package/dist/types/modes/interactive-mode.d.ts +12 -7
  25. package/dist/types/modes/rpc/rpc-client.d.ts +5 -0
  26. package/dist/types/modes/rpc/rpc-mode.d.ts +64 -1
  27. package/dist/types/modes/session-teardown.d.ts +63 -0
  28. package/dist/types/modes/session-teardown.test.d.ts +1 -0
  29. package/dist/types/modes/theme/theme.d.ts +9 -4
  30. package/dist/types/modes/types.d.ts +2 -3
  31. package/dist/types/modes/utils/copy-targets.d.ts +2 -0
  32. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +1 -0
  33. package/dist/types/sdk.d.ts +9 -0
  34. package/dist/types/session/agent-session.d.ts +46 -13
  35. package/dist/types/session/exit-diagnostics.d.ts +48 -0
  36. package/dist/types/session/session-loader.d.ts +5 -0
  37. package/dist/types/task/executor.d.ts +12 -5
  38. package/dist/types/task/parallel.d.ts +1 -0
  39. package/dist/types/task/render.test.d.ts +1 -0
  40. package/dist/types/thinking.d.ts +2 -0
  41. package/dist/types/tools/checkpoint.d.ts +8 -0
  42. package/dist/types/tools/eval-render.d.ts +1 -0
  43. package/dist/types/tools/fetch.d.ts +11 -1
  44. package/dist/types/tools/index.d.ts +3 -1
  45. package/dist/types/tools/irc.d.ts +1 -0
  46. package/dist/types/tools/output-meta.d.ts +7 -0
  47. package/dist/types/tools/output-schema-validator.d.ts +7 -4
  48. package/dist/types/tools/path-utils.d.ts +16 -0
  49. package/dist/types/tools/render-utils.d.ts +4 -1
  50. package/dist/types/utils/git.d.ts +47 -2
  51. package/dist/types/web/search/provider.d.ts +7 -0
  52. package/dist/types/web/search/types.d.ts +1 -1
  53. package/package.json +12 -12
  54. package/src/advisor/config.ts +4 -2
  55. package/src/cli/bench-cli.ts +7 -2
  56. package/src/collab/guest.ts +94 -5
  57. package/src/collab/host.ts +80 -3
  58. package/src/collab/protocol.ts +9 -2
  59. package/src/commit/model-selection.ts +8 -2
  60. package/src/config/keybindings.ts +3 -1
  61. package/src/config/model-discovery.ts +66 -2
  62. package/src/config/model-registry.ts +7 -3
  63. package/src/config/model-resolver.ts +51 -23
  64. package/src/config/models-config-schema.ts +13 -0
  65. package/src/config/settings-schema.ts +44 -14
  66. package/src/edit/hashline/diff.ts +13 -2
  67. package/src/edit/index.ts +58 -8
  68. package/src/edit/modes/patch.ts +53 -18
  69. package/src/edit/streaming.ts +7 -6
  70. package/src/eval/__tests__/agent-bridge.test.ts +57 -1
  71. package/src/eval/agent-bridge.ts +15 -5
  72. package/src/exec/bash-executor.ts +7 -12
  73. package/src/extensibility/legacy-pi-coding-agent-shim.ts +534 -16
  74. package/src/extensibility/skills.ts +29 -6
  75. package/src/goals/guided-setup.ts +4 -3
  76. package/src/internal-urls/docs-index.generated.txt +1 -1
  77. package/src/lsp/client.ts +11 -14
  78. package/src/main.ts +38 -10
  79. package/src/mnemopi/state.ts +43 -7
  80. package/src/modes/acp/acp-agent.ts +1 -1
  81. package/src/modes/components/agent-hub.ts +10 -2
  82. package/src/modes/components/agent-transcript-viewer.ts +39 -7
  83. package/src/modes/components/assistant-message.ts +16 -10
  84. package/src/modes/components/chat-transcript-builder.ts +11 -1
  85. package/src/modes/components/custom-editor.test.ts +20 -0
  86. package/src/modes/components/hook-selector.ts +44 -25
  87. package/src/modes/components/model-selector.ts +1 -1
  88. package/src/modes/components/status-line/component.test.ts +44 -0
  89. package/src/modes/components/status-line/component.ts +9 -1
  90. package/src/modes/components/status-line/segments.ts +3 -3
  91. package/src/modes/components/usage-row.ts +16 -2
  92. package/src/modes/controllers/command-controller.ts +6 -21
  93. package/src/modes/controllers/event-controller.ts +11 -9
  94. package/src/modes/controllers/extension-ui-controller.ts +84 -3
  95. package/src/modes/controllers/input-controller.ts +16 -13
  96. package/src/modes/controllers/selector-controller.ts +3 -8
  97. package/src/modes/controllers/streaming-reveal.ts +75 -53
  98. package/src/modes/controllers/tool-args-reveal.ts +340 -10
  99. package/src/modes/interactive-mode.ts +122 -79
  100. package/src/modes/rpc/rpc-client.ts +21 -0
  101. package/src/modes/rpc/rpc-mode.ts +197 -46
  102. package/src/modes/session-teardown.test.ts +219 -0
  103. package/src/modes/session-teardown.ts +82 -0
  104. package/src/modes/setup-wizard/scenes/theme.ts +2 -2
  105. package/src/modes/skill-command.ts +7 -20
  106. package/src/modes/theme/theme.ts +29 -15
  107. package/src/modes/types.ts +2 -3
  108. package/src/modes/utils/copy-targets.ts +12 -0
  109. package/src/modes/utils/ui-helpers.ts +19 -2
  110. package/src/plan-mode/approved-plan-prompt.test.ts +36 -0
  111. package/src/prompts/advisor/system.md +1 -1
  112. package/src/prompts/agents/tester.md +6 -2
  113. package/src/prompts/skills/autoload.md +8 -0
  114. package/src/prompts/skills/user-invocation.md +11 -0
  115. package/src/prompts/steering/parent-irc.md +5 -0
  116. package/src/prompts/system/irc-incoming.md +2 -0
  117. package/src/prompts/system/mid-run-todo-nudge.md +3 -0
  118. package/src/prompts/system/plan-mode-approved.md +7 -10
  119. package/src/prompts/system/plan-mode-compact-instructions.md +2 -1
  120. package/src/prompts/system/plan-mode-reference.md +3 -4
  121. package/src/prompts/system/rewind-report.md +6 -0
  122. package/src/prompts/system/subagent-system-prompt.md +3 -0
  123. package/src/prompts/tools/irc.md +2 -1
  124. package/src/prompts/tools/job.md +2 -2
  125. package/src/prompts/tools/rewind.md +3 -2
  126. package/src/prompts/tools/ssh.md +1 -0
  127. package/src/prompts/tools/task.md +4 -0
  128. package/src/sdk.ts +18 -4
  129. package/src/session/agent-session.ts +660 -114
  130. package/src/session/exit-diagnostics.ts +202 -0
  131. package/src/session/session-context.ts +25 -13
  132. package/src/session/session-loader.ts +58 -25
  133. package/src/session/session-manager.ts +0 -1
  134. package/src/session/session-persistence.ts +30 -4
  135. package/src/session/settings-stream-fn.ts +14 -0
  136. package/src/slash-commands/builtin-registry.ts +35 -3
  137. package/src/system-prompt.ts +15 -1
  138. package/src/task/executor.ts +31 -8
  139. package/src/task/index.ts +31 -9
  140. package/src/task/isolation-runner.ts +18 -2
  141. package/src/task/parallel.ts +7 -2
  142. package/src/task/render.test.ts +121 -0
  143. package/src/task/render.ts +48 -2
  144. package/src/task/worktree.ts +12 -13
  145. package/src/task/yield-assembly.ts +8 -5
  146. package/src/thinking.ts +5 -0
  147. package/src/tools/ask.ts +188 -9
  148. package/src/tools/ast-edit.ts +7 -0
  149. package/src/tools/ast-grep.ts +11 -0
  150. package/src/tools/bash.ts +6 -30
  151. package/src/tools/checkpoint.ts +15 -1
  152. package/src/tools/eval-render.ts +15 -0
  153. package/src/tools/fetch.ts +82 -18
  154. package/src/tools/gh.ts +1 -1
  155. package/src/tools/grep.ts +45 -27
  156. package/src/tools/index.ts +3 -1
  157. package/src/tools/irc.ts +24 -9
  158. package/src/tools/output-meta.ts +50 -0
  159. package/src/tools/output-schema-validator.ts +152 -15
  160. package/src/tools/path-utils.ts +55 -10
  161. package/src/tools/read.ts +1 -1
  162. package/src/tools/render-utils.ts +5 -3
  163. package/src/tools/ssh.ts +8 -1
  164. package/src/tools/yield.ts +41 -1
  165. package/src/utils/commit-message-generator.ts +2 -2
  166. package/src/utils/git.ts +271 -29
  167. package/src/utils/thinking-display.ts +13 -0
  168. package/src/web/search/index.ts +9 -28
  169. package/src/web/search/provider.ts +26 -1
  170. package/src/web/search/providers/duckduckgo.ts +1 -1
  171. package/src/web/search/types.ts +5 -1
@@ -11,6 +11,7 @@ export const CATCHUP_FRAMES = 8;
11
11
  type AssistantContentBlock = AssistantMessage["content"][number];
12
12
  type DisplayThinkingContentBlock = Extract<AssistantContentBlock, { type: "thinking" }> & { rawThinking?: string };
13
13
  type StreamingRevealComponent = Pick<AssistantMessageComponent, "updateContent">;
14
+ type GraphemeSlicer = (index: number, text: string, units: number) => string;
14
15
 
15
16
  type StreamingRevealControllerOptions = {
16
17
  getSmoothStreaming(): boolean;
@@ -44,12 +45,29 @@ function countGraphemesFrom(text: string, start: number): { count: number; tailS
44
45
  }
45
46
  return { count, tailStart };
46
47
  }
48
+ /** Segment `text` from code-unit offset `start`, walking up to `clusters`
49
+ * graphemes. Returns the code-unit END of the final cluster walked, its START
50
+ * (`lastStart`), and how many clusters were found (`count` may be less than
51
+ * `clusters` if the suffix is shorter than requested). */
52
+ function segmentFrom(text: string, start: number, clusters: number): { end: number; lastStart: number; count: number } {
53
+ let count = 0;
54
+ let lastStart = start;
55
+ let end = start;
56
+ for (const seg of getSegmenter().segment(start === 0 ? text : text.slice(start))) {
57
+ count += 1;
58
+ lastStart = start + seg.index;
59
+ end = start + seg.index + seg.segment.length;
60
+ if (count >= clusters) break;
61
+ }
62
+ return { end, lastStart, count };
63
+ }
47
64
 
48
65
  /** Memoizes per-block grapheme counts across reveal ticks. Streaming blocks only
49
66
  * grow by appending, and an append can only alter the final grapheme cluster of
50
67
  * the previous text, so only the suffix from that cluster needs re-segmenting. */
51
- class BlockUnitCounter {
68
+ export class BlockUnitCounter {
52
69
  #entries = new Map<number, { text: string; count: number; tailStart: number }>();
70
+ #sliceEntries = new Map<number, { text: string; units: number; end: number; lastStart: number }>();
53
71
 
54
72
  count(index: number, text: string): number {
55
73
  const entry = this.#entries.get(index);
@@ -69,6 +87,29 @@ class BlockUnitCounter {
69
87
 
70
88
  reset(): void {
71
89
  this.#entries.clear();
90
+ this.#sliceEntries.clear();
91
+ }
92
+ /** Slice `text` to its first `units` graphemes. Memoized across reveal ticks:
93
+ * streaming blocks grow only by appending and the reveal target advances
94
+ * monotonically, so a previously sliced prefix is reused and only the suffix
95
+ * from the boundary cluster is re-segmented. Only an exact (text, units) hit
96
+ * skips segmentation entirely — an append can extend the boundary cluster, so
97
+ * the incremental path still re-segments from that cluster's start. */
98
+ slice(index: number, text: string, units: number): string {
99
+ if (units <= 0 || text.length === 0) return "";
100
+ const entry = this.#sliceEntries.get(index);
101
+ if (entry !== undefined && entry.text === text && entry.units === units) {
102
+ return entry.end >= text.length ? text : text.slice(0, entry.end);
103
+ }
104
+ if (entry !== undefined && (entry.text === text || text.startsWith(entry.text)) && units >= entry.units) {
105
+ const extra = units - entry.units + 1;
106
+ const seg = segmentFrom(text, entry.lastStart, extra);
107
+ this.#sliceEntries.set(index, { text, units, end: seg.end, lastStart: seg.lastStart });
108
+ return seg.end >= text.length ? text : text.slice(0, seg.end);
109
+ }
110
+ const seg = segmentFrom(text, 0, units);
111
+ this.#sliceEntries.set(index, { text, units, end: seg.end, lastStart: seg.lastStart });
112
+ return seg.end >= text.length ? text : text.slice(0, seg.end);
72
113
  }
73
114
  }
74
115
 
@@ -104,20 +145,24 @@ function revealTextBlock(
104
145
  block: Extract<AssistantContentBlock, { type: "text" }>,
105
146
  remaining: number,
106
147
  units: number,
148
+ index: number,
149
+ sliceOf: GraphemeSlicer,
107
150
  ): AssistantContentBlock {
108
151
  if (remaining <= 0) return block.text.length === 0 ? block : { ...block, text: "" };
109
152
  if (remaining >= units) return block;
110
- return { ...block, text: sliceGraphemes(block.text, remaining) };
153
+ return { ...block, text: sliceOf(index, block.text, remaining) };
111
154
  }
112
155
 
113
156
  function revealThinkingBlock(
114
157
  block: Extract<AssistantContentBlock, { type: "thinking" }>,
115
158
  remaining: number,
116
159
  units: number,
160
+ index: number,
161
+ sliceOf: GraphemeSlicer,
117
162
  ): AssistantContentBlock {
118
163
  if (remaining <= 0) return block.thinking.length === 0 ? block : { ...block, thinking: "" };
119
164
  if (remaining >= units) return block;
120
- return { ...block, thinking: sliceGraphemes(block.thinking, remaining) };
165
+ return { ...block, thinking: sliceOf(index, block.thinking, remaining) };
121
166
  }
122
167
 
123
168
  export function buildDisplayMessage(
@@ -126,6 +171,7 @@ export function buildDisplayMessage(
126
171
  hideThinking: boolean,
127
172
  proseOnly = true,
128
173
  countOf: (index: number, text: string) => number = (_index, text) => countGraphemes(text),
174
+ sliceOf: GraphemeSlicer = (_index, text, units) => sliceGraphemes(text, units),
129
175
  ): AssistantMessage {
130
176
  let remaining = Math.max(0, Math.floor(revealed));
131
177
  const content: AssistantContentBlock[] = [];
@@ -133,7 +179,7 @@ export function buildDisplayMessage(
133
179
  const block = target.content[i]!;
134
180
  if (block.type === "text") {
135
181
  const units = countOf(i, block.text);
136
- content.push(revealTextBlock(block, remaining, units));
182
+ content.push(revealTextBlock(block, remaining, units, i, sliceOf));
137
183
  remaining = Math.max(0, remaining - units);
138
184
  } else if (block.type === "thinking" && !hideThinking) {
139
185
  const formatted = formatThinkingForDisplay(block.thinking, proseOnly);
@@ -144,7 +190,7 @@ export function buildDisplayMessage(
144
190
  thinking: formatted,
145
191
  rawThinking: block.thinking,
146
192
  };
147
- content.push(revealThinkingBlock(displayBlock, remaining, units));
193
+ content.push(revealThinkingBlock(displayBlock, remaining, units, i, sliceOf));
148
194
  remaining = Math.max(0, remaining - units);
149
195
  } else {
150
196
  content.push(block);
@@ -174,6 +220,8 @@ export class StreamingRevealController {
174
220
  #smoothStreaming = true;
175
221
  readonly #unitCounter = new BlockUnitCounter();
176
222
  readonly #countOf = (index: number, text: string): number => this.#unitCounter.count(index, text);
223
+ readonly #sliceOf = (index: number, text: string, units: number): string =>
224
+ this.#unitCounter.slice(index, text, units);
177
225
 
178
226
  constructor(options: StreamingRevealControllerOptions) {
179
227
  this.#getSmoothStreaming = options.getSmoothStreaming;
@@ -181,6 +229,16 @@ export class StreamingRevealController {
181
229
  this.#getProseOnlyThinking = options.getProseOnlyThinking;
182
230
  this.#requestRender = options.requestRender;
183
231
  }
232
+ #build(target: AssistantMessage, revealed: number): AssistantMessage {
233
+ return buildDisplayMessage(
234
+ target,
235
+ revealed,
236
+ this.#hideThinkingBlock,
237
+ this.#proseOnlyThinking,
238
+ this.#countOf,
239
+ this.#sliceOf,
240
+ );
241
+ }
184
242
 
185
243
  begin(component: StreamingRevealComponent, message: AssistantMessage): void {
186
244
  this.stop();
@@ -192,10 +250,7 @@ export class StreamingRevealController {
192
250
  this.#smoothStreaming = this.#getSmoothStreaming();
193
251
  if (!this.#smoothStreaming) {
194
252
  const total = this.#visibleUnits(message);
195
- component.updateContent(
196
- buildDisplayMessage(message, total, this.#hideThinkingBlock, this.#proseOnlyThinking, this.#countOf),
197
- { transient: true },
198
- );
253
+ component.updateContent(this.#build(message, total), { transient: true });
199
254
  return;
200
255
  }
201
256
  const total = this.#visibleUnits(message);
@@ -203,18 +258,9 @@ export class StreamingRevealController {
203
258
  // A tool call is a transcript-order boundary: finish any leading
204
259
  // assistant text before EventController renders the separate tool card.
205
260
  this.#revealed = total;
206
- component.updateContent(
207
- buildDisplayMessage(
208
- message,
209
- this.#revealed,
210
- this.#hideThinkingBlock,
211
- this.#proseOnlyThinking,
212
- this.#countOf,
213
- ),
214
- {
215
- transient: true,
216
- },
217
- );
261
+ component.updateContent(this.#build(message, this.#revealed), {
262
+ transient: true,
263
+ });
218
264
  return;
219
265
  }
220
266
  this.#renderCurrent();
@@ -229,10 +275,7 @@ export class StreamingRevealController {
229
275
  if (!this.#component) return;
230
276
  if (!this.#smoothStreaming) {
231
277
  const total = this.#visibleUnits(message);
232
- this.#component.updateContent(
233
- buildDisplayMessage(message, total, this.#hideThinkingBlock, this.#proseOnlyThinking, this.#countOf),
234
- { transient: true },
235
- );
278
+ this.#component.updateContent(this.#build(message, total), { transient: true });
236
279
  return;
237
280
  }
238
281
  const total = this.#visibleUnits(message);
@@ -241,18 +284,9 @@ export class StreamingRevealController {
241
284
  // assistant text before EventController renders the separate tool card.
242
285
  this.#revealed = total;
243
286
  this.#stopTimer();
244
- this.#component.updateContent(
245
- buildDisplayMessage(
246
- message,
247
- this.#revealed,
248
- this.#hideThinkingBlock,
249
- this.#proseOnlyThinking,
250
- this.#countOf,
251
- ),
252
- {
253
- transient: true,
254
- },
255
- );
287
+ this.#component.updateContent(this.#build(message, this.#revealed), {
288
+ transient: true,
289
+ });
256
290
  return;
257
291
  }
258
292
  if (this.#revealed > total) {
@@ -309,16 +343,7 @@ export class StreamingRevealController {
309
343
  // Every controller render is an in-flight streaming snapshot, even when
310
344
  // smooth reveal has temporarily caught up to the current target. The
311
345
  // message_end handler performs the only stable non-transient render.
312
- this.#component.updateContent(
313
- buildDisplayMessage(
314
- this.#target,
315
- this.#revealed,
316
- this.#hideThinkingBlock,
317
- this.#proseOnlyThinking,
318
- this.#countOf,
319
- ),
320
- { transient: true },
321
- );
346
+ this.#component.updateContent(this.#build(this.#target, this.#revealed), { transient: true });
322
347
  }
323
348
 
324
349
  #syncTimer(total = this.#target ? this.#visibleUnits(this.#target) : 0): void {
@@ -356,12 +381,9 @@ export class StreamingRevealController {
356
381
  return;
357
382
  }
358
383
  this.#revealed = Math.min(total, this.#revealed + nextStep(total - this.#revealed));
359
- component.updateContent(
360
- buildDisplayMessage(target, this.#revealed, this.#hideThinkingBlock, this.#proseOnlyThinking, this.#countOf),
361
- {
362
- transient: true,
363
- },
364
- );
384
+ component.updateContent(this.#build(target, this.#revealed), {
385
+ transient: true,
386
+ });
365
387
  this.#requestRender();
366
388
  if (this.#revealed >= total) {
367
389
  this.#stopTimer();
@@ -6,11 +6,289 @@ type ToolArgsRevealComponent = {
6
6
  updateArgs(args: unknown, toolCallId?: string): void;
7
7
  };
8
8
 
9
+ // Top-level string args a renderer reads mid-stream. The streamed-args decode
10
+ // reads these fields incrementally between throttled full-JSON parses so a
11
+ // long payload updates preview args at reveal cadence instead of stalling for
12
+ // STREAMING_JSON_PARSE_MIN_GROWTH bytes at a time. Nested-array modes (edit
13
+ // patch/replace `edits[].diff`) still fall through to the throttled parse.
14
+ const STREAMING_STRING_KEYS_BY_TOOL: Record<string, readonly string[]> = {
15
+ write: ["content"],
16
+ edit: ["input"],
17
+ eval: ["code"],
18
+ };
19
+
20
+ /** String fields the streamed-args decode reads incrementally for `toolName`. */
21
+ export function streamingStringKeysForTool(toolName: string, rawInput: boolean): readonly string[] | undefined {
22
+ if (rawInput) return undefined;
23
+ return STREAMING_STRING_KEYS_BY_TOOL[toolName];
24
+ }
25
+
9
26
  type ToolArgsRevealControllerOptions = {
10
27
  getSmoothStreaming(): boolean;
11
28
  requestRender(): void;
12
29
  };
13
30
 
31
+ type StreamingJsonStringExtractorResult = {
32
+ values: Record<string, string>;
33
+ changed: boolean;
34
+ };
35
+
36
+ function decodeJsonStringEscape(ch: string): string {
37
+ switch (ch) {
38
+ case '"':
39
+ case "\\":
40
+ case "/":
41
+ return ch;
42
+ case "b":
43
+ return "\b";
44
+ case "f":
45
+ return "\f";
46
+ case "n":
47
+ return "\n";
48
+ case "r":
49
+ return "\r";
50
+ case "t":
51
+ return "\t";
52
+ default:
53
+ return ch;
54
+ }
55
+ }
56
+
57
+ function isHexDigit(ch: string): boolean {
58
+ return (ch >= "0" && ch <= "9") || (ch >= "a" && ch <= "f") || (ch >= "A" && ch <= "F");
59
+ }
60
+
61
+ type StreamingJsonStringExtractorState = "scan" | "candidate" | "afterCandidate" | "beforeValue" | "target";
62
+
63
+ class StreamingJsonStringExtractor {
64
+ readonly #keys: Set<string>;
65
+ #source = "";
66
+ #offset = 0;
67
+ /** `{`/`[` nesting outside strings. Candidate keys match only at depth 1 —
68
+ * the top level of the args object — so a nested object's key (e.g.
69
+ * `{"meta":{"content":…}}`) is never captured as a streamed top-level arg. */
70
+ #depth = 0;
71
+ #state: StreamingJsonStringExtractorState = "scan";
72
+ #candidate = "";
73
+ #candidateEscaped = false;
74
+ #candidateUnicode = "";
75
+ #matchedKey: string | undefined;
76
+ #targetKey: string | undefined;
77
+ #targetEscaped = false;
78
+ #targetUnicode = "";
79
+ #values: Record<string, string> = {};
80
+ #changed = false;
81
+
82
+ constructor(keys: readonly string[]) {
83
+ this.#keys = new Set(keys);
84
+ }
85
+
86
+ reset(): void {
87
+ this.#source = "";
88
+ this.#offset = 0;
89
+ this.#depth = 0;
90
+ this.#state = "scan";
91
+ this.#candidate = "";
92
+ this.#candidateEscaped = false;
93
+ this.#candidateUnicode = "";
94
+ this.#matchedKey = undefined;
95
+ this.#targetKey = undefined;
96
+ this.#targetEscaped = false;
97
+ this.#targetUnicode = "";
98
+ this.#values = {};
99
+ this.#changed = false;
100
+ }
101
+
102
+ update(prefix: string): StreamingJsonStringExtractorResult {
103
+ if (!prefix.startsWith(this.#source)) {
104
+ this.reset();
105
+ }
106
+ this.#source = prefix;
107
+ this.#changed = false;
108
+ while (this.#offset < prefix.length) {
109
+ const ch = prefix[this.#offset]!;
110
+ switch (this.#state) {
111
+ case "scan":
112
+ this.#scan(ch);
113
+ break;
114
+ case "candidate":
115
+ this.#readCandidate(ch);
116
+ break;
117
+ case "afterCandidate":
118
+ this.#afterCandidate(ch);
119
+ break;
120
+ case "beforeValue":
121
+ this.#beforeValue(ch);
122
+ break;
123
+ case "target":
124
+ this.#readTarget(ch);
125
+ break;
126
+ }
127
+ }
128
+ return { values: { ...this.#values }, changed: this.#changed };
129
+ }
130
+
131
+ #scan(ch: string): void {
132
+ if (ch === '"') {
133
+ this.#candidate = "";
134
+ this.#candidateEscaped = false;
135
+ this.#candidateUnicode = "";
136
+ this.#state = "candidate";
137
+ } else if (ch === "{" || ch === "[") {
138
+ this.#depth++;
139
+ } else if (ch === "}" || ch === "]") {
140
+ this.#depth--;
141
+ }
142
+ this.#offset++;
143
+ }
144
+
145
+ #readCandidate(ch: string): void {
146
+ if (this.#candidateUnicode) {
147
+ this.#readCandidateUnicode(ch);
148
+ return;
149
+ }
150
+ if (this.#candidateEscaped) {
151
+ if (ch === "u") {
152
+ this.#candidateUnicode = "u";
153
+ } else {
154
+ this.#candidate += decodeJsonStringEscape(ch);
155
+ this.#candidateEscaped = false;
156
+ }
157
+ this.#offset++;
158
+ return;
159
+ }
160
+ if (ch === "\\") {
161
+ this.#candidateEscaped = true;
162
+ this.#offset++;
163
+ return;
164
+ }
165
+ if (ch === '"') {
166
+ this.#matchedKey = this.#depth === 1 && this.#keys.has(this.#candidate) ? this.#candidate : undefined;
167
+ this.#state = "afterCandidate";
168
+ this.#offset++;
169
+ return;
170
+ }
171
+ this.#candidate += ch;
172
+ this.#offset++;
173
+ }
174
+
175
+ #readCandidateUnicode(ch: string): void {
176
+ if (isHexDigit(ch)) {
177
+ this.#candidateUnicode += ch;
178
+ if (this.#candidateUnicode.length === 5) {
179
+ this.#candidate += String.fromCharCode(Number.parseInt(this.#candidateUnicode.slice(1), 16));
180
+ this.#candidateUnicode = "";
181
+ this.#candidateEscaped = false;
182
+ }
183
+ } else {
184
+ this.#candidate += this.#candidateUnicode + ch;
185
+ this.#candidateUnicode = "";
186
+ this.#candidateEscaped = false;
187
+ }
188
+ this.#offset++;
189
+ }
190
+
191
+ #afterCandidate(ch: string): void {
192
+ if (/\s/.test(ch)) {
193
+ this.#offset++;
194
+ return;
195
+ }
196
+ const matchedKey = this.#matchedKey;
197
+ this.#matchedKey = undefined;
198
+ if (ch === ":" && matchedKey) {
199
+ this.#targetKey = matchedKey;
200
+ this.#state = "beforeValue";
201
+ this.#offset++;
202
+ return;
203
+ }
204
+ this.#state = "scan";
205
+ }
206
+
207
+ #beforeValue(ch: string): void {
208
+ if (/\s/.test(ch)) {
209
+ this.#offset++;
210
+ return;
211
+ }
212
+ if (ch === '"' && this.#targetKey) {
213
+ if (this.#values[this.#targetKey]) {
214
+ this.#values[this.#targetKey] = "";
215
+ this.#changed = true;
216
+ }
217
+ this.#targetEscaped = false;
218
+ this.#targetUnicode = "";
219
+ this.#state = "target";
220
+ this.#offset++;
221
+ return;
222
+ }
223
+ this.#targetKey = undefined;
224
+ this.#state = "scan";
225
+ }
226
+
227
+ #readTarget(ch: string): void {
228
+ if (this.#targetUnicode) {
229
+ this.#readTargetUnicode(ch);
230
+ return;
231
+ }
232
+ if (this.#targetEscaped) {
233
+ if (ch === "u") {
234
+ this.#targetUnicode = "u";
235
+ } else {
236
+ this.#appendTarget(decodeJsonStringEscape(ch));
237
+ this.#targetEscaped = false;
238
+ }
239
+ this.#offset++;
240
+ return;
241
+ }
242
+ if (ch === "\\") {
243
+ this.#targetEscaped = true;
244
+ this.#offset++;
245
+ return;
246
+ }
247
+ if (ch === '"') {
248
+ this.#targetKey = undefined;
249
+ this.#state = "scan";
250
+ this.#offset++;
251
+ return;
252
+ }
253
+ this.#appendTarget(ch);
254
+ this.#offset++;
255
+ }
256
+
257
+ #readTargetUnicode(ch: string): void {
258
+ if (isHexDigit(ch)) {
259
+ this.#targetUnicode += ch;
260
+ if (this.#targetUnicode.length === 5) {
261
+ this.#appendTarget(String.fromCharCode(Number.parseInt(this.#targetUnicode.slice(1), 16)));
262
+ this.#targetUnicode = "";
263
+ this.#targetEscaped = false;
264
+ }
265
+ } else {
266
+ this.#appendTarget(this.#targetUnicode + ch);
267
+ this.#targetUnicode = "";
268
+ this.#targetEscaped = false;
269
+ }
270
+ this.#offset++;
271
+ }
272
+
273
+ #appendTarget(text: string): void {
274
+ if (!this.#targetKey || text.length === 0) return;
275
+ this.#values[this.#targetKey] = `${this.#values[this.#targetKey] ?? ""}${text}`;
276
+ this.#changed = true;
277
+ }
278
+ }
279
+
280
+ function createStringExtractor(keys: readonly string[] | undefined): StreamingJsonStringExtractor | undefined {
281
+ return keys && keys.length > 0 ? new StreamingJsonStringExtractor(keys) : undefined;
282
+ }
283
+
284
+ function sameStringKeys(a: readonly string[], b: readonly string[] | undefined): boolean {
285
+ if (a.length !== (b?.length ?? 0)) return false;
286
+ for (let i = 0; i < a.length; i++) {
287
+ if (a[i] !== b?.[i]) return false;
288
+ }
289
+ return true;
290
+ }
291
+
14
292
  type RevealEntry = {
15
293
  component: ToolArgsRevealComponent | undefined;
16
294
  /** Latest raw streamed argument text (JSON for function tools, raw text for custom tools). */
@@ -29,6 +307,9 @@ type RevealEntry = {
29
307
  displayArgs: Record<string, unknown>;
30
308
  /** Raw prefix carried by `displayArgs.__partialJson`. */
31
309
  displayPrefix: string;
310
+ /** JSON string fields decoded incrementally between full JSON parses. */
311
+ streamingStringKeys: readonly string[];
312
+ stringExtractor: StreamingJsonStringExtractor | undefined;
32
313
  };
33
314
 
34
315
  /** Clamp a slice end into `text`, never splitting a surrogate pair: a prefix
@@ -45,7 +326,7 @@ function clampSliceEnd(text: string, end: number): number {
45
326
  type ToolArgsRevealTarget = {
46
327
  rawInput: boolean;
47
328
  exposeRawPartialJson: boolean;
48
- fullArgs: Record<string, unknown>;
329
+ streamingStringKeys?: readonly string[];
49
330
  };
50
331
 
51
332
  type DisplayArgsStep = {
@@ -62,6 +343,7 @@ function resetDisplayState(entry: RevealEntry): void {
62
343
  entry.parsedLen = 0;
63
344
  entry.displayArgs = initialDisplayArgs();
64
345
  entry.displayPrefix = "";
346
+ entry.stringExtractor?.reset();
65
347
  }
66
348
 
67
349
  /** Display args for a revealed prefix. Function-tool JSON is parsed at the same
@@ -91,6 +373,11 @@ function displayArgsForPrefix(entry: RevealEntry, prefix: string, forceParse = f
91
373
  parsedChanged = true;
92
374
  }
93
375
  }
376
+ const extracted = entry.stringExtractor?.update(prefix);
377
+ if (extracted?.changed) {
378
+ entry.parsedArgs = { ...entry.parsedArgs, ...extracted.values };
379
+ parsedChanged = true;
380
+ }
94
381
 
95
382
  const rawPrefixChanged = entry.exposeRawPartialJson && prefix !== entry.displayPrefix;
96
383
  if (!parsedChanged && !rawPrefixChanged) return { args: entry.displayArgs, changed: false };
@@ -102,6 +389,39 @@ function displayArgsForPrefix(entry: RevealEntry, prefix: string, forceParse = f
102
389
  return { args, changed: true };
103
390
  }
104
391
 
392
+ type StreamedToolArgsSource = {
393
+ /** Custom-tool raw text stream (`customWireName` tools): never JSON-parsed. */
394
+ rawInput: boolean;
395
+ /** Provider-parsed arguments, spread UNDER the fresh decode: a dialect
396
+ * projector may carry keys a raw re-parse cannot recover, but any key the
397
+ * fresh parse does recover wins — provider parses lag the stream by up to
398
+ * STREAMING_JSON_PARSE_MIN_GROWTH bytes mid-stream. */
399
+ fullArgs?: Record<string, unknown>;
400
+ /** See {@link streamingStringKeysForTool}. */
401
+ streamingStringKeys?: readonly string[];
402
+ };
403
+
404
+ /**
405
+ * One-shot decode of a streamed tool-call argument buffer into display args —
406
+ * the same decode the live reveal applies frame-by-frame, for paths that see
407
+ * the buffer once (transcript rebuilds on theme change, settings, focus
408
+ * replay). Keeps a rebuilt preview identical to the live preview: parsed
409
+ * fields come from a fresh parse of the full buffer, `streamingStringKeys`
410
+ * fields from the incremental string decoder (which also wins ties in the
411
+ * live path), never from the provider's throttled `arguments`.
412
+ */
413
+ export function decodeStreamedToolArgs(partialJson: string, source: StreamedToolArgsSource): Record<string, unknown> {
414
+ if (source.rawInput) {
415
+ return { input: partialJson, __partialJson: partialJson };
416
+ }
417
+ const parsed = parseStreamingJson<Record<string, unknown>>(partialJson);
418
+ const args: Record<string, unknown> = source.fullArgs ? { ...source.fullArgs, ...parsed } : { ...parsed };
419
+ const extracted = createStringExtractor(source.streamingStringKeys)?.update(partialJson);
420
+ if (extracted) Object.assign(args, extracted.values);
421
+ args.__partialJson = partialJson;
422
+ return args;
423
+ }
424
+
105
425
  /**
106
426
  * Paces streamed tool-call arguments the same way StreamingRevealController
107
427
  * paces assistant text: providers that deliver `partialJson` in large batches
@@ -129,16 +449,15 @@ export class ToolArgsRevealController {
129
449
 
130
450
  /**
131
451
  * Record the latest streamed argument text for a tool call and return the
132
- * args to render right now. With smoothing disabled the full target passes
133
- * through in the caller's legacy shape (`{ ...args, __partialJson }`).
452
+ * args to render right now. With smoothing disabled nothing is paced — the
453
+ * full received buffer decodes in one step but the entry still runs the
454
+ * incremental string decoder + parse throttle, so streamed text fields
455
+ * (write `content`, edit bodies, eval `code`) stay fresh between the
456
+ * provider's own throttled full-JSON parses instead of lagging up to
457
+ * STREAMING_JSON_PARSE_MIN_GROWTH bytes behind.
134
458
  */
135
459
  setTarget(id: string, partialJson: string, target: ToolArgsRevealTarget): Record<string, unknown> {
136
- const { rawInput, exposeRawPartialJson, fullArgs } = target;
137
- if (!this.#getSmoothStreaming()) {
138
- // Toggle may flip mid-call: drop any live entry so ticks stop.
139
- this.#entries.delete(id);
140
- return { ...fullArgs, __partialJson: partialJson };
141
- }
460
+ const { rawInput, exposeRawPartialJson, streamingStringKeys } = target;
142
461
  let entry = this.#entries.get(id);
143
462
  if (!entry) {
144
463
  entry = {
@@ -151,13 +470,21 @@ export class ToolArgsRevealController {
151
470
  parsedLen: 0,
152
471
  displayArgs: initialDisplayArgs(),
153
472
  displayPrefix: "",
473
+ streamingStringKeys: streamingStringKeys ?? [],
474
+ stringExtractor: createStringExtractor(streamingStringKeys),
154
475
  };
155
476
  this.#entries.set(id, entry);
156
477
  } else {
157
- if (entry.rawInput !== rawInput || entry.exposeRawPartialJson !== exposeRawPartialJson) {
478
+ if (
479
+ entry.rawInput !== rawInput ||
480
+ entry.exposeRawPartialJson !== exposeRawPartialJson ||
481
+ !sameStringKeys(entry.streamingStringKeys, streamingStringKeys)
482
+ ) {
158
483
  entry.rawInput = rawInput;
159
484
  entry.exposeRawPartialJson = exposeRawPartialJson;
160
485
  resetDisplayState(entry);
486
+ entry.streamingStringKeys = streamingStringKeys ?? [];
487
+ entry.stringExtractor = createStringExtractor(streamingStringKeys);
161
488
  }
162
489
  // Streams only append; a non-prefix target means a rewind — snap into range.
163
490
  if (!partialJson.startsWith(entry.target)) {
@@ -166,6 +493,9 @@ export class ToolArgsRevealController {
166
493
  }
167
494
  entry.target = partialJson;
168
495
  }
496
+ // Toggle may flip mid-call: snap the reveal to everything received so
497
+ // pacing stops (and never restarts while the toggle stays off).
498
+ if (!this.#getSmoothStreaming()) entry.revealed = entry.target.length;
169
499
  entry.revealed = clampSliceEnd(entry.target, entry.revealed);
170
500
  this.#syncTimer();
171
501
  return displayArgsForPrefix(entry, entry.target.slice(0, entry.revealed)).args;