linkshell-cli 0.10.53 → 0.10.54

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.
@@ -46,8 +46,9 @@ export const watchTimelineItemSchema = z.object({
46
46
  "error",
47
47
  ]),
48
48
  title: z.string().max(120).optional(),
49
- text: z.string().max(600),
50
- detail: z.string().max(500).optional(),
49
+ // The session the watch has open carries several KB per item (see watchSnapshotContent).
50
+ text: z.string().max(8000),
51
+ detail: z.string().max(4000).optional(),
51
52
  status: z.enum(["running", "completed", "failed", "waiting"]).optional(),
52
53
  filePath: z.string().max(200).optional(),
53
54
  diffAdded: z.number().int().min(0).optional(),
@@ -119,6 +120,8 @@ export const watchSnapshotSchema = z.object({
119
120
  computerKey: z.string().max(512),
120
121
  computerName: z.string().max(80),
121
122
  online: z.boolean(),
123
+ /** The session the watch last opened: it keeps the largest share of the byte budget. */
124
+ focusedSessionId: z.string().min(1).max(128).optional(),
122
125
  statusLabel: z.string().max(80),
123
126
  waitingCount: z.number().int().min(0),
124
127
  runningCount: z.number().int().min(0),
@@ -130,6 +133,17 @@ export const watchSnapshotSchema = z.object({
130
133
  sessions: z.array(watchSessionSchema).max(12),
131
134
  terminals: z.array(watchTerminalSchema).max(8),
132
135
  });
136
+ export const WATCH_PROMPT_MAX = 8000;
137
+ /**
138
+ * WCSession's applicationContext and sendMessage carry about 65 KB; the rest is
139
+ * headroom for the envelope and for Swift's re-serialization of the JSON.
140
+ */
141
+ export const WATCH_SNAPSHOT_BYTE_LIMIT = 56_000;
142
+ /** Snapshot sessions and terminals; the phone attaches exactly the terminals it sends. */
143
+ export const WATCH_SESSION_LIMIT = 10;
144
+ export const WATCH_TERMINAL_LIMIT = 6;
145
+ export const WATCH_FOCUSED_ITEMS = 40;
146
+ export const WATCH_MAX_ITEMS = 60;
133
147
  export const watchCommandSchema = z.discriminatedUnion("kind", [
134
148
  z.object({
135
149
  id: z.string().min(1).max(128),
@@ -141,7 +155,8 @@ export const watchCommandSchema = z.discriminatedUnion("kind", [
141
155
  kind: z.literal("prompt"),
142
156
  computerKey: z.string().min(1).max(512),
143
157
  sessionId: z.string().min(1).max(128),
144
- text: z.string().min(1).max(2000),
158
+ // Dictation runs long; the host's sessions.prompt has no limit of its own.
159
+ text: z.string().min(1).max(WATCH_PROMPT_MAX),
145
160
  now: z.boolean().optional(),
146
161
  }),
147
162
  z.object({
@@ -188,7 +203,7 @@ export const watchCommandSchema = z.discriminatedUnion("kind", [
188
203
  computerKey: z.string().min(1).max(512),
189
204
  agent: z.string().min(1).max(48),
190
205
  cwd: z.string().min(1).max(240),
191
- prompt: z.string().max(2000).optional(),
206
+ prompt: z.string().max(WATCH_PROMPT_MAX).optional(),
192
207
  }),
193
208
  z.object({
194
209
  id: z.string().min(1).max(128),
@@ -208,7 +223,7 @@ export const watchCommandSchema = z.discriminatedUnion("kind", [
208
223
  kind: z.literal("terminal-input"),
209
224
  computerKey: z.string().min(1).max(512),
210
225
  terminalId: z.string().min(1).max(128),
211
- data: z.string().min(1).max(2000),
226
+ data: z.string().min(1).max(WATCH_PROMPT_MAX),
212
227
  }),
213
228
  z.object({
214
229
  id: z.string().min(1).max(128),
@@ -236,6 +251,106 @@ export const DEFAULT_WATCH_QUICK_PROMPTS = [
236
251
  function cut(value, max) {
237
252
  return [...(value ?? "")].slice(0, max).join("");
238
253
  }
254
+ function utf8Length(value) {
255
+ let bytes = 0;
256
+ for (const ch of value) {
257
+ const code = ch.codePointAt(0);
258
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
259
+ }
260
+ return bytes;
261
+ }
262
+ /**
263
+ * Cuts to a UTF-8 byte budget: the WatchConnectivity limit is in bytes, and CJK text
264
+ * takes three a character, so a character count would let it overflow threefold.
265
+ */
266
+ export function cutWatchBytes(value, maxBytes) {
267
+ const text = value ?? "";
268
+ // Every character is at most 4 bytes, so short strings need no counting.
269
+ if (text.length * 4 <= maxBytes || utf8Length(text) <= maxBytes)
270
+ return text;
271
+ const budget = Math.max(0, maxBytes - 3);
272
+ let used = 0;
273
+ let end = 0;
274
+ for (const ch of text) {
275
+ const code = ch.codePointAt(0);
276
+ const size = code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
277
+ if (used + size > budget)
278
+ break;
279
+ used += size;
280
+ end += ch.length;
281
+ }
282
+ return `${text.slice(0, end).trimEnd()}…`;
283
+ }
284
+ /**
285
+ * Message text for the watch's conversation view. Unlike plainPreview (one line, for
286
+ * lists and Live Activities) it keeps line breaks and markdown: the watch renders
287
+ * answers as markdown, code blocks included. Only whitespace noise is tidied.
288
+ */
289
+ export function watchTimelineText(text) {
290
+ return text
291
+ .replace(/\r\n?/g, "\n")
292
+ .replace(/\t/g, " ")
293
+ .replace(/[ \u00a0]+$/gm, "")
294
+ .replace(/\n{3,}/g, "\n\n")
295
+ .trim();
296
+ }
297
+ /**
298
+ * Line-kept text for rows the watch shows as plain Text (thoughts): inline markup
299
+ * outside code (emphasis, headings, links) is unwrapped; code lines stay as they are.
300
+ */
301
+ export function watchPlainText(text) {
302
+ let inFence = false;
303
+ return watchTimelineText(text)
304
+ .split("\n")
305
+ .map((line) => {
306
+ if (/^\s*(```|~~~)/.test(line)) {
307
+ inFence = !inFence;
308
+ return line.trim();
309
+ }
310
+ if (inFence)
311
+ return line;
312
+ return line
313
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
314
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
315
+ .replace(/^(\s{0,3})#{1,6}\s+/, "$1")
316
+ .replace(/^(\s*)[-*+]\s+/, "$1• ")
317
+ .replace(/(\*\*|__|~~)(\S(?:.*?\S)?)\1/g, "$2")
318
+ .replace(/`([^`\n]+)`/g, "$1");
319
+ })
320
+ .join("\n");
321
+ }
322
+ /**
323
+ * Like cutWatchBytes, but keeps the end too: a long answer's conclusion is at the bottom.
324
+ * Three quarters of the budget go to the beginning, the rest to the end.
325
+ */
326
+ export function cutWatchBytesMiddle(value, maxBytes) {
327
+ const text = value ?? "";
328
+ if (text.length * 4 <= maxBytes || utf8Length(text) <= maxBytes)
329
+ return text;
330
+ const chars = [...text];
331
+ const take = (from, step, budget) => {
332
+ let used = 0;
333
+ let count = 0;
334
+ for (let i = from; i >= 0 && i < chars.length; i += step) {
335
+ const size = utf8Length(chars[i]);
336
+ if (used + size > budget)
337
+ break;
338
+ used += size;
339
+ count += 1;
340
+ }
341
+ return count;
342
+ };
343
+ const marker = (omitted) => `\n…(中间略去 ${omitted} 字)…\n`;
344
+ const room = maxBytes - utf8Length(marker(chars.length));
345
+ if (room < 200)
346
+ return cutWatchBytes(text, maxBytes);
347
+ const headCount = take(0, 1, Math.floor(room * 0.75));
348
+ const tailCount = take(chars.length - 1, -1, room - utf8Length(chars.slice(0, headCount).join("")));
349
+ return `${chars.slice(0, headCount).join("").trimEnd()}${marker(chars.length - headCount - tailCount)}${chars
350
+ .slice(chars.length - tailCount)
351
+ .join("")
352
+ .trimStart()}`;
353
+ }
239
354
  function baseName(path) {
240
355
  const parts = path.split(/[\\/]/).filter(Boolean);
241
356
  return parts[parts.length - 1] ?? path;
@@ -347,7 +462,9 @@ export function buildWatchSession(session, online, computerKey = "", options = {
347
462
  ...(options.planProgress.currentStep ? { currentStep: cut(options.planProgress.currentStep, 100) } : {}),
348
463
  }
349
464
  : undefined;
350
- const maxItems = Math.max(4, Math.min(options.maxItems ?? 14, 56));
465
+ const maxItems = options.focused
466
+ ? Math.max(WATCH_FOCUSED_ITEMS, Math.min(options.maxItems ?? 0, WATCH_MAX_ITEMS))
467
+ : Math.max(4, Math.min(options.maxItems ?? 14, 56));
351
468
  const rawItems = options.items?.length ? options.items : fallbackTimelineItems(session, commandPreview, preview);
352
469
  const items = maxItems > 14
353
470
  ? rawItems.slice(-maxItems)
@@ -402,6 +519,29 @@ export function buildWatchTerminal(terminal, online, computerKey = "", outputPre
402
519
  deepLink: liveActivityDetailLink(computerKey, "terminal", terminal.id),
403
520
  };
404
521
  }
522
+ /** Failed, then busy, then most recently active: the order the watch lists terminals in. */
523
+ export function sortWatchTerminals(terminals) {
524
+ const rank = (s) => (s.failed ? 0 : s.busy ? 1 : 2);
525
+ return [...terminals].sort((a, b) => {
526
+ const ra = rank(terminalState(a));
527
+ const rb = rank(terminalState(b));
528
+ if (ra !== rb)
529
+ return ra - rb;
530
+ if (a.activeAt !== b.activeAt)
531
+ return b.activeAt - a.activeAt;
532
+ return a.id.localeCompare(b.id);
533
+ });
534
+ }
535
+ /** The first `max`, with the focused one swapped in for the last when it falls outside. */
536
+ function keepFocused(list, max, focusedId) {
537
+ const head = list.slice(0, max);
538
+ if (!focusedId || head.some((item) => item.id === focusedId))
539
+ return head;
540
+ const focused = list.find((item) => item.id === focusedId);
541
+ if (!focused)
542
+ return head;
543
+ return head.length < max ? [...head, focused] : [...head.slice(0, max - 1), focused];
544
+ }
405
545
  /**
406
546
  * Builds the structured Apple Watch companion snapshot for the selected computer.
407
547
  * Prioritizes sessions needing approval/input, then errors, then running, then pinned/recent.
@@ -442,16 +582,10 @@ export function buildWatchSnapshot(computerName, sessions, terminals, online, op
442
582
  return b.updatedAt - a.updatedAt;
443
583
  return a.id.localeCompare(b.id);
444
584
  });
445
- const sortedTerminals = [...terminals].sort((a, b) => {
446
- const sa = terminalState(a);
447
- const sb = terminalState(b);
448
- const rank = (s) => (s.failed ? 0 : s.busy ? 1 : 2);
449
- if (rank(sa) !== rank(sb))
450
- return rank(sa) - rank(sb);
451
- if (a.activeAt !== b.activeAt)
452
- return b.activeAt - a.activeAt;
453
- return a.id.localeCompare(b.id);
454
- });
585
+ const sortedTerminals = sortWatchTerminals(terminals);
586
+ const focusedId = options.focusedSessionId && liveSessions.some((s) => s.id === options.focusedSessionId)
587
+ ? options.focusedSessionId
588
+ : undefined;
455
589
  const statusLabel = !online
456
590
  ? "连接中断 · 正在重连"
457
591
  : waitingCount > 0
@@ -492,9 +626,8 @@ export function buildWatchSnapshot(computerName, sessions, terminals, online, op
492
626
  addWorkspace(s.worktree?.source ?? s.cwd);
493
627
  for (const t of sortedTerminals)
494
628
  addWorkspace(t.cwd);
495
- const availableAgents = (options.availableAgents?.length
496
- ? options.availableAgents
497
- : ["claude", "codex", "gemini", "cursor", "opencode"])
629
+ // Only what the computer reported: an invented agent would fail when the watch starts it.
630
+ const availableAgents = (options.availableAgents ?? [])
498
631
  .map((id) => cut(id.trim(), 48))
499
632
  .filter(Boolean)
500
633
  .slice(0, 10);
@@ -504,6 +637,7 @@ export function buildWatchSnapshot(computerName, sessions, terminals, online, op
504
637
  computerKey: cut(computerKey, 512),
505
638
  computerName: cut(computerName || "电脑", 64),
506
639
  online,
640
+ ...(focusedId ? { focusedSessionId: focusedId } : {}),
507
641
  statusLabel: cut(statusLabel, 80),
508
642
  waitingCount,
509
643
  runningCount,
@@ -512,7 +646,7 @@ export function buildWatchSnapshot(computerName, sessions, terminals, online, op
512
646
  availableAgents,
513
647
  ...(workspaces.length ? { workspaces } : {}),
514
648
  computers: watchComputers,
515
- sessions: sortedSessions.slice(0, 10).map((session) => {
649
+ sessions: keepFocused(sortedSessions, WATCH_SESSION_LIMIT, focusedId).map((session) => {
516
650
  const goal = goals[session.id];
517
651
  return buildWatchSession(session, online, computerKey, {
518
652
  pinned: pinnedSet.has(session.id),
@@ -524,24 +658,122 @@ export function buildWatchSnapshot(computerName, sessions, terminals, online, op
524
658
  maxItems: itemLimits[session.id],
525
659
  mode: modes[session.id],
526
660
  configOptions: configs[session.id],
661
+ focused: session.id === focusedId,
527
662
  });
528
663
  }),
529
- terminals: sortedTerminals.slice(0, 6).map((terminal) => buildWatchTerminal(terminal, online, computerKey, terminalOutputs[terminal.id])),
664
+ terminals: sortedTerminals.slice(0, WATCH_TERMINAL_LIMIT).map((terminal) => buildWatchTerminal(terminal, online, computerKey, terminalOutputs[terminal.id])),
530
665
  };
531
666
  return watchSnapshotContent(snapshot);
532
667
  }
668
+ const ITEM_TEXT_BYTES = 600;
669
+ const ITEM_DETAIL_BYTES = 360;
670
+ const FOCUSED_TEXT_BYTES = 6_000;
671
+ const FOCUSED_DETAIL_BYTES = 2_400;
672
+ const IMAGE_MAX = 12_000;
673
+ /** What the open conversation may take before the other sessions are squeezed. */
674
+ const FOCUSED_SHARE = 0.65;
675
+ function jsonBytes(value) {
676
+ return utf8Length(JSON.stringify(value));
677
+ }
678
+ function boundItem(item, focused) {
679
+ const { imageBase64, imageMimeType, ...rest } = item;
680
+ // A cut base64 string is a broken picture, so an oversized one is left out whole.
681
+ const image = imageBase64 && imageBase64.length <= IMAGE_MAX ? imageBase64 : undefined;
682
+ return {
683
+ ...rest,
684
+ id: cut(item.id, 120),
685
+ ...(item.title ? { title: cut(item.title, 80) } : {}),
686
+ text: focused ? cutWatchBytesMiddle(item.text, FOCUSED_TEXT_BYTES) : cutWatchBytes(item.text, ITEM_TEXT_BYTES),
687
+ ...(item.detail ? { detail: cutWatchBytes(item.detail, focused ? FOCUSED_DETAIL_BYTES : ITEM_DETAIL_BYTES) } : {}),
688
+ ...(item.filePath ? { filePath: cut(item.filePath, 160) } : {}),
689
+ ...(item.subagentType ? { subagentType: cut(item.subagentType, 48) } : {}),
690
+ ...(item.subagentName ? { subagentName: cut(item.subagentName, 48) } : {}),
691
+ ...(item.subagentModel ? { subagentModel: cut(item.subagentModel, 64) } : {}),
692
+ ...(image ? { imageBase64: image, ...(imageMimeType ? { imageMimeType: cut(imageMimeType, 48) } : {}) } : {}),
693
+ };
694
+ }
695
+ function withoutImage(item) {
696
+ if (!item.imageBase64)
697
+ return item;
698
+ const { imageBase64: _image, imageMimeType: _mime, ...rest } = item;
699
+ return rest;
700
+ }
533
701
  /**
534
- * Bounds a WatchSnapshot so it fits comfortably within WatchConnectivity's
535
- * applicationContext / message budget (<= 24 KB), never truncating action IDs.
702
+ * The newest items that fit in `budget` bytes. The newest message is the one being
703
+ * read, so it is cut rather than dropped; only the newest picture is kept; and the
704
+ * latest question from the user stays when the tail has none, so the answer has context.
536
705
  */
537
- export function watchSnapshotContent(snapshot) {
706
+ function fitItems(items, budget) {
707
+ const kept = [];
708
+ let used = 2;
709
+ let imageKept = false;
710
+ let index = items.length - 1;
711
+ for (; index >= 0; index -= 1) {
712
+ let item = items[index];
713
+ if (imageKept)
714
+ item = withoutImage(item);
715
+ let size = jsonBytes(item) + 1;
716
+ if (used + size > budget && item.imageBase64) {
717
+ item = withoutImage(item);
718
+ size = jsonBytes(item) + 1;
719
+ }
720
+ if (used + size > budget) {
721
+ if (kept.length > 0)
722
+ break;
723
+ let room = budget - used - jsonBytes({ ...item, text: "" }) - 1;
724
+ while (room >= 120 && used + size > budget) {
725
+ item = { ...item, text: cutWatchBytes(item.text, room) };
726
+ size = jsonBytes(item) + 1;
727
+ room = Math.floor(room * 0.8);
728
+ }
729
+ if (used + size > budget)
730
+ break;
731
+ }
732
+ if (item.imageBase64)
733
+ imageKept = true;
734
+ kept.push(item);
735
+ used += size;
736
+ }
737
+ if (index >= 0 && !kept.some((item) => item.kind === "user")) {
738
+ const earlier = items.slice(0, index + 1).reverse().find((item) => item.kind === "user");
739
+ if (earlier) {
740
+ const asked = withoutImage({ ...earlier, text: cutWatchBytes(earlier.text, ITEM_TEXT_BYTES) });
741
+ const size = jsonBytes(asked) + 1;
742
+ if (used + size <= budget)
743
+ kept.push(asked);
744
+ else if (kept.length > 1 && jsonBytes(kept[kept.length - 1]) + 1 + budget - used >= size) {
745
+ kept[kept.length - 1] = asked;
746
+ }
747
+ }
748
+ }
749
+ return kept.reverse();
750
+ }
751
+ function squeezeSession(s, cap, textBytes) {
752
+ if (!s.items)
753
+ return s;
754
+ const items = sliceTimelinePreservingUsers(s.items, cap).map((item) => {
755
+ const { detail: _detail, ...rest } = withoutImage(item);
756
+ return { ...rest, text: cutWatchBytes(item.text, textBytes) };
757
+ });
758
+ return { ...s, items, hasEarlier: s.hasEarlier || items.length < s.items.length || undefined };
759
+ }
760
+ /**
761
+ * Bounds a WatchSnapshot to WATCH_SNAPSHOT_BYTE_LIMIT UTF-8 bytes, never truncating
762
+ * action IDs. The session the watch has open gets the largest share: pictures and the
763
+ * other sessions' items go first, and whatever their trimming frees goes back to it.
764
+ */
765
+ export function watchSnapshotContent(snapshot, limit = WATCH_SNAPSHOT_BYTE_LIMIT) {
766
+ const focusedId = snapshot.focusedSessionId && snapshot.sessions.some((s) => s.id === snapshot.focusedSessionId)
767
+ ? snapshot.focusedSessionId
768
+ : undefined;
769
+ const isFocused = (s) => s.id === focusedId;
770
+ const { focusedSessionId: _focused, ...base } = snapshot;
538
771
  const next = {
539
- ...snapshot,
772
+ ...base,
773
+ ...(focusedId ? { focusedSessionId: focusedId } : {}),
540
774
  computerName: cut(snapshot.computerName, 64),
541
775
  statusLabel: cut(snapshot.statusLabel, 80),
542
- ...(snapshot.availableAgents?.length
543
- ? { availableAgents: snapshot.availableAgents.slice(0, 10).map((a) => cut(a, 48)) }
544
- : {}),
776
+ ...(snapshot.availableAgents ? { availableAgents: snapshot.availableAgents.slice(0, 10).map((a) => cut(a, 48)) } : {}),
545
777
  ...(snapshot.workspaces?.length
546
778
  ? {
547
779
  workspaces: snapshot.workspaces.slice(0, 8).map((w) => ({
@@ -551,25 +783,16 @@ export function watchSnapshotContent(snapshot) {
551
783
  }
552
784
  : {}),
553
785
  computers: snapshot.computers.slice(0, 12).map((c) => ({ ...c, name: cut(c.name, 64) })),
554
- sessions: snapshot.sessions.slice(0, 10).map((s) => {
786
+ sessions: keepFocused(snapshot.sessions, WATCH_SESSION_LIMIT, focusedId).map((s) => {
787
+ const focused = isFocused(s);
555
788
  const validActions = (s.actions ?? []).filter((a) => watchActionSchema.safeParse(a).success).slice(0, 3);
556
789
  const rawValidItems = (s.items ?? []).filter((item) => Boolean(item.id && item.text?.trim()));
557
- const itemCap = rawValidItems.length > 14 ? Math.min(rawValidItems.length, 48) : 14;
558
- const validItems = (itemCap > 14
559
- ? rawValidItems.slice(-itemCap)
560
- : sliceTimelinePreservingUsers(rawValidItems, itemCap)).map((item) => ({
561
- ...item,
562
- id: cut(item.id, 120),
563
- ...(item.title ? { title: cut(item.title, 80) } : {}),
564
- text: cut(item.text, 360),
565
- ...(item.detail ? { detail: cut(item.detail, 360) } : {}),
566
- ...(item.filePath ? { filePath: cut(item.filePath, 160) } : {}),
567
- ...(item.subagentType ? { subagentType: cut(item.subagentType, 48) } : {}),
568
- ...(item.subagentName ? { subagentName: cut(item.subagentName, 48) } : {}),
569
- ...(item.subagentModel ? { subagentModel: cut(item.subagentModel, 64) } : {}),
570
- ...(item.imageBase64 ? { imageBase64: item.imageBase64.slice(0, 12_000) } : {}),
571
- ...(item.imageMimeType ? { imageMimeType: cut(item.imageMimeType, 48) } : {}),
572
- }));
790
+ const itemCap = focused
791
+ ? WATCH_MAX_ITEMS
792
+ : rawValidItems.length > 14
793
+ ? Math.min(rawValidItems.length, 48)
794
+ : 14;
795
+ const validItems = (itemCap > 14 ? rawValidItems.slice(-itemCap) : sliceTimelinePreservingUsers(rawValidItems, itemCap)).map((item) => boundItem(item, focused));
573
796
  const validConfigs = (s.configOptions ?? []).slice(0, 4).map((opt) => ({
574
797
  ...opt,
575
798
  id: cut(opt.id, 48),
@@ -609,10 +832,10 @@ export function watchSnapshotContent(snapshot) {
609
832
  ...(validActions.length ? { actions: validActions } : { actions: undefined }),
610
833
  hasMoreActions: s.hasMoreActions || validActions.length < (s.actions?.length ?? 0) || undefined,
611
834
  ...(hasEarlier ? { hasEarlier: true } : { hasEarlier: undefined }),
612
- ...(validItems.length ? { items: validItems } : {}),
835
+ ...(validItems.length ? { items: validItems } : { items: undefined }),
613
836
  };
614
837
  }),
615
- terminals: snapshot.terminals.slice(0, 6).map((t) => ({
838
+ terminals: snapshot.terminals.slice(0, WATCH_TERMINAL_LIMIT).map((t) => ({
616
839
  ...t,
617
840
  title: cut(t.title, 80),
618
841
  subtitle: cut(t.subtitle, 120),
@@ -622,62 +845,60 @@ export function watchSnapshotContent(snapshot) {
622
845
  ...(t.outputPreview ? { outputPreview: cut(t.outputPreview, 1400) } : {}),
623
846
  })),
624
847
  };
625
- const size = () => new TextEncoder().encode(JSON.stringify(next)).length;
626
- if (size() > 24_000) {
627
- next.sessions = next.sessions.map((s, idx) => {
628
- const isExpanded = (s.items?.length ?? 0) > 14;
629
- const cap = isExpanded ? 32 : idx < 4 ? 10 : 4;
630
- return {
848
+ const size = () => jsonBytes(next);
849
+ const over = () => size() > limit;
850
+ const others = (change) => {
851
+ next.sessions = next.sessions.map((s) => (isFocused(s) ? s : change(s)));
852
+ };
853
+ const focusedItems = next.sessions.find(isFocused)?.items ?? [];
854
+ // Everything but the open conversation, counted with an empty one in its place.
855
+ const restBytes = () => jsonBytes({ ...next, sessions: next.sessions.map((s) => (isFocused(s) ? { ...s, items: [], hasEarlier: true } : s)) });
856
+ let focusedTrimmed = false;
857
+ const fitFocused = (budget) => {
858
+ if (!focusedItems.length)
859
+ return;
860
+ focusedTrimmed = true;
861
+ const items = fitItems(focusedItems, budget);
862
+ next.sessions = next.sessions.map((s) => isFocused(s)
863
+ ? {
631
864
  ...s,
632
- hasEarlier: s.hasEarlier || (s.items?.length ?? 0) > cap || undefined,
633
- items: s.items
634
- ? (isExpanded ? s.items.slice(-cap) : sliceTimelinePreservingUsers(s.items, cap)).map((i) => ({
635
- ...i,
636
- text: cut(i.text, idx < 4 || isExpanded ? 260 : 160),
637
- ...(i.detail ? { detail: cut(i.detail, 150) } : {}),
638
- imageBase64: idx === 0 ? i.imageBase64 : undefined,
639
- }))
640
- : undefined,
641
- };
642
- });
643
- }
644
- if (size() > 24_000) {
865
+ items: items.length ? items : undefined,
866
+ hasEarlier: s.hasEarlier || items.length < focusedItems.length || undefined,
867
+ }
868
+ : s);
869
+ };
870
+ if (over())
871
+ others((s) => (s.items ? { ...s, items: s.items.map(withoutImage) } : s));
872
+ if (over())
873
+ fitFocused(Math.max(Math.floor(limit * FOCUSED_SHARE), limit - restBytes()));
874
+ if (over())
875
+ others((s) => squeezeSession(s, 6, 300));
876
+ if (over()) {
645
877
  next.terminals = next.terminals.map((t) => ({
646
878
  ...t,
647
879
  outputPreview: t.outputPreview ? cut(t.outputPreview, 800) : undefined,
648
880
  }));
649
881
  }
650
- if (size() > 24_000) {
651
- next.sessions = next.sessions.map((s, idx) => {
652
- const isExpanded = (s.items?.length ?? 0) > 14;
653
- const cap = isExpanded ? 24 : idx < 3 ? 6 : 3;
654
- return {
655
- ...s,
656
- configOptions: idx < 2 || isExpanded ? s.configOptions : undefined,
657
- hasEarlier: s.hasEarlier || (s.items?.length ?? 0) > cap || undefined,
658
- items: s.items
659
- ? (isExpanded ? s.items.slice(-cap) : sliceTimelinePreservingUsers(s.items, cap)).map((i) => ({
660
- ...i,
661
- text: cut(i.text, 160),
662
- detail: undefined,
663
- imageBase64: undefined,
664
- }))
665
- : undefined,
666
- };
667
- });
668
- }
669
- while (size() > 24_000 && next.terminals.length > 2)
882
+ if (over())
883
+ others((s) => ({ ...squeezeSession(s, 3, 160), configOptions: undefined }));
884
+ while (over() && next.terminals.length > 2)
670
885
  next.terminals.pop();
671
- while (size() > 24_000 && next.sessions.length > 4)
672
- next.sessions.pop();
673
- if (size() > 24_000) {
674
- next.sessions = next.sessions.map((s) => ({
886
+ while (over() && next.sessions.filter((s) => !isFocused(s)).length > 3) {
887
+ const lastOther = next.sessions.map(isFocused).lastIndexOf(false);
888
+ next.sessions.splice(lastOther, 1);
889
+ }
890
+ if (over()) {
891
+ others((s) => ({
675
892
  ...s,
676
893
  detailPreview: s.detailPreview ? cut(s.detailPreview, 60) : undefined,
677
894
  commandPreview: s.commandPreview ? cut(s.commandPreview, 60) : undefined,
678
895
  items: undefined,
896
+ hasEarlier: s.items?.length ? true : s.hasEarlier,
679
897
  }));
680
898
  }
899
+ // Whatever squeezing the others freed goes back to the open conversation.
900
+ if (focusedTrimmed || over())
901
+ fitFocused(limit - restBytes());
681
902
  return next;
682
903
  }
683
904
  /**