@oh-my-pi/pi-tui 18.7.0 → 18.8.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 (79) hide show
  1. package/CHANGELOG.md +21 -52
  2. package/dist/types/apps/debug/log-viewer.d.ts +4 -0
  3. package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
  4. package/dist/types/chat/fence-figure.d.ts +42 -0
  5. package/dist/types/chat/svg-figure.d.ts +1 -1
  6. package/dist/types/chat/tool-execution.d.ts +2 -0
  7. package/dist/types/components/editor.d.ts +8 -3
  8. package/dist/types/components/markdown.d.ts +11 -6
  9. package/dist/types/fuzzy.d.ts +17 -3
  10. package/dist/types/native/backend.d.ts +2 -1
  11. package/dist/types/native/blobs.d.ts +4 -2
  12. package/dist/types/native/encode.d.ts +3 -4
  13. package/dist/types/overlays/agent-transcript-viewer.d.ts +8 -1
  14. package/dist/types/overlays/history-search.d.ts +10 -0
  15. package/dist/types/overlays/model-browser.d.ts +6 -0
  16. package/dist/types/prompt/custom-editor.d.ts +7 -4
  17. package/dist/types/prompt/interactive-context-helpers.d.ts +2 -1
  18. package/dist/types/tools/renderer.d.ts +22 -0
  19. package/dist/types/tools/streaming-output.d.ts +11 -1
  20. package/dist/types/tools/write.d.ts +8 -1
  21. package/package.json +9 -9
  22. package/src/apps/debug/log-viewer.ts +188 -72
  23. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  24. package/src/apps/debug/raw-sse.ts +39 -5
  25. package/src/apps/git/diff-pane.ts +26 -5
  26. package/src/autocomplete.ts +59 -33
  27. package/src/chat/assistant-message.ts +30 -18
  28. package/src/chat/fence-figure.ts +80 -0
  29. package/src/chat/svg-figure.ts +2 -4
  30. package/src/chat/tool-execution.ts +53 -4
  31. package/src/components/editor.ts +16 -3
  32. package/src/components/markdown.ts +80 -307
  33. package/src/fuzzy.ts +59 -14
  34. package/src/native/backend.ts +170 -15
  35. package/src/native/blobs.ts +4 -2
  36. package/src/native/encode.ts +58 -31
  37. package/src/overlays/agent-hub-projection.ts +70 -2
  38. package/src/overlays/agent-hub.ts +3 -2
  39. package/src/overlays/agent-transcript-viewer.ts +64 -13
  40. package/src/overlays/agents-hub.ts +14 -7
  41. package/src/overlays/copy-selector.ts +30 -8
  42. package/src/overlays/extensions/state-manager.ts +52 -12
  43. package/src/overlays/history-search.ts +100 -7
  44. package/src/overlays/jobs-panel.ts +30 -2
  45. package/src/overlays/model-browser.ts +107 -21
  46. package/src/overlays/model-hub.ts +34 -7
  47. package/src/overlays/oauth-selector.ts +41 -26
  48. package/src/overlays/plan-review-overlay.ts +142 -64
  49. package/src/overlays/rewind-selector.ts +86 -17
  50. package/src/overlays/session-selector.ts +24 -4
  51. package/src/overlays/settings-selector.ts +21 -6
  52. package/src/overlays/tree-selector.ts +20 -4
  53. package/src/overlays/usage-dashboard.ts +12 -15
  54. package/src/prompt/attachment-chips.ts +2 -1
  55. package/src/prompt/composer-attachments.ts +6 -4
  56. package/src/prompt/custom-editor.ts +48 -19
  57. package/src/prompt/interactive-context-helpers.ts +27 -18
  58. package/src/prompt/macos-spelling.ts +7 -0
  59. package/src/prompt/magic-keywords.ts +11 -2
  60. package/src/prompt/word-completion.ts +38 -5
  61. package/src/setup/scenes/splash.ts +109 -45
  62. package/src/status-line/component.ts +220 -64
  63. package/src/status-line/segments.ts +5 -2
  64. package/src/terminal.ts +3 -2
  65. package/src/tools/ast-edit.ts +59 -35
  66. package/src/tools/ast-grep.ts +35 -16
  67. package/src/tools/default-renderer.ts +35 -19
  68. package/src/tools/eval-format/index.ts +28 -2
  69. package/src/tools/eval-format/javascript.ts +7 -5
  70. package/src/tools/eval.ts +164 -78
  71. package/src/tools/grep.ts +81 -48
  72. package/src/tools/lsp.ts +81 -59
  73. package/src/tools/mcp.ts +98 -68
  74. package/src/tools/renderer.ts +27 -0
  75. package/src/tools/streaming-output.ts +120 -48
  76. package/src/tools/task.ts +111 -54
  77. package/src/tools/todo.ts +115 -29
  78. package/src/tools/wait.ts +34 -24
  79. package/src/tools/write.ts +77 -39
