@oh-my-pi/pi-tui 18.4.1 → 18.4.3

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 (67) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/chat/thinking-display.d.ts +2 -0
  3. package/dist/types/chrome/transcript-container.d.ts +15 -1
  4. package/dist/types/components/editor.d.ts +5 -0
  5. package/dist/types/loop-watchdog.d.ts +9 -0
  6. package/dist/types/overlays/model-browser.d.ts +15 -0
  7. package/dist/types/overlays/model-picker.d.ts +2 -1
  8. package/dist/types/overlays/rewind-selector.d.ts +2 -1
  9. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  10. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  11. package/dist/types/render/width-aware-text.d.ts +6 -0
  12. package/dist/types/setup/scenes/sign-in.d.ts +9 -9
  13. package/dist/types/setup/scenes/types.d.ts +0 -26
  14. package/dist/types/stdin-buffer.d.ts +10 -7
  15. package/dist/types/terminal.d.ts +16 -0
  16. package/dist/types/theme/color.d.ts +1 -0
  17. package/dist/types/theme/session-color.d.ts +3 -3
  18. package/dist/types/theme/shimmer.d.ts +1 -1
  19. package/dist/types/theme/theme-class.d.ts +1 -1
  20. package/dist/types/tools/web-search-types.d.ts +90 -0
  21. package/dist/types/tools/web-search.d.ts +1 -168
  22. package/dist/types/tui.d.ts +3 -2
  23. package/package.json +9 -9
  24. package/src/chat/assistant-message.ts +192 -40
  25. package/src/chat/thinking-display.ts +73 -11
  26. package/src/chat/tool-execution.ts +24 -4
  27. package/src/chrome/transcript-container.ts +178 -31
  28. package/src/components/editor.ts +51 -15
  29. package/src/components/image.ts +15 -7
  30. package/src/components/loader.ts +37 -17
  31. package/src/components/markdown.ts +110 -56
  32. package/src/loop-watchdog.ts +38 -10
  33. package/src/overlays/agent-hub.ts +16 -6
  34. package/src/overlays/copy-selector.ts +5 -0
  35. package/src/overlays/model-browser.ts +106 -25
  36. package/src/overlays/model-picker.ts +9 -6
  37. package/src/overlays/rewind-selector.ts +10 -2
  38. package/src/overlays/settings-selector.ts +2 -10
  39. package/src/prompt/composer-attachments.ts +54 -7
  40. package/src/prompt/composer.ts +5 -2
  41. package/src/prompt/custom-editor.ts +24 -6
  42. package/src/prompt/model-mention-autocomplete.ts +22 -10
  43. package/src/render/width-aware-text.ts +9 -0
  44. package/src/setup/scenes/sign-in.ts +17 -12
  45. package/src/setup/scenes/types.ts +0 -27
  46. package/src/setup/wizard.ts +1 -1
  47. package/src/status-line/component.ts +229 -134
  48. package/src/status-line/segments.ts +34 -29
  49. package/src/stdin-buffer.ts +27 -21
  50. package/src/terminal.ts +60 -20
  51. package/src/theme/color.ts +15 -0
  52. package/src/theme/mermaid-cache.ts +10 -1
  53. package/src/theme/session-color.ts +26 -4
  54. package/src/theme/shimmer.ts +55 -76
  55. package/src/theme/theme-class.ts +29 -12
  56. package/src/theme/tui-adapters.ts +14 -1
  57. package/src/tools/bash.ts +53 -13
  58. package/src/tools/edit.ts +110 -29
  59. package/src/tools/streaming-output.ts +79 -16
  60. package/src/tools/web-search-types.ts +99 -0
  61. package/src/tools/web-search.ts +1 -172
  62. package/src/tui.ts +143 -113
  63. package/src/utils.ts +15 -0
  64. package/dist/types/setup/scenes/providers.d.ts +0 -3
  65. package/dist/types/setup/scenes/web-search.d.ts +0 -22
  66. package/src/setup/scenes/providers.ts +0 -110
  67. package/src/setup/scenes/web-search.ts +0 -165
