dsh-codinput 0.1.1 → 0.3.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.
@@ -4,15 +4,15 @@
4
4
  * 点外关闭。 */
5
5
 
6
6
  import { useEffect, useRef, useState } from 'react';
7
- import { useSessionStats, formatTokens, type SessionStats } from '../stats';
7
+ import { useSessionStats, formatTokens, formatTokensPerSecond, formatCacheHitPercent, formatDuration, type SessionStats } from '../stats';
8
8
  import type { UseProjection } from '../host-types';
9
9
  import { t } from '../i18n';
10
10
 
11
11
  export interface StatsRowProps {
12
12
  useProjection: UseProjection | undefined;
13
13
  sessionId: string | undefined;
14
- /** chat 树解析的轮次计时(模型用时 / TTFT)。 */
15
- chatTimings?: { modelMs?: number; ttftMs?: number };
14
+ /** chat legacy 节点数组(sessionStats 投影缺席时按官方 deriveStats 派生)。 */
15
+ chatNodes?: unknown;
16
16
  }
17
17
 
18
18
  /** 官方仪表盘 glyph(弧 + 指针 + 中点,stroke 1.25)。 */
@@ -67,17 +67,44 @@ function StatPanel(props: {
67
67
  );
68
68
  }
69
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。 */
70
79
  function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }): JSX.Element {
71
80
  const { stats, open, onToggle } = props;
72
- const roundsPart =
73
- stats.turns !== undefined && stats.steps !== undefined
74
- ? t('stats.turns', { turns: stats.turns, steps: stats.steps })
75
- : stats.turns !== undefined
76
- ? t('stats.turns', { turns: stats.turns, steps: 0 })
77
- : t('stats.turns', { turns: 0, steps: 0 });
78
- const speedPart = stats.tokensPerSecond !== undefined ? `${Math.round(stats.tokensPerSecond)} tok/s` : null;
79
- const modelSec = stats.modelMs !== undefined ? stats.modelMs / 1000 : null;
80
- 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
+ }
81
108
  return (
82
109
  <span className="dci-stat-anchor">
83
110
  <button
@@ -85,27 +112,26 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }):
85
112
  className="dci-stat-pill"
86
113
  aria-haspopup="dialog"
87
114
  aria-expanded={open}
115
+ aria-label={tps !== null ? `${roundsPart} · ${t('stats.tps', { tps })}` : roundsPart}
88
116
  onClick={onToggle}
89
117
  >
90
118
  <GaugeIcon />
91
- <span className="dci-stat-label">
92
- {roundsPart}
93
- {speedPart !== null ? (
94
- <>
95
- <span className="dci-stat-sep" aria-hidden="true">·</span>
96
- {speedPart}
97
- </>
98
- ) : null}
99
- </span>
119
+ {label}
100
120
  </button>
101
121
  {open ? (
102
122
  <StatPanel
103
123
  label={t("stats.session")}
104
124
  icon={<GaugeIcon />}
105
125
  rows={[
106
- [t('stats.modelTime'), modelSec !== null ? `${modelSec.toFixed(1)}s` : '—'],
107
- [t('stats.ttft'), ttftSec !== null ? `${ttftSec.toFixed(1)}s` : '—'],
108
- [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
+ ],
109
135
  ]}
110
136
  />
111
137
  ) : null}
@@ -113,10 +139,17 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }):
113
139
  );
114
140
  }
115
141
 
