dsh-codinput 0.1.0 → 0.2.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.
@@ -1,17 +1,18 @@
1
- /** 数据行(卡片下方):官方 composer stats 复刻(bOPqQW + bRhRbq)——两枚
2
- * 胶囊(仪表盘「轮/步 · tok/s」、数据库「累计 tok · 缓存命中」),点击弹出
3
- * 官方同构详情面板(会话统计 / Token 用量),锚点上方弹出、点外关闭。 */
1
+ /** 数据行(卡片下方):官方 composer dock 复刻——两枚胶囊(仪表盘「轮/步 · tok/s」、
2
+ * 数据库「累计 tok · 缓存命中」)+ 上下文小圈(0.1.7-rc.2 起官方把它挪进本行,圈+百分比
3
+ * 药丸),点击弹出官方同构详情面板(会话统计 / Token 用量 / 上下文分段),锚点上方弹出、
4
+ * 点外关闭。 */
4
5
 
5
6
  import { useEffect, useRef, useState } from 'react';
6
- import { useSessionStats, formatTokens, type SessionStats } from '../stats';
7
+ import { useSessionStats, formatTokens, formatTokensPerSecond, formatCacheHitPercent, formatDuration, type SessionStats } from '../stats';
7
8
  import type { UseProjection } from '../host-types';
8
9
  import { t } from '../i18n';
9
10
 
10
11
  export interface StatsRowProps {
11
12
  useProjection: UseProjection | undefined;
12
13
  sessionId: string | undefined;
13
- /** chat 树解析的轮次计时(模型用时 / TTFT)。 */
14
- chatTimings?: { modelMs?: number; ttftMs?: number };
14
+ /** chat legacy 节点数组(sessionStats 投影缺席时按官方 deriveStats 派生)。 */
15
+ chatNodes?: unknown;
15
16
  }
16
17
 
17
18
  /** 官方仪表盘 glyph(弧 + 指针 + 中点,stroke 1.25)。 */
@@ -66,17 +67,44 @@ function StatPanel(props: {
66
67
  );
67
68
  }
68
69
 
70
+ /** 官方 exactCount:千分位分组 + " tok"。 */
71
+ function exactCount(value: number | undefined): string {
72
+ const digits = String(value ?? 0);
73
+ const groups: string[] = [];
74
+ for (let end = digits.length; end > 0; end -= 3) groups.unshift(digits.slice(Math.max(0, end - 3), end));
75
+ return `${groups.join(',')} tok`;
76
+ }
77
+
78
+ /** 官方 TimePill:轮/步 + · tok/s(decodeMs>0 才出),面板 = 模型/工具用时、TTFT 均值、TPS。 */
69
79
  function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }): JSX.Element {
70
80
  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;
81
+ const roundsPart = t('stats.turns', { turns: stats.turns ?? 0, steps: stats.steps ?? 0 });
82
+ const tps =
83
+ (stats.decodeMs ?? 0) > 0 ? formatTokensPerSecond((stats.decodeTokens ?? 0) / ((stats.decodeMs ?? 1) / 1000)) : null;
84
+ const hasTimings =
85
+ (stats.llmMs ?? 0) > 0 || (stats.toolMs ?? 0) > 0 || (stats.ttftSteps ?? 0) > 0 || (stats.decodeMs ?? 0) > 0;
86
+ const label = (
87
+ <span className="dci-stat-label">
88
+ {roundsPart}
89
+ {tps !== null ? (
90
+ <>
91
+ <span className="dci-stat-sep" aria-hidden="true">·</span>
92
+ {t('stats.tps', { tps })}
93
+ </>
94
+ ) : null}
95
+ </span>
96
+ );
97
+ // 官方:无任何计时数据时胶囊不可点(无面板可看)。
98
+ if (!hasTimings) {
99
+ return (
100
+ <span className="dci-stat-anchor">
101
+ <span className="dci-stat-pill">
102
+ <GaugeIcon />
103
+ {label}
104
+ </span>
105
+ </span>
106
+ );
107
+ }
80
108
  return (
81
109
  <span className="dci-stat-anchor">
82
110
  <button
@@ -84,27 +112,26 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }):
84
112
  className="dci-stat-pill"
