dsh-codinput 0.3.9 → 0.4.0

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/NOTICE +37 -4
  3. package/README.en.md +26 -12
  4. package/README.md +26 -12
  5. package/TESTING.md +86 -6
  6. package/assets/donate-qr.jpg +0 -0
  7. package/lib/client.js +2875 -98
  8. package/package.json +5 -2
  9. package/src/client/cm.ts +19 -1
  10. package/src/client/command.ts +39 -8
  11. package/src/client/components/CodinputSurface.tsx +80 -27
  12. package/src/client/components/EditorPane.tsx +3 -1
  13. package/src/client/components/InputSlotSeat.tsx +56 -1
  14. package/src/client/components/SlashMenu.tsx +294 -41
  15. package/src/client/components/StatsRow.tsx +40 -23
  16. package/src/client/components/Toolbar.tsx +284 -48
  17. package/src/client/components/UserMessageMarkdown.tsx +158 -115
  18. package/src/client/components/VoiceActivity.tsx +30 -69
  19. package/src/client/components/slotEntry.tsx +37 -0
  20. package/src/client/host-types.ts +11 -3
  21. package/src/client/hotkey.ts +38 -0
  22. package/src/client/i18n.ts +18 -4
  23. package/src/client/index.tsx +9 -0
  24. package/src/client/markdown/code.ts +281 -0
  25. package/src/client/markdown/extensions.ts +576 -0
  26. package/src/client/markdown/heavy-assets.d.ts +11 -0
  27. package/src/client/markdown/heavy-shared.ts +50 -0
  28. package/src/client/markdown/heavy.ts +159 -0
  29. package/src/client/markdown/index.ts +289 -0
  30. package/src/client/markdown/math.ts +134 -0
  31. package/src/client/markdown/mermaid.ts +358 -0
  32. package/src/client/markdown/sanitize.ts +103 -0
  33. package/src/client/markdown/styles.ts +171 -0
  34. package/src/client/prefs.ts +206 -23
  35. package/src/client/settings.tsx +7 -0
  36. package/src/client/stats.ts +92 -20
  37. package/src/client/styles.ts +55 -30
@@ -1,15 +1,22 @@
1
1
  /**
2
- * '/' '@' 候选菜单:官方 MenuView 复刻。主模式为卡片顶部锚定的全宽
3
- * 浮层(absolute bottom calc(100%+4px),随卡片等宽,max-height 320 随
4
- * 上方空间收缩);侧栏形态(side)改为头部行下方**向下**弹出——卡片
5
- * 铺满面板后上方没有空间,向下弹才能留在卡片边界内不被面板裁剪;
6
- * crumbs 导航行在滚动区外;组标题按来源本地化(指令/技能/子智能体,
7
- * 未知来源显示原名),item.section 生成小节标题;pending 空组出骨架屏;
8
- * 拾取/悬停/面包屑/钻取全部路由回控制器(combobox 语义:
9
- * aria-activedescendant,焦点不离开编辑器)。卡片内指针按下不关闭。
2
+ * '/' '@' 候选菜单:官方 MenuView 复刻。两种形态:
3
+ *
4
+ * - normal(主输入):卡片顶锚定的全宽浮层(absolute bottom: calc(100% + 4px),
5
+ * 随卡片等宽,max-height 400 随上方空间收缩)。0.3.9 已验收,几何不动。
6
+ * - dropdown(侧栏/悬浮):portal 到 body 的自适应 fixed 浮层。以 .dci-card 的
7
+ * 实时矩形为不可覆盖区,在卡片上/下/左/右找空位:优先能完整容纳自然高度的
8
+ * 一侧,按可用空间取最大;都放不下时把高度压到该侧空间(min 120 / max 400,
9
+ * 菜单内 .dci-slash-viewport 滚动)。浮层与卡片不重叠——不遮输入区,也不
10
+ * 挤压/重排卡片;打开期间用 rAF 复算(窗口 resize、侧栏宽度、悬浮卡拖动)。
11
+ *
12
+ * crumbs 导航行在滚动区外;组标题按来源本地化(指令/技能/子智能体,未知来源
13
+ * 显示原名),item.section 生成小节标题;pending 空组出骨架屏;拾取/悬停/面包屑/
14
+ * 钻取全部路由回控制器(combobox 语义:aria-activedescendant,焦点不离开编辑器)。
15
+ * 卡片内指针按下不关闭(portal 后按保存的 .dci-card 元素判定,不用 DOM 祖先)。
10
16
  */