142
+ /** 官方 UsagePill:总量 · 缓存命中(缓存命中用官方百分比精度规则)。 */
116
143
  function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void }): JSX.Element | null {
117
144
  const { stats, open, onToggle } = props;
118
145
  if (stats.totalTokens === undefined) return null;
119
- 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
+ );
120
153
  return (
121
154
  <span className="dci-stat-anchor">
122
155
  <button
@@ -124,15 +157,20 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
124
157
  className="dci-stat-pill"
125
158
  aria-haspopup="dialog"
126
159
  aria-expanded={open}
160
+ aria-label={
161
+ cacheHitText !== null
162
+ ? `${formatTokens(total)} · ${t('stats.cacheHit', { percent: cacheHitText })}`
163
+ : formatTokens(total)
164
+ }
127
165
  onClick={onToggle}
128
166
  >
129
167
  <DatabaseIcon />
130
168
  <span className="dci-stat-label">
131
- {formatTokens(stats.totalTokens)}
132
- {stats.cacheHitPct !== undefined ? (
169
+ {formatTokens(total)}
170
+ {cacheHitText !== null ? (
133
171
  <>
134
172
  <span className="dci-stat-sep" aria-hidden="true">·</span>
135
- 缓存命中 {Math.round(stats.cacheHitPct)}%
173
+ {t('stats.cacheHit', { percent: cacheHitText })}
136
174
  </>
137
175
  ) : null}
138
176
  </span>
@@ -141,12 +179,15 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
141
179
  <StatPanel
142
180
  label={t("stats.usage")}
143
181
  icon={<DatabaseIcon />}
144
- titleValue={`${comma(stats.totalTokens)} tok`}
182
+ titleValue={exactCount(total)}
145
183
  rows={[
146
- [t('stats.cacheHitLabel'), stats.cacheHitPct !== undefined ? `${Math.round(stats.cacheHitPct)}%` : '—'],
147
- [t('stats.uncached'), `${comma(stats.uncachedInputTokens ?? 0)} tok`],
148
- [t('stats.cacheRead'), `${comma(stats.cacheReadTokens ?? 0)} tok`],
149
- [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)],
150
191
  ]}
151
192
  />
152
193
  ) : null}
@@ -155,18 +196,10 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
155
196
  }
156
197
 
