@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/dist/watch.d.ts +46 -3
- package/dist/watch.js +311 -90
- package/dist/watch.js.map +1 -1
- package/package.json +1 -1
- package/src/watch.ts +314 -89
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
600
|
-
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
645
|
-
*
|
|
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
|
-
|
|
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
|
-
...
|
|
887
|
+
...base,
|
|
888
|
+
...(focusedId ? { focusedSessionId: focusedId } : {}),
|
|
650
889
|
computerName: cut(snapshot.computerName, 64),
|
|
651
890
|
statusLabel: cut(snapshot.statusLabel, 80),
|
|
652
|
-
...(snapshot.availableAgents
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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 = () =>
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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 (
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
783
|
-
|
|
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
|
|