dsh-context 0.5.0 → 0.6.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/README.md CHANGED
@@ -72,6 +72,8 @@ pnpm test # typecheck + functional tests for both halves
72
72
 
73
73
  `build.mjs` also smoke-checks the outputs (both halves must parse; the host half must import with the `name`/`inject`/`apply` plugin shape).
74
74
 
75
+ Commits are guarded by a [husky](https://typicode.github.io/husky/) pre-commit hook that runs `pnpm run typecheck` (`tsc --noEmit`, strict).
76
+
75
77
  ## Files
76
78
 
77
79
  | File | Role |
package/lib/client.js CHANGED
@@ -18,14 +18,17 @@ const DICT_ZH = {
18
18
  "overview.title": "\u5F53\u524D\u6784\u6210",
19
19
  "overview.ofWindow": "tokens\uFF08\u7EA6 {p}%\uFF09",
20
20
  "overview.estimate": "tokens\uFF08\u4F30\u7B97\uFF09",
21
+ "overview.actual": "\xB7 \u4E0A\u6B21\u5B9E\u9645 prompt {n}",
21
22
  "tools.top": "\u5DE5\u5177\u5B9A\u4E49 Top\uFF1A",
22
23
  "tools.more": "\u7B49 {n} \u4E2A",
23
24
  "trend.title": "\u5386\u53F2\u8D8B\u52BF",
24
25
  "gran.step": "\u6B65\u9AA4",
25
26
  "gran.turn": "\u8F6E\u6B21",
26
- "trend.hint": "\u6BCF\u6B21\u6A21\u578B\u8BF7\u6C42\u4E00\u6BB5\uFF1B\u70B9\u51FB\u67F1\u5B50\u67E5\u770B\u8BE6\u60C5\uFF0C\u2702 \u8868\u793A\u538B\u7F29/\u526A\u679D",
27
+ "trend.hint": "\u6BCF\u6B21\u6A21\u578B\u8BF7\u6C42\u4E00\u6BB5\uFF1B\u70B9\u51FB\u67F1\u5B50\u67E5\u770B\u8BE6\u60C5\uFF0C\u2702 \u8868\u793A\u538B\u7F29/\u526A\u679D\uFF1B\u5DE6\u53F3\u6ED1\u52A8\u53EF\u56DE\u770B\u66F4\u65E9",
27
28
  "trend.empty": "\u53D1\u8D77\u4E00\u8F6E\u5BF9\u8BDD\u540E\uFF0C\u8FD9\u91CC\u4F1A\u5C55\u793A\u6BCF\u6B21\u6A21\u578B\u8BF7\u6C42\u7684\u4E0A\u4E0B\u6587\u6784\u6210",
28
29
  "detail.step": "T{t} \xB7 \u7B2C {s} \u6B65",
30
+ "detail.turn": "T{t} \xB7 \u5171 {n} \u6B65",
31
+ "detail.lastStep": "\u672B\u6B65",
29
32
  "detail.estTotal": "\u4F30\u7B97\u5408\u8BA1 \u2248 {n}",
30
33
  "detail.actual": "\u5B9E\u9645 prompt {n}",
31
34
  "detail.output": "\u8F93\u51FA {n}",
@@ -39,6 +42,7 @@ const DICT_ZH = {
39
42
  "error": "\u4E0A\u4E0B\u6587\u6570\u636E\u8BFB\u53D6\u5931\u8D25\uFF1A",
40
43
  "footer": "\u4F30\u7B97\u53E3\u5F84\uFF1A\u4E0E dsh \u5185\u7F6E tokenMeter \u76F8\u540C\u7684\u56FA\u5B9A\u5BC6\u5EA6\u542F\u53D1\u5F0F\uFF08\u7EA6 4 \u5B57\u7B26 \u2248 1 token\uFF09\uFF1B\u300C\u5B9E\u9645\u300D\u4E3A\u4F9B\u5E94\u5546\u4E0A\u62A5\u7528\u91CF\u3002",
41
44
  "tip.step": "T{t} \xB7 \u7B2C{s}\u6B65",
45
+ "tip.turn": "T{t} \xB7 \u5171 {n} \u6B65",
42
46
  "tip.total": "\u5408\u8BA1 \u2248 {n}",
43
47
  "tip.actual": "\uFF08\u5B9E\u9645 {n}\uFF09",
44
48
  "ev.compaction": "\u538B\u7F29\u4E0A\u4E0B\u6587\uFF08\u6458\u8981\u66FF\u6362 {n} \u6761\u6D88\u606F\uFF09",
@@ -69,14 +73,17 @@ const DICT_EN = {
69
73
  "overview.title": "Current composition",
70
74
  "overview.ofWindow": "tokens (~{p}%)",
71
75
  "overview.estimate": "tokens (estimated)",
76
+ "overview.actual": "\xB7 last actual prompt {n}",
72
77
  "tools.top": "Top tool schemas:",
73
78
  "tools.more": "of {n}",
74
79
  "trend.title": "History",
75
80
  "gran.step": "Step",
76
81
  "gran.turn": "Turn",
77
- "trend.hint": "one bar per model request; click a bar for details, \u2702 marks compaction/prune",
82
+ "trend.hint": "one bar per model request; click a bar for details, \u2702 marks compaction/prune; scroll sideways to see earlier",
78
83
  "trend.empty": "Send a message and each model request\u2019s context makeup shows up here",
79
84
  "detail.step": "T{t} \xB7 step {s}",
85
+ "detail.turn": "T{t} \xB7 {n} steps",
86
+ "detail.lastStep": "last step",
80
87
  "detail.estTotal": "estimated \u2248 {n}",
81
88
  "detail.actual": "actual prompt {n}",
82
89
  "detail.output": "output {n}",
@@ -90,6 +97,7 @@ const DICT_EN = {
90
97
  "error": "Failed to read context data: ",
91
98
  "footer": "Estimate: same fixed-density heuristic as dsh\u2019s built-in tokenMeter (~4 chars \u2248 1 token); \u201Cactual\u201D is provider-reported usage.",
92
99
  "tip.step": "T{t} \xB7 step {s}",
100
+ "tip.turn": "T{t} \xB7 {n} steps",
93
101
  "tip.total": "total \u2248 {n}",
94
102
  "tip.actual": " (actual {n})",
95
103
  "ev.compaction": "Context compacted (summary replaced {n} messages)",
@@ -124,10 +132,16 @@ function fmtTime(t) {
124
132
  }
125
133
  function aggregateByTurn(requests) {
126
134
  const out = [];
135
+ let runSteps = 0;
127
136
  for (const req of requests) {
128
137
  const last = out.length > 0 ? out[out.length - 1] : null;
129
- if (last !== null && (last.turn ?? 0) === (req.turn ?? 0)) out[out.length - 1] = req;
130
- else out.push(req);
138
+ if (last !== null && (last.turn ?? 0) === (req.turn ?? 0)) {
139
+ runSteps++;
140
+ out[out.length - 1] = { ...req, stepCount: runSteps };
141
+ } else {
142
+ runSteps = 1;
143
+ out.push({ ...req, stepCount: 1 });
144
+ }
131
145
  }
132
146
  return out;
133
147
  }
@@ -172,18 +186,21 @@ function makeView(ctx, t) {
172
186
  let total = 0;
173
187
  for (const p of props.parts) total += p.value;
174
188
  const scale = props.max !== void 0 && props.max > total ? props.max : total;
175
- const wrapRef = React.useRef(null);
176
- const [tip, setTip] = React.useState(null);
177
- const showTip = (e, p) => {
178
- const rect = wrapRef.current?.getBoundingClientRect();
179
- const left = rect ? e.clientX - rect.left : 50;
180
- const width = rect?.width ?? 200;
181
- setTip({
182
- key: p.key,
183
- text: catLabel(p.key) + " " + fmt(p.value) + " (" + Math.round(p.value / total * 100) + "%)",
184
- left: Math.max(48, Math.min(left, width - 48))
185
- });
186
- };
189
+ let tip = null;
190
+ if (props.hoverKey !== null && props.hoverKey !== void 0) {
191
+ let acc = 0;
192
+ for (const p of props.parts) {
193
+ const pct = scale > 0 ? p.value / scale * 100 : 0;
194
+ if (p.key === props.hoverKey && p.value > 0) {
195
+ tip = {
196
+ text: catLabel(p.key) + " " + fmt(p.value) + " (" + Math.round(p.value / total * 100) + "%)",
197
+ leftPct: Math.max(12, Math.min(acc + pct / 2, 88))
198
+ };
199
+ break;
200
+ }
201
+ acc += pct;
202
+ }
203
+ }
187
204
  return h(
188
205
  "div",
189
206
  { className: "lc-stacked-wrap" },
@@ -192,26 +209,24 @@ function makeView(ctx, t) {
192
209
  {
193
210
  className: "lc-stacked",
194
211
  style: { height: (props.height || 14) + "px" },
195
- ref: wrapRef,
196
212
  onMouseLeave: () => {
197
- setTip(null);
213
+ if (props.onHoverKey !== void 0) props.onHoverKey(null);
198
214
  }
199
215
  },
200
216
  total <= 0 ? null : props.parts.map((p) => {
201
217
  if (!p.value) return null;
218
+ const on = props.hoverKey !== void 0 && props.hoverKey === p.key;
202
219
  return h("div", {
203
220
  key: p.key,
221
+ className: "lc-stacked-seg" + (on ? " lc-stacked-seg-on" : ""),
204
222
  style: { width: p.value / scale * 100 + "%", background: p.color },
205
- onMouseEnter: (e) => {
206
- showTip(e, p);
207
- },
208
- onMouseMove: (e) => {
209
- showTip(e, p);
223
+ onMouseEnter: () => {
224
+ if (props.onHoverKey !== void 0) props.onHoverKey(p.key);
210
225
  }
211
226
  });
212
227
  })
213
228
  ),
214
- tip ? h("div", { className: "lc-bar-tip", style: { left: tip.left + "px" } }, tip.text) : null
229
+ tip ? h("div", { className: "lc-bar-tip", style: { left: tip.leftPct + "%" } }, tip.text) : null
215
230
  );
216
231
  }
217
232
  function Legend(props) {
@@ -221,9 +236,19 @@ function makeView(ctx, t) {
221
236
  "div",
222
237
  { className: "lc-legend" },
223
238
  props.parts.map((p) => {
239
+ const on = props.hoverKey !== void 0 && props.hoverKey === p.key;
224
240
  return h(
225
241
  "span",
226
- { key: p.key, className: "lc-chip" },
242
+ {
243
+ key: p.key,
244
+ className: "lc-chip" + (on ? " lc-chip-on" : ""),
245
+ onMouseEnter: () => {
246
+ if (props.onHoverKey !== void 0) props.onHoverKey(p.key);
247
+ },
248
+ onMouseLeave: () => {
249
+ if (props.onHoverKey !== void 0) props.onHoverKey(null);
250
+ }
251
+ },
227
252
  h("i", { style: { background: p.color } }),
228
253
  catLabel(p.key) + " " + fmt(p.value),
229
254
  total > 0 ? h("em", null, Math.round(p.value / total * 100) + "%") : null
@@ -257,17 +282,36 @@ function makeView(ctx, t) {
257
282
  const groups = [];
258
283
  for (const req of requests) {
259
284
  let grp = groups.length > 0 ? groups[groups.length - 1] : null;
260
- if (grp === null || grp.turn !== req.turn) {
261
- grp = { turn: req.turn ?? 0, count: 0 };
285
+ if (grp === null || grp.turn !== (req.turn ?? 0)) {
286
+ grp = { turn: req.turn ?? 0, count: 0, span: 0, agg: req.stepCount !== void 0 };
262
287
  groups.push(grp);
263
288
  }
264
289
  grp.count++;
290
+ grp.span += req.stepCount ?? 1;
265
291
  }
266
292
  const scrollRef = React.useRef(null);
267
- React.useEffect(() => {
293
+ const scrolledOnce = React.useRef(false);
294
+ const lastGranRef = React.useRef(props.granularity);
295
+ const [edges, setEdges] = React.useState({ left: false, right: false });
296
+ const updateEdges = (el) => {
297
+ const left = el.scrollLeft > 4;
298
+ const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 4;
299
+ setEdges((prev) => prev.left === left && prev.right === right ? prev : { left, right });
300
+ };
301
+ React.useLayoutEffect(() => {
268
302
  const el = scrollRef.current;
269
303
  if (el === null) return;
270
- if (el.scrollLeft + el.clientWidth >= el.scrollWidth - 24) el.scrollLeft = el.scrollWidth;
304
+ if (props.granularity !== lastGranRef.current) {
305
+ lastGranRef.current = props.granularity;
306
+ scrolledOnce.current = false;
307
+ }
308
+ if (!scrolledOnce.current) {
309
+ scrolledOnce.current = true;
310
+ el.scrollLeft = el.scrollWidth;
311
+ } else if (el.scrollLeft + el.clientWidth >= el.scrollWidth - 24) {
312
+ el.scrollLeft = el.scrollWidth;
313
+ }
314
+ updateEdges(el);
271
315
  });
272
316
  return h(
273
317
  "div",
@@ -281,7 +325,16 @@ function makeView(ctx, t) {
281
325
  ),
282
326
  h(
283
327
  "div",
284
- { className: "lc-chart-scroll", ref: scrollRef },
328
+ {
329
+ className: "lc-chart-scroll",
330
+ ref: scrollRef,
331
+ onScroll: (e) => {
332
+ updateEdges(e.currentTarget);
333
+ }
334
+ },
335
+ // Edge fades: visible whenever more history sits beyond the viewport,
336
+ // so the horizontal scroll affordance is obvious.
337
+ edges.left ? h("div", { className: "lc-chart-fade lc-chart-fade-l" }) : null,
285
338
  // Hovering a bar previews it in the detail below; leaving the plot
286
339
  // clears the preview (a pinned selection, if any, takes over again).
287
340
  h(
@@ -295,12 +348,15 @@ function makeView(ctx, t) {
295
348
  const selected = props.selectedSeq === req.seq;
296
349
  const hovered = props.hoveredSeq === req.seq;
297
350
  const inTurn = props.activeTurn !== null && (req.turn ?? 0) === props.activeTurn;
298
- const tip = tr("tip.step", { t: req.turn ?? 0, s: req.step ?? 0 }) + " \xB7 " + fmtTime(req.time) + "\n" + tr("tip.total", { n: fmt(req.total) }) + (req.prompt !== void 0 ? tr("tip.actual", { n: fmt(req.prompt) }) : "") + "\n" + CATS.map((c) => catLabel(c.key) + " " + fmt(req[c.key] || 0)).join(" / ");
351
+ const tip = (req.stepCount !== void 0 && req.stepCount > 1 ? tr("tip.turn", { t: req.turn ?? 0, n: req.stepCount }) : tr("tip.step", { t: req.turn ?? 0, s: req.step ?? 0 })) + " \xB7 " + fmtTime(req.time) + "\n" + tr("tip.total", { n: fmt(req.total) }) + (req.prompt !== void 0 ? tr("tip.actual", { n: fmt(req.prompt) }) : "") + "\n" + CATS.map((c) => catLabel(c.key) + " " + fmt(req[c.key] || 0)).join(" / ");
299
352
  return h(
300
353
  "div",
301
354
  {
302
355
  key: req.seq,
356
+ // Uniform column width in BOTH granularities: turn aggregates
357
+ // keep the same fixed width as step bars.
303
358
  className: "lc-bar" + (selected ? " lc-bar-selected" : "") + (hovered ? " lc-bar-hovered" : "") + (inTurn ? " lc-bar-in-turn" : ""),
359
+ style: { width: BAR_W + "px" },
304
360
  title: tip,
305
361
  onClick: () => {
306
362
  props.onSelect(selected ? null : req.seq);
@@ -334,11 +390,12 @@ function makeView(ctx, t) {
334
390
  } },
335
391
  groups.map((grp, gi) => {
336
392
  const on = props.activeTurn === grp.turn;
393
+ const blockW = grp.agg ? BAR_W : grp.span * (BAR_W + BAR_GAP) - BAR_GAP;
337
394
  return h("span", {
338
395
  key: "turn-" + gi,
339
396
  className: "lc-turn" + (on ? " lc-turn-on" : ""),
340
397
  style: {
341
- width: grp.count * (BAR_W + BAR_GAP) - BAR_GAP + "px",
398
+ width: blockW + "px",
342
399
  background: TURN_COLORS[gi % TURN_COLORS.length]
343
400
  },
344
401
  title: "T" + grp.turn,
@@ -347,20 +404,24 @@ function makeView(ctx, t) {
347
404
  }
348
405
  }, "T" + grp.turn);
349
406
  })
350
- )
407
+ ),
408
+ edges.right ? h("div", { className: "lc-chart-fade lc-chart-fade-r" }) : null
351
409
  )
352
410
  );
353
411
  }
354
412
  function RequestDetail(props) {
355
413
  const req = props.request;
356
414
  if (!req) return null;
415
+ const isTurn = req.stepCount !== void 0 && req.stepCount > 1;
416
+ const head = isTurn ? tr("detail.turn", { t: req.turn ?? 0, n: req.stepCount ?? 0 }) : tr("detail.step", { t: req.turn ?? 0, s: req.step ?? 0 });
357
417
  return h(
358
418
  "div",
359
419
  { className: "lc-detail" },
360
420
  h(
361
421
  "div",
362
422
  { className: "lc-detail-head" },
363
- h("b", null, tr("detail.step", { t: req.turn ?? 0, s: req.step ?? 0 })),
423
+ h("b", null, head),
424
+ isTurn ? h("span", { className: "lc-detail-tag" }, t("detail.lastStep")) : null,
364
425
  h("span", null, fmtTime(req.time)),
365
426
  h("span", null, tr("detail.estTotal", { n: fmt(req.total) })),
366
427
  req.prompt !== void 0 ? h("span", { className: "lc-actual" }, tr("detail.actual", { n: fmt(req.prompt) })) : null,
@@ -432,6 +493,8 @@ function makeView(ctx, t) {
432
493
  { key: n.seq, className: "lc-node" },
433
494
  h("i", { style: { background: catColor[n.cat] || "#999" } }),
434
495
  h("span", { className: "lc-node-preview", title: text }, text),
496
+ // Timestamp when the host event carried one.
497
+ typeof n.time === "number" ? h("span", { className: "lc-node-time" }, fmtTime(n.time)) : null,
435
498
  h("span", { className: "lc-node-tokens" }, fmt(n.tokens))
436
499
  );
437
500
  })
@@ -446,6 +509,7 @@ function makeView(ctx, t) {
446
509
  const [hoverTurn, setHoverTurn] = React.useState(null);
447
510
  const [tick, setTick] = React.useState(0);
448
511
  const [granularity, setGranularity] = React.useState("step");
512
+ const [hoverCat, setHoverCat] = React.useState(null);
449
513
  React.useEffect(() => {
450
514
  if (typeof sessionId !== "string" || sessionId === "") return void 0;
451
515
  let alive = true;
@@ -521,10 +585,19 @@ function makeView(ctx, t) {
521
585
  "div",
522
586
  { className: "lc-overview-num" },
523
587
  h("b", null, fmt(current.total)),
524
- h("span", null, data.contextWindow ? " / " + fmt(data.contextWindow) + " " + tr("overview.ofWindow", { p: windowPct ?? 0 }) : " " + t("overview.estimate"))
588
+ h("span", null, data.contextWindow ? " / " + fmt(data.contextWindow) + " " + tr("overview.ofWindow", { p: windowPct ?? 0 }) : " " + t("overview.estimate")),
589
+ // The provider-reported prompt of the last request is the best
590
+ // ground truth for what the model actually received; the fixed
591
+ // density heuristic can undercount CJK-heavy content, so show the
592
+ // real number alongside the estimate.
593
+ displayRequests.length > 0 && displayRequests[displayRequests.length - 1].prompt !== void 0 ? h(
594
+ "span",
595
+ { className: "lc-actual" },
596
+ tr("overview.actual", { n: fmt(displayRequests[displayRequests.length - 1].prompt ?? 0) })
597
+ ) : null
525
598
  ),
526
- h(StackedBar, { parts: partsOf(current), height: 16, max: data.contextWindow }),
527
- h(Legend, { parts: partsOf(current) }),
599
+ h(StackedBar, { parts: partsOf(current), height: 16, max: data.contextWindow, hoverKey: hoverCat, onHoverKey: setHoverCat }),
600
+ h(Legend, { parts: partsOf(current), hoverKey: hoverCat, onHoverKey: setHoverCat }),
528
601
  data.toolList && data.toolList.length > 0 ? h(
529
602
  "div",
530
603
  { className: "lc-tools" },
@@ -565,11 +638,17 @@ function makeView(ctx, t) {
565
638
  "div",
566
639
  null,
567
640
  h(TrendChart, {
568
- requests: displayRequests.slice(-80),
641
+ // Remount per session: switching sessions re-anchors the chart
642
+ // at the newest bars instead of inheriting stale scroll state.
643
+ key: sessionId,
644
+ // The host caps the log at 160 requests; render them ALL so
645
+ // earlier turns/steps stay reachable via horizontal scroll.
646
+ requests: displayRequests,
569
647
  events,
570
648
  selectedSeq: pinnedReq ? pinnedReq.seq : null,
571
649
  hoveredSeq,
572
650
  activeTurn,
651
+ granularity,
573
652
  onSelect: setSelectedSeq,
574
653
  onHover: setHoveredSeq,
575
654
  onHoverTurn: setHoverTurn
@@ -612,7 +691,7 @@ const STYLES = [
612
691
  ".lc-gran { margin-left: auto; display: flex; gap: 2px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 1px; }",
613
692
  ".lc-gran-btn { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 1; padding: 3px 8px; border-radius: 5px; cursor: pointer; font-family: inherit; }",
614
693
  ".lc-gran-btn:hover { color: var(--dsw-alias-label-primary); }",
615
- ".lc-gran-on, .lc-gran-on:hover { background: var(--dsw-alias-brand-primary); color: #fff; }",
694
+ ".lc-gran-on, .lc-gran-on:hover { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); }",
616
695
  ".lc-overview-num { margin-bottom: 8px; }",
617
696
  ".lc-overview-num b { font-size: 20px; }",
618
697
  ".lc-overview-num span { color: var(--dsw-alias-label-secondary); }",
@@ -620,10 +699,13 @@ const STYLES = [
620
699
  ".lc-stacked { display: flex; width: 100%; border-radius: 5px; overflow: hidden; background: rgba(128,128,128,0.18); }",
621
700
  ".lc-bar-tip { position: absolute; bottom: calc(100% + 6px); transform: translateX(-50%); z-index: 5; white-space: nowrap; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 3px 8px; font-size: 12px; color: var(--dsw-alias-label-primary); box-shadow: 0 2px 8px rgba(0,0,0,0.18); pointer-events: none; }",
622
701
  ".lc-stacked > div { height: 100%; }",
702
+ ".lc-stacked-seg-on { filter: brightness(1.18); }",
623
703
  ".lc-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 10px; }",
624
704
  ".lc-chip { display: inline-flex; align-items: center; gap: 5px; color: var(--dsw-alias-label-primary); }",
625
705
  ".lc-chip i, .lc-detail-row i, .lc-node i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }",
626
706
  ".lc-chip em { font-style: normal; color: var(--dsw-alias-label-secondary); }",
707
+ ".lc-chip-on { font-weight: 600; }",
708
+ ".lc-chip-on i { box-shadow: 0 0 0 1px var(--dsw-alias-brand-primary); }",
627
709
  ".lc-tools { margin-top: 10px; color: var(--dsw-alias-label-secondary); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }",
628
710
  ".lc-tool-chip { background: var(--dsw-alias-bg-layer-2); border-radius: 4px; padding: 1px 7px; font-size: 12px; color: var(--dsw-alias-label-primary); }",
629
711
  ".lc-chartrow { display: flex; gap: 6px; align-items: stretch; }",
@@ -632,10 +714,14 @@ const STYLES = [
632
714
  ".lc-axis-top { top: 13px; }",
633
715
  ".lc-axis-mid { top: 69px; }",
634
716
  ".lc-axis-bot { top: 125px; }",
635
- ".lc-chart-scroll { flex: 1; overflow-x: auto; overflow-y: hidden; min-width: 0; scrollbar-width: thin; }",
636
- ".lc-chart-scroll::-webkit-scrollbar { height: 6px; }",
637
- ".lc-chart-scroll::-webkit-scrollbar-thumb { background: var(--dsw-alias-border-l1); border-radius: 3px; }",
717
+ ".lc-chart-scroll { position: relative; flex: 1; overflow-x: auto; overflow-y: hidden; min-width: 0; scrollbar-width: thin; }",
718
+ ".lc-chart-scroll::-webkit-scrollbar { height: 8px; }",
719
+ ".lc-chart-scroll::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--dsw-alias-label-secondary) 45%, transparent); border-radius: 4px; }",
720
+ ".lc-chart-scroll::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--dsw-alias-label-secondary) 70%, transparent); }",
638
721
  ".lc-chart-scroll::-webkit-scrollbar-track { background: transparent; }",
722
+ ".lc-chart-fade { position: absolute; top: 0; bottom: 0; width: 26px; pointer-events: none; z-index: 2; }",
723
+ ".lc-chart-fade-l { left: 0; background: linear-gradient(to right, var(--dsw-alias-bg-layer-1), transparent); }",
724
+ ".lc-chart-fade-r { right: 0; background: linear-gradient(to left, var(--dsw-alias-bg-layer-1), transparent); }",
639
725
  ".lc-chart { position: relative; display: flex; align-items: flex-end; gap: 2px; height: 130px; padding-top: 18px; box-sizing: border-box; width: max-content; min-width: 100%; }",
640
726
  ".lc-grid { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--dsw-alias-border-l1); pointer-events: none; }",
641
727
  ".lc-grid-top { top: 18px; }",
@@ -649,12 +735,13 @@ const STYLES = [
649
735
  ".lc-bar-stack > div { width: 100%; }",
650
736
  ".lc-bar-marker { position: absolute; top: -16px; left: 50%; transform: translateX(-50%); font-size: 11px; color: var(--dsw-alias-state-warn-primary); }",
651
737
  ".lc-turns { display: flex; gap: 2px; width: max-content; min-width: 100%; margin-top: 4px; }",
652
- ".lc-turn { flex: none; min-width: 16px; box-sizing: border-box; text-align: center; font-size: 10px; line-height: 14px; font-weight: 600; color: #fff; border-radius: 3px; height: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; transition: filter 120ms; }",
738
+ ".lc-turn { flex: none; box-sizing: border-box; text-align: center; font-size: 10px; line-height: 14px; font-weight: 600; color: #fff; border-radius: 3px; height: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; transition: filter 120ms; }",
653
739
  ".lc-turn-on { filter: brightness(1.35); box-shadow: 0 0 0 1px rgba(255,255,255,0.4); }",
654
740
  ".lc-detail { margin-top: 12px; border-top: 1px solid var(--dsw-alias-border-l1); padding-top: 12px; }",
655
741
  ".lc-detail-head { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-bottom: 8px; color: var(--dsw-alias-label-secondary); }",
656
742
  ".lc-detail-head b { color: var(--dsw-alias-label-primary); }",
657
743
  ".lc-detail-head .lc-actual { color: var(--dsw-alias-state-success-primary); }",
744
+ ".lc-detail-tag { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px; padding: 0 6px; font-size: 11px; color: var(--dsw-alias-label-secondary); }",
658
745
  ".lc-detail-rows { margin-top: 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; }",
659
746
  ".lc-detail-row { display: flex; align-items: center; gap: 8px; }",
660
747
  ".lc-detail-label { min-width: 70px; white-space: nowrap; color: var(--dsw-alias-label-secondary); }",
@@ -675,6 +762,7 @@ const STYLES = [
675
762
  ".lc-event-time { color: var(--dsw-alias-label-secondary); font-size: 12px; }",
676
763
  ".lc-node { display: flex; align-items: center; gap: 8px; padding: 3px 0; }",
677
764
  ".lc-node-preview { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); }",
765
+ ".lc-node-time { color: var(--dsw-alias-label-secondary); font-size: 12px; min-width: 54px; text-align: right; }",
678
766
  ".lc-node-tokens { color: var(--dsw-alias-label-secondary); }",
679
767
  ".lc-nodes-more { color: var(--dsw-alias-label-secondary); padding: 3px 0; }",
680
768
  ".lc-empty { color: var(--dsw-alias-label-secondary); padding: 18px 0; text-align: center; }",
package/lib/index.js CHANGED
@@ -25,7 +25,10 @@ function estimateBlocks(blocks) {
25
25
  }
26
26
  return tokens;
27
27
  }
28
- function estimateMessage(message) {
28
+ function estimateMessage(message, emptyIsZero = false) {
29
+ if (emptyIsZero && (message === null || message === void 0 || !Array.isArray(message.content) || message.content.length === 0)) {
30
+ return 0;
31
+ }
29
32
  return estimateBlocks(message?.content) + ROLE_OVERHEAD;
30
33
  }
31
34
  function estimateSystem(text) {
@@ -55,6 +58,23 @@ function toolCallNames(blocks) {
55
58
  function isInjection(source) {
56
59
  return source !== null && typeof source === "object" && (source.kind === "plugin" || source.kind === "skill-invocation" || typeof source.form === "string");
57
60
  }
61
+ const MAX_REQUEST_STEPS = 1500;
62
+ const MAX_KEPT_TURNS = 300;
63
+ function trimToLastTurns(requests, maxTurns) {
64
+ let runs = 0;
65
+ let start = requests.length;
66
+ let prevTurn;
67
+ for (let i = requests.length - 1; i >= 0; i--) {
68
+ const turn = requests[i].turn;
69
+ if (turn !== prevTurn) {
70
+ if (runs >= maxTurns) break;
71
+ runs++;
72
+ prevTurn = turn;
73
+ }
74
+ start = i;
75
+ }
76
+ return requests.slice(start);
77
+ }
58
78
  function createFold() {
59
79
  return {
60
80
  n: 0,
@@ -86,7 +106,14 @@ function categoryOf(type, message) {
86
106
  }
87
107
  function applySurface(st, ev, type, data, message) {
88
108
  const cat = categoryOf(type, message);
89
- const node = { seq: ev.seq, cat, tokens: estimateMessage(message) };
109
+ const node = {
110
+ seq: ev.seq,
111
+ time: ev.time,
112
+ cat,
113
+ // Empty assistant messages project to no model message (usage-only), so
114
+ // they price 0 — mirroring dsh's deriveEventMessage/estimate.
115
+ tokens: estimateMessage(message, type === "assistant/message")
116
+ };
90
117
  const source = message?.source;
91
118
  const form = source?.form;
92
119
  if (typeof form === "string") node.form = form;
@@ -151,8 +178,7 @@ function foldInto(st, events) {
151
178
  name: typeof t.name === "string" ? t.name : "?",
152
179
  tokens: estimateToolSchema(t)
153
180
  }));
154
- st.toolsTokens = st.toolList.reduce((a, t) => a + t.tokens, 0);
155
- if (tools.length > 0) st.toolsTokens += BLOCK_OVERHEAD;
181
+ st.toolsTokens = tools.length > 0 ? Math.ceil(JSON.stringify(tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD : 0;
156
182
  st.systemTokens = estimateSystem(header.system);
157
183
  if (header.config && typeof header.config.model === "string") st.model = header.config.model;
158
184
  if (header.config && typeof header.config.provider === "string") st.provider = header.config.provider;
@@ -192,9 +218,11 @@ function foldInto(st, events) {
192
218
  }
193
219
  break;
194
220
  }
195
- case "tool/result":
196
- applySurface(st, ev, ev.type, data, data);
221
+ case "tool/result": {
222
+ const toolMsg = data?.message ?? null;
223
+ applySurface(st, ev, ev.type, data, toolMsg);
197
224
  break;
225
+ }
198
226
  case "assistant/message": {
199
227
  const usage = data?.usage;
200
228
  const total = st.systemTokens + st.toolsTokens + st.sums.user + st.sums.inject + st.sums.assistant + st.sums.tool;
@@ -216,7 +244,8 @@ function foldInto(st, events) {
216
244
  if (typeof usage.outputTokens === "number") record.output = usage.outputTokens;
217
245
  }
218
246
  st.requests.push(record);
219
- applySurface(st, ev, ev.type, data, data);
247
+ const asstMsg = data?.message ?? null;
248
+ applySurface(st, ev, ev.type, data, asstMsg);
220
249
  break;
221
250
  }
222
251
  case "compaction/summary":
@@ -241,8 +270,11 @@ function foldInto(st, events) {
241
270
  }
242
271
  }
243
272
  st.n = events.length;
244
- if (st.requests.length > 160) st.requests = st.requests.slice(-160);
245
- if (st.events.length > 150) st.events = st.events.slice(-150);
273
+ if (st.requests.length > MAX_REQUEST_STEPS) {
274
+ st.requests = trimToLastTurns(st.requests, MAX_KEPT_TURNS);
275
+ if (st.requests.length > MAX_REQUEST_STEPS) st.requests = st.requests.slice(-MAX_REQUEST_STEPS);
276
+ }
277
+ if (st.events.length > 400) st.events = st.events.slice(-400);
246
278
  }
247
279
  function buildResult(st) {
248
280
  const surfaceTotal = st.sums.user + st.sums.inject + st.sums.assistant + st.sums.tool;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-context",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Context insight panel for DeepSeek Harness: a Context tab (beside Chat/Trajectory) showing what the model's context window is made of and how it evolves — composition, per-request history, compactions, injections, and model switches.",
5
5
  "author": "bowenliang123",
6
6
  "repository": {
@@ -28,7 +28,8 @@
28
28
  "build": "node scripts/build.mjs",
29
29
  "typecheck": "tsc --noEmit",
30
30
  "test": "npm run typecheck && node scripts/test-host.mjs && node scripts/test-client.mjs",
31
- "release": "bash scripts/publish.sh"
31
+ "release": "bash scripts/publish.sh",
32
+ "prepare": "husky"
32
33
  },
33
34
  "dsh": {
34
35
  "bundle": {
@@ -58,6 +59,10 @@
58
59
  "@deepseek-ai/cordis": "^4.0.1",
59
60
  "@types/react": "^18.3.31",
60
61
  "esbuild": "^0.28.2",
62
+ "husky": "^9.1.7",
63
+ "jsdom": "^30.0.1",
64
+ "react": "^18.3.1",
65
+ "react-dom": "^18.3.1",
61
66
  "typescript": "^7.0.2"
62
67
  },
63
68
  "packageManager": "pnpm@11.9.0"