dsh-context-fold 0.1.3 → 0.1.5

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.d.ts CHANGED
@@ -3,38 +3,6 @@ window.__ModuleLoader__.load({
3
3
  factory: (require) => {
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
- /**
7
- * `conversation.view` tab: what this plugin folded, what it saved, and the full
8
- * text behind each fold.
9
- *
10
- * ## Why the page reads from the server route, not the session log
11
- *
12
- * The browser client cannot walk a session log: the fold decisions are durable
13
- * plugin records, and the full original reasoning lives in the log's assistant
14
- * events. Both are server-side. The tab therefore renders exactly what
15
- * `/api/context-fold/report` returns and does no derivation of its own.
16
- *
17
- * ## What it shows, and how honestly
18
- *
19
- * The token comparison is a **counterfactual estimate**, not a measured A/B.
20
- * The "before" case never ran. The panel says so in the UI, lists the
21
- * assumptions the number depends on, and marks whether the session reported real
22
- * provider usage at all — because a number built on heuristics must not look
23
- * like a bill.
24
- *
25
- * The original text of every fold is shown on demand, fetched by its recovery
26
- * path, so the tab doubles as the answer to "what exactly did it remove".
27
- *
28
- * ## How this file is shipped
29
- *
30
- * `tsdown` wraps the output in the shell's module-factory form
31
- * (`window.__ModuleLoader__.load({id, factory(require){…}})`), which is the only
32
- * shape the client module system executes. Imports are therefore converted into
33
- * `require` calls resolved against the shared platform module table, and the
34
- * plugin's exports (`name`/`inject`/`apply`) are what the factory returns.
35
- *
36
- * @module dsh-context-fold/client
37
- */
38
6
  /** One fold row as the server reports it. */
39
7
  interface FoldRow {
40
8
  /** Surface seq. */
package/lib/client.js CHANGED
@@ -4,7 +4,380 @@ window.__ModuleLoader__.load({
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ // src/panel-css.ts
8
+ /**
9
+ * The conversation tab's stylesheet.
10
+ *
11
+ * ## Why a stylesheet instead of inline styles
12
+ *
13
+ * The first version rendered with inline `style` objects: no hover states, no
14
+ * dark-theme variants, no transitions, and a monospace wall that ignored the
15
+ * shell's design language. The shell already ships a token layer
16
+ * (`--dsw-alias-*` for colour, `--ds-transition-duration` / `--ds-ease-in-out`
17
+ * for motion) plus a `data-ds-dark-theme` body attribute, so the tab should
18
+ * consume those instead of inventing a palette.
19
+ *
20
+ * ## How it reaches the page
21
+ *
22
+ * A client bundle cannot import a CSS file — the loader executes the bundle as a
23
+ * classic script — so the sheet is injected once at module scope as
24
+ * `<style data-plugin-css>`. The `data-plugin-css` marker makes the injection
25
+ * idempotent across HMR and re-mounts, which is the same mechanism the bundled
26
+ * first-party plugins use.
27
+ *
28
+ * @module dsh-context-fold/panel-css
29
+ */
30
+ /** Unique key so the sheet is injected at most once per document. */
31
+ const CSS_KEY = "dsh-context-fold/panel.css";
32
+ /**
33
+ * Every rule is scoped under `.cf-`, so nothing here can leak into the shell.
34
+ *
35
+ * Colours come from the alias tokens with literal fallbacks: if a token is ever
36
+ * renamed, the tab still renders legibly rather than going transparent.
37
+ */
38
+ const CSS = `
39
+ .cf-root {
40
+ box-sizing: border-box;
41
+ display: flex;
42
+ flex-direction: column;
43
+ gap: 14px;
44
+ height: 100%;
45
+ overflow-y: auto;
46
+ padding: 16px 20px 32px;
47
+ font-size: 13px;
48
+ line-height: 1.55;
49
+ color: var(--dsw-alias-label-primary, #e8e8ea);
50
+ background: var(--dsw-alias-bg-base, transparent);
51
+ }
52
+
53
+ .cf-head {
54
+ display: flex;
55
+ align-items: baseline;
56
+ gap: 10px;
57
+ flex-wrap: wrap;
58
+ }
59
+
60
+ .cf-head-title {
61
+ font-size: 15px;
62
+ font-weight: 600;
63
+ letter-spacing: .01em;
64
+ }
65
+
66
+ .cf-head-note {
67
+ font-size: 12px;
68
+ color: var(--dsw-alias-label-tertiary, #8b8b93);
69
+ }
70
+
71
+ .cf-card {
72
+ border: .5px solid var(--dsw-alias-border-l3, #3a3a40);
73
+ border-radius: 10px;
74
+ background: var(--dsw-alias-bg-layer-1, #1c1c20);
75
+ padding: 14px 16px;
76
+ }
77
+
78
+ .cf-card-title {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 8px;
82
+ margin-bottom: 12px;
83
+ font-size: 12px;
84
+ font-weight: 600;
85
+ letter-spacing: .04em;
86
+ text-transform: uppercase;
87
+ color: var(--dsw-alias-label-secondary, #a9a9b2);
88
+ }
89
+
90
+ .cf-grid {
91
+ display: grid;
92
+ grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
93
+ gap: 10px;
94
+ }
95
+
96
+ .cf-metric {
97
+ border: .5px solid var(--dsw-alias-border-l2, #303036);
98
+ border-radius: 8px;
99
+ background: var(--dsw-alias-bg-layer-2, #202024);
100
+ padding: 10px 12px;
101
+ transition: background-color var(--ds-transition-duration, .2s) var(--ds-ease-in-out, ease-in-out),
102
+ border-color var(--ds-transition-duration, .2s) var(--ds-ease-in-out, ease-in-out);
103
+ }
104
+
105
+ .cf-metric:hover {
106
+ background: var(--dsw-alias-interactive-bg-hover, #26262c);
107
+ }
108
+
109
+ .cf-metric-label {
110
+ font-size: 11px;
111
+ color: var(--dsw-alias-label-tertiary, #8b8b93);
112
+ margin-bottom: 4px;
113
+ white-space: nowrap;
114
+ overflow: hidden;
115
+ text-overflow: ellipsis;
116
+ }
117
+
118
+ .cf-metric-value {
119
+ font-size: 18px;
120
+ font-weight: 600;
121
+ font-variant-numeric: tabular-nums;
122
+ line-height: 1.2;
123
+ }
124
+
125
+ .cf-metric-sub {
126
+ margin-top: 2px;
127
+ font-size: 11px;
128
+ color: var(--dsw-alias-label-tertiary, #8b8b93);
129
+ font-variant-numeric: tabular-nums;
130
+ }
131
+
132
+ .cf-good { color: var(--dsw-alias-state-success-primary, #4ade80); }
133
+ .cf-bad { color: var(--dsw-alias-state-error-primary, #f87171); }
134
+ .cf-warn { color: var(--dsw-alias-state-warn-primary, #facc15); }
135
+ .cf-dim { color: var(--dsw-alias-label-tertiary, #8b8b93); }
136
+
137
+ .cf-bar {
138
+ display: flex;
139
+ height: 8px;
140
+ overflow: hidden;
141
+ border-radius: 999px;
142
+ background: var(--dsw-alias-bg-layer-3, #2a2a30);
143
+ margin-top: 10px;
144
+ }
145
+
146
+ .cf-bar-seg {
147
+ height: 100%;
148
+ transition: width var(--ds-transition-duration, .2s) var(--ds-ease-in-out, ease-in-out);
149
+ }
150
+
151
+ .cf-bar-legend {
152
+ display: flex;
153
+ flex-wrap: wrap;
154
+ gap: 14px;
155
+ margin-top: 10px;
156
+ font-size: 11px;
157
+ color: var(--dsw-alias-label-secondary, #a9a9b2);
158
+ }
159
+
160
+ .cf-legend-item {
161
+ display: inline-flex;
162
+ align-items: center;
163
+ gap: 6px;
164
+ }
165
+
166
+ .cf-dot {
167
+ width: 8px;
168
+ height: 8px;
169
+ border-radius: 2px;
170
+ flex: none;
171
+ }
172
+
173
+ .cf-notice {
174
+ display: flex;
175
+ gap: 8px;
176
+ border-radius: 8px;
177
+ border: .5px solid var(--dsw-alias-border-l3, #3a3a40);
178
+ background: var(--dsw-alias-bg-layer-2, #202024);
179
+ padding: 10px 12px;
180
+ font-size: 12px;
181
+ line-height: 1.5;
182
+ color: var(--dsw-alias-label-secondary, #a9a9b2);
183
+ }
184
+
185
+ .cf-notice-strong {
186
+ font-weight: 600;
187
+ color: var(--dsw-alias-label-primary, #e8e8ea);
188
+ }
189
+
190
+ .cf-notice.cf-notice-warn {
191
+ border-color: var(--dsw-alias-state-warn-primary, #facc15);
192
+ }
193
+
194
+ .cf-folds {
195
+ display: flex;
196
+ flex-direction: column;
197
+ gap: 8px;
198
+ }
199
+
200
+ .cf-fold {
201
+ border: .5px solid var(--dsw-alias-border-l2, #303036);
202
+ border-radius: 8px;
203
+ background: var(--dsw-alias-bg-layer-1, #1c1c20);
204
+ overflow: hidden;
205
+ transition: border-color var(--ds-transition-duration, .2s) var(--ds-ease-in-out, ease-in-out);
206
+ }
207
+
208
+ .cf-fold:hover {
209
+ border-color: var(--dsw-alias-border-l4, #4a4a52);
210
+ }
211
+
212
+ .cf-fold[open] {
213
+ border-color: var(--dsw-alias-border-l4, #4a4a52);
214
+ }
215
+
216
+ .cf-fold-head {
217
+ display: flex;
218
+ align-items: center;
219
+ gap: 10px;
220
+ cursor: pointer;
221
+ padding: 9px 12px;
222
+ list-style: none;
223
+ user-select: none;
224
+ transition: background-color var(--ds-transition-duration, .2s) var(--ds-ease-in-out, ease-in-out);
225
+ }
226
+
227
+ .cf-fold-head::-webkit-details-marker { display: none; }
228
+
229
+ .cf-fold-head:hover {
230
+ background: var(--dsw-alias-interactive-bg-hover, #26262c);
231
+ }
232
+
233
+ .cf-caret {
234
+ flex: none;
235
+ width: 10px;
236
+ font-size: 9px;
237
+ color: var(--dsw-alias-label-tertiary, #8b8b93);
238
+ transition: transform var(--ds-transition-duration, .2s) var(--ds-ease-in-out, ease-in-out);
239
+ }
240
+
241
+ .cf-fold[open] .cf-caret { transform: rotate(90deg); }
242
+
243
+ .cf-tag {
244
+ flex: none;
245
+ border-radius: 5px;
246
+ padding: 1px 7px;
247
+ font-size: 11px;
248
+ font-weight: 500;
249
+ line-height: 1.6;
250
+ background: var(--dsw-alias-bg-layer-3, #2a2a30);
251
+ color: var(--dsw-alias-label-secondary, #a9a9b2);
252
+ }
253
+
254
+ .cf-tag-reasoning {
255
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #6b8afd) 18%, transparent);
256
+ color: var(--dsw-alias-brand-primary, #8aa2ff);
257
+ }
258
+
259
+ .cf-fold-pos {
260
+ flex: none;
261
+ font-size: 11px;
262
+ font-variant-numeric: tabular-nums;
263
+ color: var(--dsw-alias-label-tertiary, #8b8b93);
264
+ }
265
+
266
+ .cf-fold-saving {
267
+ margin-left: auto;
268
+ flex: none;
269
+ font-size: 11px;
270
+ font-variant-numeric: tabular-nums;
271
+ color: var(--dsw-alias-state-success-primary, #4ade80);
272
+ }
273
+
274
+ .cf-fold-body {
275
+ padding: 0 12px 12px;
276
+ border-top: .5px solid var(--dsw-alias-border-l2, #303036);
277
+ }
278
+
279
+ .cf-label {
280
+ margin: 10px 0 4px;
281
+ font-size: 11px;
282
+ letter-spacing: .04em;
283
+ text-transform: uppercase;
284
+ color: var(--dsw-alias-label-tertiary, #8b8b93);
285
+ }
286
+
287
+ .cf-pre {
288
+ margin: 0;
289
+ padding: 9px 10px;
290
+ border-radius: 6px;
291
+ background: var(--dsw-alias-bg-layer-3, #2a2a30);
292
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
293
+ font-size: 12px;
294
+ line-height: 1.5;
295
+ white-space: pre-wrap;
296
+ word-break: break-word;
297
+ max-height: 16em;
298
+ overflow: auto;
299
+ }
300
+
301
+ .cf-pre-original {
302
+ max-height: 26em;
303
+ border-left: 2px solid var(--dsw-alias-brand-primary, #6b8afd);
304
+ }
305
+
306
+ .cf-locator {
307
+ margin-top: 8px;
308
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
309
+ font-size: 11px;
310
+ color: var(--dsw-alias-label-tertiary, #8b8b93);
311
+ word-break: break-all;
312
+ }
313
+
314
+ .cf-empty {
315
+ padding: 24px 4px;
316
+ text-align: center;
317
+ font-size: 12px;
318
+ color: var(--dsw-alias-label-tertiary, #8b8b93);
319
+ }
320
+
321
+ .cf-empty-title {
322
+ font-size: 13px;
323
+ font-weight: 600;
324
+ color: var(--dsw-alias-label-secondary, #a9a9b2);
325
+ margin-bottom: 6px;
326
+ }
327
+
328
+ .cf-aside {
329
+ font-size: 11px;
330
+ color: var(--dsw-alias-label-tertiary, #8b8b93);
331
+ }
332
+ `;
333
+ /**
334
+ * Inject the sheet once.
335
+ *
336
+ * Safe to call on every mount: the `data-plugin-css` key makes a second call a
337
+ * no-op, and a non-browser environment (tests, SSR) simply returns.
338
+ */
339
+ function ensurePanelCss() {
340
+ if (typeof document === "undefined") return;
341
+ if (document.querySelector(`style[data-plugin-css=${JSON.stringify(CSS_KEY)}]`) !== null) return;
342
+ const style = document.createElement("style");
343
+ style.dataset.plugin = "dsh-context-fold";
344
+ style.dataset.pluginCss = CSS_KEY;
345
+ style.textContent = CSS;
346
+ document.head.appendChild(style);
347
+ }
7
348
  // src/client.ts
349
+ /**
350
+ * `conversation.view` tab: what this plugin folded, what it saved, and the full
351
+ * text behind each fold.
352
+ *
353
+ * ## Why the page reads from the server route, not the session log
354
+ *
355
+ * The browser client cannot walk a session log: the fold decisions are durable
356
+ * plugin records, and the full original reasoning lives in the log's assistant
357
+ * events. Both are server-side. The tab therefore renders exactly what
358
+ * `/api/context-fold/report` returns and does no derivation of its own.
359
+ *
360
+ * ## What it shows, and how honestly
361
+ *
362
+ * The token comparison is a **counterfactual estimate**, not a measured A/B.
363
+ * The "before" case never ran. The panel says so in the UI, lists the
364
+ * assumptions the number depends on, and marks whether the session reported real
365
+ * provider usage at all — because a number built on heuristics must not look
366
+ * like a bill.
367
+ *
368
+ * The original text of every fold is shown on demand, fetched by its recovery
369
+ * path, so the tab doubles as the answer to "what exactly did it remove".
370
+ *
371
+ * ## How this file is shipped
372
+ *
373
+ * `tsdown` wraps the output in the shell's module-factory form
374
+ * (`window.__ModuleLoader__.load({id, factory(require){…}})`), which is the only
375
+ * shape the client module system executes. Imports are therefore converted into
376
+ * `require` calls resolved against the shared platform module table, and the
377
+ * plugin's exports (`name`/`inject`/`apply`) are what the factory returns.
378
+ *
379
+ * @module dsh-context-fold/client
380
+ */
8
381
  /** Locale namespace owned by this plugin's client half. */
9
382
  const NS = "contextFold";
10
383
  /** Dictionary: keep keys aligned with `locale/*.json`. */
@@ -12,16 +385,7 @@ const zh = { "tab.contextFold": "上下文折叠" };
12
385
  /** Dictionary: keep keys aligned with `locale/*.json`. */
13
386
  const en = { "tab.contextFold": "Context fold" };
14
387
  const react = require("react");
15
- /**
16
- * Fetch the report for one session.
17
- *
18
- * Failures are reported rather than swallowed: before the routes existed the
19
- * tab rendered "no report available", which reads like "this session folded
20
- * nothing" instead of "the request never landed".
21
- *
22
- * @param sessionId - session to report on.
23
- * @returns the payload, or a message describing why there is none.
24
- */
388
+ /** Fetch the report for one session. */
25
389
  async function fetchReport(sessionId) {
26
390
  let response;
27
391
  try {
@@ -52,131 +416,112 @@ async function fetchOriginal(sessionId, locator) {
52
416
  const body = await response.json();
53
417
  return body.ok === true ? body.value?.text : void 0;
54
418
  }
55
- /** Format a number for display. */
419
+ /** Format a number with thousands separators and at most one decimal. */
56
420
  function num(value) {
57
421
  return value.toLocaleString("en-US", { maximumFractionDigits: 1 });
58
422
  }
59
- /** A signed figure with an explicit sign, so a loss never looks like a gain. */
423
+ /** A signed figure with an explicit sign, so a loss never reads as a gain. */
60
424
  function signed(value) {
61
425
  const rounded = Math.round(value * 10) / 10;
62
426
  return `${rounded > 0 ? "+" : ""}${num(rounded)}`;
63
427
  }
428
+ /** Compact token count: 1.2M rather than 1200000. */
429
+ function compact(value) {
430
+ if (Math.abs(value) >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
431
+ if (Math.abs(value) >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
432
+ return num(value);
433
+ }
64
434
  /** Read the active session id out of the slot props. */
65
435
  function sessionIdOf(props) {
66
436
  const value = props?.sessionId;
67
437
  return typeof value === "string" && value !== "" ? value : void 0;
68
438
  }
69
- /** Inline styles, kept as plain objects because no CSS module ships here. */
70
- const styles = {
71
- root: {
72
- padding: "12px",
73
- font: "13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace",
74
- overflow: "auto",
75
- height: "100%"
76
- },
77
- title: {
78
- fontWeight: 600,
79
- marginBottom: "8px"
80
- },
81
- muted: { color: "#888" },
82
- box: {
83
- marginBottom: "12px",
84
- padding: "8px",
85
- border: "1px solid #333",
86
- borderRadius: "6px"
87
- },
88
- heading: {
89
- fontWeight: 600,
90
- marginBottom: "6px"
91
- },
92
- table: { borderCollapse: "collapse" },
93
- labelCell: {
94
- padding: "1px 12px 1px 0",
95
- color: "#aaa"
96
- },
97
- warning: {
98
- marginTop: "6px",
99
- color: "#facc15"
100
- },
101
- error: {
102
- marginTop: "6px",
103
- color: "#f87171"
104
- },
105
- reread: {
106
- marginTop: "6px",
107
- color: "#f87171"
108
- },
109
- fold: {
110
- marginBottom: "6px",
111
- padding: "6px",
112
- border: "1px solid #2a2a2a",
113
- borderRadius: "6px"
114
- },
115
- foldSummary: { cursor: "pointer" },
116
- inlinePre: {
117
- whiteSpace: "pre-wrap",
118
- margin: "6px 0",
119
- color: "#bbb",
120
- maxHeight: "12em",
121
- overflow: "auto"
122
- },
123
- originalPre: {
124
- whiteSpace: "pre-wrap",
125
- margin: "6px 0",
126
- color: "#ddd",
127
- maxHeight: "24em",
128
- overflow: "auto",
129
- borderTop: "1px dashed #333",
130
- paddingTop: "6px"
131
- },
132
- locator: {
133
- color: "#666",
134
- fontSize: "11px",
135
- wordBreak: "break-all"
136
- },
137
- notRecoverable: {
138
- color: "#f87171",
139
- fontSize: "11px"
140
- },
141
- detailsToggle: {
142
- cursor: "pointer",
143
- color: "#888",
144
- marginTop: "6px"
145
- },
146
- assumptionList: {
147
- margin: "4px 0 0 16px",
148
- padding: 0,
149
- color: "#999"
150
- }
151
- };
152
- /** The counterfactual summary block. */
439
+ const el = react.createElement;
440
+ /** One labelled metric tile. */
441
+ function Metric(props) {
442
+ const toneClass = props.tone === void 0 ? "" : ` cf-${props.tone}`;
443
+ const children = [el("div", {
444
+ className: "cf-metric-label",
445
+ key: "l"
446
+ }, props.label), el("div", {
447
+ className: `cf-metric-value${toneClass}`,
448
+ key: "v"
449
+ }, props.value)];
450
+ if (props.sub !== void 0) children.push(el("div", {
451
+ className: "cf-metric-sub",
452
+ key: "s"
453
+ }, props.sub));
454
+ return el("div", { className: "cf-metric" }, ...children);
455
+ }
456
+ /** A notice strip, optionally warning-toned. */
457
+ function Notice(props) {
458
+ return el("div", { className: props.warn === true ? "cf-notice cf-notice-warn" : "cf-notice" }, props.children);
459
+ }
460
+ /** The counterfactual summary card. */
153
461
  function Summary(props) {
154
462
  const cf = props.report.counterfactual;
155
- const rows = [
156
- ["Folded content, now", `${num(cf.tokensNow)} tokens`],
157
- ["If it had stayed unfolded", `${num(cf.tokensIfUnfolded)} tokens`],
158
- ["Prompt reduction per step", `${num(cf.tokensSavedPerStep)} tokens`],
159
- ["Steps still paying the folded prompt", num(cf.remainingSteps)],
160
- ["Gross saving", signed(cf.grossSaving)],
161
- ["Prefix invalidation cost", signed(-cf.invalidationCost)],
162
- ["Net", signed(cf.netSaving)]
163
- ];
164
- return react.createElement("section", { style: styles.box }, react.createElement("div", {
165
- style: styles.heading,
463
+ const savedChars = props.report.folds.reduce((total, fold) => total + (fold.charsBefore - fold.charsAfter), 0);
464
+ const netTone = cf.netSaving >= 0 ? "good" : "bad";
465
+ return el("section", { className: "cf-card" }, el("div", {
466
+ className: "cf-card-title",
166
467
  key: "h"
167
- }, "Token comparison (estimate)"), react.createElement("table", {
168
- style: styles.table,
169
- key: "t"
170
- }, ...rows.map(([label, value]) => react.createElement("tr", { key: label }, react.createElement("td", { style: styles.labelCell }, label), react.createElement("td", label === "Net" ? { style: {
171
- fontWeight: 600,
172
- color: cf.netSaving >= 0 ? "#4ade80" : "#f87171"
173
- } } : null, value)))), react.createElement("div", {
174
- style: styles.warning,
175
- key: "f"
176
- }, cf.hasRealUsage ? `Priced against this session's real usage${cf.cacheReadShare === void 0 ? "" : ` (cache-read share ${(cf.cacheReadShare * 100).toFixed(1)}%)`}.` : "This session reported NO provider usage — every figure here is heuristic token counts, not a bill."), react.createElement("details", { key: "a" }, react.createElement("summary", { style: styles.detailsToggle }, "Assumptions"), react.createElement("ul", { style: styles.assumptionList }, ...cf.assumptions.map((assumption, index) => react.createElement("li", { key: String(index) }, assumption)))), props.report.reReads > 0 ? react.createElement("div", {
177
- style: styles.reread,
468
+ }, "Token comparison"), el("div", {
469
+ className: "cf-grid",
470
+ key: "g"
471
+ }, el(Metric, {
472
+ key: "now",
473
+ label: "Folded content now",
474
+ value: `${compact(cf.tokensNow)} tok`,
475
+ sub: "what the window carries"
476
+ }), el(Metric, {
477
+ key: "before",
478
+ label: "If never folded",
479
+ value: `${compact(cf.tokensIfUnfolded)} tok`,
480
+ sub: "counterfactual"
481
+ }), el(Metric, {
482
+ key: "per",
483
+ label: "Saving per step",
484
+ value: `${num(cf.tokensSavedPerStep)} tok`,
485
+ sub: `${num(cf.remainingSteps)} steps left`
486
+ }), el(Metric, {
487
+ key: "gross",
488
+ label: "Gross saving",
489
+ value: signed(cf.grossSaving),
490
+ sub: "before invalidation",
491
+ tone: cf.grossSaving >= 0 ? "good" : "bad"
492
+ }), el(Metric, {
493
+ key: "cost",
494
+ label: "Invalidation cost",
495
+ value: signed(-cf.invalidationCost),
496
+ sub: "one-off prefix re-charge",
497
+ tone: "warn"
498
+ }), el(Metric, {
499
+ key: "net",
500
+ label: "Net",
501
+ value: signed(cf.netSaving),
502
+ sub: cf.netSaving >= 0 ? "net win" : "net loss — folded too late",
503
+ tone: netTone
504
+ })), el("div", {
505
+ style: { marginTop: "12px" },
506
+ key: "n"
507
+ }, el(Notice, { warn: !cf.hasRealUsage }, el("span", null, cf.hasRealUsage ? el("span", null, el("span", { className: "cf-notice-strong" }, "Priced against real usage. "), `Cache-read share ${cf.cacheReadShare === void 0 ? "n/a" : `${(cf.cacheReadShare * 100).toFixed(1)}%`}.`) : el("span", null, el("span", { className: "cf-notice-strong" }, "Heuristic only. "), "This session reported no provider usage, so these are token counts — not a bill.")))), el("div", {
508
+ className: "cf-aside",
509
+ style: { marginTop: "10px" },
510
+ key: "a"
511
+ }, `${num(savedChars)} characters removed across ${String(props.report.folds.length)} fold(s).`), props.report.reReads > 0 ? el("div", {
512
+ className: "cf-notice cf-notice-warn",
513
+ style: { marginTop: "10px" },
178
514
  key: "r"
179
- }, `Re-read signals: ${String(props.report.reReads)} — folded content was fetched again after being folded.`) : null);
515
+ }, el("span", null, el("span", { className: "cf-notice-strong" }, `${String(props.report.reReads)} re-read signal(s). `), "Folded content was fetched again after being folded — folding it may have cost more than it saved.")) : null, el("details", {
516
+ key: "asm",
517
+ style: { marginTop: "10px" }
518
+ }, el("summary", { className: "cf-aside" }, "Assumptions"), el("ul", { style: {
519
+ margin: "6px 0 0 18px",
520
+ padding: 0
521
+ } }, ...cf.assumptions.map((assumption, index) => el("li", {
522
+ className: "cf-aside",
523
+ key: String(index)
524
+ }, assumption)))));
180
525
  }
181
526
  /** One fold row; the original text loads when the row is opened. */
182
527
  function FoldItem(props) {
@@ -191,48 +536,42 @@ function FoldItem(props) {
191
536
  setOriginal(text ?? "(could not read the stored original)");
192
537
  });
193
538
  };
194
- const summary = `${fold.kind === "reasoning" ? "reasoning" : fold.toolName ?? "tool"} · turn ${String(fold.turn)}.${String(fold.step)} · ${num(fold.charsBefore)} → ${num(fold.charsAfter)} chars · ${fold.recoverable ? "recoverable" : "NOT recoverable"}`;
195
- const children = [react.createElement("summary", {
196
- style: styles.foldSummary,
197
- key: "s"
198
- }, summary), react.createElement("pre", {
199
- style: styles.inlinePre,
200
- key: "i"
201
- }, fold.inline)];
202
- if (fold.locator !== void 0) {
203
- children.push(react.createElement("div", {
204
- style: styles.locator,
205
- key: "l"
206
- }, fold.locator));
207
- if (original !== null) children.push(react.createElement("pre", {
208
- style: styles.originalPre,
209
- key: "o"
210
- }, original));
211
- } else if (!fold.recoverable) children.push(react.createElement("div", {
212
- style: styles.notRecoverable,
213
- key: "w"
214
- }, "No recovery path: the original is in the session log but no tool exposes it."));
215
- return react.createElement("details", {
216
- style: styles.fold,
539
+ const isReasoning = fold.kind === "reasoning";
540
+ const tagClass = isReasoning ? "cf-tag cf-tag-reasoning" : "cf-tag";
541
+ const saved = fold.tokensBefore - fold.tokensAfter;
542
+ const body = [el("div", {
543
+ className: "cf-fold-body",
544
+ key: "b"
545
+ }, el("div", { className: "cf-label" }, "What stays in the window"), el("pre", { className: "cf-pre" }, fold.inline), fold.locator !== void 0 ? el("div", null, el("div", { className: "cf-label" }, "Full original"), original === null ? el("div", { className: "cf-aside" }, "Expand to load from the recovery path.") : el("pre", { className: "cf-pre cf-pre-original" }, original), el("div", { className: "cf-locator" }, fold.locator)) : el("div", {
546
+ className: "cf-aside cf-bad",
547
+ style: { marginTop: "8px" }
548
+ }, "No recovery path: the original is in the session log but no tool exposes it."))];
549
+ const children = [el("summary", {
550
+ className: "cf-fold-head",
551
+ key: "h"
552
+ }, el("span", { className: "cf-caret" }, "▶"), el("span", { className: tagClass }, isReasoning ? "reasoning" : fold.toolName ?? "tool"), el("span", { className: "cf-fold-pos" }, `turn ${String(fold.turn)}.${String(fold.step)}`), el("span", { className: "cf-fold-pos" }, `${compact(fold.charsBefore)} → ${compact(fold.charsAfter)} chars`), el("span", { className: "cf-fold-saving" }, `−${compact(Math.max(0, saved))} tok`))];
553
+ children.push(...body);
554
+ return el("details", {
555
+ className: "cf-fold",
217
556
  onToggle
218
557
  }, ...children);
219
558
  }
220
- /** The per-fold list. */
559
+ /** The per-fold list card. */
221
560
  function FoldList(props) {
222
561
  const { report, sessionId } = props;
223
- const heading = react.createElement("div", {
224
- style: styles.heading,
562
+ const title = el("div", {
563
+ className: "cf-card-title",
225
564
  key: "h"
226
- }, `Folded content (${String(report.folds.length)})`);
227
- if (report.folds.length === 0) return react.createElement("section", null, heading, react.createElement("div", {
228
- style: styles.muted,
565
+ }, `Folded content · ${String(report.folds.length)}`);
566
+ if (report.folds.length === 0) return el("section", { className: "cf-card" }, title, el("div", {
567
+ className: "cf-empty",
229
568
  key: "e"
230
- }, "Nothing has been folded in this session yet."));
231
- return react.createElement("section", null, heading, ...report.folds.map((fold) => react.createElement(FoldItem, {
569
+ }, el("div", { className: "cf-empty-title" }, "Nothing folded in this session yet"), el("div", null, "Folds appear once a step batch is old enough and large enough to be worth replacing.")));
570
+ return el("section", { className: "cf-card" }, title, el("div", { className: "cf-folds" }, ...report.folds.map((fold) => el(FoldItem, {
232
571
  key: String(fold.seq),
233
572
  sessionId,
234
573
  fold
235
- })));
574
+ }))));
236
575
  }
237
576
  /**
238
577
  * The tab body.
@@ -264,41 +603,26 @@ function ContextFoldTab(props) {
264
603
  cancelled = true;
265
604
  };
266
605
  }, [sessionId]);
267
- const children = [react.createElement("div", {
268
- style: styles.title,
269
- key: "title"
270
- }, "Context fold")];
271
- if (sessionId === void 0) children.push(react.createElement("div", {
272
- style: styles.muted,
273
- key: "m"
274
- }, "No active session."));
275
- else if (state.status === "loading") children.push(react.createElement("div", {
276
- style: styles.muted,
277
- key: "m"
278
- }, "Loading…"));
279
- else if (state.status === "error") children.push(react.createElement("div", {
280
- style: styles.error,
281
- key: "e"
282
- }, `Could not load the report: ${state.error ?? "unknown"}`));
283
- else if (state.status !== "ready" || state.report === void 0) children.push(react.createElement("div", {
284
- style: styles.muted,
285
- key: "m"
286
- }, "This session has no folds to report."));
606
+ const children = [el("div", {
607
+ className: "cf-head",
608
+ key: "head"
609
+ }, el("span", { className: "cf-head-title" }, "Context fold"), sessionId === void 0 ? null : el("span", { className: "cf-head-note" }, state.status === "ready" ? `${String(state.report?.folds.length ?? 0)} fold(s) on the current surface` : "stage-boundary folding"))];
610
+ if (sessionId === void 0) children.push(el("div", { className: "cf-card" }, el("div", { className: "cf-empty" }, el("div", { className: "cf-empty-title" }, "No active session"), el("div", null, "Open a conversation to see its folds."))));
611
+ else if (state.status === "loading") children.push(el("div", { className: "cf-card" }, el("div", { className: "cf-empty" }, "Loading…")));
612
+ else if (state.status === "error") children.push(el("div", { className: "cf-card" }, el("div", { className: "cf-empty" }, el("div", { className: "cf-empty-title cf-bad" }, "Could not load the report"), el("div", null, state.error ?? "unknown error"))));
613
+ else if (state.status !== "ready" || state.report === void 0) children.push(el("div", { className: "cf-card" }, el("div", { className: "cf-empty" }, "This session has no folds to report.")));
287
614
  else {
288
- children.push(react.createElement(Summary, {
615
+ children.push(el(Summary, {
289
616
  key: "summary",
290
617
  report: state.report
291
618
  }));
292
- children.push(react.createElement(FoldList, {
619
+ children.push(el(FoldList, {
293
620
  key: "folds",
294
621
  sessionId,
295
622
  report: state.report
296
623
  }));
297
624
  }
298
- return react.createElement("div", {
299
- className: "context-fold-tab",
300
- style: styles.root
301
- }, ...children);
625
+ return el("div", { className: "cf-root" }, ...children);
302
626
  }
303
627
  /** Client plugin name. */
304
628
  const name = "context-fold-client";
@@ -313,6 +637,7 @@ const inject = ["slots", "locale"];
313
637
  * @param ctx - client plugin context.
314
638
  */
315
639
  function apply(ctx) {
640
+ ensurePanelCss();
316
641
  const registerDictionaries = () => {
317
642
  ctx.locale.register(NS, {
318
643
  zh,
package/lib/index.js CHANGED
@@ -1351,19 +1351,26 @@ function counterfactualOf(folds, steps, options = {}) {
1351
1351
  const savedTokenRate = cacheReadShare === void 0 ? price.uncachedInput : cacheReadShare * price.cacheRead + (1 - cacheReadShare) * price.uncachedInput;
1352
1352
  const grossSaving = tokensSavedPerStep * remainingSteps * savedTokenRate;
1353
1353
  let invalidationCost = 0;
1354
- if (foldSteps !== void 0) for (const foldStep of foldSteps) {
1355
- const at = steps.find((step) => step.step >= foldStep);
1356
- if (at === void 0) continue;
1357
- const prompt = promptTokensOf(at);
1358
- invalidationCost += prompt * (price.uncachedInput - price.cacheRead);
1354
+ if (foldSteps !== void 0) {
1355
+ const distinct = [...new Set(foldSteps)];
1356
+ for (const foldStep of distinct) {
1357
+ const at = steps.find((step) => step.step >= foldStep);
1358
+ if (at === void 0) continue;
1359
+ const prompt = promptTokensOf(at);
1360
+ invalidationCost += prompt * (price.uncachedInput - price.cacheRead);
1361
+ }
1359
1362
  }
1360
1363
  const counterfactualPromptCost = actualPromptCost + tokensSavedPerStep * remainingSteps * savedTokenRate;
1361
1364
  const assumptions = [];
1362
1365
  assumptions.push(hasRealUsage ? `Prices are DeepSeek ratios (cache read ${price.cacheRead}×, write ${price.cacheWrite}×, uncached ${price.uncachedInput}×), applied to this session's observed usage.` : "This session reported no provider usage, so even the \"actual\" figures are heuristic token counts, not billed amounts.");
1363
1366
  if (cacheReadShare !== void 0) assumptions.push(`Saved tokens are priced at this session's observed cache-read share (${(cacheReadShare * 100).toFixed(1)}%), not at the uncached rate.`);
1364
1367
  if (foldSteps === void 0 || foldSteps.length === 0) assumptions.push("Fold step numbers were unavailable, so every observed step is credited with the saving. Treat the figure as an upper bound.");
1365
- else assumptions.push(`Each fold is charged one prefix invalidation: the whole prompt after it re-billed from cache-read to uncached once.`);
1368
+ else {
1369
+ const distinctFoldSteps = new Set(foldSteps).size;
1370
+ assumptions.push(`Each of the ${String(distinctFoldSteps)} distinct fold step(s) is charged one prefix invalidation: the prompt after it re-billed from cache-read to uncached once. Folds sharing a step share that charge, because they invalidate the same prefix.`);
1371
+ }
1366
1372
  assumptions.push("The counterfactual assumes the same number of steps and the same cache behaviour as the real session — a folded prompt might have hit cache differently.");
1373
+ assumptions.push("\"Saving per step\" is the total prompt reduction on the surface, not a per-fold figure: it is multiplied by the steps that followed each fold.");
1367
1374
  return {
1368
1375
  tokensNow,
1369
1376
  tokensIfUnfolded,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-context-fold",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "dsh-context-fold: stage-boundary folding of completed tool results into a conclusion plus a spill path, deterministic and replay-safe.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",