157
198
  export function StatsRow(props: StatsRowProps): JSX.Element | null {
158
- const { useProjection, sessionId, chatTimings } = props;
199
+ const { useProjection, sessionId, chatNodes } = props;
159
200
  const [open, setOpen] = useState<'rounds' | 'tokens' | null>(null);
160
201
  const rootRef = useRef<HTMLDivElement | null>(null);
161
- const sampled = useSessionStats(useProjection, sessionId);
162
- // chat 树计时优先(历史会话也有值),采样值兜底。
163
- const stats = sampled
164
- ? {
165
- ...sampled,
166
- ...(chatTimings?.modelMs !== undefined ? { modelMs: chatTimings.modelMs } : {}),
167
- ...(chatTimings?.ttftMs !== undefined ? { ttftMs: chatTimings.ttftMs } : {}),
168
- }
169
- : sampled;
202
+ const stats = useSessionStats(useProjection, sessionId, chatNodes);
170
203
 
171
204
  // 点外面关闭(面板内/胶囊上不关)。
172
205
  useEffect(() => {
@@ -1,21 +1,23 @@
1
1
  /**
2
2
  * 工具行:结构与样式逐条对齐官方 InputBar 工具行——
3
- * 左侧 .dci-tools:指令(plus)、添加附件(paperclip)、权限(shield 触发器);
4
- * 右侧 .dci-trailing:模型与思考强度(触发器)、发送(.primary 圆钮)。
3
+ * 左侧 .dci-tools:指令(plus)、权限(shield 触发器);
4
+ * 右侧 .dci-trailing:模型与思考强度(触发器)、语音输入(官方 activity 槽位
5
+ * 条目,桌面版宿主才有)、发送(.primary 圆钮)。
5
6
  * 按钮沿用官方 keepFocus 语义(mousedown 不夺走编辑器焦点)。
7
+ * 附件无独立按钮(官方已并入 slash「添加 · 文件」),粘贴/拖拽路径保留。
6
8
  * 0.1.7-rc.2 起官方上下文小圈不再在工具行(挪进卡片下方 dock 行,见 StatsRow)。
9
+ * 语音展开时隐藏 tools 与模型(官方 activity 同款:capture 行接管整行,
10
+ * 发送/停止保持可见)。
7
11
  */
8
12
 
9
- import { useRef } from 'react';
13
+ import { useCallback, useState } from 'react';
10
14
  import type { DraftAttachmentId, InputActions, InputState, InputTriggerController, UseProjection } from '../host-types';
11
15
  import { openCommandMenu } from '../triggers';
12
- import { stash } from '../services';
13
- import { addFilesToDraft } from '../attachments';
14
16
  import { t, useT } from '../i18n';
15
- import { IconPaperclipOfficial, IconPlusOfficial, IconSendOfficial } from '../icons';
17
+ import { IconPlusOfficial, IconSendOfficial, IconStopOfficial } from '../icons';
16
18
  import { ModelMenu } from './ModelMenu';
17
19
  import { PermissionMenu } from './PermissionMenu';
18
- import { useSnapshot } from './useSnapshot';
20
+ import { VoiceActivity } from './VoiceActivity';
19
21
 
20
22
  export interface ToolbarProps {
21
23
  sessionId: string | undefined;
@@ -31,6 +33,10 @@ export interface ToolbarProps {
31
33
  /** 主发送手势(与编辑器发送键同一策略)。 */
32
34
  onSend(): void;
33
35
  sendDisabled?: boolean;
36
+ /** 会话运行中且草稿空(官方 primaryStops):主按钮变停止。 */
37
+ primaryStops?: boolean;
38
+ /** 停止生成(官方 stop:conversation.cancel)。 */
39
+ onStop?(): void;
34
40
  }
35
41
 
36
42
  /** 官方 keepFocus:mousedown 阻止默认,避免编辑器失焦。 */
@@ -41,21 +47,19 @@ function keepFocus(event: React.MouseEvent): void {
41
47
  export function Toolbar(props: ToolbarProps): JSX.Element {
42
48
  const { sessionId, state, actions, triggers, caretRef, useProjection, disabled, onNotify } = props;
43
49
  useT();
44
- const fileRef = useRef<HTMLInputElement | null>(null);
45
50
 
46
51
  const attachments: readonly DraftAttachmentId[] = state?.attachmentIds ?? [];
52
+ // 官方 activity 语义:语音展开时本行左侧工具与模型控件隐藏、capture 行伸展。
53
+ const [voiceActive, setVoiceActive] = useState(false);
54
+ const onVoiceActiveChange = useCallback((active: boolean): void => setVoiceActive(active), []);
47
55
 
48
56
  const onCommandClick = (): void => {
49
57
  if (!triggers) return;
50
58
  openCommandMenu(triggers, undefined, caretRef.current ?? 0);
51
59
  };
52
60
 
53
- const onAttach = (files: FileList | null): void => {
54
- addFilesToDraft(sessionId, actions, files, onNotify);
55
- };
56
-
57
61
  return (
58
- <div className="dci-row">
62
+ <div className="dci-row" data-voice-active={voiceActive || undefined}>
59
63
  <div className="dci-tools">
60
64
  <button
61
65
  type="button"
@@ -69,26 +73,6 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
69
73
  >
70
74
  <IconPlusOfficial size={14} />
71
75
  </button>
72
- <button
73
- type="button"
74
- className="dci-add"
75
- aria-label={t("tool.attach")}
76
- title={t("tool.attach")}
77
- onMouseDown={keepFocus}
78
- onClick={() => fileRef.current?.click()}
79
- >
80
- <IconPaperclipOfficial size={14} />
81
- </button>
82
- <input
83
- ref={fileRef}
84
- type="file"
85
- multiple
86
- hidden
87
- onChange={(e) => {
88
- onAttach(e.target.files);
89
- e.target.value = '';
90
- }}
91
- />
92
76
  <PermissionMenu
93
77
  sessionId={sessionId}
94
78
  actions={actions}
@@ -99,20 +83,30 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
99
83
  />
100
84
  </div>
101
85
  <div className="dci-trailing">
102
- <ModelMenu
103
- sessionId={sessionId}
104
- onNotify={onNotify}
105
- />
86
+ <div className="dci-standard-controls">
87
+ <ModelMenu
88
+ sessionId={sessionId}
89
+ onNotify={onNotify}
90
+ />
91
+ </div>
92
+ <div className="dci-voice-seat">
93
+ <VoiceActivity
94
+ sessionId={sessionId}
95
+ inputActions={actions}
96
+ locked={disabled === true}
97
+ onActiveChange={onVoiceActiveChange}
98
+ />
99
+ </div>
106
100
  <button
107
101
  type="button"
108
102
  className="dci-send"
109
- aria-label={t("tool.send")}
110
- title={t("tool.send")}
111
- disabled={props.sendDisabled}
103
+ aria-label={props.primaryStops ? t('tool.stop') : t('tool.send')}
104
+ title={props.primaryStops ? t('tool.stop') : t('tool.send')}
105
+ disabled={props.primaryStops ? !props.onStop : props.sendDisabled}
112
106
  onMouseDown={keepFocus}
113
- onClick={() => props.onSend()}
107
+ onClick={() => (props.primaryStops ? props.onStop?.() : props.onSend())}
114
108
  >
115
- <IconSendOfficial size={16} />
109
+ {props.primaryStops ? <IconStopOfficial size={16} /> : <IconSendOfficial size={16} />}
116
110
  </button>
117
111
  </div>
118
112
  </div>
@@ -0,0 +1,172 @@
1
+ /**
2
+ * 语音输入按钮:渲染官方 voice-input 插件注册在 conversation.input.activity
3
+ * 槽位的**条目组件本体**(麦克风触发钮、录音波形、转写、未就绪引导弹窗全部
4
+ * 是官方代码,其闭包自带 primitives 与样式注入)。
5
+ *
6
+ * 检测即兼容:官方桌面版(0.2.0-rc.2 起)挂载 experimental-client-ui-voice-input
7
+ * 后,原生输入框工具行右侧(模型选择器与发送钮之间)出现麦克风钮;web 宿主
8
+ * (0.1.x)没有该插件、槽位无条目,官方输入框也没有按钮——Codinput 同样不
9
+ * 渲染。条目出现/消失经 slots.subscribe 响应式跟随(用户开关语音插件即时生效)。
10
+ *
11
+ * props 组装复刻官方 renderer 的合成公式:
12
+ * {...standard(sessionId/inputActions), ...entry.inject()(其 hooks 子表的
13
+ * speechReadiness 按 standardHookPropName 变成 useSpeechReadiness 选择器钩子),
14
+ * ...ownerProps(locked/onActiveChange), t = locale.bind(entry.locale)}
15
+ * inject 结果按(条目 × 会话)缓存——官方 renderer 同款缓存轴,避免每次渲染
16
+ * 重建对象让钩子身份漂移。宿主未就绪(slots 无检视面 / inject 不可调 /
17
+ * locale 缺席)一律不渲染,绝不半组装。
18
+ */
19
+
20
+ import { createElement, useCallback, useMemo, useSyncExternalStore, Component } from 'react';
21
+ import type { HostObservable, SlotEntryLike } from '../host-types';
22
+ import { stash } from '../services';
23
+ import { bindForeign } from '../i18n';
24
+ import { diagError } from '../diag';
25
+
26
+ const ACTIVITY_SLOT = 'conversation.input.activity';
27
+ const VOICE_NS_FALLBACK = 'voice-input';
28
+
29
+ type Translator = (key: string, params?: Record<string, string | number>) => string;
30
+ type ReadinessSelector = (value: unknown) => unknown;
31
+ type ReadinessHook = (selector: ReadinessSelector) => unknown;
32
+
33
+ const EMPTY_ENTRIES: readonly unknown[] = [];
34
+
35
+ /** observableHook 同款缓存轴:钩子身份按 readiness store 稳定,不逐渲染重订。 */
36
+ const readinessHookCache = new WeakMap<object, ReadinessHook>();
37
+ /** 官方 renderer 的 inject 缓存轴:条目 × 会话绑定。 */
38
+ const injectCache = new WeakMap<object, Map<string, Record<string, unknown> | null>>();
39
+ const translatorCache = new Map<string, Translator | null>();
40
+
41
+ /** 槽位条目快照:entries 引用在变更之间稳定(官方契约),直接作 uSES 源。 */
42
+ function useSlotEntries(key: string): readonly unknown[] {
43
+ const slots = stash.slots;
44
+ const subscribe = useCallback(
45
+ (fn: () => void) => {
46
+ if (typeof slots?.subscribe !== 'function') return () => {};
47
+ try {
48
+ return slots.subscribe(key, fn) ?? (() => {});
49
+ } catch {
50
+ return () => {};
51
+ }
52
+ },
53
+ [slots, key],
54
+ );
55
+ const getSnapshot = useCallback(() => {
56
+ if (typeof slots?.entries !== 'function') return EMPTY_ENTRIES;
57
+ try {
58
+ return slots.entries(key) ?? EMPTY_ENTRIES;
59
+ } catch {
60
+ return EMPTY_ENTRIES;
61
+ }
62
+ }, [slots, key]);
63
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
64
+ }
65
+
66
+ /** 调条目 inject 工厂取 props 面(session 槽位以 sessionId 调用,官方同参)。 */
67
+ function resolveInjected(entry: SlotEntryLike, sessionId: string): Record<string, unknown> | null {
68
+ if (typeof entry.inject !== 'function') return null;
69
+ let perSession = injectCache.get(entry);
70
+ if (!perSession) {
71
+ perSession = new Map();
72
+ injectCache.set(entry, perSession);
73
+ }
74
+ if (!perSession.has(sessionId)) {
75
+ let face: Record<string, unknown> | null = null;
76
+ try {
77
+ face = (entry.inject as (key: string) => Record<string, unknown>)(sessionId) ?? null;
78
+ } catch (error) {
79
+ diagError('voice.inject', error);
80
+ face = null;
81
+ }
82
+ perSession.set(sessionId, face);
83
+ }
84
+ return perSession.get(sessionId) ?? null;
85
+ }
86
+
87
+ /** 官方 renderer 的 locale 座位:locale.bind(entry.locale),按 ns 缓存。 */
88
+ function translatorFor(entry: SlotEntryLike): Translator | null {
89
+ const ns = entry.locale ?? VOICE_NS_FALLBACK;
90
+ if (!translatorCache.has(ns)) translatorCache.set(ns, bindForeign(ns));
91
+ return translatorCache.get(ns) ?? null;
92
+ }
93
+
94
+ /** hooks.speechReadiness(快照 store)→ use<Name> 选择器钩子(observableHook 同款)。 */
95
+ function readinessHookFor(source: unknown): ReadinessHook | null {
96
+ const face = source as HostObservable<unknown> | undefined;
97
+ if (!face || typeof face.subscribe !== 'function' || typeof face.getSnapshot !== 'function') return null;
98
+ let hook = readinessHookCache.get(face);
99
+ if (!hook) {
100
+ hook = (selector: ReadinessSelector): unknown =>
101
+ useSyncExternalStore(
102
+ (fn: () => void) => face.subscribe(fn) ?? (() => {}),
103
+ () => selector(face.getSnapshot()),
104
+ () => selector(face.getSnapshot()),
105
+ );
106
+ readinessHookCache.set(face, hook);
107
+ }
108
+ return hook;
109
+ }
110
+
111
+ /** 条目崩溃只丢语音钮,不连坐 Codinput 的 bar 条目(bar 被废黜 = 全部输入消失)。 */
112
+ class VoiceBoundary extends Component<{ children?: React.ReactNode }, { failed: boolean }> {
113
+ state = { failed: false };
114
+ static getDerivedStateFromError(): { failed: boolean } {
115
+ return { failed: true };
116
+ }
117
+ componentDidCatch(error: unknown): void {
118
+ diagError('voice.entry', error);
119
+ }
120
+ render(): React.ReactNode {
121
+ return this.state.failed ? null : this.props.children;
122
+ }
123
+ }
124
+
125
+ export interface VoiceActivityProps {
126
+ sessionId: string | undefined;
127
+ /** 官方输入机动作面(bar 槽位 standard props 里的 inputActions 原样透传)。 */
128
+ inputActions: unknown;
129
+ /** 输入锁定(官方 ownerProps.locked:submitting/inert 时触发钮禁用)。 */
130
+ locked: boolean;
131
+ /** 展开/收起上报(官方 ownerProps.onActiveChange:录音时宿主隐藏其余工具)。 */
132
+ onActiveChange(active: boolean): void;
133
+ }
134
+
135
+ export function VoiceActivity(props: VoiceActivityProps): JSX.Element | null {
136
+ const { sessionId, inputActions, locked, onActiveChange } = props;
137
+ const entries = useSlotEntries(ACTIVITY_SLOT);
138
+
139
+ // single 槽位赢家 = 优先级最低者 = entries 首个(register 即按优先级排序)。
140
+ const entry = useMemo((): SlotEntryLike | null => {
141
+ for (const candidate of entries) {
142
+ const record = candidate as SlotEntryLike | null | undefined;
143
+ if (record && typeof record === 'object' && typeof record.component === 'function') return record;
144
+ }
145
+ return null;
146
+ }, [entries]);
147
+
148
+ const injected = useMemo(
149
+ () => (entry && sessionId ? resolveInjected(entry, sessionId) : null),
150
+ [entry, sessionId],
151
+ );
152
+ const translator = useMemo(() => (entry ? translatorFor(entry) : null), [entry]);
153
+ const readinessSource = (injected?.['hooks'] as { speechReadiness?: unknown } | undefined)?.speechReadiness;
154
+ const useReadiness = useMemo(() => readinessHookFor(readinessSource), [readinessSource]);
155
+
156
+ if (!entry || !sessionId || !injected || !translator || !useReadiness) return null;
157
+
158
+ // hooks 子表已折成 use* 钩子(bindInjectSources 同款),其余 props 原样下传。
159
+ const { hooks: _hooks, ...actions } = injected;
160
+ void _hooks;
161
+ return createElement(VoiceBoundary, {
162
+ children: createElement(entry.component as React.ComponentType<Record<string, unknown>>, {
163
+ sessionId,
164
+ inputActions,
165
+ locked,
166
+ onActiveChange,
167
+ ...actions,
168
+ useSpeechReadiness: useReadiness,
169
+ t: translator,
170
+ }),
171
+ });
172
+ }
@@ -44,6 +44,24 @@ export interface SlotsService {
44
44
  register(options: SlotRegisterOptions, component: unknown): () => void;
45
45
  /** 锚定槽位:当该槽被声明时运行工厂;工厂 yield 掉的可清理返回值统一登记生命周期。 */
46
46
  inject(slotName: string, factory: () => Generator<unknown> | (() => unknown)): void;
47
+ /**
48
+ * 槽位检视面(0.2.x 起的 SlotCore 公开方法,鸭子探测):entries 返回条目
49
+ * 快照(引用在变更之间稳定,可作 uSES getSnapshot 源;未声明槽返回稳定
50
+ * 空数组);subscribe 订阅该槽条目变更。
51
+ */
52
+ entries?(key: string): readonly unknown[];
53
+ subscribe?(key: string, fn: () => void): () => void;
54
+ }
55
+
56
+ /** 槽位条目记录(SlotCore.register 产物)里我们消费的切面。 */
57
+ export interface SlotEntryLike {
58
+ /** 条目 React 组件(官方 renderer 以 {...kit,...injected,...ownerProps} 渲染)。 */
59
+ readonly component?: unknown;
60
+ /** 注入工厂:session 槽位以 binding.key(sessionId)调用,返回 props 面。 */
61
+ readonly inject?: (...args: unknown[]) => Record<string, unknown>;
62
+ /** 条目词典命名空间(locale.bind(ns) 即官方传给组件的 t)。 */
63
+ readonly locale?: string;
64
+ readonly registrant?: string;
47
65
  }
48
66
 
49
67
  // ---------- input machine ----------
@@ -324,5 +342,10 @@ export interface SettingsSectionProps {
324
342
  */
325
343
  export interface SidebarTabInfo {
326
344
  readonly sidebar: { readonly expanded: boolean; readonly fullscreen: boolean };
327
- readonly tab: { readonly visible: boolean };
345
+ readonly tab: {
346
+ readonly visible: boolean;
347
+ readonly id?: string;
348
+ /** tab 域动作面(occurrence held):close() 关闭本标签。 */
349
+ readonly actions?: { readonly close?: () => void };
350
+ };
328
351
  }
@@ -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
  };
@@ -218,9 +232,11 @@ interface LocaleFaceLike {
218
232
  getSnapshot?(): { active?: string };
219
233
  }
220
234
 
235
+ let hostFace: LocaleFaceLike | null = null;
221
236
  let bound: ((key: string, params?: TranslateParams) => string) | null = null;
222
237
  let activeLocale = 'zh';
223
238
  const listeners = new Set<() => void>();
239
+ const foreignBinds = new Map<string, ((key: string, params?: TranslateParams) => string) | null>();
224
240
 
225
241
  function emit(): void {
226
242
  for (const fn of listeners) fn();
@@ -243,6 +259,7 @@ function builtinDict(): Dict {
243
259
  export function installI18n(locale: unknown): void {
244
260
  const face = locale as LocaleFaceLike | undefined;
245
261
  if (!face) return;
262
+ hostFace = face;
246
263
  try {
247
264
  face.register?.(NS, 'zh', ZH);
248
265
  face.register?.(NS, 'en', EN);
@@ -268,6 +285,24 @@ export function installI18n(locale: unknown): void {
268
285
  }
269
286
  }
270
287
 
288
+ /**
289
+ * 绑定其他命名空间的宿主词典(如语音插件注册的 voice-input——渲染官方条目
290
+ * 组件时,官方 renderer 传的就是 locale.bind(entry.locale))。返回引用稳定
291
+ * 的翻译函数;宿主 face 未就绪或 bind 失败返回 null。结果按 ns 缓存。
292
+ */
293
+ export function bindForeign(ns: string): ((key: string, params?: TranslateParams) => string) | null {
294
+ if (!foreignBinds.has(ns)) {
295
+ let bound: ((key: string, params?: TranslateParams) => string) | null = null;
296
+ try {
297
+ bound = hostFace?.bind?.(ns) ?? null;
298
+ } catch {
299
+ bound = null;
300
+ }
301
+ foreignBinds.set(ns, bound);
302
+ }
303
+ return foreignBinds.get(ns) ?? null;
304
+ }
305
+
271
306
  export function subscribeI18n(fn: () => void): () => void {
272
307
  listeners.add(fn);
273
308
  return () => {