dsh-codinput 0.1.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 (54) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +18 -0
  4. package/README.en.md +167 -0
  5. package/README.md +167 -0
  6. package/TESTING.md +95 -0
  7. package/assets/en/preview-ball-detail.png +0 -0
  8. package/assets/en/preview-float.png +0 -0
  9. package/assets/en/preview-normal.png +0 -0
  10. package/assets/en/preview-side.png +0 -0
  11. package/assets/en/preview-split.png +0 -0
  12. package/assets/icon.svg +1 -0
  13. package/assets/preview-ball-detail.png +0 -0
  14. package/assets/preview-float.png +0 -0
  15. package/assets/preview-normal.png +0 -0
  16. package/assets/preview-side.png +0 -0
  17. package/assets/preview-split.png +0 -0
  18. package/cordis.patch.yml +5 -0
  19. package/lib/client.js +544 -0
  20. package/lib/index.js +5 -0
  21. package/package.json +79 -0
  22. package/src/client/attachments.ts +43 -0
  23. package/src/client/cm.ts +207 -0
  24. package/src/client/command.ts +78 -0
  25. package/src/client/components/AttachmentRail.tsx +241 -0
  26. package/src/client/components/CodinputBar.tsx +357 -0
  27. package/src/client/components/CodinputSurface.tsx +375 -0
  28. package/src/client/components/EditorPane.tsx +214 -0
  29. package/src/client/components/ExpandBall.tsx +122 -0
  30. package/src/client/components/MenuSurface.tsx +72 -0
  31. package/src/client/components/ModelMenu.tsx +234 -0
  32. package/src/client/components/PermissionMenu.tsx +257 -0
  33. package/src/client/components/PopupSelectView.tsx +140 -0
  34. package/src/client/components/SlashMenu.tsx +255 -0
  35. package/src/client/components/StatsRow.tsx +188 -0
  36. package/src/client/components/Toolbar.tsx +239 -0
  37. package/src/client/components/popup-filter.ts +13 -0
  38. package/src/client/components/useSnapshot.ts +18 -0
  39. package/src/client/diag.ts +58 -0
  40. package/src/client/host-types.ts +328 -0
  41. package/src/client/hotkey.ts +82 -0
  42. package/src/client/i18n.ts +312 -0
  43. package/src/client/icons.tsx +296 -0
  44. package/src/client/index.tsx +178 -0
  45. package/src/client/modeldir.ts +42 -0
  46. package/src/client/permission.ts +123 -0
  47. package/src/client/prefs.ts +123 -0
  48. package/src/client/services.ts +78 -0
  49. package/src/client/settings.tsx +236 -0
  50. package/src/client/sidebar.tsx +114 -0
  51. package/src/client/sideinput.ts +171 -0
  52. package/src/client/stats.ts +166 -0
  53. package/src/client/styles.ts +489 -0
  54. package/src/client/triggers.ts +84 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * popupSelect 壳:官方 PopupSelectController 的 headless 状态渲染