85
113
  aria-haspopup="dialog"
86
114
  aria-expanded={open}
115
+ aria-label={tps !== null ? `${roundsPart} · ${t('stats.tps', { tps })}` : roundsPart}
87
116
  onClick={onToggle}
88
117
  >
89
118
  <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>
119
+ {label}
99
120
  </button>
100
121
  {open ? (
101
122
  <StatPanel
102
123
  label={t("stats.session")}
103
124
  icon={<GaugeIcon />}
104
125
  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 ?? '—'],
126
+ [t('stats.modelTime'), (stats.llmMs ?? 0) > 0 ? formatDuration(stats.llmMs!) : '—'],
127
+ [t('stats.toolTime'), (stats.toolMs ?? 0) > 0 ? formatDuration(stats.toolMs!) : '—'],
128
+ [t('stats.ttft'), (stats.ttftSteps ?? 0) > 0 ? formatDuration(stats.ttftMs! / stats.ttftSteps!) : '—'],
129
+ [
130
+ t('stats.tpsLabel'),
131
+ (stats.decodeMs ?? 0) > 0
132
+ ? t('stats.tps', { tps: formatTokensPerSecond((stats.decodeTokens ?? 0) / ((stats.decodeMs ?? 1) / 1000)) })
133
+ : '—',
134
+ ],
108
135
  ]}
109
136
  />
110
137
  ) : null}
@@ -112,10 +139,17 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }):
112
139
  );
113
140
  }
114
141
 