@@ -14,8 +14,18 @@
14
14
  * render while blocked only marks the surface dirty; the next `ack` reconciles
15
15
  * once against the last sent state, so every intermediate change coalesces
16
16
  * into one frame (`set`s merged, `text append`s joined).
17
+ *
18
+ * Blobs. Each image's bytes reach the terminal once per connection. A
19
+ * terminal with the `blobs` feature is asked first which it already holds:
20
+ * under `TERN_BLOB_DIR` (Tern's on-disk blob cache, on this machine) every new
21
+ * blob is written there and then asked about, so its bytes never cross the
22
+ * pty; otherwise the first pass after (re)connecting asks, so a resumed
23
+ * session doesn't resend what the terminal kept. Whatever the reply lacks
24
+ * (or a reply that never comes) is sent inline with `b`. Frames never wait:
25
+ * the terminal draws an image whose blob arrives after the frame.
17
26
  */
18
27
  import * as fs from "node:fs";
28
+ import * as path from "node:path";
19
29
  import * as logger from "@oh-my-pi/pi-utils/logger";
20
30
  import {
21
31
  TSP_DEFAULT_APC_LIMIT,
@@ -40,7 +50,7 @@ import {
40
50
  } from "../theme/theme";
41
51
  import type { Component, OverlayOptions, RenderScheduler, RenderTimer } from "../tui";
42
52
  import { TspDocument } from "./apply";
43
- import { getNativeBlob } from "./blobs";
53
+ import { getNativeBlob, type NativeBlob } from "./blobs";
44
54
  import { node } from "./describe";
45
55
  import { encodeTspJson, encodeTspMessage, type TspHello, TspReader, splitTspMessage } from "./encode";
46
56
  import type { DescribeContext, NativeChild, NativeNode, NativeSurface, NativeUiEvent } from "./node";
@@ -115,6 +125,44 @@ const RECENT_FRAMES = 64;
115
125
  const STALLED_ACK_MS = 5000;
116
126
  /** Role of the session's surfaces; a screen page may name its own. */
117
127
  const SESSION_ROLE = "omp.session";
128
+ /** A `blobs` query unanswered this long counts its ids as missing: they go inline. */
129
+ const BLOB_REPLY_MS = 3000;
130
+ /** Expired queries kept to pair late replies with their queries, at most. */
131
+ const MAX_BLOB_QUERIES = 16;
132
+
133
+ /** A `blobs` query awaiting its reply; `timer` is unset once its ids no longer depend on it. */
134
+ interface BlobQuery {
135
+ readonly ids: readonly string[];
136
+ timer: RenderTimer | undefined;
137
+ }
138
+
139
+ /**
140
+ * Save `blob` in the terminal's blob cache as `<dir>/<id>` (written aside,
141
+ * then renamed; skipped when present). A failure only means the terminal
142
+ * won't find it, so the blob goes inline.
143
+ */
144
+ async function cacheBlob(dir: string, blob: NativeBlob): Promise<void> {
145
+ const target = path.join(dir, blob.id);
146
+ const temp = `${target}.tmp${process.pid}`;
147
+ try {
148
+ if (await Bun.file(target).exists()) return;
149
+ // Not `Bun.write`: it would create a missing folder, which only Tern may.
150
+ await fs.promises.writeFile(temp, blob.bytes);
151
+ await fs.promises.rename(temp, target);
152
+ } catch (error) {
153
+ logger.debug("TSP: could not save a blob to the terminal's cache", { dir, id: blob.id, error: String(error) });
154
+ await fs.promises.rm(temp, { force: true }).catch(() => {});
155
+ }
156
+ }
157
+
158
+ /**
159
+ * A `b` message's parameters. Tern names a blob by the sha256 of the bytes it
160
+ * receives and ignores `id`, but Tern 0.5.3 and earlier reject a blob without
161
+ * it, so it stays until those are gone.
162
+ */
163
+ function blobParams(blob: NativeBlob): Record<string, string> {
164
+ return { id: blob.id, mime: blob.mime };
165
+ }
118
166
 
119
167
  class NativeContext implements DescribeContext {
120
168
  cols: number;
@@ -158,7 +206,8 @@ function sameSet(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
158
206
  * The reply a v1 terminal is assumed to give before its real `hello` arrives
159
207
  * (the `TERM_PROGRAM=tern` optimistic start): the whole vocabulary, the
160
208
  * default APC limit and credits, the terminal's width, the appearance omp
161
- * already detected, and full motion.
209
+ * already detected, full motion, and `blobs`, so a resumed session's images
210
+ * are asked about rather than resent before the reply comes.
162
211
  */
163
212
  export function assumedTspHello(terminal: Terminal): TspHello {
164
213
  const appearance = terminal.appearance;
@@ -167,6 +216,7 @@ export function assumedTspHello(terminal: Terminal): TspHello {
167
216
  v: TSP_VERSION,
168
217
  term: "tern",
169
218
  kinds: TSP_KINDS,
219
+ features: ["blobs"],
170
220
  apc: TSP_DEFAULT_APC_LIMIT,
171
221
  credits: TSP_DEFAULT_CREDITS,
172
222
  cols: terminal.columns,
@@ -186,7 +236,6 @@ class Surface {
186
236
  acked = 0;
187
237
  /** Send times of unacknowledged frames, oldest first. */
188
238
  unacked: number[] = [];
189
- uploaded = new Set<string>();
190
239
  focus: string | null = null;
191
240
  dirty = false;
192
241
 
@@ -256,6 +305,14 @@ export class NativeBackend {
256
305
  #paletteKey: string | undefined;
257
306
  /** Serialized palette last sent, to skip resends of an unchanged theme. */
258
307
  #paletteSent: string | undefined;
308
+ /** Blobs handled on this connection: asked about in a `blobs` query, or sent (or held by the terminal). */
309
+ #blobs = new Map<string, "asked" | "sent">();
310
+ /** `blobs` queries sent, oldest first: the terminal answers each once, in order. */
311
+ #blobQueries: BlobQuery[] = [];
312
+ /** The next pass with new blobs asks the terminal which it holds (a fresh connection). */
313
+ #askHeld = true;
314
+ /** Bumped when the connection resets or stops, so blob cache writes finishing later are dropped. */
315
+ #blobEpoch = 0;
259
316
 
260
317
  constructor(host: NativeHost, hello: TspHello, options: NativeBackendOptions = {}) {
261
318
  this.#host = host;
@@ -369,6 +426,9 @@ export class NativeBackend {
369
426
  this.#unbindTheme = undefined;
370
427
  this.#useNerdSymbols(false);
371
428
  this.#clearStallTimer();
429
+ // Replies no longer reach the backend: forget the queries outright.
430
+ this.#resetBlobs();
431
+ this.#blobQueries = [];
372
432
  }
373
433
 
374
434
  /**
@@ -383,6 +443,7 @@ export class NativeBackend {
383
443
  this.#live = true;
384
444
  this.#useNerdSymbols(true);
385
445
  this.#watchTheme();
446
+ this.#resetBlobs();
386
447
  const surface = this.#inline;
387
448
  surface.reconciler.detachLive();
388
449
  surface.unacked = [];
@@ -491,7 +552,7 @@ export class NativeBackend {
491
552
  logger.debug("TSP frame", { sf: surface.id, ops: ops.length, rows: surface.reconciler.fallbackCount });
492
553
  }
493
554
  if (ops.length === 0) return;
494
- this.#uploadBlobs(surface, ops);
555
+ this.#uploadBlobs(ops);
495
556
  this.#sendFrame(surface, ops);
496
557
  }
497
558
 
@@ -505,6 +566,7 @@ export class NativeBackend {
505
566
  this.#record("in", raw.verb, raw.params, raw.body);
506
567
  const message = this.#reader.feed(sequence);
507
568
  if (message?.verb === "e") this.#handleEvent(message.event);
569
+ else if (message?.reply.r === "blobs") this.#onBlobsReply(message.reply.have);
508
570
  return true;
509
571
  }
510
572
 
@@ -583,25 +645,117 @@ export class NativeBackend {
583
645
  this.#write("f", frame);
584
646
  }
585
647
 
586
- #uploadBlobs(surface: Surface, ops: readonly TspOp[]): void {
648
+ /** Deliver the blobs `ops` reference that this connection hasn't handled yet (see the module doc). */
649
+ #uploadBlobs(ops: readonly TspOp[]): void {
587
650
  const ids = new Set<string>();
588
651
  for (const op of ops) collectBlobs(op, ids);
652
+ const fresh: NativeBlob[] = [];
589
653
  for (const id of ids) {
590
- if (surface.uploaded.has(id)) continue;
654
+ if (this.#blobs.has(id)) continue;
591
655
  const blob = getNativeBlob(id);
592
- if (!blob) {
593
- logger.warn("TSP: image references an unregistered blob", { id });
594
- continue;
656
+ if (blob) fresh.push(blob);
657
+ else logger.warn("TSP: image references an unregistered blob", { id });
658
+ }
659
+ if (fresh.length === 0) return;
660
+ if (!this.#cx.feature("blobs")) {
661
+ for (const blob of fresh) this.#sendBlob(blob);
662
+ return;
663
+ }
664
+ const dir = Bun.env.TERN_BLOB_DIR;
665
+ if (dir) {
666
+ for (const blob of fresh) this.#blobs.set(blob.id, "asked");
667
+ void this.#cacheBlobs(dir, fresh);
668
+ return;
669
+ }
670
+ if (this.#askHeld) {
671
+ this.#askHeld = false;
672
+ this.#askBlobs(fresh.map(blob => blob.id));
673
+ return;
674
+ }
675
+ for (const blob of fresh) this.#sendBlob(blob);
676
+ }
677
+
678
+ /** Write `blobs` to the terminal's cache, then ask about the ones still pending. */
679
+ async #cacheBlobs(dir: string, blobs: readonly NativeBlob[]): Promise<void> {
680
+ const epoch = this.#blobEpoch;
681
+ await Promise.all(blobs.map(blob => cacheBlob(dir, blob)));
682
+ if (epoch !== this.#blobEpoch) return;
683
+ const ids = blobs.map(blob => blob.id).filter(id => this.#blobs.get(id) === "asked");
684
+ if (ids.length > 0) this.#askBlobs(ids);
685
+ }
686
+
687
+ /** Ask the terminal which of `ids` it holds; the reply (or its absence) settles them. */
688
+ #askBlobs(ids: readonly string[]): void {
689
+ for (const id of ids) this.#blobs.set(id, "asked");
690
+ const query: BlobQuery = { ids, timer: undefined };
691
+ query.timer = this.#scheduler.scheduleRender(() => {
692
+ query.timer = undefined;
693
+ logger.warn("TSP: no reply to a blobs query; sending its blobs inline", { ids: ids.length });
694
+ this.#sendMissing(ids);
695
+ }, BLOB_REPLY_MS);
696
+ // Expired queries stay only to pair late replies; a terminal that never answers mustn't grow the list.
697
+ while (this.#blobQueries.length >= MAX_BLOB_QUERIES && !this.#blobQueries[0]!.timer) this.#blobQueries.shift();
698
+ this.#blobQueries.push(query);
699
+ this.#write("q", { q: "blobs", ids });
700
+ }
701
+
702
+ /**
703
+ * The reply to the oldest outstanding query. Any `have` id still asked
704
+ * about is held by the terminal (whichever query asked); the query's other
705
+ * ids go inline.
706
+ */
707
+ #onBlobsReply(have: readonly string[]): void {
708
+ const query = this.#blobQueries.shift();
709
+ for (const id of have) {
710
+ if (this.#blobs.get(id) !== "asked") continue;
711
+ this.#blobs.set(id, "sent");
712
+ // The bytes went another way; a recording still carries them so a replay without them shows the image.
713
+ const blob = this.#recordPath ? getNativeBlob(id) : undefined;
714
+ if (blob) {
715
+ const body = Buffer.from(blob.bytes.buffer, blob.bytes.byteOffset, blob.bytes.byteLength).toString(
716
+ "base64",
717
+ );
718
+ this.#record("out", "b", blobParams(blob), body);
595
719
  }
596
- surface.uploaded.add(id);
597
- const body = Buffer.from(blob.bytes.buffer, blob.bytes.byteOffset, blob.bytes.byteLength).toString("base64");
598
- // The full body, so a replay (Tern's `surface-play`) shows the image.
599
- this.#record("out", "b", { id, mime: blob.mime }, body);
600
- this.#host.terminal.write(encodeTspMessage("b", body, { id, mime: blob.mime }, this.#limit));
720
+ }
721
+ if (!query?.timer) return;
722
+ query.timer.cancel();
723
+ query.timer = undefined;
724
+ this.#sendMissing(query.ids);
725
+ }
726
+
727
+ /** Send inline every id of `ids` still waiting on a query. */
728
+ #sendMissing(ids: readonly string[]): void {
729
+ for (const id of ids) {
730
+ const blob = this.#blobs.get(id) === "asked" ? getNativeBlob(id) : undefined;
731
+ if (blob) this.#sendBlob(blob);
732
+ }
733
+ }
734
+
735
+ #sendBlob(blob: NativeBlob): void {
736
+ this.#blobs.set(blob.id, "sent");
737
+ const body = Buffer.from(blob.bytes.buffer, blob.bytes.byteOffset, blob.bytes.byteLength).toString("base64");
738
+ // The full body, so a replay (Tern's `surface-play`) shows the image.
739
+ this.#record("out", "b", blobParams(blob), body);
740
+ this.#host.terminal.write(encodeTspMessage("b", body, blobParams(blob), this.#limit));
741
+ }
742
+
743
+ /**
744
+ * A new connection (resume, or a fresh inline surface after `gone`): the
745
+ * terminal is asked again. Outstanding queries stay, without their
746
+ * deadlines, only to pair their replies.
747
+ */
748
+ #resetBlobs(): void {
749
+ this.#blobEpoch++;
750
+ this.#blobs.clear();
751
+ this.#askHeld = true;
752
+ for (const query of this.#blobQueries) {
753
+ query.timer?.cancel();
754
+ query.timer = undefined;
601
755
  }
602
756
  }
603
757
 
604
- #write(verb: "o" | "f" | "t" | "x", body: unknown): void {
758
+ #write(verb: "o" | "f" | "t" | "x" | "q", body: unknown): void {
605
759
  this.#record("out", verb, undefined, body);
606
760
  this.#host.terminal.write(encodeTspJson(verb, body, undefined, this.#limit));
607
761
  }
@@ -665,6 +819,7 @@ export class NativeBackend {
665
819
  case "gone":
666
820
  if (event.ids.includes(this.#inline.id)) {
667
821
  // Adopt found nothing (evicted, or another pane): start over.
822
+ this.#resetBlobs();
668
823
  this.#inline = this.#newSurface("inline");
669
824
  if (this.#live) this.#open(this.#inline);
670
825
  this.#host.requestRender();
@@ -2,8 +2,10 @@
2
2
  * Content-addressed binary blobs (images) for `image` nodes.
3
3
  *
4
4
  * `describe()` registers the bytes and puts the returned id in `image.p.blob`;
5
- * the native backend uploads each referenced blob once per surface with verb
6
- * `b` before the frame that first references it.
5
+ * the native backend delivers each referenced blob once per terminal
6
+ * connection (asking the terminal first which it holds, or through its blob
7
+ * cache), else uploads it with verb `b` before the frame that first
8
+ * references it.
7
9
  */
8
10
  import type { TspProps } from "@oh-my-pi/pi-wire";
9
11
  import { getImageDimensions } from "../terminal-capabilities";
@@ -6,7 +6,9 @@
6
6
  * negotiated `apc` limit is split across messages with the same verb, tagged
7
7
  * `c=<chunk-id>`, with `m=1` on every chunk but the last; the receiver joins
8
8
  * the bodies byte-wise. Chunks split on code-point boundaries, so every chunk
9
- * is valid UTF-8 on its own and the joined bytes equal the original body.
9
+ * is valid UTF-8 on its own and the joined bytes equal the original body, and
10
+ * never where the next chunk would lead with a `key=value;` segment the
11
+ * receiver would read as a parameter (Tern's `tsp_chunks`).
10
12
  */
11
13
  import { isRecord } from "@oh-my-pi/pi-utils/type-guards";
12
14
  import {
@@ -34,36 +36,58 @@ export type TspParams = Readonly<Record<string, string | number>>;
34
36
 
35
37
  let nextChunkId = 1;
36
38
 
37
- /** UTF-8 byte length of one UTF-16 code unit sequence starting at `i`, and its unit count. */
38
- function codePointBytes(text: string, i: number): { bytes: number; units: number } {
39
- const c = text.charCodeAt(i);
40
- if (c < 0x80) return { bytes: 1, units: 1 };
41
- if (c < 0x800) return { bytes: 2, units: 1 };
42
- if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
43
- const next = text.charCodeAt(i + 1);
44
- if (next >= 0xdc00 && next <= 0xdfff) return { bytes: 4, units: 2 };
39
+ /** Whether `bytes` from `at` leads with a `key=value;` segment, which a receiver reads as one more parameter. */
40
+ function leadsWithParameter(bytes: Uint8Array, at: number): boolean {
41
+ let i = at;
42
+ // Key bytes: `[A-Za-z0-9_-]`.
43
+ for (; i < bytes.length; i++) {
44
+ const b = bytes[i]!;
45
+ if (
46
+ !((b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a) || b === 0x5f || b === 0x2d)
47
+ )
48
+ break;
45
49
  }
46
- return { bytes: 3, units: 1 };
50
+ if (i === at || bytes[i] !== 0x3d) return false;
51
+ i++;
52
+ // Value bytes: printable ASCII other than `;`.
53
+ while (i < bytes.length && bytes[i]! >= 0x21 && bytes[i]! <= 0x7e && bytes[i] !== 0x3b) i++;
54
+ return bytes[i] === 0x3b;
47
55
  }
48
56
 
49
- /** Split `body` into pieces of at most `limit` UTF-8 bytes, never inside a code point. */
50
- export function splitUtf8(body: string, limit: number): string[] {
51
- const max = Math.max(4, Math.trunc(limit));
57
+ /**
58
+ * Where the chunk of `bytes` starting at `start` ends: the safe cut nearest
59
+ * below `start + limit` (above `start`), else the nearest above it, else the
60
+ * end. A cut is safe on a UTF-8 character boundary where the rest doesn't
61
+ * lead with a parameter-shaped segment.
62
+ */
63
+ function chunkEnd(bytes: Uint8Array, start: number, limit: number): number {
64
+ const target = start + limit;
65
+ if (target >= bytes.length) return bytes.length;
66
+ const safe = (at: number): boolean => (bytes[at]! & 0xc0) !== 0x80 && !leadsWithParameter(bytes, at);
67
+ for (let at = target; at > start; at--) if (safe(at)) return at;
68
+ for (let at = target + 1; at < bytes.length; at++) if (safe(at)) return at;
69
+ return bytes.length;
70
+ }
71
+
72
+ /**
73
+ * Chunk bodies for `body` of `byteLength` UTF-8 bytes, cut as Tern's
74
+ * `tsp_chunks` cuts. Pure ASCII without `;` (every base64 blob) has no
75
+ * parameter-shaped segment and no multibyte character, so it is cut every
76
+ * `limit` characters.
77
+ */
78
+ function chunkBodies(body: string, byteLength: number, limit: number): string[] {
79
+ const max = Math.max(1, Math.trunc(limit));
52
80
  const pieces: string[] = [];
53
- let start = 0;
54
- let bytes = 0;
55
- let i = 0;
56
- while (i < body.length) {
57
- const step = codePointBytes(body, i);
58
- if (bytes + step.bytes > max) {
59
- pieces.push(body.slice(start, i));
60
- start = i;
61
- bytes = 0;
62
- }
63
- bytes += step.bytes;
64
- i += step.units;
81
+ if (byteLength === body.length && !body.includes(";")) {
82
+ for (let at = 0; at < body.length; at += max) pieces.push(body.slice(at, at + max));
83
+ return pieces;
84
+ }
85
+ const bytes = Buffer.from(body, "utf8");
86
+ for (let start = 0; start < bytes.length;) {
87
+ const end = chunkEnd(bytes, start, max);
88
+ pieces.push(bytes.toString("utf8", start, end));
89
+ start = end;
65
90
  }
66
- pieces.push(body.slice(start));
67
91
  return pieces;
68
92
  }
69
93
 
@@ -79,8 +103,9 @@ function encodeParams(params: TspParams | undefined): string {
79
103
  }
80
104
 
81
105
  /**
82
- * Encode one logical message, chunked when `body` exceeds `limit` UTF-8 bytes.
83
- * Returns the complete byte string to write (all chunks, in order).
106
+ * Encode one logical message, chunked when `body` exceeds `limit` UTF-8 bytes:
107
+ * the first chunk carries `params`, every chunk `c=<id>` and all but the last
108
+ * `m=1`. Returns the complete byte string to write (all chunks, in order).
84
109
  */
85
110
  export function encodeTspMessage(
86
111
  verb: TspVerb,
@@ -90,13 +115,15 @@ export function encodeTspMessage(
90
115
  ): string {
91
116
  const base = encodeParams(params);
92
117
  // Fast path: ASCII-length bound first, exact byte count only near the limit.
93
- if (body.length * 3 <= limit || Buffer.byteLength(body, "utf8") <= limit) return frame(verb, base, body);
118
+ if (body.length * 3 <= limit) return frame(verb, base, body);
119
+ const byteLength = Buffer.byteLength(body, "utf8");
120
+ if (byteLength <= limit) return frame(verb, base, body);
94
121
  const chunkId = (nextChunkId++).toString(36);
95
- const pieces = splitUtf8(body, limit);
122
+ const pieces = chunkBodies(body, byteLength, limit);
96
123
  let out = "";
97
124
  for (let i = 0; i < pieces.length; i++) {
98
125
  const more = i < pieces.length - 1 ? ";m=1" : "";
99
- out += frame(verb, `${base};c=${chunkId}${more}`, pieces[i]!);
126
+ out += frame(verb, `${i === 0 ? base : ""};c=${chunkId}${more}`, pieces[i]!);
100
127
  }
101
128
  return out;
102
129
  }
@@ -23,6 +23,68 @@ function finiteMetric(value: number | undefined): number {
23
23
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
24
24
  }
25
25
 
26
+ /** Message list a fallback metrics read was taken from; unchanged list ⇒ unchanged metrics. */
27
+ interface FallbackReadStamp {
28
+ messages: readonly unknown[];
29
+ length: number;
30
+ last: unknown;
31
+ /** Usage and block count of the last message, which a streaming turn mutates in place. */
32
+ lastShape: string;
33
+ /** Session model, whose context window the context gauge reports. */
34
+ model: unknown;
35
+ contextWindow: number | null | undefined;
36
+ }
37
+
38
+ /** Stamp of each cached fallback read, keyed by the cache entry so it cannot outlive that read. */
39
+ const fallbackReadStamps = new WeakMap<object, FallbackReadStamp>();
40
+
41
+ function lastMessageShape(last: unknown): string {
42
+ if (!last || typeof last !== "object") return "";
43
+ const { usage, content } = last as {
44
+ usage?: { input?: number; output?: number; cacheWrite?: number; cost?: { total?: number } };
45
+ content?: unknown;
46
+ };
47
+ const blocks = Array.isArray(content) ? content.length : -1;
48
+ return `${usage?.input}:${usage?.output}:${usage?.cacheWrite}:${usage?.cost?.total}:${blocks}`;
49
+ }
50
+
51
+ function fallbackReadStamp(session: NonNullable<AgentRecordLike["session"]>): FallbackReadStamp | undefined {
52
+ try {
53
+ const messages = session.agent?.state?.messages;
54
+ if (!Array.isArray(messages)) return undefined;
55
+ const last = messages[messages.length - 1];
56
+ return {
57
+ messages,
58
+ length: messages.length,
59
+ last,
60
+ lastShape: lastMessageShape(last),
61
+ model: session.model,
62
+ contextWindow: session.model?.contextWindow,
63
+ };
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Whether a cached fallback read still matches the session's message list
71
+ * (append, compaction, rewrite, streaming tail) and model (context window).
72
+ */
73
+ function fallbackReadCurrent(entry: object, session: NonNullable<AgentRecordLike["session"]>): boolean {
74
+ const stamp = fallbackReadStamps.get(entry);
75
+ if (!stamp) return false;
76
+ const current = fallbackReadStamp(session);
77
+ return (
78
+ current !== undefined &&
79
+ current.messages === stamp.messages &&
80
+ current.length === stamp.length &&
81
+ current.last === stamp.last &&
82
+ current.lastShape === stamp.lastShape &&
83
+ current.model === stamp.model &&
84
+ current.contextWindow === stamp.contextWindow
85
+ );
86
+ }
87
+
26
88
  /** Exact observer usage for one roster entry. */
27
89
  export function progressMetrics(observed: ObservableSession | undefined): AgentMetrics | undefined {
28
90
  const progress = observed?.progress;
@@ -163,8 +225,14 @@ export function aggregateMetrics<TRecord extends AgentRecordLike>(args: {
163
225
  const fallbackSession = args.fallbackStatsSession(ref, observed);
164
226
  if (fallbackSession) {
165
227
  hasFallbackLiveSessions = true;
166
- if (args.refreshFallback || !args.sessionMetrics.has(fallbackSession)) {
167
- args.sessionMetrics.set(fallbackSession, { metrics: readSessionMetrics(fallbackSession) });
228
+ const cached = args.sessionMetrics.get(fallbackSession);
229
+ // A refresh rescans every assistant message (plus the host's stats);
230
+ // skip it while the message list is provably the one already read.
231
+ if (!cached || (args.refreshFallback && !fallbackReadCurrent(cached, fallbackSession))) {
232
+ const stamp = fallbackReadStamp(fallbackSession);
233
+ const entry = { metrics: readSessionMetrics(fallbackSession) };
234
+ if (stamp) fallbackReadStamps.set(entry, stamp);
235
+ args.sessionMetrics.set(fallbackSession, entry);
168
236
  }
169
237
  }
170
238
  const metrics = args.metricsFor(ref, observed);
@@ -1589,7 +1589,8 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
1589
1589
  for (const ref of rosterRows) this.#statusCounts[ref.status]++;
1590
1590
  this.#refreshAggregate();
1591
1591
  this.#refreshActivityData(rosterRows);
1592
- this.#refreshActivityRows();
1592
+ // The 2,000-row activity query only feeds the Activity tab; switching to it refreshes.
1593
+ if (this.#section === "activity") this.#refreshActivityRows();
1593
1594
  }
1594
1595
 
1595
1596
  #refreshActivityData(refs: readonly TRecord[]): void {
@@ -1620,7 +1621,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
1620
1621
  void Promise.all(pending)
1621
1622
  .then(() => {
1622
1623
  if (this.#disposed || generation !== this.#activitySyncGeneration) return;
1623
- this.#refreshActivityRows();
1624
+ if (this.#section === "activity") this.#refreshActivityRows();
1624
1625
  this.#requestRender();
1625
1626
  })
1626
1627
  .catch(() => {