dsh-context-pruner 0.0.0-stage → 0.1.1

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/lib/client.js ADDED
@@ -0,0 +1,1230 @@
1
+ /**
2
+ * dsh-context-pruner browser plugin — step 1: the Context tab renders rounds.
3
+ *
4
+ * ## Why this is a hand-written bundle
5
+ *
6
+ * Shipped client plugins are produced by rolldown into a
7
+ * `window.__ModuleLoader__.load({ id, factory })` wrapper. That wrapper is a
8
+ * small, stable contract and `dsh-client-ui-brand-official` (2 KB) is a
9
+ * complete worked example of exactly this shape, so this plugin is authored
10
+ * directly in that format: no build step, and the source a maintainer reads is
11
+ * the artifact the browser runs. The body only registers the factory; every
12
+ * side effect lives inside it and runs at materialization.
13
+ *
14
+ * ## Verified integration points
15
+ *
16
+ * These were read from the shipped type declarations rather than guessed;
17
+ * three earlier guesses were wrong and are noted so they are not re-made:
18
+ *
19
+ * - Services come from `ctx.get(name)` / `ctx.<name>`, NOT `ctx.sessions.get(id)`.
20
+ * - Session data arrives as framework-injected props on a session-scoped
21
+ * slot body (`sessionId`, `useSessions`, `useTabInfo`, `actions`), and the
22
+ * event stream is `ctx.sessions.binding(id).eventSource`.
23
+ * - `ctx.slots.register(spec, Component)` is called directly; the generator
24
+ * form is for `ctx.effect`.
25
+ *
26
+ * ## Scope of this step
27
+ *
28
+ * Read-only: the tab lists rounds with their token weight. Selection and the
29
+ * Prune button arrive in step 2, after this renders correctly in a browser —
30
+ * client behavior cannot be proven by a Node test.
31
+ */
32
+ window.__ModuleLoader__.load({
33
+ id: "dsh-context-pruner",
34
+ factory: (require) => {
35
+ var module = { exports: {} };
36
+ var exports = module.exports;
37
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
38
+
39
+ const React = require("react");
40
+ const jsxRuntime = require("react/jsx-runtime");
41
+
42
+ const h = jsxRuntime.jsx;
43
+ const jsxs = jsxRuntime.jsxs;
44
+
45
+ /** Tab type id; the body and title slots register under this same key. */
46
+ const TAB_ID = "dsh-context-pruner";
47
+
48
+ /** Tab kind: the discriminator `ctx.sidebarRight.openTab` names. */
49
+ const TAB_KIND = "context-pruner";
50
+
51
+ /**
52
+ * Cordis SERVICE names this plugin waits for before activating.
53
+ *
54
+ * These are service names, not package names. Declaring package names
55
+ * here (an earlier mistake) leaves the entry permanently `pending`,
56
+ * because Cordis waits for services under exactly these strings:
57
+ *
58
+ * dsh-context-pruner: pending (waiting for services:
59
+ * @deepseek-ai/dsh-client-ui-slots, ...)
60
+ *
61
+ * `sidebarRight` is the controller that renders the column, and every
62
+ * shipped tab provider injects it (`dsh-client-ui-sidebar-files` lists
63
+ * `slots, locale, sidebarRightTabs, sidebarRight, ...`). Omitting it
64
+ * registers the body successfully while the pane never resolves it —
65
+ * the tab chip appears and the panel stays blank, which is exactly the
66
+ * symptom this list was corrected for.
67
+ */
68
+ const inject = ["slots", "sidebarRightTabs", "sidebarRight", "sessions", "conversation"];
69
+
70
+ /** Opening tag of a framed compaction checkpoint body. */
71
+ const SUMMARY_OPEN_TAG = "<compacted-summary>";
72
+
73
+ /** Closing tag of a framed compaction checkpoint body. */
74
+ const SUMMARY_CLOSE_TAG = "</compacted-summary>";
75
+
76
+ /**
77
+ * `source.kind` values that never represent a human turn.
78
+ *
79
+ * Verified in the DSH checkout: every harness injection is built with
80
+ * `createUserMessage({ ..., source: { kind, form } })` and delivered
81
+ * through the same `user/message` surface type as real conversation.
82
+ */
83
+ const MACHINE_SOURCE_KINDS = new Set([
84
+ "tool-jobs",
85
+ "runtime-context",
86
+ "agent-instructions",
87
+ "agent-message",
88
+ "session-reference",
89
+ "skill-invocation",
90
+ "model-selection"
91
+ ]);
92
+
93
+ /** `source.form` values that mark an injected rather than typed message. */
94
+ const MACHINE_SOURCE_FORMS = new Set(["notice", "snapshot", "instructions"]);
95
+
96
+ /**
97
+ * Command names dispatched as commands rather than chat.
98
+ *
99
+ * Mirrors `KNOWN_COMMANDS` in `lib/rounds.js`, so the two sides classify
100
+ * a slash-leading line identically. Used only when no `source` marker is
101
+ * available.
102
+ */
103
+ const KNOWN_COMMANDS = new Set([
104
+ "prune",
105
+ "compact",
106
+ "clear",
107
+ "help",
108
+ "init",
109
+ "model",
110
+ "new",
111
+ "resume",
112
+ "review",
113
+ "status",
114
+ "quit",
115
+ "exit"
116
+ ]);
117
+
118
+ /**
119
+ * Opening sentences of the fixed checkpoint boilerplate.
120
+ *
121
+ * Both framings ship: "This is an automatically generated checkpoint…"
122
+ * from the stock backend and "This is a correction checkpoint…" from this
123
+ * plugin's. The two real openings are named rather than matching a bare
124
+ * "This is ", which would strip ordinary prose starting that way.
125
+ *
126
+ * Kept in sync with `CHECKPOINT_OPENINGS` in `lib/inspect.js`; the parity
127
+ * is asserted by `test/rounds.mjs`, which executes these functions out of
128
+ * the shipped bundle against the host's output.
129
+ */
130
+ const CHECKPOINT_OPENINGS = [
131
+ "This is an automatically generated checkpoint",
132
+ "This is a correction checkpoint"
133
+ ];
134
+
135
+ /**
136
+ * Remove checkpoint boilerplate from the head of a block.
137
+ *
138
+ * The boilerplate is fixed English text. It is usually its own block, but
139
+ * a producer may emit it in the SAME block as the framed body — with or
140
+ * without a paragraph break. Dropping the whole block on a prefix match
141
+ * would then discard the summary along with it, so only the boilerplate's
142
+ * own span is removed and whatever follows is kept.
143
+ *
144
+ * @param text - one text block's content.
145
+ * @returns the text after the boilerplate, or `text` when there is none.
146
+ */
147
+ function stripCheckpointPreamble(text) {
148
+ for (const opening of CHECKPOINT_OPENINGS) {
149
+ if (!text.startsWith(opening)) continue;
150
+ const end = text.indexOf("\n\n", opening.length);
151
+ if (end !== -1) return text.slice(end + 2);
152
+ const tag = text.indexOf(SUMMARY_OPEN_TAG, opening.length);
153
+ return tag === -1 ? "" : text.slice(tag);
154
+ }
155
+ return text;
156
+ }
157
+
158
+ /**
159
+ * Whether a block is nothing but checkpoint boilerplate.
160
+ *
161
+ * @param text - one text block's content.
162
+ * @returns true when the block carries no content beyond the preamble.
163
+ */
164
+ function isCheckpointPreamble(text) {
165
+ return stripCheckpointPreamble(text).trim().length === 0 && text.trim().length > 0;
166
+ }
167
+
168
+ /**
169
+ * Whether a `source` marker identifies a harness injection.
170
+ *
171
+ * Mirrors `isMachineSource` in `lib/rounds.js`. The panel cannot import
172
+ * the host module, so the rule is restated here — and
173
+ * `test/rounds.mjs` extracts this function from the shipped bundle and
174
+ * runs it against the same table the host module is tested with, so the
175
+ * two copies cannot drift apart silently.
176
+ *
177
+ * The structured marker is preferred over text matching: it survives
178
+ * reworded notices, which the prefix list would not.
179
+ *
180
+ * @param source - a message's `source` field, when present.
181
+ * @returns true when the marker names a machine delivery.
182
+ */
183
+ function isMachineSource(source) {
184
+ if (source === null || source === undefined || typeof source !== "object") return false;
185
+ if (
186
+ typeof source.kind === "string" &&
187
+ MACHINE_SOURCE_KINDS.has(source.kind)
188
+ ) {
189
+ return true;
190
+ }
191
+ if (typeof source.form === "string" && MACHINE_SOURCE_FORMS.has(source.form)) return true;
192
+ return false;
193
+ }
194
+
195
+ /**
196
+ * Group a session's model-visible surface into conversation rounds.
197
+ *
198
+ * A round opens at each user message and absorbs everything until the
199
+ * next one — the unit a reader actually reasons about ("that round
200
+ * where we tried the wrong fix").
201
+ *
202
+ * The client has no session surface object; it has the event window.
203
+ * Surface membership is therefore derived from each event's own
204
+ * `surfaceOp` marker: `append` enters the tail, and a `replace` shadow
205
+ * retires the range it names. Compaction events carry no marker and are
206
+ * log-only, so they are skipped naturally.
207
+ *
208
+ * @param entries - event-window entries from the binding's eventSource.
209
+ * @param hasMore - whether older events remain outside the window.
210
+ * @returns `{ rounds, hasMore }` in surface order.
211
+ */
212
+ function readRounds(entries, hasMore = false) {
213
+ const shadowed = new Set();
214
+ const nodes = [];
215
+
216
+ for (const entry of entries ?? []) {
217
+ if (entry?.type !== "event") continue;
218
+ const event = entry.event;
219
+ if (event === undefined || event === null) continue;
220
+
221
+ const op = event.surfaceOp;
222
+ if (op === undefined || op === null) continue;
223
+
224
+ if (typeof op === "object" && op.op === "replace") {
225
+ // A replacement retires its named range and takes that
226
+ // position; record the shadowed seqs so they are skipped.
227
+ //
228
+ // Fall back to the replace op's own endpoints when the entry
229
+ // omits `sourceEventSeqs`: without that, a replacement would
230
+ // shadow nothing and already-retired rounds would reappear.
231
+ const cited =
232
+ Array.isArray(event.sourceEventSeqs) && event.sourceEventSeqs.length > 0
233
+ ? event.sourceEventSeqs
234
+ : [op.startSeq, op.endSeq];
235
+ for (const seq of cited) shadowed.add(seq);
236
+ nodes.push(event);
237
+ continue;
238
+ }
239
+ nodes.push(event);
240
+ }
241
+
242
+ const live = nodes.filter((event) => !shadowed.has(event.seq));
243
+
244
+ const rounds = [];
245
+ let current = null;
246
+ // Nodes seen before the first human turn. This MUST mirror
247
+ // `groupRounds` in `lib/inspect.js`, or the two sides disagree on
248
+ // every real session: the shipped `SystemPromptProjection` commits
249
+ // the rendered system prompt as surface node 0, so without the
250
+ // hold-over the panel opens a bogus round 0 on the system prompt and
251
+ // every later index is off by one. That is precisely the wrong-span
252
+ // symptom this panel was fixed for.
253
+ let leading = [];
254
+
255
+ /**
256
+ * Start a round at `descriptor`, absorbing any held-over nodes.
257
+ *
258
+ * @param descriptor - the node opening the round.
259
+ * @param held - nodes to prepend, in order.
260
+ */
261
+ const openRound = (descriptor, held) => {
262
+ const first = held.length > 0 ? held[0] : descriptor;
263
+ const absorbed = held.reduce((sum, node) => sum + node.tokens, 0);
264
+ const notices = [...held, descriptor].filter(
265
+ (node) => node.role === "user" && !node.opensRound
266
+ ).length;
267
+ current = {
268
+ index: rounds.length,
269
+ tokens: descriptor.tokens + absorbed,
270
+ position: first.position,
271
+ // The span the UI needs: the first and last surface node seq
272
+ // of this round. `startSeq` is what a drop range resolves
273
+ // against, so both ends are tracked.
274
+ startSeq: first.seq,
275
+ endSeq: descriptor.seq,
276
+ // The heading shown for this round. A round may open with
277
+ // injected context, so the human message supplies the title
278
+ // rather than the first node.
279
+ title: descriptor.opensRound ? descriptor.preview : null,
280
+ preview: descriptor.preview,
281
+ injected: notices
282
+ };
283
+ rounds.push(current);
284
+ };
285
+
286
+ for (let position = 0; position < live.length; position += 1) {
287
+ const event = live[position];
288
+ const descriptor = describeEvent(event);
289
+ descriptor.position = position;
290
+
291
+ if (current === null) {
292
+ if (descriptor.opensRound) {
293
+ openRound(descriptor, leading);
294
+ leading = [];
295
+ } else {
296
+ // Hold it: whether it deserves its own round is not known
297
+ // until a human turn decides.
298
+ leading.push(descriptor);
299
+ }
300
+ continue;
301
+ }
302
+
303
+ // Only a REAL human message opens a round. The harness injects
304
+ // background-job reports, runtime context, system reminders and
305
+ // model-change notices as `user/message` too; treating those as
306
+ // round openers both showed meaningless rows and fragmented the
307
+ // conversation (16 human turns arrived as 30 "rounds").
308
+ if (descriptor.opensRound) {
309
+ openRound(descriptor, []);
310
+ continue;
311
+ }
312
+
313
+ current.tokens += descriptor.tokens;
314
+ current.endSeq = event.seq;
315
+ if (descriptor.role === "user" && !descriptor.opensRound) current.injected += 1;
316
+ }
317
+
318
+ // A session with no human turn at all must still render one round.
319
+ if (current === null && leading.length > 0) {
320
+ openRound(leading[leading.length - 1], leading.slice(0, -1));
321
+ }
322
+
323
+ return { rounds, hasMore };
324
+ }
325
+
326
+ /**
327
+ * Whether a user message came from the human rather than the harness.
328
+ *
329
+ * The harness delivers several machine notices through the same
330
+ * `user/message` surface type. They are not conversation turns, and
331
+ * treating them as such is what made the panel show rows reading
332
+ * `background job pwsh-20 (pwsh: Start-Sleep ...)`.
333
+ *
334
+ * The structured `source` marker is checked first — it is what the
335
+ * harness itself attaches, so it survives a reworded notice. The text
336
+ * prefixes remain as a fallback for entries whose `source` is absent
337
+ * from the loaded event window.
338
+ *
339
+ * @param text - the message's flattened text.
340
+ * @param source - the message's optional structured `source` marker.
341
+ * @returns true when this is a genuine human turn.
342
+ */
343
+ function isHumanTurn(text, source) {
344
+ if (isMachineSource(source)) return false;
345
+ const head = String(text ?? "").trimStart().slice(0, 120);
346
+ // An empty message is not a turn.
347
+ if (head.length === 0) return false;
348
+ if (/^background job /i.test(head)) return false;
349
+ if (/^<system-reminder>/.test(head)) return false;
350
+ if (/^\[model changed:/.test(head)) return false;
351
+ if (/^Current runtime context\./.test(head)) return false;
352
+ if (/^This session is being continued from/.test(head)) return false;
353
+ // A slash COMMAND echo is an action, not a turn. Deliberately narrow:
354
+ // matching a bare leading "/" swallowed real prompts that begin with
355
+ // a path ("/tmp cleanup needed"), silently folding them into the
356
+ // previous round and shifting every later index.
357
+ //
358
+ // Mirrors `isHumanTurn` in `lib/rounds.js`; the parity is asserted by
359
+ // `test/rounds.mjs`, which executes this function out of the bundle.
360
+ const commandish = /^\/([a-z][\w-]*)(?::([\w-]+))?(\s|$)/i.exec(head);
361
+ if (commandish !== null) {
362
+ const rest = head.slice(commandish[0].length).trim();
363
+ if (rest.length === 0) return false;
364
+ if (KNOWN_COMMANDS.has(commandish[1].toLowerCase())) return false;
365
+ }
366
+ return true;
367
+ }
368
+
369
+ /**
370
+ * Extract the message a surface event carries.
371
+ *
372
+ * Three of the five surface event types nest the message under
373
+ * `data.message` — `assistant/message`, `tool/result`, and
374
+ * `system/message` — while `user/message` carries it directly in
375
+ * `data`. Treating `tool/result` as a flat payload is what made every
376
+ * tool row preview as raw scaffolding text instead of its output.
377
+ *
378
+ * @param event - a surface event.
379
+ * @returns the message payload, or undefined.
380
+ */
381
+ function messageOf(event) {
382
+ if (event?.type === "user/message") return event.data;
383
+ return event?.data?.message;
384
+ }
385
+
386
+ /**
387
+ * Derive a role, preview and token estimate for one surface event.
388
+ *
389
+ * @param event - a surface event carrying `surfaceOp`.
390
+ * @returns `{ role, preview, tokens, opensRound }`.
391
+ */
392
+ function describeEvent(event) {
393
+ const role =
394
+ event.type === "user/message"
395
+ ? "user"
396
+ : event.type === "assistant/message"
397
+ ? "assistant"
398
+ : event.type === "tool/result"
399
+ ? "tool"
400
+ : "system";
401
+
402
+ const message = messageOf(event);
403
+
404
+ // The content array is stripped as a WHOLE, because checkpoint
405
+ // framing spans blocks: the preamble is one block, the opening tag
406
+ // leads the next, and the closing tag is a third. A per-block API is
407
+ // what let the host and this panel drift into two different rules
408
+ // that each handled only its own layout.
409
+ const stripped = stripCheckpointFraming(message?.content ?? []);
410
+
411
+ let text = flattened(stripped);
412
+
413
+ // A tool result with no text (or an assistant turn that only called
414
+ // tools) still deserves a label: an empty preview tells the reader
415
+ // nothing about why the row exists.
416
+ if (text.length === 0) {
417
+ if (role === "tool") text = message?.isError ? "(tool error)" : "(tool output)";
418
+ else if (role === "assistant") text = "(assistant called a tool)";
419
+ else text = `(${event.type})`;
420
+ }
421
+ if (text.length > 140) text = `${text.slice(0, 140)}…`;
422
+
423
+ // The message's own text, untruncated, so classification sees the
424
+ // real prefix rather than the 140-character preview.
425
+ const humanText = role === "user" ? flattened(stripped) : "";
426
+
427
+ return {
428
+ role,
429
+ // The event's own seq, so a round can name its span without the
430
+ // caller having to re-associate descriptors with events.
431
+ seq: event.seq,
432
+ preview: text,
433
+ tokens: Math.ceil(totalCharacters(stripped) / 4),
434
+ // Only a genuine human user message starts a conversation round.
435
+ // The structured `source` marker is preferred over the text
436
+ // prefixes, so a reworded notice is still classified correctly.
437
+ opensRound: role === "user" && isHumanTurn(humanText, message?.source)
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Collapse stripped blocks into one whitespace-normalised string.
443
+ *
444
+ * @param blocks - blocks from {@link stripCheckpointFraming}.
445
+ * @returns the flattened text.
446
+ */
447
+ function flattened(blocks) {
448
+ return blocks
449
+ .map((block) => block.text)
450
+ .join(" ")
451
+ .replace(/\s+/g, " ")
452
+ .trim();
453
+ }
454
+
455
+ /**
456
+ * Sum the character weight of stripped blocks, for token estimation.
457
+ *
458
+ * @param blocks - blocks from {@link stripCheckpointFraming}.
459
+ * @returns the total character count.
460
+ */
461
+ function totalCharacters(blocks) {
462
+ return blocks.reduce((sum, block) => sum + block.characters, 0);
463
+ }
464
+
465
+ /**
466
+ * Summarize one tool call as `tool:name (first arg)`.
467
+ *
468
+ * @param block - a `tool-call` content block.
469
+ * @returns a short label.
470
+ */
471
+ function describeToolCall(block) {
472
+ const name = block.name ?? "?";
473
+ let hint = "";
474
+ try {
475
+ const args = typeof block.arguments === "string" ? JSON.parse(block.arguments) : block.arguments;
476
+ if (args && typeof args === "object") {
477
+ // The first string-valued field usually names the target:
478
+ // a path, a pattern, a command.
479
+ for (const key of ["command", "file_path", "path", "pattern", "query", "url"]) {
480
+ if (typeof args[key] === "string" && args[key].length > 0) {
481
+ hint = args[key].replace(/\s+/g, " ").slice(0, 70);
482
+ break;
483
+ }
484
+ }
485
+ }
486
+ } catch {
487
+ /* arguments may be a partial JSON string while streaming */
488
+ }
489
+ return hint.length > 0 ? `[${name}: ${hint}]` : `[${name}]`;
490
+ }
491
+
492
+ /**
493
+ * Strip compaction framing from a whole content array.
494
+ *
495
+ * A checkpoint message is three text blocks:
496
+ *
497
+ * [0] "<boilerplate preamble>"
498
+ * [1] "<compacted-summary>\n\n...the actual summary..."
499
+ * [2] "</compacted-summary>"
500
+ *
501
+ * The opening tag leads block 1, NOT the tail of block 0 — which is why
502
+ * scanning for the tag inside the boilerplate block found nothing and
503
+ * every checkpoint previewed as the same fixed English sentence.
504
+ *
505
+ * Taking the ARRAY rather than one block is what makes the rule exact:
506
+ * the preamble is dropped by its own opening words, a block carrying the
507
+ * opening tag keeps only the body, the closing tag is removed inline,
508
+ * and a block that is left empty is discarded entirely. The host's
509
+ * `previewMessage` in `lib/inspect.js` applies the same union of rules,
510
+ * and `test/rounds.mjs` pins the two implementations together.
511
+ *
512
+ * @param content - a message's content blocks.
513
+ * @returns `{ type, text, characters }` entries, text-bearing only.
514
+ */
515
+ function stripCheckpointFraming(content) {
516
+ const out = [];
517
+
518
+ for (const block of content ?? []) {
519
+ if (block === null || block === undefined) continue;
520
+
521
+ if (block.type === "tool-call") {
522
+ // Name the tool and its first meaningful argument, so a call
523
+ // row reads as "what was asked" rather than "[tool:?]".
524
+ out.push({
525
+ type: "tool-call",
526
+ label: describeToolCall(block),
527
+ text: describeToolCall(block),
528
+ characters: JSON.stringify(block.arguments ?? "").length
529
+ });
530
+ continue;
531
+ }
532
+
533
+ if (block.type === "image") {
534
+ out.push({ type: "image", text: "[image]", characters: 1024 });
535
+ continue;
536
+ }
537
+
538
+ if (block.type !== "text" || typeof block.text !== "string") continue;
539
+
540
+ // The fixed preamble carries no summary content, but the block
541
+ // may also carry the framed body, so only the preamble's own span
542
+ // is removed.
543
+ const withoutPreamble = stripCheckpointPreamble(block.text);
544
+
545
+ const open = withoutPreamble.indexOf(SUMMARY_OPEN_TAG);
546
+ let body =
547
+ open === -1
548
+ ? withoutPreamble
549
+ : withoutPreamble.slice(open + SUMMARY_OPEN_TAG.length);
550
+ body = body.split(SUMMARY_CLOSE_TAG).join("");
551
+ if (body.trim().length === 0) continue;
552
+
553
+ out.push({ type: "text", text: body, characters: block.text.length });
554
+ }
555
+
556
+ return out;
557
+ }
558
+
559
+ /**
560
+ * Render a compact token figure.
561
+ *
562
+ * @param tokens - estimated tokens.
563
+ * @returns a short label such as `3.4k`.
564
+ */
565
+ function formatTokens(tokens) {
566
+ if (tokens >= 1000) {
567
+ const thousands = tokens / 1000;
568
+ return `${thousands >= 10 ? Math.round(thousands) : thousands.toFixed(1)}k`;
569
+ }
570
+ return String(tokens);
571
+ }
572
+
573
+ /**
574
+ * Read the live event window for a session.
575
+ *
576
+ * The binding is created by retaining the session; `eventSource` is an
577
+ * observable snapshot of the contiguous window.
578
+ *
579
+ * `revision` is the field that matters for freshness: it advances on
580
+ * every accepted window mutation, including a COMPACTION. `openState` in
581
+ * the session snapshot does not, which is why a panel keyed only on it
582
+ * kept showing rounds that had already been compacted away.
583
+ *
584
+ * @param ctx - client root context.
585
+ * @param sessionId - the session to inspect.
586
+ * @returns `{ entries, hasMore, revision }`, or an empty window when unavailable.
587
+ */
588
+ function readWindow(ctx, sessionId) {
589
+ const empty = { entries: [], hasMore: false, revision: -1 };
590
+ if (sessionId === undefined || sessionId === null) return empty;
591
+ const sessions = ctx.sessions ?? ctx.get("sessions");
592
+ if (sessions === undefined || sessions === null) return empty;
593
+ if (typeof sessions.binding !== "function") return empty;
594
+ const binding = sessions.binding(sessionId);
595
+ const source = binding?.eventSource;
596
+ if (source === undefined || source === null) return empty;
597
+ const window = typeof source.getSnapshot === "function" ? source.getSnapshot() : undefined;
598
+ return {
599
+ entries: window?.entries ?? [],
600
+ hasMore: window?.hasMore === true,
601
+ revision: typeof window?.revision === "number" ? window.revision : -1
602
+ };
603
+ }
604
+
605
+ /**
606
+ * The session's event source, when it is retained and observable.
607
+ *
608
+ * @param ctx - client root context.
609
+ * @param sessionId - the session to inspect.
610
+ * @returns the observable snapshot source, or undefined.
611
+ */
612
+ function eventSource(ctx, sessionId) {
613
+ if (sessionId === undefined || sessionId === null) return undefined;
614
+ const sessions = ctx.sessions ?? ctx.get("sessions");
615
+ if (sessions === undefined || sessions === null) return undefined;
616
+ if (typeof sessions.binding !== "function") return undefined;
617
+ return sessions.binding(sessionId)?.eventSource;
618
+ }
619
+
620
+ /**
621
+ * The session face that owns paging, when the session is retained.
622
+ *
623
+ * @param ctx - client root context.
624
+ * @param sessionId - the session to inspect.
625
+ * @returns the session face, or undefined.
626
+ */
627
+ function sessionFace(ctx, sessionId) {
628
+ const sessions = ctx.sessions ?? ctx.get("sessions");
629
+ if (sessions === undefined || sessions === null) return undefined;
630
+ if (typeof sessions.binding !== "function") return undefined;
631
+ return sessions.binding(sessionId)?.session;
632
+ }
633
+
634
+ /**
635
+ * Page history backwards until the whole log is loaded.
636
+ *
637
+ * `eventSource` is a *contiguous window*, not the complete log: a long
638
+ * session opens with only its tail, and `hasMore` reports that older
639
+ * events exist. Listing just that window is what made a 28-round
640
+ * session show only 4 rounds. `loadOlder()` extends the window by at
641
+ * least 50 messages per call, so this loops until `hasMore` clears.
642
+ *
643
+ * @param ctx - client root context.
644
+ * @param sessionId - the session to load.
645
+ * @param limit - maximum pages to fetch, guarding a runaway loop.
646
+ * @returns a promise resolving when paging settles.
647
+ */
648
+ async function loadFullHistory(ctx, sessionId, limit = 40) {
649
+ const session = sessionFace(ctx, sessionId);
650
+ if (session === undefined || typeof session.loadOlder !== "function") return;
651
+ for (let page = 0; page < limit; page += 1) {
652
+ const { hasMore } = readWindow(ctx, sessionId);
653
+ if (!hasMore) return;
654
+ try {
655
+ await session.loadOlder();
656
+ } catch {
657
+ // Paging can fail while a session is still opening; the
658
+ // window already loaded stays usable.
659
+ return;
660
+ }
661
+ }
662
+ }
663
+
664
+ /**
665
+ * The Context tab body.
666
+ *
667
+ * The slot framework supplies the session standard kit (`sessionId`,
668
+ * `useSession`) and whatever the registration declared under `inject` —
669
+ * here the plugin's own `readRounds` face, so the component stays free
670
+ * of service lookups and testable on its own.
671
+ *
672
+ * @param props - slot props.
673
+ * @returns the rendered panel.
674
+ */
675
+ function ContextPrunerBody(props) {
676
+ const sessionId = props.sessionId;
677
+ const read = props.readRounds;
678
+ const prune = props.pruneRounds;
679
+ // The composer's own draft/submit actions: the only path that turns
680
+ // a slash line into a command rather than a chat message.
681
+ const inputActions = props.inputActions;
682
+
683
+ // Hooks run unconditionally and in a stable order: the session
684
+ // snapshot is also what re-renders the list as history changes.
685
+ const sessionSnapshot =
686
+ typeof props.useSession === "function" ? props.useSession((s) => s) : undefined;
687
+
688
+ const [selected, setSelected] = React.useState(() => new Set());
689
+ const [busy, setBusy] = React.useState(false);
690
+ const [outcome, setOutcome] = React.useState(null);
691
+ // Bumped when a history page lands, so the listing re-reads a window
692
+ // that has grown.
693
+ const [pages, setPages] = React.useState(0);
694
+ const [loading, setLoading] = React.useState(false);
695
+ // Bumped by the event source on every accepted window mutation, so
696
+ // the list follows compactions and new turns. `openState` alone does
697
+ // NOT change when history is rewritten.
698
+ const [revision, setRevision] = React.useState(0);
699
+
700
+ // `openState` is the observable signal that history arrived or a turn
701
+ // settled, so the list refreshes with it.
702
+ const rowsKey = sessionSnapshot ? String(sessionSnapshot.openState) : "none";
703
+
704
+ // Subscribe to the event window itself. A compaction appends
705
+ // surface-replacing events without touching `openState`, so a panel
706
+ // keyed only on that kept listing rounds that no longer exist — the
707
+ // "compacted but still shown" report.
708
+ React.useEffect(() => {
709
+ if (typeof props.subscribeEvents !== "function") return undefined;
710
+ if (sessionId === undefined || sessionId === null) return undefined;
711
+ return props.subscribeEvents(sessionId, () => setRevision((value) => value + 1));
712
+ }, [props.subscribeEvents, sessionId]);
713
+
714
+ // A long session opens with only the TAIL of its log: `eventSource`
715
+ // is a contiguous window and `hasMore` reports older events. Without
716
+ // paging, a 28-round session lists 4 rounds — the window, not the
717
+ // whole conversation. This effect pulls every page in.
718
+ React.useEffect(() => {
719
+ if (typeof props.loadFullHistory !== "function") return undefined;
720
+ if (sessionId === undefined || sessionId === null) return undefined;
721
+ let cancelled = false;
722
+ setLoading(true);
723
+ props
724
+ .loadFullHistory(sessionId)
725
+ .then(() => {
726
+ if (!cancelled) setPages((value) => value + 1);
727
+ })
728
+ .catch(() => {})
729
+ .then(() => {
730
+ if (!cancelled) setLoading(false);
731
+ });
732
+ return () => {
733
+ cancelled = true;
734
+ };
735
+ }, [props.loadFullHistory, sessionId]);
736
+
737
+ const window = React.useMemo(
738
+ () => (typeof read === "function" ? read(sessionId) : { rounds: [], hasMore: false }),
739
+ [read, sessionId, rowsKey, pages, revision]
740
+ );
741
+
742
+ const rounds = window.rounds;
743
+ const hasMore = window.hasMore === true;
744
+
745
+ const total = rounds.reduce((sum, round) => sum + round.tokens, 0);
746
+
747
+ // Selection is keyed by the round's START SEQ, never by its index.
748
+ // An index is a position in the currently loaded list, and that list
749
+ // re-numbers whenever older pages arrive or a compaction lands: a
750
+ // selection made against the previous list would then name a
751
+ // different round, and the panel would seed a command for the wrong
752
+ // span (observed: row "#15 seq 9426" produced `drop seq 8055-8310`).
753
+ // Seqs are durable identities, so a selection survives re-numbering.
754
+ const selectedRounds = rounds.filter((round) => selected.has(round.startSeq));
755
+ const selectedTokens = selectedRounds.reduce((sum, round) => sum + round.tokens, 0);
756
+
757
+ const toggle = (startSeq) => {
758
+ setOutcome(null);
759
+ setSelected((previous) => {
760
+ const next = new Set(previous);
761
+ if (next.has(startSeq)) next.delete(startSeq);
762
+ else next.add(startSeq);
763
+ return next;
764
+ });
765
+ };
766
+
767
+ const clear = () => {
768
+ setSelected(new Set());
769
+ setOutcome(null);
770
+ };
771
+
772
+ const runPrune = async () => {
773
+ if (selectedRounds.length === 0 || busy) return;
774
+ setBusy(true);
775
+ setOutcome(null);
776
+ try {
777
+ const result = await prune(sessionId, selectedRounds, inputActions);
778
+ if (result?.kind === "error") {
779
+ setOutcome({ kind: "error", text: result.text });
780
+ } else {
781
+ setSelected(new Set());
782
+ setOutcome({ kind: "ok", text: result?.text ?? "Pruned." });
783
+ }
784
+ } catch (error) {
785
+ setOutcome({ kind: "error", text: String(error?.message ?? error) });
786
+ }
787
+ setBusy(false);
788
+ };
789
+
790
+ if (sessionId === undefined || sessionId === null) {
791
+ return h(Notice, { children: "Select a session to inspect its context." });
792
+ }
793
+ if (rounds.length === 0) {
794
+ // Distinguish "still loading" from "genuinely empty": the same
795
+ // branch covers a window that has not arrived yet, and telling
796
+ // the user a session has no history while it is still paging in
797
+ // is a wrong diagnosis.
798
+ return h(Notice, {
799
+ children:
800
+ hasMore || loading
801
+ ? "Still loading this session's history…"
802
+ : "This session has no conversation history yet."
803
+ });
804
+ }
805
+
806
+ return jsxs("div", {
807
+ style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0, fontSize: "13px" },
808
+ key: "pruner-root",
809
+ children: [
810
+ // Header: totals plus the selection summary.
811
+ jsxs("div", {
812
+ style: {
813
+ padding: "10px 12px",
814
+ borderBottom: "1px solid rgba(128,128,128,0.25)",
815
+ display: "flex",
816
+ flexDirection: "column",
817
+ gap: "6px",
818
+ flex: "none"
819
+ },
820
+ key: "header",
821
+ children: [
822
+ jsxs("div", {
823
+ style: { display: "flex", justifyContent: "space-between", gap: "8px", opacity: 0.75 },
824
+ key: "totals",
825
+ children: [
826
+ h("span", {
827
+ key: "count",
828
+ children: `${rounds.length} round${rounds.length === 1 ? "" : "s"}${hasMore || loading ? " (loading older…)" : ""}`
829
+ }),
830
+ h("span", { children: `~${formatTokens(total)} tok total`, key: "tokens" })
831
+ ]
832
+ }),
833
+ // Never present a partial window as the whole
834
+ // conversation: if older pages are still outstanding the
835
+ // listing is silently under-reporting, and a reader
836
+ // choosing what to prune would be working from a
837
+ // truncated view.
838
+ hasMore
839
+ ? h("div", {
840
+ key: "partial",
841
+ style: { fontSize: "12px", opacity: 0.75, lineHeight: "1.4" },
842
+ children:
843
+ "Showing the newest part of this session only; older history is still loading. Rounds below are numbered from the loaded window."
844
+ })
845
+ : null,
846
+ jsxs("div", {
847
+ style: { display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap" },
848
+ key: "actions",
849
+ children: [
850
+ h("button", {
851
+ key: "prune",
852
+ type: "button",
853
+ disabled: busy || selectedRounds.length === 0,
854
+ onClick: runPrune,
855
+ style: {
856
+ padding: "5px 12px",
857
+ borderRadius: "6px",
858
+ border: "1px solid rgba(128,128,128,0.35)",
859
+ background: selectedRounds.length === 0 ? "transparent" : "var(--dsw-alias-bg-accent, #3b6fe0)",
860
+ color: selectedRounds.length === 0 ? "inherit" : "#fff",
861
+ cursor: busy || selectedRounds.length === 0 ? "default" : "pointer",
862
+ opacity: busy || selectedRounds.length === 0 ? 0.5 : 1,
863
+ fontSize: "12px"
864
+ },
865
+ children: busy
866
+ ? "Preparing…"
867
+ : selectedRounds.length === 0
868
+ ? "Prepare prune"
869
+ : `Prepare prune (${selectedRounds.length})`
870
+ }),
871
+ h("button", {
872
+ key: "clear",
873
+ type: "button",
874
+ disabled: busy || selectedRounds.length === 0,
875
+ onClick: clear,
876
+ style: {
877
+ background: "none",
878
+ border: "none",
879
+ cursor: selectedRounds.length === 0 ? "default" : "pointer",
880
+ opacity: selectedRounds.length === 0 ? 0.35 : 0.75,
881
+ fontSize: "12px",
882
+ color: "inherit",
883
+ padding: 0
884
+ },
885
+ children: "Clear"
886
+ }),
887
+ selectedRounds.length > 0
888
+ ? h("span", {
889
+ key: "frees",
890
+ style: { opacity: 0.65, fontSize: "12px" },
891
+ children: `frees ~${formatTokens(selectedTokens)} tok`
892
+ })
893
+ : null
894
+ ]
895
+ }),
896
+ outcome !== null
897
+ ? h("div", {
898
+ key: "outcome",
899
+ // Announced to assistive tech: without a live
900
+ // region a screen-reader user gets no signal
901
+ // that the composer was seeded, because the
902
+ // change happens in a different widget.
903
+ role: "status",
904
+ "aria-live": "polite",
905
+ style: {
906
+ fontSize: "12px",
907
+ lineHeight: "1.4",
908
+ color: outcome.kind === "error" ? "var(--dsw-alias-danger, #d9534f)" : "inherit",
909
+ opacity: outcome.kind === "error" ? 1 : 0.9
910
+ },
911
+ children: outcome.text
912
+ })
913
+ : null
914
+ ]
915
+ }),
916
+ // The scrollable round list, one checkbox per round.
917
+ h("div", {
918
+ key: "list",
919
+ style: { overflowY: "auto", flex: "1 1 auto", padding: "4px" },
920
+ children: rounds.map((round) => {
921
+ const isSelected = selected.has(round.startSeq);
922
+ return h("label", {
923
+ key: `round-${round.startSeq}`,
924
+ style: {
925
+ display: "flex",
926
+ gap: "8px",
927
+ alignItems: "flex-start",
928
+ padding: "6px 8px",
929
+ borderRadius: "6px",
930
+ cursor: "pointer",
931
+ background: isSelected ? "rgba(80,140,255,0.12)" : "transparent"
932
+ },
933
+ children: [
934
+ h("input", {
935
+ key: "box",
936
+ type: "checkbox",
937
+ checked: isSelected,
938
+ onChange: () => toggle(round.startSeq),
939
+ // The visible text is the preview alone, which
940
+ // omits the index/tokens/seq the meta row shows.
941
+ "aria-label": `Round ${round.index}, ${formatTokens(round.tokens)} tokens, seq ${round.startSeq}`,
942
+ style: { marginTop: "2px", flex: "none" }
943
+ }),
944
+ jsxs("span", {
945
+ key: "text",
946
+ style: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
947
+ children: [
948
+ jsxs("span", {
949
+ key: "meta",
950
+ style: {
951
+ display: "flex",
952
+ gap: "8px",
953
+ fontSize: "11px",
954
+ opacity: 0.65,
955
+ fontVariantNumeric: "tabular-nums"
956
+ },
957
+ children: [
958
+ h("span", { children: `#${round.index}`, key: "idx" }),
959
+ h("span", { children: `${formatTokens(round.tokens)} tok`, key: "tok" }),
960
+ h("span", { children: `seq ${round.startSeq}`, key: "seq" }),
961
+ // How much of this round is harness
962
+ // scaffolding rather than conversation.
963
+ // Without it a round that is mostly job
964
+ // reports is indistinguishable from one
965
+ // that is mostly discussion.
966
+ round.injected > 0
967
+ ? h("span", {
968
+ key: "notices",
969
+ children: `+${round.injected} notice${round.injected === 1 ? "" : "s"}`
970
+ })
971
+ : null
972
+ ]
973
+ }),
974
+ h("span", {
975
+ key: "preview",
976
+ style: { fontSize: "12px", lineHeight: "1.4", wordBreak: "break-word" },
977
+ // The HUMAN message heads the round; a round
978
+ // that opens with injected context would
979
+ // otherwise be titled by scaffolding.
980
+ children: round.title ?? round.preview
981
+ })
982
+ ]
983
+ })
984
+ ]
985
+ });
986
+ })
987
+ })
988
+ ]
989
+ });
990
+ }
991
+
992
+ /**
993
+ * A muted single-line message used for empty states.
994
+ *
995
+ * `children` must live in the props object: the automatic JSX runtime's
996
+ * `jsx(type, props)` takes two arguments, so a third positional child is
997
+ * silently dropped and the component renders an empty element.
998
+ *
999
+ * @param props - `{ children }`.
1000
+ * @returns the rendered notice.
1001
+ */
1002
+ function Notice(props) {
1003
+ return h("div", {
1004
+ style: { padding: "16px 12px", opacity: 0.6, fontSize: "13px" },
1005
+ children: props.children
1006
+ });
1007
+ }
1008
+
1009
+ /**
1010
+ * The tab chip's label.
1011
+ *
1012
+ * @returns the rendered title.
1013
+ */
1014
+ function ContextPrunerTitle() {
1015
+ return h("span", { children: "Context" });
1016
+ }
1017
+
1018
+ /**
1019
+ * Seed the composer with the prune command for the selected rounds.
1020
+ *
1021
+ * ## Why the command carries SEQS, not round indices
1022
+ *
1023
+ * A round index is only meaningful relative to whoever numbered the
1024
+ * rounds. The panel numbers by "genuine human turn"; the host command
1025
+ * numbers every user-role surface event, so its count runs higher (69
1026
+ * vs 66 on one real session) and the two disagree from early on —
1027
+ * `#9` named seq 742 to the panel and seq 608 to the host.
1028
+ *
1029
+ * Sending those indices compacted a DIFFERENT span than the one the user
1030
+ * selected, with nothing in the UI revealing it. Surface seqs are durable
1031
+ * identifiers both sides agree on, so the panel sends
1032
+ * `/prune drop seq <start>-<end> --yes`.
1033
+ *
1034
+ * @param ctx - client root context.
1035
+ * @param sessionId - session to act on.
1036
+ * @param rounds - the selected round objects (they carry startSeq/endSeq).
1037
+ * @param inputActions - the composer's own draft/submit actions.
1038
+ * @returns `{ kind: 'ok' | 'error', text }`.
1039
+ */
1040
+ async function pruneRounds(ctx, sessionId, rounds, inputActions) {
1041
+ if (!Array.isArray(rounds) || rounds.length === 0) {
1042
+ return { kind: "ok", text: "Nothing selected." };
1043
+ }
1044
+
1045
+ // The manual fallback deliberately names the SEQ form. The round-index
1046
+ // form (`drop 3-7`) is the one the host and this panel used to number
1047
+ // differently, and it is not what any row in this list displays, so
1048
+ // instructing the user to type it would send them back into the
1049
+ // wrong-span bug this panel was fixed for.
1050
+ const fallback =
1051
+ "This panel cannot reach the composer, so it cannot seed the command. " +
1052
+ "Either retry Prepare prune, or type it yourself using the seq shown on each row: " +
1053
+ "/prune drop seq <startSeq>-<endSeq> --yes";
1054
+
1055
+ // A slash command is NOT a chat message: `conversation.sendSession`
1056
+ // would deliver it as an ordinary prompt, and the model would answer
1057
+ // that it has no such tool. Only the composer's own draft/submit path
1058
+ // participates in the command transaction.
1059
+ const actions = inputActions;
1060
+ if (
1061
+ actions === undefined ||
1062
+ actions === null ||
1063
+ typeof actions.setDraft !== "function"
1064
+ ) {
1065
+ return { kind: "error", text: fallback };
1066
+ }
1067
+
1068
+ // Contiguous runs of SELECTED rounds become one seq span each, so a
1069
+ // scattered selection never swallows the unselected rounds between
1070
+ // them. Selecting 6, 9, 10, 11 yields two spans: `6-6` and `9-11`.
1071
+ const sorted = [...rounds].sort((a, b) => a.index - b.index);
1072
+ const ranges = [];
1073
+ let current = null;
1074
+ for (const round of sorted) {
1075
+ if (current === null) {
1076
+ current = { first: round, last: round };
1077
+ continue;
1078
+ }
1079
+ // Contiguous only when the round indices are adjacent; their seqs
1080
+ // are not required to be.
1081
+ if (round.index === current.last.index + 1) {
1082
+ current.last = round;
1083
+ } else {
1084
+ ranges.push(current);
1085
+ current = { first: round, last: round };
1086
+ }
1087
+ }
1088
+ if (current !== null) ranges.push(current);
1089
+
1090
+ // EVERY span rides one command: the host accepts a comma-separated
1091
+ // list, so a scattered selection is one Enter rather than one per
1092
+ // range. Seeding only the first (an earlier limitation) left the
1093
+ // user to run the rest by hand.
1094
+ const spec = ranges
1095
+ .map((range) => `seq ${range.first.startSeq}-${range.last.endSeq}`)
1096
+ .join(", ");
1097
+ const line = `/prune drop ${spec} --yes`;
1098
+
1099
+ try {
1100
+ actions.setDraft(line);
1101
+ } catch (error) {
1102
+ return { kind: "error", text: `Could not seed the composer: ${String(error?.message ?? error)}` };
1103
+ }
1104
+
1105
+ // We deliberately do NOT call submit(): a programmatic submit does
1106
+ // not execute a slash command (verified: no `command/run` event is
1107
+ // produced), and delivering the text as a prompt instead would cost
1108
+ // a model turn and answer nonsense. Command dispatch belongs to the
1109
+ // composer's own Enter transaction, so the panel seeds the line and
1110
+ // hands the trigger to the user.
1111
+ //
1112
+ // The message names the action's destructiveness and states that
1113
+ // nothing has happened yet. An earlier wording ("Ready: <line> —
1114
+ // press Enter") read as a completed step, and the line itself ends
1115
+ // in `--yes`, so a user skimming it could commit an irreversible
1116
+ // replacement without a second look.
1117
+ return {
1118
+ kind: "ok",
1119
+ text: [
1120
+ `Nothing has changed yet. ${ranges.length === 1 ? "A command is" : `${ranges.length} spans are`} ready in the input box:`,
1121
+ line,
1122
+ "Press Enter there to run it. This REPLACES those rounds with one summary and cannot be undone."
1123
+ ].join("\n")
1124
+ };
1125
+ }
1126
+
1127
+ /**
1128
+ * Register the tab type and its body/title seats.
1129
+ *
1130
+ * Stage one declares the type; stage two supplies the body and chip
1131
+ * under the definition's `id` in the keyed seats.
1132
+ *
1133
+ * The `inject` face is the pattern the shipped sidebar tabs use: the
1134
+ * registration hands the component its data-reading closure instead of
1135
+ * letting the component reach for services, so the component stays pure
1136
+ * and the service dependency is declared where it is resolved.
1137
+ *
1138
+ * @param ctx - client root context.
1139
+ */
1140
+ function apply(ctx) {
1141
+ // `inject` must be a FACTORY, not a plain object: the slot framework
1142
+ // calls it as `inject(sessionId, actions)` and spreads the returned
1143
+ // object onto the component's props. Passing an object throws
1144
+ // `TypeError: inject is not a function` inside the renderer, which
1145
+ // leaves the pane blank — the exact failure this shape fixes.
1146
+ // The shipped tab providers do the same (`filesFace(...)` returns a
1147
+ // `(sessionId, actions) => ({...})` closure).
1148
+ const injectFace = (sessionId) => ({
1149
+ // `readRounds` reports `hasMore` alongside the rounds, so the
1150
+ // panel can tell "this is the whole conversation" from "this is
1151
+ // the window we happen to have loaded".
1152
+ readRounds: (id) => {
1153
+ const target = id ?? sessionId;
1154
+ const window = readWindow(ctx, target);
1155
+ return readRounds(window.entries, window.hasMore);
1156
+ },
1157
+ loadFullHistory: (id) => loadFullHistory(ctx, id ?? sessionId),
1158
+ // `ObservableSnapshot.subscribe` publishes on every accepted
1159
+ // window mutation, which is what makes the list follow a
1160
+ // compaction instead of freezing on stale rounds.
1161
+ subscribeEvents: (id, listener) => {
1162
+ const source = eventSource(ctx, id ?? sessionId);
1163
+ if (source === undefined || typeof source.subscribe !== "function") return undefined;
1164
+ return source.subscribe(listener);
1165
+ },
1166
+ pruneRounds: (id, rounds, actions) => pruneRounds(ctx, id ?? sessionId, rounds, actions)
1167
+ });
1168
+
1169
+ ctx.effect(
1170
+ () =>
1171
+ ctx.slots.inject("sidebar.right.pane.tab", () =>
1172
+ ctx.slots.register(
1173
+ { name: "sidebar.right.pane.tab", key: TAB_ID, inject: injectFace },
1174
+ ContextPrunerBody
1175
+ )
1176
+ ),
1177
+ "context-pruner: tab body"
1178
+ );
1179
+
1180
+ ctx.effect(
1181
+ () =>
1182
+ ctx.slots.inject("sidebar.right.pane.tab.title", () =>
1183
+ ctx.slots.register(
1184
+ { name: "sidebar.right.pane.tab.title", key: TAB_ID },
1185
+ ContextPrunerTitle
1186
+ )
1187
+ ),
1188
+ "context-pruner: tab title"
1189
+ );
1190
+
1191
+ ctx.effect(
1192
+ () =>
1193
+ ctx.sidebarRightTabs.register({
1194
+ id: TAB_ID,
1195
+ kind: TAB_KIND,
1196
+ // The shipped tab types declare a band; the default is
1197
+ // `extension`, which is the band that takes over another
1198
+ // type's kind. Declaring `builtin` keeps this row's
1199
+ // conflict semantics identical to a shipped tab.
1200
+ priority: "builtin",
1201
+ title: () => "Context",
1202
+ // Registering a type does NOT open a tab: the strip stays
1203
+ // empty until something calls `openTab(kind)`. The guide
1204
+ // is the sidebar's own "open a page" surface, so an entry
1205
+ // here is what makes the tab reachable without a command
1206
+ // or a rebuilt shell.
1207
+ guide: [
1208
+ {
1209
+ // Distinct from TAB_ID, matching the shipped
1210
+ // pattern (`sidebar-files` uses id "workspace"
1211
+ // with type id `FILES_ID`). Sharing the two made
1212
+ // the guide card and the tab type collide on one
1213
+ // identity, which the registry reports as a
1214
+ // duplicate id.
1215
+ id: "context-pruner-guide",
1216
+ order: 60,
1217
+ title: () => "Context",
1218
+ description: () => "Inspect conversation rounds and prune the ones you choose"
1219
+ }
1220
+ ]
1221
+ }),
1222
+ "context-pruner: tab type"
1223
+ );
1224
+ }
1225
+
1226
+ exports.apply = apply;
1227
+ exports.inject = inject;
1228
+ return module.exports;
1229
+ }
1230
+ });