package/src/tools/bash.ts CHANGED
@@ -130,19 +130,51 @@ function unescapePartialJsonString(value: string): string {
130
130
  return output;
131
131
  }
132
132
 
133
- function extractPartialBashEnv(partialJson: string | undefined): Record<string, string> | undefined {
133
+ // One-slot memo: every render path over one streamed args object (call
134
+ // preview, result header, repaint) asks for the same buffer.
135
+ let lastPartialEnvJson: string | undefined;
136
+ let lastPartialEnv: Readonly<Record<string, string>> | undefined;
137
+
138
+ function extractPartialBashEnv(partialJson: string | undefined): Readonly<Record<string, string>> | undefined {
134
139
  if (!partialJson) return undefined;
140
+ if (partialJson === lastPartialEnvJson) return lastPartialEnv;
141
+ let env: Record<string, string> | undefined;
135
142
  const envStart = partialJson.search(/"env"\s*:\s*\{/u);
136
- if (envStart === -1) return undefined;
137
- const objectStart = partialJson.indexOf("{", envStart);
138
- if (objectStart === -1) return undefined;
139
- const envBody = partialJson.slice(objectStart + 1);
140
- const env: Record<string, string> = {};
141
- const matcher = /"([A-Za-z_][A-Za-z0-9_]*)"\s*:\s*"((?:\\.|[^"\\])*)(?:"|$)/gu;
142
- for (const match of envBody.matchAll(matcher)) {
143
- env[match[1]!] = unescapePartialJsonString(match[2]!);
143
+ const objectStart = envStart === -1 ? -1 : partialJson.indexOf("{", envStart);
144
+ if (objectStart !== -1) {
145
+ // Scan only the env object: stop at its closing brace (outside strings)
146
+ // so the keys that follow it are not read as env assignments.
147
+ let objectEnd = partialJson.length;
148
+ let depth = 0;
149
+ for (let i = objectStart + 1; i < partialJson.length; i++) {
150
+ const ch = partialJson.charCodeAt(i);
151
+ if (ch === 0x22) {
152
+ // Skip the string body, honoring escapes; an unterminated string runs to the end.
153
+ for (i++; i < partialJson.length; i++) {
154
+ const inner = partialJson.charCodeAt(i);
155
+ if (inner === 0x5c) i++;
156
+ else if (inner === 0x22) break;
157
+ }
158
+ } else if (ch === 0x7b || ch === 0x5b) {
159
+ depth++;
160
+ } else if (ch === 0x7d || ch === 0x5d) {
161
+ if (depth === 0) {
162
+ objectEnd = i;
163
+ break;
164
+ }
165
+ depth--;
166
+ }
167
+ }
168
+ const envBody = partialJson.slice(objectStart + 1, objectEnd);
169
+ const matcher = /"([A-Za-z_][A-Za-z0-9_]*)"\s*:\s*"((?:\\.|[^"\\])*)(?:"|$)/gu;
170
+ for (const match of envBody.matchAll(matcher)) {
171
+ env ??= {};
172
+ env[match[1]!] = unescapePartialJsonString(match[2]!);
173
+ }
144
174
  }
145
- return Object.keys(env).length > 0 ? env : undefined;
175
+ lastPartialEnvJson = partialJson;
176
+ lastPartialEnv = env ? Object.freeze(env) : undefined;
177
+ return lastPartialEnv;
146
178
  }
147
179
 