11
17
 
12
- import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react';
18
+ import { Fragment, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from 'react';
19
+ import { createPortal } from 'react-dom';
13
20
  import { t, tOptional } from '../i18n';
14
21
  import type { InputTriggerController, MenuState } from '../host-types';
15
22
  import { useSnapshot } from './useSnapshot';
@@ -17,12 +24,24 @@ import { IconChevronOfficial, IconFile, IconFolder, IconSearch } from '../icons'
17
24
 
18
25
  export interface SlashMenuProps {
19
26
  triggers: InputTriggerController;
20
- /** 向下弹出(侧栏/悬浮形态):菜单锚在头部行下方、留在卡片边界内。 */
27
+ /** true = 侧栏/悬浮形态:portal 到 body 的自适应浮层(卡片四周找空位)。 */
21
28
  dropdown?: boolean;
29
+ /** 锚点卡片(.dci-card)引用:浮层定位与「卡片内按下不关闭」判定用。 */
30
+ anchorRef?: RefObject<HTMLElement | null>;
22
31
  }
23
32
 
24
33
  /** 组标题按来源映射到 i18n;未知来源回退原名(与官方同语义)。 */
25
34
  const MAX_HEIGHT = 400;
35
+ /** 放不下时压到的最小高度(菜单内滚动)。 */
36
+ const MIN_HEIGHT = 120;
37
+ /** 浮层与视口边缘的最小间距。 */
38
+ const VIEWPORT_MARGIN = 12;
39
+ /** 浮层与卡片的间距(与 normal 形态的 4px 一致)。 */
40
+ const CARD_GAP = 4;
41
+ /** 左/右侧位的宽度上限(卡片更宽时也不铺满)。 */
42
+ const SIDE_MAX_WIDTH = 360;
43
+ /** 左/右侧位可接受的最小宽度(低于此值视为该侧放不下)。 */
44
+ const SIDE_MIN_WIDTH = 180;
26
45
 
27
46
  function sourceLabel(source: string): string {
28
47
  return tOptional(`menu.source.${source}`) ?? source;
@@ -32,6 +51,152 @@ function optionId(source: string, index: number): string {
32
51
  return `dsh-slash-option-${source}-${index}`;
33
52
  }
34
53
 
54
+ function clampNumber(value: number, lo: number, hi: number): number {
55
+ return Math.min(hi, Math.max(lo, value));
56
+ }
57
+
58
+ /** dropdown 浮层的视口坐标(px,均为整数)。 */
59
+ interface DropdownPlacement {
60
+ left: number;
61
+ top: number;
62
+ width: number;
63
+ maxHeight: number;
64
+ }
65
+
66
+ type PlacementSide = 'top' | 'bottom' | 'left' | 'right';
67
+
68
+ /** 一个方向的候选位;space = 该方向可用轴长(上/下=纵向,左/右=横向)。 */
69
+ interface PlacementCandidate {
70
+ side: PlacementSide;
71
+ space: number;
72
+ /** 该方向能否完整容纳自然高度(左右位还要求宽度够)。 */
73
+ fits: boolean;
74
+ placement: DropdownPlacement;
75
+ }
76
+
77
+ /** 自然高度(内容高度,已按 MAX_HEIGHT 封顶;宽/窄两种宽度分别量)。 */
78
+ interface NaturalHeights {
79
+ wide: number;
80
+ side: number;
81
+ }
82
+
83
+ /**
84
+ * 四个方向的候选位:上/下位与卡片同宽、水平对齐卡片;左/右位宽度取
85
+ * min(卡片宽, 360) 且不超过该侧可用空间。
86
+ */
87
+ function buildPlacementCandidates(cardRect: DOMRect, natural: NaturalHeights): PlacementCandidate[] {
88
+ const vw = window.innerWidth;
89
+ const vh = window.innerHeight;
90
+ const margin = VIEWPORT_MARGIN;
91
+ const cardWidth = Math.max(0, Math.min(cardRect.width, vw - margin * 2));
92
+ const sideMaxWidth = Math.min(cardWidth, SIDE_MAX_WIDTH);
93
+ const alignedLeft = clampNumber(Math.round(cardRect.left), margin, Math.max(margin, vw - margin - cardWidth));
94
+ /** 左/右位的顶边对齐卡片顶,再夹进视口。 */
95
+ const sideTop = (height: number): number =>
96
+ clampNumber(Math.round(cardRect.top), margin, Math.max(margin, vh - margin - height));
97
+ /** 上/下位高度:够放自然高度就用自然高度,否则压到该侧空间(不低于 MIN_HEIGHT)。 */
98
+ const verticalHeight = (space: number, wanted: number): number => {
99
+ const naturalHeight = Math.min(MAX_HEIGHT, wanted);
100
+ return naturalHeight <= space ? naturalHeight : Math.round(clampNumber(space, MIN_HEIGHT, MAX_HEIGHT));
101
+ };
102
+ /** 左/右位宽度:优先 min(卡片宽, 360) 与可用空间的较小者;太窄时退到最小宽度。 */
103
+ const sideWidth = (space: number): number => {
104
+ const preferred = Math.min(sideMaxWidth, Math.max(space, 0));
105
+ return preferred >= SIDE_MIN_WIDTH
106
+ ? preferred
107
+ : Math.min(Math.max(sideMaxWidth, SIDE_MIN_WIDTH), SIDE_MIN_WIDTH);
108
+ };
109
+
110
+ const topSpace = cardRect.top - CARD_GAP - margin;
111
+ const bottomSpace = vh - cardRect.bottom - CARD_GAP - margin;
112
+ const topHeight = verticalHeight(topSpace, natural.wide);
113
+ const bottomHeight = verticalHeight(bottomSpace, natural.wide);
114
+ const sideSpace = vh - margin * 2;
115
+ const sideHeight = verticalHeight(sideSpace, natural.side);
116
+ const sideFits = Math.min(MAX_HEIGHT, natural.side) <= sideSpace;
117
+ const leftSpace = cardRect.left - CARD_GAP - margin;
118
+ const rightSpace = vw - cardRect.right - CARD_GAP - margin;
119
+ const leftWidth = Math.round(sideWidth(leftSpace));
120
+ const rightWidth = Math.round(sideWidth(rightSpace));
121
+
122
+ return [
123
+ {
124
+ side: 'top',
125
+ space: topSpace,
126
+ fits: natural.wide <= topSpace,
127
+ placement: {
128
+ left: alignedLeft,
129
+ top: Math.round(cardRect.top - CARD_GAP - topHeight),
130
+ width: Math.round(cardWidth),
131
+ maxHeight: topHeight,
132
+ },
133
+ },
134
+ {
135
+ side: 'bottom',
136
+ space: bottomSpace,
137
+ fits: natural.wide <= bottomSpace,
138
+ placement: {
139
+ left: alignedLeft,
140
+ top: Math.round(cardRect.bottom + CARD_GAP),
141
+ width: Math.round(cardWidth),
142
+ maxHeight: bottomHeight,
143
+ },
144
+ },
145
+ {
146
+ side: 'left',
147
+ space: leftSpace,
148
+ fits: leftSpace >= SIDE_MIN_WIDTH && sideFits,
149
+ placement: {
150
+ left: Math.round(cardRect.left - CARD_GAP - leftWidth),
151
+ top: sideTop(sideHeight),
152
+ width: leftWidth,
153
+ maxHeight: sideHeight,
154
+ },
155
+ },
156
+ {
157
+ side: 'right',
158
+ space: rightSpace,
159
+ fits: rightSpace >= SIDE_MIN_WIDTH && sideFits,
160
+ placement: {
161
+ left: Math.round(cardRect.right + CARD_GAP),
162
+ top: sideTop(sideHeight),
163
+ width: rightWidth,
164
+ maxHeight: sideHeight,
165
+ },
166
+ },
167
+ ];
168
+ }
169
+
170
+ /** 浮层整体夹进视口(左/上留 margin,右/下不出界;高度上限夹在 MAX_HEIGHT)。 */
171
+ function clampPlacementToViewport(placement: DropdownPlacement): DropdownPlacement {
172
+ const vw = window.innerWidth;
173
+ const vh = window.innerHeight;
174
+ const margin = VIEWPORT_MARGIN;
175
+ const width = Math.round(clampNumber(placement.width, 0, Math.max(0, vw - margin * 2)));
176
+ // 实际渲染高度 = min(自然高度, maxHeight):条目少的小菜单自然高度可能 < MIN_HEIGHT,
177
+ // 此时不能把 max-height 抬到最小高度——否则上/下位的 top 会按更大的高度夹取,
178
+ // 反而把浮层推进卡片(MIN_HEIGHT 只在“空间不够、需要压缩”时由 verticalHeight 兼底)。
179
+ const maxHeight = Math.round(clampNumber(placement.maxHeight, 1, MAX_HEIGHT));
180
+ const left = Math.round(clampNumber(placement.left, margin, Math.max(margin, vw - margin - width)));
181
+ const top = Math.round(clampNumber(placement.top, margin, Math.max(margin, vh - margin - maxHeight)));
182
+ return { left, top, width, maxHeight };
183
+ }
184
+
185
+ /**
186
+ * 四向选位:能完整容纳自然高度的方向里取可用空间最大的一侧(悬浮卡四周有空
187
+ * 就选空最大且放得下的一侧;侧栏形态天然选左侧对话区);都放不下时退最大化
188
+ * 空间的一侧并夹进视口——极小窗口允许与卡片少量重叠,优先保住编辑区。
189
+ */
190
+ function chooseDropdownPlacement(cardRect: DOMRect, natural: NaturalHeights): DropdownPlacement | null {
191
+ const candidates = buildPlacementCandidates(cardRect, natural);
192
+ if (candidates.length === 0) return null;
193
+ const fitting = candidates.filter((candidate) => candidate.fits);
194
+ const pool = fitting.length > 0 ? fitting : candidates;
195
+ let best = pool[0];
196
+ for (const candidate of pool) if (candidate.space > best.space) best = candidate;
197
+ return clampPlacementToViewport(best.placement);
198
+ }
199
+
35
200
  function candidateIcon(icon: unknown): JSX.Element | null {
36
201
  if (icon === 'file') return <IconFile size={16} />;
37
202
  if (icon === 'folder') return <IconFolder size={16} />;
@@ -55,46 +220,126 @@ function ChevronRight({ size = 14 }: { size?: number }): JSX.Element {
55
220
  }
56
221
 
57
222
  export function SlashMenu(props: SlashMenuProps): JSX.Element | null {
58
- const { triggers, dropdown } = props;
223
+ const { triggers, dropdown, anchorRef } = props;
59
224
  const menu = useSnapshot(triggers.menu) as MenuState | undefined;
60
225
  const headers = useSnapshot(triggers.headers);
61
226
  const rootRef = useRef<HTMLDivElement | null>(null);
227
+ /** normal 形态的 max-height(卡片上方空间)。 */
62
228
  const [maxHeight, setMaxHeight] = useState(MAX_HEIGHT);
63
- /** 向下弹出时的菜单顶边(相对卡片顶;null = 向上弹出)。 */
64
- const [menuTop, setMenuTop] = useState<number | null>(null);
229
+ /** dropdown 形态的浮层几何;null = 首帧未测量(隐藏渲染,layout effect 内落位)。 */
230
+ const [placement, setPlacement] = useState<DropdownPlacement | null>(null);
231
+ const placementRef = useRef<DropdownPlacement | null>(null);
232
+ /** 自然高度缓存:key = generation + 内容签名;宽度变化时重量。 */
233
+ const naturalRef = useRef<{ key: string; wideWidth: number; sideWidth: number; wide: number; side: number } | null>(null);
65
234
 
66
235
  const open = menu?.open === true;
67
236
  const highlight = open ? menu.highlight ?? null : null;
237
+ /** 内容签名:pending→ready、钻取/分组变化都按它触发重新测量。 */
238
+ const menuKey = menu
239
+ ? `${menu.generation}:${menu.groups.map((group) => `${group.source}:${group.status}:${group.items.length}`).join('|')}`
240
+ : '';
241
+
242
+ /** 定位锚点:优先外部传入的 .dci-card 引用(portal 后 DOM 祖先不可用)。 */
243
+ const anchorCard = (): HTMLElement | null => {
244
+ const anchored = anchorRef?.current ?? null;
245
+ if (anchored) return anchored;
246
+ if (!dropdown) return rootRef.current?.closest<HTMLElement>('.dci-card') ?? null;
247
+ return document.querySelector<HTMLElement>('.dci-card');
248
+ };
68
249
 
69
- // 可用空间收缩 max-height:向上弹出(主模式)菜单底边固定在卡片顶上方
70
- // 4px,可吃满卡片到视口顶的距离;向下弹出(侧栏/悬浮)菜单顶边固定在
71
- // 头部行下方,吃满卡片内头部行以下的距离——卡片靠视口顶时上方没有
72
- // 空间,向下弹才能留在视口与卡片边界内。
250
+ // normal 形态:卡片顶上方浮层。0.3.9 已验收的取值原样保留
251
+ // (max-height = min(400, 卡片顶到视口顶 - 16))。
73
252
  useLayoutEffect(() => {
74
- if (!open) return;
75
- const card = rootRef.current?.closest('.dci-card');
253
+ if (!open || dropdown) return;
254
+ const card = anchorCard();
76
255
  if (!card) return;
77
- if (dropdown) {
78
- const header = card.querySelector<HTMLElement>('.dci-accessory');
79
- const cardRect = card.getBoundingClientRect();
80
- const headerBottom = header
81
- ? header.getBoundingClientRect().bottom - cardRect.top
82
- : 36;
83
- setMenuTop(Math.round(headerBottom) + 2);
84
- // 侧栏吃满面板内剩余空间;悬浮卡可较小,吃满视口内剩余空间(可
85
- // 伸出卡片、不出视口)。
86
- const inFloat = !!rootRef.current?.closest('.dci-float');
87
- const budget = inFloat
88
- ? window.innerHeight - (cardRect.top + headerBottom) - 16
89
- : cardRect.height - headerBottom - 10;
90
- setMaxHeight(Math.max(120, Math.min(MAX_HEIGHT, budget)));
91
- return;
92
- }
93
- setMenuTop(null);
94
256
  const top = card.getBoundingClientRect().top;
95
- setMaxHeight(Math.max(120, Math.min(MAX_HEIGHT, top - 16)));
257
+ setMaxHeight(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, top - 16)));
96
258
  }, [open, menu?.generation, dropdown]);
97
259
 
260
+ // dropdown 形态:量自然高度 → 四向选位 → 内联几何;打开期间逐帧复算
261
+ // (窗口 resize、侧栏宽度变化、悬浮卡拖动都会改变卡片/视口矩形)。
262
+ useLayoutEffect(() => {
263
+ if (!dropdown) return;
264
+ if (!open) {
265
+ naturalRef.current = null;
266
+ placementRef.current = null;
267
+ setPlacement(null);
268
+ return;
269
+ }
270
+ const key = menuKey;
271
+ let frame = 0;
272
+ const apply = (next: DropdownPlacement | null): void => {
273
+ const prev = placementRef.current;
274
+ if (next === null) {
275
+ if (prev !== null) {
276
+ placementRef.current = null;
277
+ setPlacement(null);
278
+ }
279
+ return;
280
+ }
281
+ if (
282
+ prev !== null &&
283
+ prev.left === next.left &&
284
+ prev.top === next.top &&
285
+ prev.width === next.width &&
286
+ prev.maxHeight === next.maxHeight
287
+ ) {
288
+ return;
289
+ }
290
+ placementRef.current = next;
291
+ setPlacement(next);
292
+ };
293
+ /** 临时放开 max-height 量自然高度;量完还原 React 托管的样式(不触发渲染)。 */
294
+ const measureHeight = (el: HTMLElement, width: number): number => {
295
+ const prevMaxHeight = el.style.maxHeight;
296
+ const prevWidth = el.style.width;
297
+ try {
298
+ el.style.maxHeight = `${MAX_HEIGHT}px`;
299
+ el.style.width = `${Math.max(1, Math.round(width))}px`;
300
+ return Math.min(MAX_HEIGHT, Math.max(0, Math.round(el.getBoundingClientRect().height)));
301
+ } finally {
302
+ el.style.maxHeight = prevMaxHeight;
303
+ el.style.width = prevWidth;
304
+ }
305
+ };
306
+ const recalc = (): void => {
307
+ const el = rootRef.current;
308
+ const card = anchorCard();
309
+ if (!el || !card) return;
310
+ const cardRect = card.getBoundingClientRect();
311
+ if (cardRect.width < 1 || cardRect.height < 1) {
312
+ apply(null);
313
+ return;
314
+ }
315
+ const wideWidth = Math.max(1, Math.min(cardRect.width, window.innerWidth - VIEWPORT_MARGIN * 2));
316
+ const sideWidth = Math.max(1, Math.min(wideWidth, SIDE_MAX_WIDTH));
317
+ const cached = naturalRef.current;
318
+ let natural: NaturalHeights;
319
+ if (
320
+ cached &&
321
+ cached.key === key &&
322
+ Math.abs(cached.wideWidth - wideWidth) < 1 &&
323
+ Math.abs(cached.sideWidth - sideWidth) < 1
324
+ ) {
325
+ natural = { wide: cached.wide, side: cached.side };
326
+ } else {
327
+ const wide = measureHeight(el, wideWidth);
328
+ const side = Math.abs(sideWidth - wideWidth) < 1 ? wide : measureHeight(el, sideWidth);
329
+ natural = { wide, side };
330
+ naturalRef.current = { key, wideWidth, sideWidth, wide, side };
331
+ }
332
+ apply(chooseDropdownPlacement(cardRect, natural));
333
+ };
334
+ recalc();
335
+ const tick = (): void => {
336
+ recalc();
337
+ frame = window.requestAnimationFrame(tick);
338
+ };
339
+ frame = window.requestAnimationFrame(tick);
340
+ return () => window.cancelAnimationFrame(frame);
341
+ }, [open, dropdown, menuKey]);
342
+
98
343
  // 高亮行滚动进视野(官方 scrollIntoView block nearest)。
99
344
  useEffect(() => {
100
345
  if (!highlight) return;
@@ -103,13 +348,14 @@ export function SlashMenu(props: SlashMenuProps): JSX.Element | null {
103
348
  ?.scrollIntoView({ block: 'nearest' });
104
349
  }, [highlight]);
105
350
 
106
- // 外部指针按下关闭;卡片内(含工具行)不关闭(官方 dismiss 语义)。
351
+ // 外部指针按下关闭;卡片内(含工具行/拖动区)不关闭(官方 dismiss 语义)。
352
+ // portal 后菜单已不在卡片 DOM 子树里,祖先查询失效——按保存的 .dci-card 元素判定。
107
353
  useEffect(() => {
108
354
  if (!open) return;
109
355
  const onPointerDown = (event: PointerEvent): void => {
110
356
  if (!(event.target instanceof Node)) return;
111
357
  if (rootRef.current?.contains(event.target)) return;
112
- if (rootRef.current?.closest('[data-composer-card]')?.contains(event.target)) return;
358
+ if (anchorCard()?.contains(event.target)) return;
113
359
  triggers.dismiss();
114
360
  };
115
361
  document.addEventListener('pointerdown', onPointerDown, true);
@@ -119,14 +365,19 @@ export function SlashMenu(props: SlashMenuProps): JSX.Element | null {
119
365
  if (!open || !menu) return null;
120
366
 
121
367
  const crumbsFor = (source: string) => headers?.get(source);
368
+ const menuStyle: CSSProperties = dropdown
369
+ ? placement
370
+ ? { left: placement.left, top: placement.top, width: placement.width, maxHeight: placement.maxHeight }
371
+ : { left: 0, top: 0, maxHeight: MAX_HEIGHT, visibility: 'hidden' }
372
+ : { maxHeight };
122
373
 
123
- return (
374
+ const menuNode = (
124
375
  <div
125
376
  ref={rootRef}
126
377
  className="dci-slash-menu"
127
378
  data-trigger-menu=""
128
379
  data-dropdown={dropdown || undefined}
129
- style={dropdown ? { top: menuTop ?? 38, maxHeight } : { maxHeight }}
380
+ style={menuStyle}
130
381
  >
131
382
  {menu.groups.map((group) => {
132
383
  const trail = crumbsFor(group.source);
@@ -252,4 +503,6 @@ export function SlashMenu(props: SlashMenuProps): JSX.Element | null {
252
503
  </div>
253
504
  </div>
254
505
  );
506
+
507
+ return dropdown ? createPortal(menuNode, document.body) : menuNode;
255
508
  }
@@ -171,9 +171,11 @@ function StatPanel(props: {
171
171
  );
172
172
  }
173
173
 
174
- /** 官方 exactCount:千分位分组 + " tok"。 */
174
+ /** 官方 formatExactTokens:千分位分组 + " tok";NaN/Infinity/负值不渲染("—")。 */
175
175
  function exactCount(value: number | undefined): string {
176
- const digits = String(value ?? 0);
176
+ if (value === undefined) return '0 tok';
177
+ if (!Number.isFinite(value) || value < 0) return '—';
178
+ const digits = String(Math.trunc(value));
177
179
  const groups: string[] = [];
178
180
  for (let end = digits.length; end > 0; end -= 3) groups.unshift(digits.slice(Math.max(0, end - 3), end));
179
181
  return `${groups.join(',')} tok`;
@@ -230,15 +232,13 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void; onC
230
232
  panelRef={panelRef}
231
233
  position={position}
232
234
  rows={[
233
- [t('stats.modelTime'), (stats.llmMs ?? 0) > 0 ? formatDuration(stats.llmMs!) : '—'],
234
- [t('stats.toolTime'), (stats.toolMs ?? 0) > 0 ? formatDuration(stats.toolMs!) : '—'],
235
- [t('stats.ttft'), (stats.ttftSteps ?? 0) > 0 ? formatDuration(stats.ttftMs! / stats.ttftSteps!) : '—'],
235
+ [t('stats.modelTime'), (stats.llmMs ?? 0) > 0 ? (formatDuration(stats.llmMs!) ?? '—') : '—'],
236
+ [t('stats.toolTime'), (stats.toolMs ?? 0) > 0 ? (formatDuration(stats.toolMs!) ?? '—') : '—'],
236
237
  [
237
- t('stats.tpsLabel'),
238
- (stats.decodeMs ?? 0) > 0
239
- ? t('stats.tps', { tps: formatTokensPerSecond((stats.decodeTokens ?? 0) / ((stats.decodeMs ?? 1) / 1000)) })
240
- : '—',
238
+ t('stats.ttft'),
239
+ (stats.ttftSteps ?? 0) > 0 ? (formatDuration(stats.ttftMs! / stats.ttftSteps!) ?? '—') : '—',
241
240
  ],
241
+ [t('stats.tpsLabel'), tps !== null ? t('stats.tps', { tps }) : '—'],
242
242
  ]}
243
243
  />
244
244
  ) : null}
@@ -252,6 +252,8 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
252
252
  const { rootRef, panelRef, position } = useStatDialogSeat(open, onClose);
253
253
  if (stats.totalTokens === undefined) return null;
254
254
  const total = stats.totalTokens;
255
+ const totalText = formatTokens(total);
256
+ if (totalText === null) return null;
255
257
  const cacheHitText =
256
258
  stats.cacheHitText ??
257
259
  formatCacheHitPercent(
@@ -267,14 +269,14 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
267
269
  aria-expanded={open}
268
270
  aria-label={
269
271
  cacheHitText !== null
270
- ? `${formatTokens(total)} · ${t('stats.cacheHit', { percent: cacheHitText })}`
271
- : formatTokens(total)
272
+ ? `${totalText} · ${t('stats.cacheHit', { percent: cacheHitText })}`
273
+ : totalText
272
274
  }
273
275
  onClick={onToggle}
274
276
  >
275
277
  <DatabaseIcon />
276
278
  <span className="dci-stat-label">
277
- {formatTokens(total)}
279
+ {totalText}
278
280
  {cacheHitText !== null ? (
279
281
  <>
280
282
  <span className="dci-stat-sep" aria-hidden="true">·</span>
@@ -320,7 +322,11 @@ export function StatsRow(props: StatsRowProps): JSX.Element | null {
320
322
  const pressure = useProjection
321
323
  ? (useProjection('contextPressure') as PressureSnapshot | undefined)
322
324
  : undefined;
323
- const hasGauge = pressure !== undefined && !!pressure.contextWindow;
325
+ const hasGauge =
326
+ pressure !== undefined &&
327
+ typeof pressure.contextWindow === 'number' &&
328
+ Number.isFinite(pressure.contextWindow) &&
329
+ pressure.contextWindow > 0;
324
330
  // 面板打开期间其数据源消失(切换会话 / 投影短暂缺席)时自动收起,避免恢复后「自己重开」。
325
331
  useEffect(() => {
326
332
  if (open === null) return;
@@ -361,7 +367,9 @@ interface BreakdownSnapshot {
361
367
  toolsTokens?: number;
362
368
  }
363
369
 
370
+ /** 上下文分段 token 紧凑文本;NaN/Infinity/负值不渲染("—")。 */
364
371
  function shortTokens(n: number): string {
372
+ if (!Number.isFinite(n) || n < 0) return '—';
365
373
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
366
374
  if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
367
375
  return `${n}`;
@@ -386,16 +394,25 @@ export function ContextGauge(props: {
386
394
  ? (useProjection('contextBreakdown') as BreakdownSnapshot | undefined)
387
395
  : undefined;
388
396
 
389
- if (!pressure || !pressure.contextWindow) return null;
390
- const windowTokens = pressure.contextWindow;
397
+ const windowTokens = pressure?.contextWindow;
398
+ if (windowTokens === undefined || !Number.isFinite(windowTokens) || windowTokens <= 0) return null;
391
399
  // 官方 contextOccupancy 语义:优先 projectedTokens(含投影增长),缺失退 pressureTokens。
392
- const usedTokens = pressure.projectedTokens ?? pressure.pressureTokens ?? 0;
393
- const pct = Math.min(100, Math.round((usedTokens / windowTokens) * 100));
400
+ // 任一脏值(NaN/Infinity/负数)都不进入几何计算。
401
+ const projected = pressure?.projectedTokens;
402
+ const pressureTokens = pressure?.pressureTokens;
403
+ const usedRaw = projected !== undefined ? projected : pressureTokens !== undefined ? pressureTokens : 0;
404
+ const usedTokens = Number.isFinite(usedRaw) && usedRaw >= 0 ? usedRaw : 0;
405
+ const pct = Math.min(100, Math.max(0, Math.round((usedTokens / windowTokens) * 100)));
394
406
  const dash = (pct / 100) * GAUGE_CIRCUMFERENCE;
407
+ const breakdownTokens = (value: number | undefined): number =>
408
+ value !== undefined && Number.isFinite(value) && value > 0 ? value : 0;
409
+ const systemTokens = breakdownTokens(breakdown?.systemTokens);
410
+ const toolsTokens = breakdownTokens(breakdown?.toolsTokens);
411
+ const messageTokens = breakdownTokens(breakdown?.messageTokens);
395
412
  const segments: readonly [string, number][] = [
396
- ['dci-gauge-colorSystem', breakdown?.systemTokens ?? 0],
397
- ['dci-gauge-colorTools', breakdown?.toolsTokens ?? 0],
398
- ['dci-gauge-colorMessages', breakdown?.messageTokens ?? 0],
413
+ ['dci-gauge-colorSystem', systemTokens],
414
+ ['dci-gauge-colorTools', toolsTokens],
415
+ ['dci-gauge-colorMessages', messageTokens],
399
416
  ];
400
417
  return (
401
418
  <span className="dci-gauge-root" ref={rootRef}>
@@ -450,21 +467,21 @@ export function ContextGauge(props: {
450
467
  <span className={`dci-gauge-swatch dci-gauge-colorSystem`} aria-hidden="true" />
451
468
  {t("stats.prompt")}
452
469
  </dt>
453
- <dd>~{shortTokens(breakdown?.systemTokens ?? 0)}</dd>
470
+ <dd>~{shortTokens(systemTokens)}</dd>
454
471
  </div>
455
472
  <div className="dci-gauge-row">
456
473
  <dt>
457
474
  <span className={`dci-gauge-swatch dci-gauge-colorTools`} aria-hidden="true" />
458
475
  {t("stats.tools")}
459
476
  </dt>
460
- <dd>~{shortTokens(breakdown?.toolsTokens ?? 0)}</dd>
477
+ <dd>~{shortTokens(toolsTokens)}</dd>
461
478
  </div>
462
479
  <div className="dci-gauge-row">
463
480
  <dt>
464
481
  <span className={`dci-gauge-swatch dci-gauge-colorMessages`} aria-hidden="true" />
465
482
  {t("stats.messages")}
466
483
  </dt>
467
- <dd>~{shortTokens(breakdown?.messageTokens ?? 0)}</dd>
484
+ <dd>~{shortTokens(messageTokens)}</dd>
468
485
  </div>
469
486
  </dl>
470
487
  </div>,