@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.
- package/CHANGELOG.md +34 -0
- package/dist/types/chat/thinking-display.d.ts +2 -0
- package/dist/types/chrome/transcript-container.d.ts +15 -1
- package/dist/types/components/editor.d.ts +5 -0
- package/dist/types/loop-watchdog.d.ts +9 -0
- package/dist/types/overlays/model-browser.d.ts +15 -0
- package/dist/types/overlays/model-picker.d.ts +2 -1
- package/dist/types/overlays/rewind-selector.d.ts +2 -1
- package/dist/types/prompt/composer-attachments.d.ts +7 -0
- package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
- package/dist/types/render/width-aware-text.d.ts +6 -0
- package/dist/types/setup/scenes/sign-in.d.ts +9 -9
- package/dist/types/setup/scenes/types.d.ts +0 -26
- package/dist/types/stdin-buffer.d.ts +10 -7
- package/dist/types/terminal.d.ts +16 -0
- package/dist/types/theme/color.d.ts +1 -0
- package/dist/types/theme/session-color.d.ts +3 -3
- package/dist/types/theme/shimmer.d.ts +1 -1
- package/dist/types/theme/theme-class.d.ts +1 -1
- package/dist/types/tools/web-search-types.d.ts +90 -0
- package/dist/types/tools/web-search.d.ts +1 -168
- package/dist/types/tui.d.ts +3 -2
- package/package.json +9 -9
- package/src/chat/assistant-message.ts +192 -40
- package/src/chat/thinking-display.ts +73 -11
- package/src/chat/tool-execution.ts +24 -4
- package/src/chrome/transcript-container.ts +178 -31
- package/src/components/editor.ts +51 -15
- package/src/components/image.ts +15 -7
- package/src/components/loader.ts +37 -17
- package/src/components/markdown.ts +110 -56
- package/src/loop-watchdog.ts +38 -10
- package/src/overlays/agent-hub.ts +16 -6
- package/src/overlays/copy-selector.ts +5 -0
- package/src/overlays/model-browser.ts +106 -25
- package/src/overlays/model-picker.ts +9 -6
- package/src/overlays/rewind-selector.ts +10 -2
- package/src/overlays/settings-selector.ts +2 -10
- package/src/prompt/composer-attachments.ts +54 -7
- package/src/prompt/composer.ts +5 -2
- package/src/prompt/custom-editor.ts +24 -6
- package/src/prompt/model-mention-autocomplete.ts +22 -10
- package/src/render/width-aware-text.ts +9 -0
- package/src/setup/scenes/sign-in.ts +17 -12
- package/src/setup/scenes/types.ts +0 -27
- package/src/setup/wizard.ts +1 -1
- package/src/status-line/component.ts +229 -134
- package/src/status-line/segments.ts +34 -29
- package/src/stdin-buffer.ts +27 -21
- package/src/terminal.ts +60 -20
- package/src/theme/color.ts +15 -0
- package/src/theme/mermaid-cache.ts +10 -1
- package/src/theme/session-color.ts +26 -4
- package/src/theme/shimmer.ts +55 -76
- package/src/theme/theme-class.ts +29 -12
- package/src/theme/tui-adapters.ts +14 -1
- package/src/tools/bash.ts +53 -13
- package/src/tools/edit.ts +110 -29
- package/src/tools/streaming-output.ts +79 -16
- package/src/tools/web-search-types.ts +99 -0
- package/src/tools/web-search.ts +1 -172
- package/src/tui.ts +143 -113
- package/src/utils.ts +15 -0
- package/dist/types/setup/scenes/providers.d.ts +0 -3
- package/dist/types/setup/scenes/web-search.d.ts +0 -22
- package/src/setup/scenes/providers.ts +0 -110
- 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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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 = {
|
|
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.#
|
|
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
|
-
|
|
740
|
-
this.#
|
|
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.#
|
|
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.#
|
|
787
|
-
this.#pos = 0;
|
|
802
|
+
this.#setHead("", 0, true);
|
|
788
803
|
return;
|
|
789
804
|
}
|
|
790
805
|
if (this.#pos <= max) return;
|
|
791
806
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
+
}
|