148
180
  function formatWallTimeSeconds(wallTimeMs: number): string {
@@ -243,8 +275,12 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
243
275
  return {
244
276
  renderCall(args: TArgs, options: RenderResultOptions, uiTheme: Theme): Component {
245
277
  const renderArgs = toBashRenderArgs(args, config);
246
- const cmdLines = formatBashCommandLines(renderArgs, uiTheme);
278
+ // Highlighting the whole (possibly still-streaming) command is the
279
+ // expensive part: defer it to the first paint, once per component,
280
+ // so a rebuild that is replaced before painting never pays for it.
281
+ let cmdLines: string[] | undefined;
247
282
  return framedToolCard(uiTheme, () => {
283
+ cmdLines ??= formatBashCommandLines(renderArgs, uiTheme);
248
284
  const header =
249
285
  config.showHeader === false
250
286
  ? undefined
@@ -275,7 +311,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
275
311
  args?: TArgs,
276
312
  ): Component {
277
313
  const renderArgs = toBashRenderArgs(args, config);
278
- const cmdLines = args ? formatBashCommandLines(renderArgs, uiTheme) : undefined;
314
+ let cmdLines: string[] | undefined;
279
315
  const isError = result.isError === true;
280
316
  const isPartial = options.isPartial === true;
281
317
  const success = !isPartial && !isError;
@@ -438,7 +474,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
438
474
  {
439
475
  // Viewport-sized tail window in every state — streaming and final
440
476
  // render identically; only ctrl+o uncaps.
441
- content: capPreviewLines(cmdLines ?? [], uiTheme, { expanded }),
477
+ content: capPreviewLines(
478
+ args ? (cmdLines ??= formatBashCommandLines(renderArgs, uiTheme)) : [],
479
+ uiTheme,
480
+ { expanded },
481
+ ),
442
482
  },
443
483
  { label: uiTheme.fg("toolTitle", "Output"), content: outputLines },
444
484
  ],
package/src/tools/edit.ts CHANGED
@@ -239,16 +239,14 @@ function decodePartialJsonStringFragment(fragment: string): string {
239
239
  }
240
240
  }
241
241
 
242
- function extractPartialJsonString(partialJson: string | undefined, key: string): string | undefined {
243
- if (!partialJson) return undefined;
244
- const pattern = new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)`, "u");
245
- const match = pattern.exec(partialJson);
246
- if (!match) return undefined;
247
- return decodePartialJsonStringFragment(match[1]);
248
- }
242
+ // `"path": "<possibly unterminated string>` in a raw streamed args buffer.
243
+ const PARTIAL_JSON_PATH_RE = /"path"\s*:\s*"((?:\\.|[^"\\])*)/u;
249
244
 
250
245
  function getPartialJsonEditPath(args: EditRenderArgs): string | undefined {
251
- return filePathFromEditEntry(extractPartialJsonString(args.__partialJson, "path"));
246
+ const partialJson = args.__partialJson;
247
+ if (!partialJson) return undefined;
248
+ const match = PARTIAL_JSON_PATH_RE.exec(partialJson);
249
+ return match ? decodePartialJsonStringFragment(match[1]!) : undefined;
252
250
  }
253
251
 
254
252
  /** Count distinct file paths in an edits array. */
@@ -427,8 +425,33 @@ function hasEditCallPayload(args: EditRenderArgs, renderContext: EditRenderConte
427
425
  return false;
428
426
  }
429
427
 