142
+ /** 官方 UsagePill:总量 · 缓存命中(缓存命中用官方百分比精度规则)。 */
115
143
  function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void }): JSX.Element | null {
116
144
  const { stats, open, onToggle } = props;
117
145
  if (stats.totalTokens === undefined) return null;
118
- const comma = (n: number): string => Math.round(n).toLocaleString('en-US');
146
+ const total = stats.totalTokens;
147
+ const cacheHitText =
148
+ stats.cacheHitText ??
149
+ formatCacheHitPercent(
150
+ stats.cacheReadTokens ?? 0,
151
+ (stats.cacheReadTokens ?? 0) + (stats.cacheWriteTokens ?? 0) + (stats.uncachedInputTokens ?? 0),
152
+ );
119
153
  return (
120
154
  <span className="dci-stat-anchor">
121
155
  <button
@@ -123,15 +157,20 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
123
157
  className="dci-stat-pill"
124
158
  aria-haspopup="dialog"
125
159
  aria-expanded={open}
160
+ aria-label={
161
+ cacheHitText !== null
162
+ ? `${formatTokens(total)} · ${t('stats.cacheHit', { percent: cacheHitText })}`
163
+ : formatTokens(total)
164
+ }
126
165
  onClick={onToggle}
127
166
  >
128
167
  <DatabaseIcon />
129
168
  <span className="dci-stat-label">
130
- {formatTokens(stats.totalTokens)}
131
- {stats.cacheHitPct !== undefined ? (
169
+ {formatTokens(total)}
170
+ {cacheHitText !== null ? (
132
171
  <>
133
172
  <span className="dci-stat-sep" aria-hidden="true">·</span>
134
- 缓存命中 {Math.round(stats.cacheHitPct)}%
173
+ {t('stats.cacheHit', { percent: cacheHitText })}
135
174
  </>
136
175
  ) : null}
137
176
  </span>
@@ -140,12 +179,15 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
140
179
  <StatPanel
141
180
  label={t("stats.usage")}
142
181
  icon={<DatabaseIcon />}
143
- titleValue={`${comma(stats.totalTokens)} tok`}
182
+ titleValue={exactCount(total)}
144
183
  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`],
184
+ [t('stats.cacheHitLabel'), cacheHitText !== null ? `${cacheHitText}%` : '—'],
185
+ [t('stats.uncached'), exactCount(stats.uncachedInputTokens)],
186
+ [t('stats.cacheRead'), exactCount(stats.cacheReadTokens)],
187
+ ...(stats.cacheWriteTokens
188
+ ? [[t('stats.cacheWrite'), exactCount(stats.cacheWriteTokens)] as [string, string]]
189
+ : []),
190
+ [t('stats.output'), exactCount(stats.outputTokens)],
149
191
  ]}
150
192
  />
151
193
  ) : null}
@@ -154,18 +196,10 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
154
196
  }
155
197
 
156
198
  export function StatsRow(props: StatsRowProps): JSX.Element | null {
157
- const { useProjection, sessionId, chatTimings } = props;
199
+ const { useProjection, sessionId, chatNodes } = props;
158
200
  const [open, setOpen] = useState<'rounds' | 'tokens' | null>(null);
159
201
  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;
202
+ const stats = useSessionStats(useProjection, sessionId, chatNodes);
169
203
 
170
204
  // 点外面关闭(面板内/胶囊上不关)。
171
205
  useEffect(() => {
@@ -178,11 +212,144 @@ export function StatsRow(props: StatsRowProps): JSX.Element | null {
178
212
  return () => window.removeEventListener('pointerdown', onDown, true);
179
213
  }, [open]);
180
214
 
181
- if (!stats) return null;
215
+ // 官方 rc.2:上下文小圈独立于 stats 胶囊渲染(无 stats 也要出圈)。
216
+ const pressure = useProjection
217
+ ? (useProjection('contextPressure') as PressureSnapshot | undefined)
218
+ : undefined;
219
+ const hasGauge = pressure !== undefined && !!pressure.contextWindow;
220
+ if (!stats && !hasGauge) return null;
182
221
  return (
183
222
  <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')} />
223
+ {stats ? (
224
+ <>
225
+ <Pill stats={stats} open={open === 'rounds'} onToggle={() => setOpen(open === 'rounds' ? null : 'rounds')} />
226
+ <TokenPill stats={stats} open={open === 'tokens'} onToggle={() => setOpen(open === 'tokens' ? null : 'tokens')} />
227
+ </>
228
+ ) : null}
229
+ <ContextGauge useProjection={useProjection} />
186
230
  </div>
187
231
  );
188
232
  }
233
+
234
+ const GAUGE_CIRCUMFERENCE = 2 * Math.PI * 5.5;
235
+
236
+ interface PressureSnapshot {
237
+ contextWindow?: number;
238
+ pressureTokens?: number;
239
+ projectedTokens?: number;
240
+ }
241
+
242
+ interface BreakdownSnapshot {
243
+ messageTokens?: number;
244
+ systemTokens?: number;
245
+ toolsTokens?: number;
246
+ }
247
+
248
+ function shortTokens(n: number): string {
249
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
250
+ if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
251
+ return `${n}`;
252
+ }
253
+
254
+ /** 上下文占用小圈(官方 JObwrW 复刻):双环仪表 + 百分比,点击展开分段面板。
255
+ * 0.1.7-rc.2 起官方渲染在 dock 行 stats 胶囊之后(原先在工具行 trailing)。 */
256
+ export function ContextGauge(props: { useProjection: UseProjection | undefined }): JSX.Element | null {
257
+ const { useProjection } = props;
258
+ const [open, setOpen] = useState(false);
259
+ const rootRef = useRef<HTMLSpanElement | null>(null);
260
+ const pressure = useProjection
261
+ ? (useProjection('contextPressure') as PressureSnapshot | undefined)
262
+ : undefined;
263
+ const breakdown = useProjection
264
+ ? (useProjection('contextBreakdown') as BreakdownSnapshot | undefined)
265
+ : undefined;
266
+ // 点外面关闭。
267
+ useEffect(() => {
268
+ if (!open) return;
269
+ const onDown = (event: PointerEvent): void => {
270
+ if (rootRef.current && event.target instanceof Node && rootRef.current.contains(event.target)) return;
271
+ setOpen(false);
272
+ };
273
+ window.addEventListener('pointerdown', onDown, true);
274
+ return () => window.removeEventListener('pointerdown', onDown, true);
275
+ }, [open]);
276
+
277
+ if (!pressure || !pressure.contextWindow) return null;
278
+ const windowTokens = pressure.contextWindow;
279
+ // 官方 contextOccupancy 语义:优先 projectedTokens(含投影增长),缺失退 pressureTokens。
280
+ const usedTokens = pressure.projectedTokens ?? pressure.pressureTokens ?? 0;
281
+ const pct = Math.min(100, Math.round((usedTokens / windowTokens) * 100));
282
+ const dash = (pct / 100) * GAUGE_CIRCUMFERENCE;
283
+ const segments: readonly [string, number][] = [
284
+ ['dci-gauge-colorSystem', breakdown?.systemTokens ?? 0],
285
+ ['dci-gauge-colorTools', breakdown?.toolsTokens ?? 0],
286
+ ['dci-gauge-colorMessages', breakdown?.messageTokens ?? 0],
287
+ ];
288
+ return (
289
+ <span className="dci-gauge-root" ref={rootRef}>
290
+ <button
291
+ type="button"
292
+ className="dci-gauge-trigger"
293
+ aria-label={t("tool.context", { percent: Math.round(pct) })}
294
+ aria-haspopup="dialog"
295
+ aria-expanded={open}
296
+ title={t("tool.context", { percent: Math.round(pct) })}
297
+ onClick={() => setOpen(!open)}
298
+ >
299
+ <svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true">
300
+ <circle className="dci-gauge-track" cx="7" cy="7" r="5.5" />
301
+ <circle
302
+ className="dci-gauge-fill"
303
+ cx="7" cy="7" r="5.5"
304
+ strokeDasharray={`${dash} ${GAUGE_CIRCUMFERENCE}`}
305
+ transform="rotate(-90 7 7)"
306
+ />
307
+ </svg>
308
+ <span>{Math.round(pct)}%</span>
309
+ </button>
310
+ {open ? (
311
+ <div className="dci-gauge-panel" role="dialog" aria-label={t("tool.contextTitle")}>
312
+ <div className="dci-gauge-header">
313
+ <span className="dci-gauge-headline">{t("tool.contextTitle")}</span>
314
+ <span className="dci-gauge-percent">{Math.round(pct)}%</span>
315
+ <span className="dci-gauge-figures">
316
+ ~{shortTokens(usedTokens)} / {shortTokens(windowTokens)}
317
+ </span>
318
+ </div>
319
+ <div className="dci-gauge-bar">
320
+ {segments.map(([cls, tokens]) => (
321
+ <div
322
+ key={cls}
323
+ className={`dci-gauge-segment ${cls}`}
324
+ style={{ width: `${(tokens / windowTokens) * 100}%` }}
325
+ />
326
+ ))}
327
+ </div>
328
+ <dl className="dci-gauge-rows">
329
+ <div className="dci-gauge-row">
330
+ <dt>
331
+ <span className={`dci-gauge-swatch dci-gauge-colorSystem`} aria-hidden="true" />
332
+ {t("stats.prompt")}
333
+ </dt>
334
+ <dd>~{shortTokens(breakdown?.systemTokens ?? 0)}</dd>
335
+ </div>
336
+ <div className="dci-gauge-row">
337
+ <dt>
338
+ <span className={`dci-gauge-swatch dci-gauge-colorTools`} aria-hidden="true" />
339
+ {t("stats.tools")}
340
+ </dt>
341
+ <dd>~{shortTokens(breakdown?.toolsTokens ?? 0)}</dd>
342
+ </div>
343
+ <div className="dci-gauge-row">
344
+ <dt>
345
+ <span className={`dci-gauge-swatch dci-gauge-colorMessages`} aria-hidden="true" />
346
+ {t("stats.messages")}
347
+ </dt>
348
+ <dd>~{shortTokens(breakdown?.messageTokens ?? 0)}</dd>
349
+ </div>
350
+ </dl>
351
+ </div>
352
+ ) : null}
353
+ </span>
354
+ );
355
+ }
@@ -1,20 +1,18 @@
1
1
  /**
2
2
  * 工具行:结构与样式逐条对齐官方 InputBar 工具行——
3
- * 左侧 .dci-tools:指令(plus)、添加附件(paperclip)、权限(shield 触发器);
3
+ * 左侧 .dci-tools:指令(plus)、权限(shield 触发器);
4
4
  * 右侧 .dci-trailing:模型与思考强度(触发器)、发送(.primary 圆钮)。
5
5
  * 按钮沿用官方 keepFocus 语义(mousedown 不夺走编辑器焦点)。
6
+ * 附件无独立按钮(官方已并入 slash「添加 · 文件」),粘贴/拖拽路径保留。
7
+ * 0.1.7-rc.2 起官方上下文小圈不再在工具行(挪进卡片下方 dock 行,见 StatsRow)。
6
8
  */
7
9
 
8
- import { useEffect, useRef, useState } from 'react';
9
10
  import type { DraftAttachmentId, InputActions, InputState, InputTriggerController, UseProjection } from '../host-types';
10
11
  import { openCommandMenu } from '../triggers';
11
- import { stash } from '../services';
12
- import { addFilesToDraft } from '../attachments';
13
12
  import { t, useT } from '../i18n';
14
- import { IconPaperclipOfficial, IconPlusOfficial, IconSendOfficial } from '../icons';
13
+ import { IconPlusOfficial, IconSendOfficial, IconStopOfficial } from '../icons';
15
14
  import { ModelMenu } from './ModelMenu';
16
15
  import { PermissionMenu } from './PermissionMenu';
17
- import { useSnapshot } from './useSnapshot';
18
16
 
19
17
  export interface ToolbarProps {
20
18
  sessionId: string | undefined;
@@ -30,6 +28,10 @@ export interface ToolbarProps {
30
28
  /** 主发送手势(与编辑器发送键同一策略)。 */
31
29
  onSend(): void;
32
30
  sendDisabled?: boolean;
31
+ /** 会话运行中且草稿空(官方 primaryStops):主按钮变停止。 */
32
+ primaryStops?: boolean;
33
+ /** 停止生成(官方 stop:conversation.cancel)。 */
34
+ onStop?(): void;
33
35
  }
34
36
 
35
37
  /** 官方 keepFocus:mousedown 阻止默认,避免编辑器失焦。 */
@@ -40,7 +42,6 @@ function keepFocus(event: React.MouseEvent): void {
40
42
  export function Toolbar(props: ToolbarProps): JSX.Element {
41
43
  const { sessionId, state, actions, triggers, caretRef, useProjection, disabled, onNotify } = props;
42
44
  useT();
43
- const fileRef = useRef<HTMLInputElement | null>(null);
44
45
 
45
46
  const attachments: readonly DraftAttachmentId[] = state?.attachmentIds ?? [];
46
47
 
@@ -49,10 +50,6 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
49
50
  openCommandMenu(triggers, undefined, caretRef.current ?? 0);
50
51
  };
51
52
 
52
- const onAttach = (files: FileList | null): void => {
53
- addFilesToDraft(sessionId, actions, files, onNotify);
54
- };
55
-
56
53
  return (
57
54
  <div className="dci-row">
58
55
  <div className="dci-tools">
@@ -68,26 +65,6 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
68
65
  >
69
66
  <IconPlusOfficial size={14} />
70
67
  </button>
71
- <button
72
- type="button"
73
- className="dci-add"
74
- aria-label={t("tool.attach")}
75
- title={t("tool.attach")}
76
- onMouseDown={keepFocus}
77
- onClick={() => fileRef.current?.click()}
78
- >
79
- <IconPaperclipOfficial size={14} />
80
- </button>
81
- <input
82
- ref={fileRef}
83
- type="file"
84
- multiple
85
- hidden
86
- onChange={(e) => {
87
- onAttach(e.target.files);
88
- e.target.value = '';
89
- }}
90
- />
91
68
  <PermissionMenu
92
69
  sessionId={sessionId}
93
70
  actions={actions}
@@ -102,138 +79,18 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
102
79
  sessionId={sessionId}
103
80
  onNotify={onNotify}
104
81
  />
105
- <ContextGauge useProjection={useProjection} />
106
82
  <button
107
83
  type="button"
108
84
  className="dci-send"
109
- aria-label={t("tool.send")}
110
- title={t("tool.send")}
111
- disabled={props.sendDisabled}
85
+ aria-label={props.primaryStops ? t('tool.stop') : t('tool.send')}
86
+ title={props.primaryStops ? t('tool.stop') : t('tool.send')}
87
+ disabled={props.primaryStops ? !props.onStop : props.sendDisabled}
112
88
  onMouseDown={keepFocus}
113
- onClick={() => props.onSend()}
89
+ onClick={() => (props.primaryStops ? props.onStop?.() : props.onSend())}
114
90
  >
115
- <IconSendOfficial size={16} />
91
+ {props.primaryStops ? <IconStopOfficial size={16} /> : <IconSendOfficial size={16} />}
116
92
  </button>
117
93
  </div>
118
94
  </div>
119
95
  );
120
96
  }
121
-
122
- const GAUGE_CIRCUMFERENCE = 2 * Math.PI * 5.5;
123
-
124
- interface PressureSnapshot {
125
- contextWindow?: number;
126
- pressureTokens?: number;
127
- projectedTokens?: number;
128
- }
129
-
130
- interface BreakdownSnapshot {
131
- messageTokens?: number;
132
- systemTokens?: number;
133
- toolsTokens?: number;
134
- }
135
-
136
- function shortTokens(n: number): string {
137
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
138
- if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
139
- return `${n}`;
140
- }
141
-
142
- /** 上下文占用小圈(官方 JObwrW 复刻):双环仪表 + 点击展开分段面板。 */
143
- function ContextGauge(props: { useProjection: UseProjection | undefined }): JSX.Element | null {
144
- const { useProjection } = props;
145
- const [open, setOpen] = useState(false);
146
- const rootRef = useRef<HTMLSpanElement | null>(null);
147
- const pressure = useProjection
148
- ? (useProjection('contextPressure') as PressureSnapshot | undefined)
149
- : undefined;
150
- const breakdown = useProjection
151
- ? (useProjection('contextBreakdown') as BreakdownSnapshot | undefined)
152
- : undefined;
153
- // 点外面关闭。
154
- useEffect(() => {
155
- if (!open) return;
156
- const onDown = (event: PointerEvent): void => {
157
- if (rootRef.current && event.target instanceof Node && rootRef.current.contains(event.target)) return;
158
- setOpen(false);
159
- };
160
- window.addEventListener('pointerdown', onDown, true);
161
- return () => window.removeEventListener('pointerdown', onDown, true);
162
- }, [open]);
163
-
164
- if (!pressure || !pressure.contextWindow) return null;
165
- const windowTokens = pressure.contextWindow;
166
- const pct = Math.min(100, Math.max(0, ((pressure.pressureTokens ?? 0) / windowTokens) * 100));
167
- const dash = (pct / 100) * GAUGE_CIRCUMFERENCE;
168
- const segments: readonly [string, number][] = [
169
- ['dci-gauge-colorSystem', breakdown?.systemTokens ?? 0],
170
- ['dci-gauge-colorTools', breakdown?.toolsTokens ?? 0],
171
- ['dci-gauge-colorMessages', breakdown?.messageTokens ?? 0],
172
- ];
173
- return (
174
- <span className="dci-gauge-root" ref={rootRef}>
175
- <button
176
- type="button"
177
- className="dci-gauge-trigger"
178
- aria-label={t("tool.context", { percent: Math.round(pct) })}
179
- aria-haspopup="dialog"
180
- aria-expanded={open}
181
- title={t("tool.context", { percent: Math.round(pct) })}
182
- onClick={() => setOpen(!open)}
183
- >
184
- <svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true">
185
- <circle className="dci-gauge-track" cx="7" cy="7" r="5.5" />
186
- <circle
187
- className="dci-gauge-fill"
188
- cx="7" cy="7" r="5.5"
189
- strokeDasharray={`${dash} ${GAUGE_CIRCUMFERENCE}`}
190
- transform="rotate(-90 7 7)"
191
- />
192
- </svg>
193
- </button>
194
- {open ? (
195
- <div className="dci-gauge-panel" role="dialog" aria-label={t("tool.contextTitle")}>
196
- <div className="dci-gauge-header">
197
- <span className="dci-gauge-headline">{t("tool.contextTitle")}</span>
198
- <span className="dci-gauge-percent">{Math.round(pct)}%</span>
199
- <span className="dci-gauge-figures">
200
- ~{shortTokens(pressure.pressureTokens ?? 0)} / {shortTokens(windowTokens)}
201
- </span>
202
- </div>
203
- <div className="dci-gauge-bar">
204
- {segments.map(([cls, tokens]) => (
205
- <div
206
- key={cls}
207
- className={`dci-gauge-segment ${cls}`}
208
- style={{ width: `${(tokens / windowTokens) * 100}%` }}
209
- />
210
- ))}
211
- </div>
212
- <dl className="dci-gauge-rows">
213
- <div className="dci-gauge-row">
214
- <dt>
215
- <span className={`dci-gauge-swatch dci-gauge-colorSystem`} aria-hidden="true" />
216
- {t("stats.prompt")}
217
- </dt>
218
- <dd>~{shortTokens(breakdown?.systemTokens ?? 0)}</dd>
219
- </div>
220
- <div className="dci-gauge-row">
221
- <dt>
222
- <span className={`dci-gauge-swatch dci-gauge-colorTools`} aria-hidden="true" />
223
- {t("stats.tools")}
224
- </dt>
225
- <dd>~{shortTokens(breakdown?.toolsTokens ?? 0)}</dd>
226
- </div>
227
- <div className="dci-gauge-row">
228
- <dt>
229
- <span className={`dci-gauge-swatch dci-gauge-colorMessages`} aria-hidden="true" />
230
- {t("stats.messages")}
231
- </dt>
232
- <dd>~{shortTokens(breakdown?.messageTokens ?? 0)}</dd>
233
- </div>
234
- </dl>
235
- </div>
236
- ) : null}
237
- </span>
238
- );
239
- }
@@ -324,5 +324,10 @@ export interface SettingsSectionProps {
324
324
  */
325
325
  export interface SidebarTabInfo {
326
326
  readonly sidebar: { readonly expanded: boolean; readonly fullscreen: boolean };
327
- readonly tab: { readonly visible: boolean };
327
+ readonly tab: {
328
+ readonly visible: boolean;
329
+ readonly id?: string;
330
+ /** tab 域动作面(occurrence held):close() 关闭本标签。 */
331
+ readonly actions?: { readonly close?: () => void };
332
+ };
328
333
  }
@@ -27,7 +27,6 @@ const ZH: Dict = {
27
27
  'editor.placeholder': '描述你想要构建的内容,/ 调用指令,@ 文件或对话',
28
28
  'preview.empty': '暂无可预览内容',
29
29
  'tool.command': '指令(/)',
30
- 'tool.attach': '添加附件',
31
30
  'tool.send': '发送',
32
31
  'tool.model': '选择模型,当前 {name}',
33
32
  'tool.modelPlain': '选择模型',
@@ -47,6 +46,7 @@ const ZH: Dict = {
47
46
  'stats.turns': '{turns} 轮 {steps} 步',
48
47
  'stats.tps': '{tps} tok/s',
49
48
  'stats.modelTime': '模型用时',
49
+ 'stats.toolTime': '工具调用用时',
50
50
  'stats.ttft': '首 token 平均(TTFT)',
51
51
  'stats.tpsLabel': '输出速度(TPS)',
52
52
  'stats.totalTokens': '{tokens} tok',
@@ -110,6 +110,13 @@ const ZH: Dict = {
110
110
  'ball.title': '展开 Codinput',
111
111
  'side.notice': '本会话的输入面已在另一个分栏中打开(输入入口唯一)。',
112
112
  'side.guideDesc': '代码编辑器风格的输入面板;开启期间主输入框由本面板接管(共用同一台输入机)',
113
+ 'side.disabled': 'Codinput 接管已关闭:主输入在原输入位(官方输入框)。输入 /codinput 或到 设置 → Codinput 重新开启。',
114
+ 'tool.attach': '文件',
115
+ 'tool.stop': '停止',
116
+ 'image.preview': '原图预览',
117
+ 'image.closePreview': '关闭原图预览',
118
+ 'image.openOriginal': '查看原图',
119
+ 'image.openOriginalLabel': '{name},点击查看原图',
113
120
  'cmd.enter': '进入 Codinput 输入接管',
114
121
  'cmd.exit': '退出 Codinput,恢复官方输入框',
115
122
  };
@@ -124,7 +131,6 @@ const EN: Dict = {
124
131
  'editor.placeholder': 'Describe what you want to build — / for commands, @ for files or chats',
125
132
  'preview.empty': 'Nothing to preview',
126
133
  'tool.command': 'Commands (/)',
127
- 'tool.attach': 'Add attachment',
128
134
  'tool.send': 'Send',
129
135
  'tool.model': 'Select model, current {name}',
130
136
  'tool.modelPlain': 'Select model',
@@ -144,6 +150,7 @@ const EN: Dict = {
144
150
  'stats.turns': '{turns} turns · {steps} steps',
145
151
  'stats.tps': '{tps} tok/s',
146
152
  'stats.modelTime': 'Model time',
153
+ 'stats.toolTime': 'Tool call time',
147
154
  'stats.ttft': 'Avg first token (TTFT)',
148
155
  'stats.tpsLabel': 'Output speed (TPS)',
149
156
  'stats.totalTokens': '{tokens} tok',
@@ -207,6 +214,13 @@ const EN: Dict = {
207
214
  'ball.title': 'Open Codinput',
208
215
  'side.notice': "This session's input panel is already open in another split pane (only one input surface exists).",
209
216
  'side.guideDesc': 'Code-editor style input panel. While enabled it takes over the composer (sharing the same input machine).',
217
+ 'side.disabled': 'Codinput takeover is off: the composer is back at its original place (official input). Type /codinput or open Settings → Codinput to re-enable.',
218
+ 'tool.attach': 'File',
219
+ 'tool.stop': 'Stop',
220
+ 'image.preview': 'Original image preview',
221
+ 'image.closePreview': 'Close original image preview',
222
+ 'image.openOriginal': 'View original',
223
+ 'image.openOriginalLabel': '{name}, click to view the original',
210
224
  'cmd.enter': 'Enable the Codinput composer takeover',
211
225
  'cmd.exit': 'Disable Codinput and restore the official composer',
212
226
  };