@linkshell/wire 0.2.17 → 0.2.18

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/src/watch.ts CHANGED
@@ -77,8 +77,9 @@ export const watchTimelineItemSchema = z.object({
77
77
  "error",
78
78
  ]),
79
79
  title: z.string().max(120).optional(),
80
- text: z.string().max(600),
81
- detail: z.string().max(500).optional(),
80
+ // The session the watch has open carries several KB per item (see watchSnapshotContent).
81
+ text: z.string().max(8000),
82
+ detail: z.string().max(4000).optional(),
82
83
  status: z.enum(["running", "completed", "failed", "waiting"]).optional(),
83
84
  filePath: z.string().max(200).optional(),
84
85
  diffAdded: z.number().int().min(0).optional(),
@@ -156,6 +157,8 @@ export const watchSnapshotSchema = z.object({
156
157
  computerKey: z.string().max(512),
157
158
  computerName: z.string().max(80),
158
159
  online: z.boolean(),
160
+ /** The session the watch last opened: it keeps the largest share of the byte budget. */
161
+ focusedSessionId: z.string().min(1).max(128).optional(),
159
162
  statusLabel: z.string().max(80),
160
163
  waitingCount: z.number().int().min(0),
161
164
  runningCount: z.number().int().min(0),
@@ -169,6 +172,18 @@ export const watchSnapshotSchema = z.object({
169
172
  });
170
173
  export type WatchSnapshot = z.infer<typeof watchSnapshotSchema>;
171
174
 
175
+ export const WATCH_PROMPT_MAX = 8000;
176
+ /**
177
+ * WCSession's applicationContext and sendMessage carry about 65 KB; the rest is
178
+ * headroom for the envelope and for Swift's re-serialization of the JSON.
179
+ */
180
+ export const WATCH_SNAPSHOT_BYTE_LIMIT = 56_000;
181
+ /** Snapshot sessions and terminals; the phone attaches exactly the terminals it sends. */
182
+ export const WATCH_SESSION_LIMIT = 10;
183
+ export const WATCH_TERMINAL_LIMIT = 6;
184
+ export const WATCH_FOCUSED_ITEMS = 40;
185
+ export const WATCH_MAX_ITEMS = 60;
186
+
172
187
  export const watchCommandSchema = z.discriminatedUnion("kind", [
173
188
  z.object({
174
189
  id: z.string().min(1).max(128),
@@ -180,7 +195,8 @@ export const watchCommandSchema = z.discriminatedUnion("kind", [
180
195
  kind: z.literal("prompt"),
181
196
  computerKey: z.string().min(1).max(512),
182
197
  sessionId: z.string().min(1).max(128),
183
- text: z.string().min(1).max(2000),
198
+ // Dictation runs long; the host's sessions.prompt has no limit of its own.
199
+ text: z.string().min(1).max(WATCH_PROMPT_MAX),
184
200
  now: z.boolean().optional(),
185
201
  }),
186
202
  z.object({
@@ -227,7 +243,7 @@ export const watchCommandSchema = z.discriminatedUnion("kind", [
227
243
  computerKey: z.string().min(1).max(512),
228
244
  agent: z.string().min(1).max(48),
229
245
  cwd: z.string().min(1).max(240),
230
- prompt: z.string().max(2000).optional(),
246
+ prompt: z.string().max(WATCH_PROMPT_MAX).optional(),
231
247
  }),
232
248
  z.object({
233
249
  id: z.string().min(1).max(128),
@@ -247,7 +263,7 @@ export const watchCommandSchema = z.discriminatedUnion("kind", [
247
263
  kind: z.literal("terminal-input"),
248
264
  computerKey: z.string().min(1).max(512),
249
265
  terminalId: z.string().min(1).max(128),
250
- data: z.string().min(1).max(2000),
266
+ data: z.string().min(1).max(WATCH_PROMPT_MAX),
251
267
  }),
252
268
  z.object({
253
269
  id: z.string().min(1).max(128),
@@ -286,6 +302,105 @@ function cut(value: string | undefined, max: number): string {
286
302
  return [...(value ?? "")].slice(0, max).join("");
287
303
  }
288
304
 
305
+ function utf8Length(value: string): number {
306
+ let bytes = 0;
307
+ for (const ch of value) {
308
+ const code = ch.codePointAt(0)!;
309
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
310
+ }
311
+ return bytes;
312
+ }
313
+
314
+ /**
315
+ * Cuts to a UTF-8 byte budget: the WatchConnectivity limit is in bytes, and CJK text
316
+ * takes three a character, so a character count would let it overflow threefold.
317
+ */
318
+ export function cutWatchBytes(value: string | undefined, maxBytes: number): string {
319
+ const text = value ?? "";
320
+ // Every character is at most 4 bytes, so short strings need no counting.
321
+ if (text.length * 4 <= maxBytes || utf8Length(text) <= maxBytes) return text;
322
+ const budget = Math.max(0, maxBytes - 3);
323
+ let used = 0;
324
+ let end = 0;
325
+ for (const ch of text) {
326
+ const code = ch.codePointAt(0)!;
327
+ const size = code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
328
+ if (used + size > budget) break;
329
+ used += size;
330
+ end += ch.length;
331
+ }
332
+ return `${text.slice(0, end).trimEnd()}…`;
333
+ }
334
+
335
+ /**
336
+ * Message text for the watch's conversation view. Unlike plainPreview (one line, for
337
+ * lists and Live Activities) it keeps line breaks and markdown: the watch renders
338
+ * answers as markdown, code blocks included. Only whitespace noise is tidied.
339
+ */
340
+ export function watchTimelineText(text: string): string {
341
+ return text
342
+ .replace(/\r\n?/g, "\n")
343
+ .replace(/\t/g, " ")
344
+ .replace(/[ \u00a0]+$/gm, "")
345
+ .replace(/\n{3,}/g, "\n\n")
346
+ .trim();
347
+ }
348
+
349
+ /**
350
+ * Line-kept text for rows the watch shows as plain Text (thoughts): inline markup
351
+ * outside code (emphasis, headings, links) is unwrapped; code lines stay as they are.
352
+ */
353
+ export function watchPlainText(text: string): string {
354
+ let inFence = false;
355
+ return watchTimelineText(text)
356
+ .split("\n")
357
+ .map((line) => {
358
+ if (/^\s*(```|~~~)/.test(line)) {
359
+ inFence = !inFence;
360
+ return line.trim();
361
+ }
362
+ if (inFence) return line;
363
+ return line
364
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
365
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
366
+ .replace(/^(\s{0,3})#{1,6}\s+/, "$1")
367
+ .replace(/^(\s*)[-*+]\s+/, "$1• ")
368
+ .replace(/(\*\*|__|~~)(\S(?:.*?\S)?)\1/g, "$2")
369
+ .replace(/`([^`\n]+)`/g, "$1");
370
+ })
371
+ .join("\n");
372
+ }
373
+
374
+ /**
375
+ * Like cutWatchBytes, but keeps the end too: a long answer's conclusion is at the bottom.
376
+ * Three quarters of the budget go to the beginning, the rest to the end.
377
+ */
378
+ export function cutWatchBytesMiddle(value: string | undefined, maxBytes: number): string {
379
+ const text = value ?? "";
380
+ if (text.length * 4 <= maxBytes || utf8Length(text) <= maxBytes) return text;
381
+ const chars = [...text];
382
+ const take = (from: number, step: 1 | -1, budget: number) => {
383
+ let used = 0;
384
+ let count = 0;
385
+ for (let i = from; i >= 0 && i < chars.length; i += step) {
386
+ const size = utf8Length(chars[i]!);
387
+ if (used + size > budget) break;
388
+ used += size;
389
+ count += 1;
390
+ }
391
+ return count;
392
+ };
393
+ const marker = (omitted: number) => `\n…(中间略去 ${omitted} 字)…\n`;
394
+ const room = maxBytes - utf8Length(marker(chars.length));
395
+ if (room < 200) return cutWatchBytes(text, maxBytes);
396
+ const headCount = take(0, 1, Math.floor(room * 0.75));
397
+ const tailCount = take(chars.length - 1, -1, room - utf8Length(chars.slice(0, headCount).join("")));
398
+ return `${chars.slice(0, headCount).join("").trimEnd()}${marker(chars.length - headCount - tailCount)}${chars
399
+ .slice(chars.length - tailCount)
400
+ .join("")
401
+ .trimStart()}`;
402
+ }
403
+
289
404
  function baseName(path: string): string {
290
405
  const parts = path.split(/[\\/]/).filter(Boolean);
291
406
  return parts[parts.length - 1] ?? path;
@@ -377,6 +492,8 @@ export function buildWatchSession(
377
492
  maxItems?: number;
378
493
  mode?: string;
379
494
  configOptions?: WatchConfigOption[];
495
+ /** The session the watch has open: its whole recent conversation, not a glance. */
496
+ focused?: boolean;
380
497
  } = {},
381
498
  ): WatchSession {
382
499
  const task = liveActivitySessionTask(session, online);
@@ -416,7 +533,9 @@ export function buildWatchSession(
416
533
  ...(options.planProgress.currentStep ? { currentStep: cut(options.planProgress.currentStep, 100) } : {}),
417
534
  }
418
535
  : undefined;
419
- const maxItems = Math.max(4, Math.min(options.maxItems ?? 14, 56));
536
+ const maxItems = options.focused
537
+ ? Math.max(WATCH_FOCUSED_ITEMS, Math.min(options.maxItems ?? 0, WATCH_MAX_ITEMS))
538
+ : Math.max(4, Math.min(options.maxItems ?? 14, 56));
420
539
  const rawItems = options.items?.length ? options.items : fallbackTimelineItems(session, commandPreview, preview);
421
540
  const items =
422
541
  maxItems > 14
@@ -498,9 +617,32 @@ export interface BuildWatchSnapshotOptions {
498
617
  availableAgents?: string[];
499
618
  workspaces?: WatchWorkspace[];
500
619
  agentTiers?: Record<string, string>;
620
+ /** The session the watch has open; it stays in the snapshot even outside the top ten. */
621
+ focusedSessionId?: string;
501
622
  now?: number;
502
623
  }
503
624
 
625
+ /** Failed, then busy, then most recently active: the order the watch lists terminals in. */
626
+ export function sortWatchTerminals(terminals: TerminalInfo[]): TerminalInfo[] {
627
+ const rank = (s: ReturnType<typeof terminalState>) => (s.failed ? 0 : s.busy ? 1 : 2);
628
+ return [...terminals].sort((a, b) => {
629
+ const ra = rank(terminalState(a));
630
+ const rb = rank(terminalState(b));
631
+ if (ra !== rb) return ra - rb;
632
+ if (a.activeAt !== b.activeAt) return b.activeAt - a.activeAt;
633
+ return a.id.localeCompare(b.id);
634
+ });
635
+ }
636
+
637
+ /** The first `max`, with the focused one swapped in for the last when it falls outside. */
638
+ function keepFocused<T extends { id: string }>(list: T[], max: number, focusedId: string | undefined): T[] {
639
+ const head = list.slice(0, max);
640
+ if (!focusedId || head.some((item) => item.id === focusedId)) return head;
641
+ const focused = list.find((item) => item.id === focusedId);
642
+ if (!focused) return head;
643
+ return head.length < max ? [...head, focused] : [...head.slice(0, max - 1), focused];
644
+ }
645
+
504
646
  /**
505
647
  * Builds the structured Apple Watch companion snapshot for the selected computer.
506
648
  * Prioritizes sessions needing approval/input, then errors, then running, then pinned/recent.
@@ -548,14 +690,10 @@ export function buildWatchSnapshot(
548
690
  return a.id.localeCompare(b.id);
549
691
  });
550
692
 
551
- const sortedTerminals = [...terminals].sort((a, b) => {
552
- const sa = terminalState(a);
553
- const sb = terminalState(b);
554
- const rank = (s: ReturnType<typeof terminalState>) => (s.failed ? 0 : s.busy ? 1 : 2);
555
- if (rank(sa) !== rank(sb)) return rank(sa) - rank(sb);
556
- if (a.activeAt !== b.activeAt) return b.activeAt - a.activeAt;
557
- return a.id.localeCompare(b.id);
558
- });
693
+ const sortedTerminals = sortWatchTerminals(terminals);
694
+ const focusedId = options.focusedSessionId && liveSessions.some((s) => s.id === options.focusedSessionId)
695
+ ? options.focusedSessionId
696
+ : undefined;
559
697
 
560
698
  const statusLabel = !online
561
699
  ? "连接中断 · 正在重连"
@@ -596,10 +734,8 @@ export function buildWatchSnapshot(
596
734
  for (const s of sortedSessions) addWorkspace(s.worktree?.source ?? s.cwd);
597
735
  for (const t of sortedTerminals) addWorkspace(t.cwd);
598
736
 
599
- const availableAgents = (options.availableAgents?.length
600
- ? options.availableAgents
601
- : ["claude", "codex", "gemini", "cursor", "opencode"]
602
- )
737
+ // Only what the computer reported: an invented agent would fail when the watch starts it.
738
+ const availableAgents = (options.availableAgents ?? [])
603
739
  .map((id) => cut(id.trim(), 48))
604
740
  .filter(Boolean)
605
741
  .slice(0, 10);
@@ -610,6 +746,7 @@ export function buildWatchSnapshot(
610
746
  computerKey: cut(computerKey, 512),
611
747
  computerName: cut(computerName || "电脑", 64),
612
748
  online,
749
+ ...(focusedId ? { focusedSessionId: focusedId } : {}),
613
750
  statusLabel: cut(statusLabel, 80),
614
751
  waitingCount,
615
752
  runningCount,
@@ -618,7 +755,7 @@ export function buildWatchSnapshot(
618
755
  availableAgents,
619
756
  ...(workspaces.length ? { workspaces } : {}),
620
757
  computers: watchComputers,
621
- sessions: sortedSessions.slice(0, 10).map((session) => {
758
+ sessions: keepFocused(sortedSessions, WATCH_SESSION_LIMIT, focusedId).map((session) => {
622
759
  const goal = goals[session.id];
623
760
  return buildWatchSession(session, online, computerKey, {
624
761
  pinned: pinnedSet.has(session.id),
@@ -630,9 +767,10 @@ export function buildWatchSnapshot(
630
767
  maxItems: itemLimits[session.id],
631
768
  mode: modes[session.id],
632
769
  configOptions: configs[session.id],
770
+ focused: session.id === focusedId,
633
771
  });
634
772
  }),
635
- terminals: sortedTerminals.slice(0, 6).map((terminal) =>
773
+ terminals: sortedTerminals.slice(0, WATCH_TERMINAL_LIMIT).map((terminal) =>
636
774
  buildWatchTerminal(terminal, online, computerKey, terminalOutputs[terminal.id]),
637
775
  ),
638
776
  };
@@ -640,18 +778,117 @@ export function buildWatchSnapshot(
640
778
  return watchSnapshotContent(snapshot);
641
779
  }
642
780
 
781
+ const ITEM_TEXT_BYTES = 600;
782
+ const ITEM_DETAIL_BYTES = 360;
783
+ const FOCUSED_TEXT_BYTES = 6_000;
784
+ const FOCUSED_DETAIL_BYTES = 2_400;
785
+ const IMAGE_MAX = 12_000;
786
+ /** What the open conversation may take before the other sessions are squeezed. */
787
+ const FOCUSED_SHARE = 0.65;
788
+
789
+ type SnapshotSession = WatchSnapshot["sessions"][number];
790
+
791
+ function jsonBytes(value: unknown): number {
792
+ return utf8Length(JSON.stringify(value));
793
+ }
794
+
795
+ function boundItem(item: WatchTimelineItem, focused: boolean): WatchTimelineItem {
796
+ const { imageBase64, imageMimeType, ...rest } = item;
797
+ // A cut base64 string is a broken picture, so an oversized one is left out whole.
798
+ const image = imageBase64 && imageBase64.length <= IMAGE_MAX ? imageBase64 : undefined;
799
+ return {
800
+ ...rest,
801
+ id: cut(item.id, 120),
802
+ ...(item.title ? { title: cut(item.title, 80) } : {}),
803
+ text: focused ? cutWatchBytesMiddle(item.text, FOCUSED_TEXT_BYTES) : cutWatchBytes(item.text, ITEM_TEXT_BYTES),
804
+ ...(item.detail ? { detail: cutWatchBytes(item.detail, focused ? FOCUSED_DETAIL_BYTES : ITEM_DETAIL_BYTES) } : {}),
805
+ ...(item.filePath ? { filePath: cut(item.filePath, 160) } : {}),
806
+ ...(item.subagentType ? { subagentType: cut(item.subagentType, 48) } : {}),
807
+ ...(item.subagentName ? { subagentName: cut(item.subagentName, 48) } : {}),
808
+ ...(item.subagentModel ? { subagentModel: cut(item.subagentModel, 64) } : {}),
809
+ ...(image ? { imageBase64: image, ...(imageMimeType ? { imageMimeType: cut(imageMimeType, 48) } : {}) } : {}),
810
+ };
811
+ }
812
+
813
+ function withoutImage(item: WatchTimelineItem): WatchTimelineItem {
814
+ if (!item.imageBase64) return item;
815
+ const { imageBase64: _image, imageMimeType: _mime, ...rest } = item;
816
+ return rest;
817
+ }
818
+
643
819
  /**
644
- * Bounds a WatchSnapshot so it fits comfortably within WatchConnectivity's
645
- * applicationContext / message budget (<= 24 KB), never truncating action IDs.
820
+ * The newest items that fit in `budget` bytes. The newest message is the one being
821
+ * read, so it is cut rather than dropped; only the newest picture is kept; and the
822
+ * latest question from the user stays when the tail has none, so the answer has context.
646
823
  */
647
- export function watchSnapshotContent(snapshot: WatchSnapshot): WatchSnapshot {
824
+ function fitItems(items: WatchTimelineItem[], budget: number): WatchTimelineItem[] {
825
+ const kept: WatchTimelineItem[] = [];
826
+ let used = 2;
827
+ let imageKept = false;
828
+ let index = items.length - 1;
829
+ for (; index >= 0; index -= 1) {
830
+ let item = items[index]!;
831
+ if (imageKept) item = withoutImage(item);
832
+ let size = jsonBytes(item) + 1;
833
+ if (used + size > budget && item.imageBase64) {
834
+ item = withoutImage(item);
835
+ size = jsonBytes(item) + 1;
836
+ }
837
+ if (used + size > budget) {
838
+ if (kept.length > 0) break;
839
+ let room = budget - used - jsonBytes({ ...item, text: "" }) - 1;
840
+ while (room >= 120 && used + size > budget) {
841
+ item = { ...item, text: cutWatchBytes(item.text, room) };
842
+ size = jsonBytes(item) + 1;
843
+ room = Math.floor(room * 0.8);
844
+ }
845
+ if (used + size > budget) break;
846
+ }
847
+ if (item.imageBase64) imageKept = true;
848
+ kept.push(item);
849
+ used += size;
850
+ }
851
+ if (index >= 0 && !kept.some((item) => item.kind === "user")) {
852
+ const earlier = items.slice(0, index + 1).reverse().find((item) => item.kind === "user");
853
+ if (earlier) {
854
+ const asked = withoutImage({ ...earlier, text: cutWatchBytes(earlier.text, ITEM_TEXT_BYTES) });
855
+ const size = jsonBytes(asked) + 1;
856
+ if (used + size <= budget) kept.push(asked);
857
+ else if (kept.length > 1 && jsonBytes(kept[kept.length - 1]) + 1 + budget - used >= size) {
858
+ kept[kept.length - 1] = asked;
859
+ }
860
+ }
861
+ }
862
+ return kept.reverse();
863
+ }
864
+
865
+ function squeezeSession(s: SnapshotSession, cap: number, textBytes: number): SnapshotSession {
866
+ if (!s.items) return s;
867
+ const items = sliceTimelinePreservingUsers(s.items, cap).map((item) => {
868
+ const { detail: _detail, ...rest } = withoutImage(item);
869
+ return { ...rest, text: cutWatchBytes(item.text, textBytes) };
870
+ });
871
+ return { ...s, items, hasEarlier: s.hasEarlier || items.length < s.items.length || undefined };
872
+ }
873
+
874
+ /**
875
+ * Bounds a WatchSnapshot to WATCH_SNAPSHOT_BYTE_LIMIT UTF-8 bytes, never truncating
876
+ * action IDs. The session the watch has open gets the largest share: pictures and the
877
+ * other sessions' items go first, and whatever their trimming frees goes back to it.
878
+ */
879
+ export function watchSnapshotContent(snapshot: WatchSnapshot, limit = WATCH_SNAPSHOT_BYTE_LIMIT): WatchSnapshot {
880
+ const focusedId =
881
+ snapshot.focusedSessionId && snapshot.sessions.some((s) => s.id === snapshot.focusedSessionId)
882
+ ? snapshot.focusedSessionId
883
+ : undefined;
884
+ const isFocused = (s: { id: string }) => s.id === focusedId;
885
+ const { focusedSessionId: _focused, ...base } = snapshot;
648
886
  const next: WatchSnapshot = {
649
- ...snapshot,
887
+ ...base,
888
+ ...(focusedId ? { focusedSessionId: focusedId } : {}),
650
889
  computerName: cut(snapshot.computerName, 64),
651
890
  statusLabel: cut(snapshot.statusLabel, 80),
652
- ...(snapshot.availableAgents?.length
653
- ? { availableAgents: snapshot.availableAgents.slice(0, 10).map((a) => cut(a, 48)) }
654
- : {}),
891
+ ...(snapshot.availableAgents ? { availableAgents: snapshot.availableAgents.slice(0, 10).map((a) => cut(a, 48)) } : {}),
655
892
  ...(snapshot.workspaces?.length
656
893
  ? {
657
894
  workspaces: snapshot.workspaces.slice(0, 8).map((w) => ({
@@ -661,27 +898,18 @@ export function watchSnapshotContent(snapshot: WatchSnapshot): WatchSnapshot {
661
898
  }
662
899
  : {}),
663
900
  computers: snapshot.computers.slice(0, 12).map((c) => ({ ...c, name: cut(c.name, 64) })),
664
- sessions: snapshot.sessions.slice(0, 10).map((s) => {
901
+ sessions: keepFocused(snapshot.sessions, WATCH_SESSION_LIMIT, focusedId).map((s) => {
902
+ const focused = isFocused(s);
665
903
  const validActions = (s.actions ?? []).filter((a) => watchActionSchema.safeParse(a).success).slice(0, 3);
666
904
  const rawValidItems = (s.items ?? []).filter((item) => Boolean(item.id && item.text?.trim()));
667
- const itemCap = rawValidItems.length > 14 ? Math.min(rawValidItems.length, 48) : 14;
905
+ const itemCap = focused
906
+ ? WATCH_MAX_ITEMS
907
+ : rawValidItems.length > 14
908
+ ? Math.min(rawValidItems.length, 48)
909
+ : 14;
668
910
  const validItems = (
669
- itemCap > 14
670
- ? rawValidItems.slice(-itemCap)
671
- : sliceTimelinePreservingUsers(rawValidItems, itemCap)
672
- ).map((item) => ({
673
- ...item,
674
- id: cut(item.id, 120),
675
- ...(item.title ? { title: cut(item.title, 80) } : {}),
676
- text: cut(item.text, 360),
677
- ...(item.detail ? { detail: cut(item.detail, 360) } : {}),
678
- ...(item.filePath ? { filePath: cut(item.filePath, 160) } : {}),
679
- ...(item.subagentType ? { subagentType: cut(item.subagentType, 48) } : {}),
680
- ...(item.subagentName ? { subagentName: cut(item.subagentName, 48) } : {}),
681
- ...(item.subagentModel ? { subagentModel: cut(item.subagentModel, 64) } : {}),
682
- ...(item.imageBase64 ? { imageBase64: item.imageBase64.slice(0, 12_000) } : {}),
683
- ...(item.imageMimeType ? { imageMimeType: cut(item.imageMimeType, 48) } : {}),
684
- }));
911
+ itemCap > 14 ? rawValidItems.slice(-itemCap) : sliceTimelinePreservingUsers(rawValidItems, itemCap)
912
+ ).map((item) => boundItem(item, focused));
685
913
  const validConfigs = (s.configOptions ?? []).slice(0, 4).map((opt) => ({
686
914
  ...opt,
687
915
  id: cut(opt.id, 48),
@@ -721,10 +949,10 @@ export function watchSnapshotContent(snapshot: WatchSnapshot): WatchSnapshot {
721
949
  ...(validActions.length ? { actions: validActions } : { actions: undefined }),
722
950
  hasMoreActions: s.hasMoreActions || validActions.length < (s.actions?.length ?? 0) || undefined,
723
951
  ...(hasEarlier ? { hasEarlier: true } : { hasEarlier: undefined }),
724
- ...(validItems.length ? { items: validItems } : {}),
952
+ ...(validItems.length ? { items: validItems } : { items: undefined }),
725
953
  };
726
954
  }),
727
- terminals: snapshot.terminals.slice(0, 6).map((t) => ({
955
+ terminals: snapshot.terminals.slice(0, WATCH_TERMINAL_LIMIT).map((t) => ({
728
956
  ...t,
729
957
  title: cut(t.title, 80),
730
958
  subtitle: cut(t.subtitle, 120),
@@ -735,60 +963,57 @@ export function watchSnapshotContent(snapshot: WatchSnapshot): WatchSnapshot {
735
963
  })),
736
964
  };
737
965
 
738
- const size = () => new TextEncoder().encode(JSON.stringify(next)).length;
739
- if (size() > 24_000) {
740
- next.sessions = next.sessions.map((s, idx) => {
741
- const isExpanded = (s.items?.length ?? 0) > 14;
742
- const cap = isExpanded ? 32 : idx < 4 ? 10 : 4;
743
- return {
744
- ...s,
745
- hasEarlier: s.hasEarlier || (s.items?.length ?? 0) > cap || undefined,
746
- items: s.items
747
- ? (isExpanded ? s.items.slice(-cap) : sliceTimelinePreservingUsers(s.items, cap)).map((i) => ({
748
- ...i,
749
- text: cut(i.text, idx < 4 || isExpanded ? 260 : 160),
750
- ...(i.detail ? { detail: cut(i.detail, 150) } : {}),
751
- imageBase64: idx === 0 ? i.imageBase64 : undefined,
752
- }))
753
- : undefined,
754
- };
755
- });
756
- }
757
- if (size() > 24_000) {
966
+ const size = () => jsonBytes(next);
967
+ const over = () => size() > limit;
968
+ const others = (change: (s: SnapshotSession) => SnapshotSession) => {
969
+ next.sessions = next.sessions.map((s) => (isFocused(s) ? s : change(s)));
970
+ };
971
+ const focusedItems = next.sessions.find(isFocused)?.items ?? [];
972
+ // Everything but the open conversation, counted with an empty one in its place.
973
+ const restBytes = () =>
974
+ jsonBytes({ ...next, sessions: next.sessions.map((s) => (isFocused(s) ? { ...s, items: [], hasEarlier: true } : s)) });
975
+ let focusedTrimmed = false;
976
+ const fitFocused = (budget: number) => {
977
+ if (!focusedItems.length) return;
978
+ focusedTrimmed = true;
979
+ const items = fitItems(focusedItems, budget);
980
+ next.sessions = next.sessions.map((s) =>
981
+ isFocused(s)
982
+ ? {
983
+ ...s,
984
+ items: items.length ? items : undefined,
985
+ hasEarlier: s.hasEarlier || items.length < focusedItems.length || undefined,
986
+ }
987
+ : s,
988
+ );
989
+ };
990
+
991
+ if (over()) others((s) => (s.items ? { ...s, items: s.items.map(withoutImage) } : s));
992
+ if (over()) fitFocused(Math.max(Math.floor(limit * FOCUSED_SHARE), limit - restBytes()));
993
+ if (over()) others((s) => squeezeSession(s, 6, 300));
994
+ if (over()) {
758
995
  next.terminals = next.terminals.map((t) => ({
759
996
  ...t,
760
997
  outputPreview: t.outputPreview ? cut(t.outputPreview, 800) : undefined,
761
998
  }));
762
999
  }
763
- if (size() > 24_000) {
764
- next.sessions = next.sessions.map((s, idx) => {
765
- const isExpanded = (s.items?.length ?? 0) > 14;
766
- const cap = isExpanded ? 24 : idx < 3 ? 6 : 3;
767
- return {
768
- ...s,
769
- configOptions: idx < 2 || isExpanded ? s.configOptions : undefined,
770
- hasEarlier: s.hasEarlier || (s.items?.length ?? 0) > cap || undefined,
771
- items: s.items
772
- ? (isExpanded ? s.items.slice(-cap) : sliceTimelinePreservingUsers(s.items, cap)).map((i) => ({
773
- ...i,
774
- text: cut(i.text, 160),
775
- detail: undefined,
776
- imageBase64: undefined,
777
- }))
778
- : undefined,
779
- };
780
- });
1000
+ if (over()) others((s) => ({ ...squeezeSession(s, 3, 160), configOptions: undefined }));
1001
+ while (over() && next.terminals.length > 2) next.terminals.pop();
1002
+ while (over() && next.sessions.filter((s) => !isFocused(s)).length > 3) {
1003
+ const lastOther = next.sessions.map(isFocused).lastIndexOf(false);
1004
+ next.sessions.splice(lastOther, 1);
781
1005
  }
782
- while (size() > 24_000 && next.terminals.length > 2) next.terminals.pop();
783
- while (size() > 24_000 && next.sessions.length > 4) next.sessions.pop();
784
- if (size() > 24_000) {
785
- next.sessions = next.sessions.map((s) => ({
1006
+ if (over()) {
1007
+ others((s) => ({
786
1008
  ...s,
787
1009
  detailPreview: s.detailPreview ? cut(s.detailPreview, 60) : undefined,
788
1010
  commandPreview: s.commandPreview ? cut(s.commandPreview, 60) : undefined,
789
1011
  items: undefined,
1012
+ hasEarlier: s.items?.length ? true : s.hasEarlier,
790
1013
  }));
791
1014
  }
1015
+ // Whatever squeezing the others freed goes back to the open conversation.
1016
+ if (focusedTrimmed || over()) fitFocused(limit - restBytes());
792
1017
  return next;
793
1018
  }
794
1019