428
+ /**
429
+ * Head-window preview of streamed replacement text. Only the displayed head is
430
+ * sanitized and split; the hidden-line count comes from a raw newline scan.
431
+ * That matches sanitizing the whole text because, on ESC-free input,
432
+ * `sanitizeText` only deletes individual code units and never a `\n`: the
433
+ * cut sits on a `\n`, so it cannot split a surrogate pair. The one non-local
434
+ * rule — a lone surrogate anywhere drops every U+FFFD — is replayed on the
435
+ * head. ESC-bearing text (ANSI stripping can span lines) takes the full path.
436
+ */
430
437
  function renderPlainTextPreview(text: string, uiTheme: Theme, _filePath?: string): string {
431
- const previewLines = cappedHeadLines(sanitizeText(text).split("\n"), CALL_TEXT_PREVIEW_LINES);
438
+ let previewLines: { lines: readonly string[]; hidden: number };
439
+ let headEnd = -1;
440
+ if (!text.includes("\x1b")) {
441
+ for (let newlines = 0; newlines < CALL_TEXT_PREVIEW_LINES; newlines++) {
442
+ headEnd = text.indexOf("\n", headEnd + 1);
443
+ if (headEnd === -1) break;
444
+ }
445
+ }
446
+ if (headEnd === -1) {
447
+ previewLines = cappedHeadLines(sanitizeText(text).split("\n"), CALL_TEXT_PREVIEW_LINES);
448
+ } else {
449
+ let head = sanitizeText(text.slice(0, headEnd));
450
+ if (head.includes("\ufffd") && !text.isWellFormed()) head = head.replaceAll("\ufffd", "");
451
+ let hidden = 1;
452
+ for (let i = text.indexOf("\n", headEnd + 1); i !== -1; i = text.indexOf("\n", i + 1)) hidden++;
453
+ previewLines = { lines: head.split("\n"), hidden };
454
+ }
432
455
  let preview = "\n\n";
433
456
  for (const line of previewLines.lines) {
434
457
  preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), CALL_TEXT_PREVIEW_WIDTH))}\n`;
@@ -751,17 +774,30 @@ interface EditCallFacts {
751
774
  * fields tolerate a small lag) or when the payload shrinks (rewind). The
752
775
  * final frame (`isPartial === false`) always recomputes. This turns the
753
776
  * per-frame O(n) stringify+parse+scan into O(n^2/K) total per call.
777
+ *
778
+ * The reveal hands over a fresh args object on every frame, so the gate
779
+ * keys on the streamed raw payload (`__partialJson`) rather than on args
780
+ * identity: a frame reuses the facts only when its payload extends, byte for
781
+ * byte, the payload the facts were derived from, and every direct field the
782
+ * facts read (path, file_path, rename, op, edits) is unchanged. Final args
783
+ * carry no `__partialJson`, so the completed call always derives exact facts.
754
784
  */
755
785
  const EDIT_FACTS_MIN_GROWTH = 512;
756
- let lastFactsCache:
757
- | {
758
- editArgs: EditRenderArgs;
759
- isPartial: boolean;
760
- editMode: EditMode | undefined;
761
- length: number;
762
- facts: EditCallFacts;
763
- }
764
- | undefined;
786
+ interface EditFactsCacheEntry {
787
+ editArgs: EditRenderArgs;
788
+ isPartial: boolean;
789
+ editMode: EditMode | undefined;
790
+ length: number;
791
+ /** Streamed raw payload the facts were derived from; set only for streamed `input` payloads. */
792
+ streamedPayload: string | undefined;
793
+ filePath: unknown;
794
+ path: unknown;
795
+ rename: unknown;
796
+ op: Operation | undefined;
797
+ edits: EditRenderEntry[] | undefined;
798
+ facts: EditCallFacts;
799
+ }
800
+ let lastFactsCache: EditFactsCacheEntry | undefined;
765
801
 
766
802
  function editFactsInputLength(editArgs: EditRenderArgs): number {
767
803
  const input = editArgs.input ?? editArgs._input;
@@ -772,27 +808,72 @@ function editFactsInputLength(editArgs: EditRenderArgs): number {
772
808
  return Array.isArray(editArgs.edits) ? editArgs.edits.length : 0;
773
809
  }
774
810
 
811
+ /**
812
+ * Raw streamed payload of a mid-stream `input` edit, or undefined when the
813
+ * args are final (no `__partialJson`) or carry no `input` string — only the
814
+ * `input` payloads pay the expensive whole-payload inspect per frame.
815
+ */
816
+ function streamedInputPayload(editArgs: EditRenderArgs): string | undefined {
817
+ if (typeof (editArgs.input ?? editArgs._input) !== "string") return undefined;
818
+ const payload = editArgs.__partialJson;
819
+ return typeof payload === "string" ? payload : undefined;
820
+ }
821
+
822
+ /**
823
+ * Whether a fresh streamed frame continues the payload `cached` was derived
824
+ * from within the growth gate. Exact: the cached payload must be a verbatim
825
+ * prefix of the new one, so a rewind or another call's payload never reuses.
826
+ */
827
+ function continuesCachedPayload(cached: EditFactsCacheEntry, editArgs: EditRenderArgs): boolean {
828
+ const base = cached.streamedPayload;
829
+ if (base === undefined) return false;
830
+ const payload = streamedInputPayload(editArgs);
831
+ if (payload === undefined || payload.length < base.length) return false;
832
+ if (payload.length - base.length >= EDIT_FACTS_MIN_GROWTH) return false;
833
+ if (
834
+ editArgs.file_path !== cached.filePath ||
835
+ editArgs.path !== cached.path ||
836
+ editArgs.rename !== cached.rename ||
837
+ editArgs.op !== cached.op ||
838
+ editArgs.edits !== cached.edits
839
+ ) {
840
+ return false;
841
+ }
842
+ return payload.startsWith(base);
843
+ }
844
+
775
845
  function resolveEditCallFacts(
776
846
  editArgs: EditRenderArgs,
777
847
  isPartial: boolean,
778
848
  editMode: EditMode | undefined,
779
849
  ): EditCallFacts {
780
850
  const cached = lastFactsCache;
781
- if (
782
- cached !== undefined &&
783
- cached.editArgs === editArgs &&
784
- cached.isPartial === isPartial &&
785
- cached.editMode === editMode
786
- ) {
787
- const length = editFactsInputLength(editArgs);
788
- // Same args object, still growing gradually: reuse. A rewind
789
- // (shorter), a jump past the gate, or the final frame recomputes.
790
- if (isPartial && length >= cached.length && length - cached.length < EDIT_FACTS_MIN_GROWTH) {
851
+ if (cached !== undefined && isPartial && cached.isPartial && cached.editMode === editMode) {
852
+ if (cached.editArgs === editArgs) {
853
+ const length = editFactsInputLength(editArgs);
854
+ // Same args object, still growing gradually: reuse. A rewind
855
+ // (shorter), a jump past the gate, or the final frame recomputes.
856
+ if (length >= cached.length && length - cached.length < EDIT_FACTS_MIN_GROWTH) {
857
+ return cached.facts;
858
+ }
859
+ } else if (continuesCachedPayload(cached, editArgs)) {
791
860
  return cached.facts;
792
861
  }
793
862
  }
794
863
  const facts = resolveEditCallFactsUncached(editArgs, isPartial, editMode);
795
- lastFactsCache = { editArgs, isPartial, editMode, length: editFactsInputLength(editArgs), facts };
864
+ lastFactsCache = {
865
+ editArgs,
866
+ isPartial,
867
+ editMode,
868
+ length: editFactsInputLength(editArgs),
869
+ streamedPayload: streamedInputPayload(editArgs),
870
+ filePath: editArgs.file_path,
871
+ path: editArgs.path,
872
+ rename: editArgs.rename,
873
+ op: editArgs.op,
874
+ edits: editArgs.edits,
875
+ facts,
876
+ };
796
877
  return facts;
797
878
  }
798
879
 
@@ -718,6 +718,12 @@ const MAX_PENDING = 10;
718
718
  export class TailBuffer {
719
719
  #pending: string[] = [];
720
720
  #pos = 0; // byte count of the currently-held tail (after trims)
721
+ // `#pending[0]` and its byte count. Clean = well-formed and `#headBytes` is its exact
722
+ // UTF-8 length (a head compacted from chunks that split a surrogate pair is neither).
723
+ #headBytes = 0;
724
+ #headClean = true;
725
+ // Every chunk after `#pending[0]` is well-formed (no lone surrogates).
726
+ #restWellFormed = true;
721
727
 