3
+ * (命令的选项弹层,如 /model 类确认选择)。键盘与指针路由回控制器。
4
+ */
5
+
6
+ import { useEffect, useMemo, useRef } from 'react';
7
+ import { filterOptions } from './popup-filter';
8
+ import type { PopupSelectController, PopupState } from '../host-types';
9
+ import { useSnapshot } from './useSnapshot';
10
+ import { t } from '../i18n';
11
+
12
+ export interface PopupSelectViewProps {
13
+ popup: PopupSelectController;
14
+ }
15
+
16
+ export function PopupSelectView(props: PopupSelectViewProps): JSX.Element | null {
17
+ const { popup } = props;
18
+ const state = useSnapshot(popup.state) as PopupState | undefined;
19
+ const searchRef = useRef<HTMLInputElement | null>(null);
20
+ const rootRef = useRef<HTMLDivElement | null>(null);
21
+
22
+ const filtered = useMemo(
23
+ () => (state ? filterOptions(state.options, state.search) : []),
24
+ [state?.options, state?.search],
25
+ );
26
+
27
+ useEffect(() => {
28
+ if (!state?.open) return;
29
+ const onDown = (event: PointerEvent): void => {
30
+ if (rootRef.current && event.target instanceof Node && rootRef.current.contains(event.target)) return;
31
+ popup.dismiss();
32
+ };
33
+ const onKey = (event: KeyboardEvent): void => {
34
+ if (!state.open || state.submitting) return;
35
+ if (state.confirming) {
36
+ if (event.key === 'Escape') {
37
+ event.preventDefault();
38
+ popup.cancelConfirmation();
39
+ }
40
+ return;
41
+ }
42
+ if (event.key === 'ArrowDown') {
43
+ event.preventDefault();
44
+ popup.move(1);
45
+ } else if (event.key === 'ArrowUp') {
46
+ event.preventDefault();
47
+ popup.move(-1);
48
+ } else if (event.key === 'Enter') {
49
+ event.preventDefault();
50
+ void popup.select(state.active);
51
+ } else if (event.key === 'Escape') {
52
+ event.preventDefault();
53
+ popup.dismiss({ focusComposer: true });
54
+ }
55
+ };
56
+ window.addEventListener('pointerdown', onDown, true);
57
+ window.addEventListener('keydown', onKey, true);
58
+ return () => {
59
+ window.removeEventListener('pointerdown', onDown, true);
60
+ window.removeEventListener('keydown', onKey, true);
61
+ };
62
+ }, [state?.open, state?.submitting, state?.confirming, state?.active, popup]);
63
+
64
+ useEffect(() => {
65
+ if (state?.open) searchRef.current?.focus();
66
+ }, [state?.open]);
67
+
68
+ if (!state?.open) return null;
69
+
70
+ const confirming = state.confirming;
71
+
72
+ return (
73
+ <div className="dci-menu" ref={rootRef}>
74
+ {state.command ? <div className="dci-group-title">{state.command}</div> : null}
75
+ {state.status === 'failed' ? (
76
+ <div className="dci-error">{state.error ?? t('menu.loadFailed')}</div>
77
+ ) : null}
78
+ {state.status === 'failed' ? (
79
+ <button type="button" className="dci-btn" onClick={() => popup.retry()}>
80
+ 重试
81
+ </button>
82
+ ) : null}
83
+ {state.status === 'ready' && !confirming ? (
84
+ <input
85
+ ref={searchRef}
86
+ className="dci-search"
87
+ type="text"
88
+ placeholder={t("menu.filter")}
89
+ value={state.search}
90
+ onChange={(e) => popup.setSearch(e.target.value)}
91
+ />
92
+ ) : null}
93
+ {confirming ? (
94
+ <div className="dci-confirm">
95
+ <div className="dci-row-name">{String(confirming.label ?? '')}</div>
96
+ {confirming.detail ? <div className="dci-row-desc">{String(confirming.detail)}</div> : null}
97
+ <label>
98
+ <input type="checkbox" checked={state.acknowledged} onChange={(e) => popup.acknowledge(e.target.checked)} />
99
+ <span>{t("popup.ack")}</span>
100
+ </label>
101
+ <div className="dci-confirm-actions">
102
+ <button type="button" className="dci-btn" onClick={() => popup.cancelConfirmation()}>
103
+ 取消
104
+ </button>
105
+ <button
106
+ type="button"
107
+ className="dci-btn"
108
+ data-primary="true"
109
+ disabled={state.submitting || !state.acknowledged}
110
+ onClick={() => void popup.confirm()}
111
+ >
112
+ 确认
113
+ </button>
114
+ </div>
115
+ </div>
116
+ ) : (
117
+ <>
118
+ {state.status === 'pending' ? <div className="dci-empty">{t('menu.loading')}</div> : null}
119
+ {state.status === 'ready' && filtered.length === 0 ? <div className="dci-empty">{t('menu.empty')}</div> : null}
120
+ {filtered.map((option, index) => (
121
+ <button
122
+ key={`${String(option.label)}:${index}`}
123
+ type="button"
124
+ className="dci-opt"
125
+ data-active={index === state.active || undefined}
126
+ onMouseEnter={() => popup.highlight(index)}
127
+ onClick={() => void popup.select(index)}
128
+ >
129
+ <span className="dci-opt-copy">
130
+ <span className="dci-opt-name">{String(option.label ?? '')}</span>
131
+ {option.detail ? <span className="dci-opt-desc">{String(option.detail)}</span> : null}
132
+ </span>
133
+ </button>
134
+ ))}
135
+ </>
136
+ )}
137
+ {state.error && state.status === 'ready' ? <div className="dci-error">{state.error}</div> : null}
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1,255 @@
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,焦点不离开编辑器)。卡片内指针按下不关闭。
10
+ */
11
+
12
+ import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react';
13
+ import { t, tOptional } from '../i18n';
14
+ import type { InputTriggerController, MenuState } from '../host-types';
15
+ import { useSnapshot } from './useSnapshot';
16
+ import { IconChevronOfficial, IconFile, IconFolder, IconSearch } from '../icons';
17
+
18
+ export interface SlashMenuProps {
19
+ triggers: InputTriggerController;
20
+ /** 向下弹出(侧栏/悬浮形态):菜单锚在头部行下方、留在卡片边界内。 */
21
+ dropdown?: boolean;
22
+ }
23
+
24
+ /** 组标题按来源映射到 i18n;未知来源回退原名(与官方同语义)。 */
25
+ const MAX_HEIGHT = 320;
26
+
27
+ function sourceLabel(source: string): string {
28
+ return tOptional(`menu.source.${source}`) ?? source;
29
+ }
30
+
31
+ function optionId(source: string, index: number): string {
32
+ return `dsh-slash-option-${source}-${index}`;
33
+ }
34
+
35
+ function candidateIcon(icon: unknown): JSX.Element | null {
36
+ if (icon === 'file') return <IconFile size={16} />;
37
+ if (icon === 'folder') return <IconFolder size={16} />;
38
+ if (icon === 'session') return <IconSearch size={16} />;
39
+ // 官方命令图标是 React 组件(primitives Icon*Outline16 同构),
40
+ // 菜单按 <icon size={16}/> 渲染(官方 MenuView 同语义)。
41
+ if (typeof icon === 'function') {
42
+ const Comp = icon as React.ComponentType<{ size?: number }>;
43
+ return <Comp size={16} />;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /** 右向 chevron(官方 IconChevronRightOutline14 的等价绘制)。 */
49
+ function ChevronRight({ size = 14 }: { size?: number }): JSX.Element {
50
+ return (
51
+ <span style={{ display: 'inline-flex', transform: 'rotate(-90deg)' }}>
52
+ <IconChevronOfficial size={size} />
53
+ </span>
54
+ );
55
+ }
56
+
57
+ export function SlashMenu(props: SlashMenuProps): JSX.Element | null {
58
+ const { triggers, dropdown } = props;
59
+ const menu = useSnapshot(triggers.menu) as MenuState | undefined;
60
+ const headers = useSnapshot(triggers.headers);
61
+ const rootRef = useRef<HTMLDivElement | null>(null);
62
+ const [maxHeight, setMaxHeight] = useState(MAX_HEIGHT);
63
+ /** 向下弹出时的菜单顶边(相对卡片顶;null = 向上弹出)。 */
64
+ const [menuTop, setMenuTop] = useState<number | null>(null);
65
+
66
+ const open = menu?.open === true;
67
+ const highlight = open ? menu.highlight ?? null : null;
68
+
69
+ // 可用空间收缩 max-height:向上弹出(主模式)菜单底边固定在卡片顶上方
70
+ // 4px,可吃满卡片到视口顶的距离;向下弹出(侧栏/悬浮)菜单顶边固定在
71
+ // 头部行下方,吃满卡片内头部行以下的距离——卡片靠视口顶时上方没有
72
+ // 空间,向下弹才能留在视口与卡片边界内。
73
+ useLayoutEffect(() => {
74
+ if (!open) return;
75
+ const card = rootRef.current?.closest('.dci-card');
76
+ 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
+ const top = card.getBoundingClientRect().top;
95
+ setMaxHeight(Math.max(120, Math.min(MAX_HEIGHT, top - 16)));
96
+ }, [open, menu?.generation, dropdown]);
97
+
98
+ // 高亮行滚动进视野(官方 scrollIntoView block nearest)。
99
+ useEffect(() => {
100
+ if (!highlight) return;
101
+ document
102
+ .getElementById(optionId(highlight.source, highlight.index))
103
+ ?.scrollIntoView({ block: 'nearest' });
104
+ }, [highlight]);
105
+
106
+ // 外部指针按下关闭;卡片内(含工具行)不关闭(官方 dismiss 语义)。
107
+ useEffect(() => {
108
+ if (!open) return;
109
+ const onPointerDown = (event: PointerEvent): void => {
110
+ if (!(event.target instanceof Node)) return;
111
+ if (rootRef.current?.contains(event.target)) return;
112
+ if (rootRef.current?.closest('[data-composer-card]')?.contains(event.target)) return;
113
+ triggers.dismiss();
114
+ };
115
+ document.addEventListener('pointerdown', onPointerDown, true);
116
+ return () => document.removeEventListener('pointerdown', onPointerDown, true);
117
+ }, [open, triggers]);
118
+
119
+ if (!open || !menu) return null;
120
+
121
+ const crumbsFor = (source: string) => headers?.get(source);
122
+
123
+ return (
124
+ <div
125
+ ref={rootRef}
126
+ className="dci-slash-menu"
127
+ data-trigger-menu=""
128
+ data-dropdown={dropdown || undefined}
129
+ style={dropdown ? { top: menuTop ?? 38, maxHeight } : { maxHeight }}
130
+ >
131
+ {menu.groups.map((group) => {
132
+ const trail = crumbsFor(group.source);
133
+ if (trail === undefined || trail.length === 0) return null;
134
+ return (
135
+ <nav key={`${group.source}:crumbs`} className="dci-slash-crumbs" aria-label={t("menu.nav")}>
136
+ {trail.map((crumb, index) => (
137
+ <span key={`${index}-${crumb.value}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
138
+ {index > 0 ? (
139
+ <span className="dci-slash-crumbSeparator" aria-hidden="true">
140
+ <ChevronRight size={12} />
141
+ </span>
142
+ ) : null}
143
+ <button
144
+ type="button"
145
+ className="dci-slash-crumb"
146
+ data-current={crumb.current === true || undefined}
147
+ aria-current={crumb.current === true ? 'location' : undefined}
148
+ disabled={crumb.current === true}
149
+ onMouseDown={(e) => {
150
+ e.preventDefault();
151
+ triggers.pickCrumb(group.source, index);
152
+ }}
153
+ >
154
+ {crumb.label}
155
+ </button>
156
+ </span>
157
+ ))}
158
+ </nav>
159
+ );
160
+ })}
161
+ <div
162
+ className="dci-slash-viewport"
163
+ role="listbox"
164
+ aria-label={t("menu.triggerCandidates")}
165
+ aria-activedescendant={highlight ? optionId(highlight.source, highlight.index) : undefined}
166
+ >
167
+ {menu.groups.map((group) => {
168
+ if (group.status === 'ready' && group.items.length === 0) return null;
169
+ const hasSections = group.items.some((item) => item.section !== undefined);
170
+ return (
171
+ <Fragment key={group.source}>
172
+ {group.showGroupTitle === false || hasSections ? null : (
173
+ <div className="dci-slash-groupTitle" role="presentation" data-source={group.source}>
174
+ {sourceLabel(group.source)}
175
+ </div>
176
+ )}
177
+ {group.status === 'pending' && group.items.length === 0 ? (
178
+ <div role="status" aria-label={tOptional("menu.loading")} data-source={group.source}>
179
+ <div className="dci-slash-skeletonRow">
180
+ <span className="dci-slash-skeletonBar" style={{ width: '32%' }} />
181
+ </div>
182
+ <div className="dci-slash-skeletonRow">
183
+ <span className="dci-slash-skeletonBar" style={{ width: '48%' }} />
184
+ </div>
185
+ </div>
186
+ ) : (
187
+ group.items.map((item, index) => {
188
+ const active = highlight?.source === group.source && highlight.index === index;
189
+ return (
190
+ <Fragment key={optionId(group.source, index)}>
191
+ {item.section !== undefined && item.section !== group.items[index - 1]?.section ? (
192
+ <div className="dci-slash-sectionTitle" role="presentation">
193
+ {item.section}
194
+ </div>
195
+ ) : null}
196
+ <button
197
+ id={optionId(group.source, index)}
198
+ type="button"
199
+ role="option"
200
+ aria-selected={active}
201
+ className="dci-slash-item"
202
+ data-active={active || undefined}
203
+ onMouseDown={(e) => {
204
+ e.preventDefault();
205
+ triggers.pick(group.source, index);
206
+ }}
207
+ onMouseMove={active ? undefined : () => triggers.hover(group.source, index)}
208
+ >
209
+ {item.icon !== undefined ? (
210
+ <span className="dci-slash-itemIcon" aria-hidden="true">
211
+ {candidateIcon(item.icon)}
212
+ </span>
213
+ ) : null}
214
+ {/* 官方语义:主名 = label ?? name;label 与 name 不同时另出灰色别名。 */}
215
+ <span className="dci-slash-itemName">{item.label ?? item.name}</span>
216
+ {item.label !== undefined && item.label.toLowerCase() !== item.name.toLowerCase() ? (
217
+ <span className="dci-slash-itemAlias">{item.name}</span>
218
+ ) : null}
219
+ {item.description !== undefined ? (
220
+ <span className="dci-slash-itemDescription">{item.description}</span>
221
+ ) : null}
222
+ {item.drill === true ? (
223
+ <span className="dci-slash-trailing">
224
+ <span className="dci-slash-drillHintText" aria-hidden="true">
225
+ 进入目录
226
+ </span>
227
+ <kbd className="dci-slash-drillHint" aria-hidden="true">
228
+ Tab
229
+ </kbd>
230
+ <span
231
+ role="button"
232
+ aria-label={tOptional("menu.drill")}
233
+ className="dci-slash-drill"
234
+ onMouseDown={(e) => {
235
+ e.preventDefault();
236
+ e.stopPropagation();
237
+ triggers.pick(group.source, index, 'drill');
238
+ }}
239
+ >
240
+ <ChevronRight size={14} />
241
+ </span>
242
+ </span>
243
+ ) : null}
244
+ </button>
245
+ </Fragment>
246
+ );
247
+ })
248
+ )}
249
+ </Fragment>
250
+ );
251
+ })}
252
+ </div>
253
+ </div>
254
+ );
255
+ }
@@ -0,0 +1,188 @@
1
+ /** 数据行(卡片下方):官方 composer stats 复刻(bOPqQW + bRhRbq)——两枚
2
+ * 胶囊(仪表盘「轮/步 · tok/s」、数据库「累计 tok · 缓存命中」),点击弹出
3
+ * 官方同构详情面板(会话统计 / Token 用量),锚点上方弹出、点外关闭。 */
4
+
5
+ import { useEffect, useRef, useState } from 'react';
6
+ import { useSessionStats, formatTokens, type SessionStats } from '../stats';
7
+ import type { UseProjection } from '../host-types';
8
+ import { t } from '../i18n';
9
+
10
+ export interface StatsRowProps {
11
+ useProjection: UseProjection | undefined;
12
+ sessionId: string | undefined;
13
+ /** chat 树解析的轮次计时(模型用时 / TTFT)。 */
14
+ chatTimings?: { modelMs?: number; ttftMs?: number };
15
+ }
16
+
17
+ /** 官方仪表盘 glyph(弧 + 指针 + 中点,stroke 1.25)。 */
18
+ export function GaugeIcon({ size = 14 }: { size?: number }): JSX.Element {
19
+ return (
20
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
21
+ <path d="M3.49 13.26A6.375 6.375 0 1 1 12.51 13.26" stroke="currentColor" strokeWidth="1.25" />
22
+ <path d="M8 8.75L11.4 5.35" stroke="currentColor" strokeWidth="1.25" />
23
+ <circle cx="8" cy="8.75" r="1.55" fill="currentColor" />
24
+ </svg>
25
+ );
26
+ }
27
+
28
+ /** 官方数据库 glyph(椭圆柱,stroke 1.25)。 */
29
+ export function DatabaseIcon({ size = 14 }: { size?: number }): JSX.Element {
30
+ return (
31
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
32
+ <ellipse cx="8" cy="3.6" rx="5.75" ry="2.4" stroke="currentColor" strokeWidth="1.25" />
33
+ <path d="M2.25 3.6V12.3A5.75 2.4 0 0 0 13.75 12.3V3.6" stroke="currentColor" strokeWidth="1.25" />
34
+ <path d="M2.25 7.95A5.75 2.4 0 0 0 13.75 7.95" stroke="currentColor" strokeWidth="1.25" />
35
+ </svg>
36
+ );
37
+ }
38
+
39
+ /** 官方详情面板(bRhRbq:标题行 + 发丝线 + dt/dd 栅格)。 */
40
+ function StatPanel(props: {
41
+ label: string;
42
+ icon: JSX.Element;
43
+ titleValue?: string;
44
+ rows: readonly [string, string][];
45
+ }): JSX.Element {
46
+ const { label, icon, titleValue, rows } = props;
47
+ return (
48
+ <div className="dci-stat-panel" role="dialog" aria-label={label}>
49
+ <div className="dci-stat-panelTitle">
50
+ <span className="dci-stat-panelLabel">
51
+ {icon}
52
+ {label}
53
+ </span>
54
+ {titleValue !== undefined ? <span className="dci-stat-panelValue">{titleValue}</span> : null}
55
+ </div>
56
+ <div className="dci-stat-panelRule" aria-hidden="true" />
57
+ <dl className="dci-stat-details">
58
+ {rows.map(([dt, dd]) => (
59
+ <div key={dt} style={{ display: 'contents' }}>
60
+ <dt>{dt}</dt>
61
+ <dd>{dd}</dd>
62
+ </div>
63
+ ))}
64
+ </dl>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }): JSX.Element {
70
+ const { stats, open, onToggle } = props;
71
+ const roundsPart =
72
+ stats.turns !== undefined && stats.steps !== undefined
73
+ ? t('stats.turns', { turns: stats.turns, steps: stats.steps })
74
+ : stats.turns !== undefined
75
+ ? t('stats.turns', { turns: stats.turns, steps: 0 })
76
+ : t('stats.turns', { turns: 0, steps: 0 });
77
+ const speedPart = stats.tokensPerSecond !== undefined ? `${Math.round(stats.tokensPerSecond)} tok/s` : null;
78
+ const modelSec = stats.modelMs !== undefined ? stats.modelMs / 1000 : null;
79
+ const ttftSec = stats.ttftMs !== undefined ? stats.ttftMs / 1000 : null;
80
+ return (
81
+ <span className="dci-stat-anchor">
82
+ <button
83
+ type="button"
84
+ className="dci-stat-pill"
85
+ aria-haspopup="dialog"
86
+ aria-expanded={open}
87
+ onClick={onToggle}
88
+ >
89
+ <GaugeIcon />
90
+ <span className="dci-stat-label">
91
+ {roundsPart}
92
+ {speedPart !== null ? (
93
+ <>
94
+ <span className="dci-stat-sep" aria-hidden="true">·</span>
95
+ {speedPart}
96
+ </>
97
+ ) : null}
98
+ </span>
99
+ </button>
100
+ {open ? (
101
+ <StatPanel
102
+ label={t("stats.session")}
103
+ icon={<GaugeIcon />}
104
+ rows={[
105
+ [t('stats.modelTime'), modelSec !== null ? `${modelSec.toFixed(1)}s` : '—'],
106
+ [t('stats.ttft'), ttftSec !== null ? `${ttftSec.toFixed(1)}s` : '—'],
107
+ [t('stats.tpsLabel'), speedPart ?? '—'],
108
+ ]}
109
+ />
110
+ ) : null}
111
+ </span>
112
+ );
113
+ }
114
+
115
+ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void }): JSX.Element | null {
116
+ const { stats, open, onToggle } = props;
117
+ if (stats.totalTokens === undefined) return null;
118
+ const comma = (n: number): string => Math.round(n).toLocaleString('en-US');
119
+ return (
120
+ <span className="dci-stat-anchor">
121
+ <button
122
+ type="button"
123
+ className="dci-stat-pill"
124
+ aria-haspopup="dialog"
125
+ aria-expanded={open}
126
+ onClick={onToggle}
127
+ >
128
+ <DatabaseIcon />
129
+ <span className="dci-stat-label">
130
+ {formatTokens(stats.totalTokens)}
131
+ {stats.cacheHitPct !== undefined ? (
132
+ <>
133
+ <span className="dci-stat-sep" aria-hidden="true">·</span>
134
+ 缓存命中 {Math.round(stats.cacheHitPct)}%
135
+ </>
136
+ ) : null}
137
+ </span>
138
+ </button>
139
+ {open ? (
140
+ <StatPanel
141
+ label={t("stats.usage")}
142
+ icon={<DatabaseIcon />}
143
+ titleValue={`${comma(stats.totalTokens)} tok`}
144
+ rows={[
145
+ [t('stats.cacheHitLabel'), stats.cacheHitPct !== undefined ? `${Math.round(stats.cacheHitPct)}%` : '—'],
146
+ [t('stats.uncached'), `${comma(stats.uncachedInputTokens ?? 0)} tok`],
147
+ [t('stats.cacheRead'), `${comma(stats.cacheReadTokens ?? 0)} tok`],
148
+ [t('stats.output'), `${comma(stats.outputTokens ?? 0)} tok`],
149
+ ]}
150
+ />
151
+ ) : null}
152
+ </span>
153
+ );
154
+ }
155
+
156
+ export function StatsRow(props: StatsRowProps): JSX.Element | null {
157
+ const { useProjection, sessionId, chatTimings } = props;
158
+ const [open, setOpen] = useState<'rounds' | 'tokens' | null>(null);
159
+ const rootRef = useRef<HTMLDivElement | null>(null);
160
+ const sampled = useSessionStats(useProjection, sessionId);
161
+ // chat 树计时优先(历史会话也有值),采样值兜底。
162
+ const stats = sampled
163
+ ? {
164
+ ...sampled,
165
+ ...(chatTimings?.modelMs !== undefined ? { modelMs: chatTimings.modelMs } : {}),
166
+ ...(chatTimings?.ttftMs !== undefined ? { ttftMs: chatTimings.ttftMs } : {}),
167
+ }
168
+ : sampled;
169
+
170
+ // 点外面关闭(面板内/胶囊上不关)。
171
+ useEffect(() => {
172
+ if (open === null) return;
173
+ const onDown = (event: PointerEvent): void => {
174
+ if (rootRef.current && event.target instanceof Node && rootRef.current.contains(event.target)) return;
175
+ setOpen(null);
176
+ };
177
+ window.addEventListener('pointerdown', onDown, true);
178
+ return () => window.removeEventListener('pointerdown', onDown, true);
179
+ }, [open]);
180
+
181
+ if (!stats) return null;
182
+ return (
183
+ <div className="dci-stat-root" data-composer-stats="codinput" ref={rootRef}>
184
+ <Pill stats={stats} open={open === 'rounds'} onToggle={() => setOpen(open === 'rounds' ? null : 'rounds')} />
185
+ <TokenPill stats={stats} open={open === 'tokens'} onToggle={() => setOpen(open === 'tokens' ? null : 'tokens')} />
186
+ </div>
187
+ );
188
+ }