722
728
  constructor(readonly maxBytes: number) {}
723
729
 
@@ -726,8 +732,7 @@ export class TailBuffer {
726
732
 
727
733
  const max = this.maxBytes;
728
734
  if (max === 0) {
729
- this.#pending.length = 0;
730
- this.#pos = 0;
735
+ this.#setHead("", 0, true);
731
736
  return;
732
737
  }
733
738
 
@@ -736,19 +741,17 @@ export class TailBuffer {
736
741
  // If the incoming chunk alone is >= budget, it fully dominates the tail.
737
742
  if (n >= max) {
738
743
  const { text: t, bytes } = truncateTailBytes(text, max);
739
- this.#pending[0] = t;
740
- this.#pending.length = 1;
741
- this.#pos = bytes;
744
+ // A chunk of exactly `max` bytes comes back verbatim, lone surrogates included.
745
+ this.#setHead(t, bytes, t.isWellFormed());
742
746
  return;
743
747
  }
744
748
 
745
- this.#pos += n;
746
-
747
749
  if (this.#pending.length === 0) {
748
- this.#pending[0] = text;
749
- this.#pending.length = 1;
750
+ this.#setHead(text, n, text.isWellFormed());
750
751
  } else {
752
+ this.#pos += n;
751
753
  this.#pending.push(text);
754
+ if (this.#restWellFormed && !text.isWellFormed()) this.#restWellFormed = false;
752
755
  if (this.#pending.length > MAX_PENDING) this.#compact();
753
756
  }
754
757
 
@@ -770,9 +773,22 @@ export class TailBuffer {
770
773
 
771
774
  // -- private ---------------------------------------------------------------
772
775
 
776
+ /** Replace the whole buffer with a single chunk. */
777
+ #setHead(text: string, bytes: number, clean: boolean): void {
778
+ this.#pending[0] = text;
779
+ this.#pending.length = 1;
780
+ this.#pos = bytes;
781
+ this.#headBytes = bytes;
782
+ this.#headClean = clean;
783
+ this.#restWellFormed = true;
784
+ }
785
+
773
786
  #compact(): void {
774
787
  this.#pending[0] = this.#pending.join("");
775
788
  this.#pending.length = 1;
789
+ this.#headBytes = this.#pos;
790
+ this.#headClean = this.#headClean && this.#restWellFormed;
791
+ this.#restWellFormed = true;
776
792
  }
777
793
 
778
794
  #flush(): string {
@@ -783,17 +799,64 @@ export class TailBuffer {
783
799
 
784
800
  #trimTo(max: number): void {
785
801
  if (max === 0) {
786
- this.#pending.length = 0;
787
- this.#pos = 0;
802
+ this.#setHead("", 0, true);
788
803
  return;
789
804
  }
790
805
  if (this.#pos <= max) return;
791
806
 
792
- const joined = this.#flush();
793
- const { text, bytes } = truncateTailBytes(joined, max);
794
- this.#pos = bytes;
795
- this.#pending[0] = text;
796
- this.#pending.length = 1;
807
+ // Steady state (one `text()` per appended chunk): the overflow lies inside the
808
+ // already-trimmed head, so only the head's front is cut and the chunks after it
809
+ // are kept verbatim instead of re-encoding the whole window. Those chunks must be
810
+ // well-formed: the full path round-trips them through UTF-8, which rewrites lone
811
+ // surrogates (and heals pairs split across chunk boundaries).
812
+ const restBytes = this.#pos - this.#headBytes;
813
+ if (restBytes < max && this.#restWellFormed) {
814
+ const head = this.#pending[0];
815
+ const keep = max - restBytes;
816
+ const drop = this.#headBytes - keep;
817
+ if (head.length === this.#headBytes) {
818
+ // All-ASCII head: byte offsets are char offsets and every byte is a boundary.
819
+ this.#pending[0] = head.slice(drop);
820
+ this.#headBytes = keep;
821
+ } else if (this.#headClean && drop <= keep) {
822
+ // Walk code points to the first boundary at or after `drop` bytes.
823
+ let dropped = 0;
824
+ let index = 0;
825
+ while (dropped < drop) {
826
+ const code = head.charCodeAt(index);
827
+ if (code < 0x80) {
828
+ dropped += 1;
829
+ index += 1;
830
+ } else if (code < 0x800) {
831
+ dropped += 2;
832
+ index += 1;
833
+ } else if (code >= 0xd800 && code <= 0xdbff) {
834
+ // Well-formed, so a high surrogate always starts a pair.
835
+ dropped += 4;
836
+ index += 2;
837
+ } else {
838
+ dropped += 3;
839
+ index += 1;
840
+ }
841
+ }
842
+ this.#pending[0] = head.slice(index);
843
+ this.#headBytes -= dropped;
844
+ } else {
845
+ // A head that already fits comes back verbatim, lone surrogates included.
846
+ const { text, bytes } = truncateTailBytes(head, keep);
847
+ this.#pending[0] = text;
848
+ this.#headBytes = bytes;
849
+ this.#headClean = text.isWellFormed();
850
+ this.#pos = this.#headBytes + restBytes;
851
+ return;
852
+ }
853
+ this.#headClean = true;
854
+ this.#pos = this.#headBytes + restBytes;
855
+ return;
856
+ }
857
+
858
+ const { text, bytes } = truncateTailBytes(this.#flush(), max);
859
+ this.#setHead(text, bytes, text.isWellFormed());
797
860
  }
798
861
  }
799
862
 
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Web search response types shared by the coding agent's search providers and
3
+ * the transcript renderer in {@link ./web-search}. Render-free, so provider code
4
+ * can import it without loading UI components.
5
+ */
6
+
7
+ /**
8
+ * Display label for every search implementation: `web/*` engines and the
9
+ * chat-model groundings. Keys are the ids providers report in
10
+ * {@link SearchResponse.provider} and search errors.
11
+ */
12
+ export const SEARCH_PROVIDER_LABELS = {
13
+ parallel: "Parallel",
14
+ perplexity: "Perplexity",
15
+ gemini: "Gemini",
16
+ anthropic: "Anthropic",
17
+ codex: "OpenAI Codex",
18
+ openai: "OpenAI API",
19
+ xai: "xAI",
20
+ openrouter: "OpenRouter",
21
+ zai: "Z.AI",
22
+ exa: "Exa",
23
+ tinyfish: "TinyFish",
24
+ jina: "Jina",
25
+ kagi: "Kagi",
26
+ tavily: "Tavily",
27
+ firecrawl: "Firecrawl",
28
+ brave: "Brave",
29
+ kimi: "Kimi",
30
+ synthetic: "Synthetic",
31
+ ollama: "Ollama",
32
+ searxng: "SearXNG",
33
+ startpage: "Startpage",
34
+ duckduckgo: "DuckDuckGo",
35
+ ecosia: "Ecosia",
36
+ google: "Google",
37
+ mojeek: "Mojeek",
38
+ public: "Public Web",
39
+ } as const;
40
+
41
+ /** Id of a search implementation; see {@link SEARCH_PROVIDER_LABELS}. */
42
+ export type SearchProviderId = keyof typeof SEARCH_PROVIDER_LABELS;
43
+
44
+ /** Label for a provider id; ids from older transcripts fall back to the raw id. */
45
+ export function getSearchProviderLabel(id: SearchProviderId): string {
46
+ return SEARCH_PROVIDER_LABELS[id] ?? id;
47
+ }
48
+
49
+ /** Source returned by search (all providers) */
50
+ export interface SearchSource {
51
+ title: string;
52
+ url: string;
53
+ snippet?: string;
54
+ /** ISO date string or relative ("2d ago") */
55
+ publishedDate?: string;
56
+ /** Age in seconds for consistent formatting */
57
+ ageSeconds?: number;
58
+ author?: string;
59
+ }
60
+
61
+ /** Citation with text reference (LLM-mediated providers) */
62
+ export interface SearchCitation {
63
+ url: string;
64
+ title: string;
65
+ citedText?: string;
66
+ }
67
+
68
+ /** Usage metrics */
69
+ export interface SearchUsage {
70
+ inputTokens?: number;
71
+ outputTokens?: number;
72
+ /** Anthropic: number of web search requests made */
73
+ searchRequests?: number;
74
+ /** Perplexity: combined token count */
75
+ totalTokens?: number;
76
+ }
77
+
78
+ /** Unified response across providers */
79
+ export interface SearchResponse {
80
+ provider: SearchProviderId | "none";
81
+ /** Synthesized answer text (LLM-mediated providers) */
82
+ answer?: string;
83
+ /** Search result sources */
84
+ sources: SearchSource[];
85
+ /** Text citations with context */
86
+ citations?: SearchCitation[];
87
+ /** Intermediate search queries (anthropic) */
88
+ searchQueries?: string[];
89
+ /** Follow-up question suggestions (provider-dependent) */
90
+ relatedQuestions?: string[];
91
+ /** Token usage metrics */
92
+ usage?: SearchUsage;
93
+ /** Model used */
94
+ model?: string;
95
+ /** Request ID for debugging */
96
+ requestId?: string;
97
+ /** Authentication mode used by the provider (e.g. oauth, api-key) */
98
+ authMode?: string;
99
+ }