dsh-codinput 0.3.1 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-codinput",
3
- "version": "0.3.1",
3
+ "version": "0.3.5",
4
4
  "description": "DSH WebUI 插件:把聊天输入框变成代码编辑器一样的输入风格(CodeMirror 6 接管 conversation.composer.bar)。",
5
5
  "license": "MIT",
6
6
  "author": "Witherwithwinter",
@@ -23,6 +23,7 @@ import { loadPrefs, savePrefs, subscribePrefs, type CodinputPrefs } from '../pre
23
23
  import { useSidebarInputCarried } from '../sideinput';
24
24
  import { CodinputBall } from './ExpandBall';
25
25
  import { CodinputSurface } from './CodinputSurface';
26
+ import { kitFromBarProps } from './slotEntry';
26
27
 
27
28
  /** ComposerBarOwnerProps 切面(官方 bar owner)。 */
28
29
  interface BarOwner {
@@ -38,6 +39,10 @@ type BarProps = BarOwner & SessionMaybeStandard & { useChat?: <S>(sel: (s: unkno
38
39
  export function CodinputBar(props: BarProps): JSX.Element | null {
39
40
  const { variant, blocked, disabled, placeholder, sessionId, useInput, inputActions, useProjection, useChat, useSession } = props;
40
41
  const state = useInput((s) => s);
42
+ // conversation.input.left/.right 第三方条目的 standard props 原样面:宿主给
43
+ // 本条目的 kit(standard 全集)减去 owner props——enhancer ✨/🎤 等按钮与
44
+ // 左座读数按官方契约消费(sessionId + useSession/useInput + inputActions + t)。
45
+ const inputStandard = kitFromBarProps(props as unknown as Record<string, unknown>);
41
46
  // 官方 StatsPills 同款数据源:chat store 的 legacy 节点数组(选择器只取
42
47
  // 数组引用,派生换算在 stats.ts 的 useSessionStats 里做)。
43
48
  const chatNodes = useChat ? useChat((s: unknown) => (s as { legacy?: { nodes?: unknown } })?.legacy?.nodes) : undefined;
@@ -56,6 +61,7 @@ export function CodinputBar(props: BarProps): JSX.Element | null {
56
61
  state={state}
57
62
  actions={inputActions}
58
63
  useProjection={useProjection}
64
+ inputStandard={inputStandard}
59
65
  placeholder={placeholder}
60
66
  disabled={disabled === true}
61
67
  blocked={blocked !== undefined}
@@ -34,6 +34,11 @@ export interface CodinputSurfaceProps {
34
34
  state: InputState | undefined;
35
35
  actions: InputActions | undefined;
36
36
  useProjection: UseProjection | undefined;
37
+ /**
38
+ * conversation.input.left / .right 条目的 standard props 原样面(宿主
39
+ * kit − owner props),经 Toolbar 转给输入座位镜像转发渲染。
40
+ */
41
+ inputStandard?: Record<string, unknown> | undefined;
37
42
  /** 宿主 bar owner props 透传:占位文案与禁用。 */
38
43
  placeholder?: string;
39
44
  disabled?: boolean;
@@ -61,7 +66,7 @@ const DOMPurify = ((DOMPurifyModule as unknown as { default?: unknown }).default
61
66
  DOMPurifyModule) as unknown as { sanitize(html: string): string };
62
67
 
63
68
  export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
64
- const { sessionId, state, actions, useProjection, placeholder, disabled, blocked, running, side, hero, chatNodes, float, onDragZonePointerDown } = props;
69
+ const { sessionId, state, actions, useProjection, inputStandard, placeholder, disabled, blocked, running, side, hero, chatNodes, float, onDragZonePointerDown } = props;
65
70
  // 语言切换时整棵表面重渲染(文案都走 i18n 的 t())。
66
71
  useT();
67
72
 
@@ -401,6 +406,7 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
401
406
  onNotify={notifyFor(sessionId)}
402
407
  onSend={sendGesture}
403
408
  sendDisabled={sendDisabled}
409
+ inputStandard={inputStandard}
404
410
  />
405
411
 
406
412
  {/* 数据行:悬浮形态收进卡片(工具行下方,随卡片圆角一体)。 */}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 左座薄壳(v0.3.5 新增;引擎见 InputSlotSeat)。
3
+ *
4
+ * 座位 = conversation.input.left:官方工具行左区 `.tools` 内
5
+ * [renderSlot(input.permission), renderSlot(input.plan), renderSlot(input.left)]
6
+ * 的末位——issue #1 里「上传视频」按钮、dsh-qoder-plugin 的额度读数等第三方
7
+ * 条目所在座位。遮蔽官方条目后该渲染点消失,本组件在自绘工具行左区同位置
8
+ * (`.dci-tools` 内、权限触发器之后)复刻官方 renderer 的 list 合成。
9
+ *
10
+ * 门槛与官方同款:`input === undefined || sessionId === undefined` 不渲染
11
+ * (Toolbar 判断 state,组件再兜底 sessionId);槽位未声明/无条目 → 零残留。
12
+ */
13
+
14
+ import { InputSlotSeat } from './InputSlotSeat';
15
+
16
+ const LEFT_SLOT = 'conversation.input.left';
17
+
18
+ export interface InputLeftSlotProps {
19
+ sessionId: string | undefined;
20
+ /** 宿主下发给本插件条目的 standard props 原样面(见 InputSlotSeat)。 */
21
+ standard: Record<string, unknown> | undefined;
22
+ }
23
+
24
+ export function InputLeftSlot(props: InputLeftSlotProps): JSX.Element | null {
25
+ return <InputSlotSeat slot={LEFT_SLOT} sessionId={props.sessionId} standard={props.standard} />;
26
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 右座薄壳(v0.3.4 引入;v0.3.5 起与左座共用 InputSlotSeat 通用引擎)。
3
+ *
4
+ * 座位 = conversation.input.right:官方工具行 `.standardControls` 内
5
+ * [renderSlot(input.right), renderSlot(input.model)] 的第一位——
6
+ * 如 dsh-prompt-enhancer 的 ✨ 提示词优化与 🎤 语音识别按钮、各渠道插件的
7
+ * 积分/余额读数。本组件在自绘工具行的同座位(`.dci-standard-controls` 内、
8
+ * 模型选择之前)把条目原样渲染回来。
9
+ *
10
+ * 门槛与官方同款:`input === undefined || sessionId === undefined` 不渲染
11
+ * (Toolbar 判断 state,组件再兜底 sessionId);槽位未声明/无条目 → 零残留。
12
+ */
13
+
14
+ import { InputSlotSeat } from './InputSlotSeat';
15
+
16
+ const RIGHT_SLOT = 'conversation.input.right';
17
+
18
+ export interface InputRightSlotProps {
19
+ sessionId: string | undefined;
20
+ /** 宿主下发给本插件条目的 standard props 原样面(见 InputSlotSeat)。 */
21
+ standard: Record<string, unknown> | undefined;
22
+ }
23
+
24
+ export function InputRightSlot(props: InputRightSlotProps): JSX.Element | null {
25
+ return <InputSlotSeat slot={RIGHT_SLOT} sessionId={props.sessionId} standard={props.standard} />;
26
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * 座位镜像通用引擎:把注册在官方 InputBar 子槽位(conversation.input.left /
3
+ * conversation.input.right 等,均为 list/session)上的**条目组件本体**在自绘
4
+ * 工具行的对应座位重新渲染。
5
+ *
6
+ * 为什么需要这里:这些子槽位由官方 InputBar(conversation.composer.bar 的缺省
7
+ * 条目)声明并在其工具行内渲染——Codinput 以 priority -1 遮蔽官方条目后,槽位
8
+ * 声明仍在(宿主按注册周期记账),但渲染点随官方输入框一起消失,第三方按钮/
9
+ * 读数便无处挂载(issue #1)。本组件复刻官方 renderer 的 list 合成,座位名与
10
+ * standard props 面由调用方给(InputLeftSlot / InputRightSlot 两片薄壳)——
11
+ * 一套引擎、N 个座位。
12
+ *
13
+ * props 合成复刻官方 SessionEntry → renderEntry 的展开式:
14
+ * {...standard(kit), ...entryInjectProps(entry, sessionId), ...(t)}
15
+ * standard 走 bar/session 条目已下发的原样通道(CodinputBar 与
16
+ * SidebarCodinputBody 把宿主 kit props 减去 owner props 传下来)——第三方
17
+ * 条目按官方契约消费 sessionId + useSession/useInput 选择器 hook +
18
+ * inputActions(enhancer v3.4.x 实证),有的条目还从 inject 面拿自带 props
19
+ * (如 qoder 左座的 directory store 与 t);这里只做原样转发,绝不重新
20
+ * 物化宿主绑定。
21
+ *
22
+ * 检测即兼容:无条目 → 渲染 null;单条目崩溃被 EntryBoundary 隔离,不连坐
23
+ * 输入面;旧宿主(无该子槽位声明)槽位未声明 → entries 空数组,同样不渲染。
24
+ * 条目声明 store 的 useStore/actions 面插件侧不可复现(官方 host.storeOf
25
+ * 私有),现网无此类条目;如出现则降级为无 store props 渲染。
26
+ */
27
+
28
+ import { createElement, useMemo } from 'react';
29
+ import { diagError } from '../diag';
30
+ import { EntryBoundary, entryId, entryInjectProps, listRows, translatorFor, useSlotEntries } from './slotEntry';
31
+
32
+ export interface InputSlotSeatProps {
33
+ /** 官方子槽位全名(如 conversation.input.left)。 */
34
+ slot: string;
35
+ sessionId: string | undefined;
36
+ /** 宿主下发给本插件条目的 standard props 原样面(见组件头注释)。 */
37
+ standard: Record<string, unknown> | undefined;
38
+ }
39
+
40
+ export function InputSlotSeat(props: InputSlotSeatProps): JSX.Element | null {
41
+ const { slot, sessionId, standard } = props;
42
+ const entries = useSlotEntries(slot);
43
+ // 官方 list 合成:id 首占位去重 + order 升序(entries 快照引用稳定,直接 memo)。
44
+ const rows = useMemo(() => listRows(entries), [entries]);
45
+
46
+ // 官方同款门槛:无会话不渲染(input.left / input.right 都是 strict session 槽位)。
47
+ if (sessionId === undefined || rows.length === 0) return null;
48
+
49
+ return (
50
+ <>
51
+ {rows.map((entry, index) => {
52
+ const Comp = entry.component as React.ComponentType<Record<string, unknown>>;
53
+ // 条目词典的 t(官方 localeSeat 同款);无 locale 或绑定失败 → 不下发,
54
+ // 组件按各自回退(enhancer 回退内置中文词典)。
55
+ let t: unknown;
56
+ try {
57
+ t = translatorFor(entry) ?? undefined;
58
+ } catch {
59
+ t = undefined;
60
+ }
61
+ let injected: Record<string, unknown> | null = null;
62
+ try {
63
+ injected = entryInjectProps(entry, sessionId);
64
+ } catch (error) {
65
+ diagError('slot.seat.inject', error);
66
+ }
67
+ return createElement(
68
+ EntryBoundary,
69
+ { key: entryId(entry, index) },
70
+ createElement(Comp, {
71
+ ...(standard ?? {}),
72
+ sessionId,
73
+ ...(injected ?? {}),
74
+ ...(t !== undefined ? { t } : {}),
75
+ }),
76
+ );
77
+ })}
78
+ </>
79
+ );
80
+ }
@@ -1,6 +1,21 @@
1
- /** 官方 Menu 容器复刻:fixed 定位、锚点上方 4px、r20 卡片、视口约束。 */
1
+ /**
2
+ * 官方 Menu 容器复刻:fixed 定位、锚点上方弹出、r20 卡片、视口约束。
3
+ *
4
+ * 定位复刻官方 ModelSelect 的 place():
5
+ * y = anchor.top - 8 - h(side=top)/ anchor.bottom + 4(side=bottom),
6
+ * x/y 各夹进 [12, innerWidth - w - 12] / [12, innerHeight - h - 12]。
7
+ * place 随**内容尺寸每次变化**重跑(ResizeObserver 挂卡片本体)+ 页面
8
+ * scroll/resize 重跑——官方同款监听。旧实现只在打开瞬间定位一次,钻入
9
+ * 更长的子页(模型/推理强度列表)时卡片保持小菜单的 top 向下生长,
10
+ * 表现为「向下弹出」且列表伸出视口外够不着。
11
+ *
12
+ * 滚动复刻官方两级结构:卡片 overflow:hidden +
13
+ * max-height: min(360px, 100vh - 96px),列表在 .dci-viewport 内滚,
14
+ * 细滚动条由卡片注入的 --dsh-scrollbar-thumb(l2) 变量着色(官方
15
+ * ModelSelect .menu 同款)。
16
+ */
2
17
 
3
- import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react';
18
+ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
4
19
 
5
20
  export interface MenuSurfaceProps {
6
21
  /** 触发器矩形(getBoundingClientRect 快照)。 */
@@ -10,31 +25,42 @@ export interface MenuSurfaceProps {
10
25
  /** 右对齐锚点(trailing 控件用)。 */
11
26
  alignEnd?: boolean;
12
27
  minWidth?: number;
13
- /** 弹出方向:top = 锚点上方(默认),bottom = 锚点下方(设置区下拉)。 */
28
+ /** 弹出方向:top = 锚点上方(默认,官方 ModelSelect/Menu 同款),bottom = 锚点下方(设置区下拉)。 */
14
29
  side?: 'top' | 'bottom';
15
30
  }
16
31
 
32
+ /** 官方 place() 的视口留距。 */
33
+ const MARGIN = 12;
34
+
17
35
  export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
18
36
  const { anchor, onClose, children, alignEnd, minWidth = 218, side = 'top' } = props;
19
37
  const ref = useRef<HTMLDivElement | null>(null);
38
+ const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
20
39
 
21
40
  useLayoutEffect(() => {
22
41
  const el = ref.current;
23
42
  if (!el) return;
24
- const rect = el.getBoundingClientRect();
25
- let top: number;
26
- if (side === 'top') {
27
- top = Math.max(12, anchor.top - rect.height - 4);
28
- el.style.bottom = 'auto';
29
- } else {
30
- top = Math.min(window.innerHeight - rect.height - 12, anchor.bottom + 4);
31
- el.style.bottom = 'auto';
32
- }
33
- el.style.top = `${top}px`;
34
- let left = anchor.left;
35
- if (alignEnd) left = anchor.right - rect.width;
36
- left = Math.min(Math.max(12, left), window.innerWidth - rect.width - 12);
37
- el.style.left = `${left}px`;
43
+ const place = (): void => {
44
+ // 尺寸取 offset*(官方同款):不受自身 top/left 变更反馈影响。
45
+ const w = el.offsetWidth;
46
+ const h = el.offsetHeight;
47
+ let x = alignEnd ? anchor.right - w : anchor.left;
48
+ let y = side === 'top' ? anchor.top - 8 - h : anchor.bottom + 4;
49
+ if (w > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - w - MARGIN);
50
+ if (h > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - h - MARGIN);
51
+ setPos((prev) => (prev && prev.left === x && prev.top === y ? prev : { left: x, top: y }));
52
+ };
53
+ place();
54
+ // 内容换页/目录热更/滚动/视口变化都重定位——官方 place 的三路监听。
55
+ window.addEventListener('scroll', place, true);
56
+ window.addEventListener('resize', place);
57
+ const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null;
58
+ observer?.observe(el);
59
+ return () => {
60
+ observer?.disconnect();
61
+ window.removeEventListener('scroll', place, true);
62
+ window.removeEventListener('resize', place);
63
+ };
38
64
  }, [anchor, alignEnd, side]);
39
65
 
40
66
  useEffect(() => {
@@ -46,16 +72,20 @@ export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
46
72
  return () => window.removeEventListener('pointerdown', onDown, true);
47
73
  }, [onClose]);
48
74
 
49
- const below = side === 'bottom';
75
+ // 未定位的首帧隐藏但仍参与布局(官方 unplaced card 同款)——place() 在
76
+ // layout effect 里同步测得尺寸后即落位,无闪烁。
50
77
  return (
51
78
  <div
52
79
  ref={ref}
53
80
  className="dci-menu"
54
- style={
55
- below
56
- ? { top: anchor.bottom + 4, bottom: 'auto', ...(alignEnd ? { left: 'auto', right: window.innerWidth - anchor.right } : { left: anchor.left }), minWidth }
57
- : { left: anchor.left, bottom: window.innerHeight - anchor.top + 4, top: 'auto', ...(alignEnd ? { left: 'auto', right: window.innerWidth - anchor.right } : {}), minWidth }
58
- }
81
+ style={{
82
+ left: pos?.left ?? 0,
83
+ top: pos?.top ?? 0,
84
+ bottom: 'auto',
85
+ right: 'auto',
86
+ minWidth,
87
+ ...(pos ? null : { visibility: 'hidden' }),
88
+ }}
59
89
  >
60
90
  <div className="dci-viewport">{children}</div>
61
91
  </div>
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 模型名标签:渲染官方 model-selection 在 `conversation.input.model.label`
3
+ * 子槽位(0.2.1 起)的条目组件本体——桌面版宿主挂载
4
+ * dsh-client-ui-deepseek-model-label-1017 后,官方给 deepseek-flash 的
5
+ * 目录名「DeepSeek-V4.1-Flash」后面追加一枚 info 色发布徽章「1017」
6
+ * (Tag 原语,官方插件的样式注入自带)。web 宿主(0.1.x)没有该插件、
7
+ * 槽位键也未声明,条目为空 → 回退纯名,与官方两个宿主的表现各自一致。
8
+ *
9
+ * 渲染公式复刻官方 ModelSelect.renderModelLabel:外包
10
+ * span[data-provider-id][data-model-id],条目 props = {provider, model,
11
+ * placement, selected, t = locale.bind(entry.locale)};无条目或条目崩溃
12
+ * 一律回退 fallback(官方 renderSlot 的 fallback 同参)。
13
+ */
14
+
15
+ import { createElement, useMemo } from 'react';
16
+ import { EntryBoundary, firstComponentEntry, translatorFor, useSlotEntries } from './slotEntry';
17
+
18
+ const LABEL_SLOT = 'conversation.input.model.label';
19
+
20
+ export type ModelLabelPlacement = 'trigger' | 'menu' | 'option';
21
+
22
+ export interface ModelLabelProps {
23
+ provider: { id: string; name: string };
24
+ model: { id: string; name: string };
25
+ placement: ModelLabelPlacement;
26
+ selected: boolean;
27
+ /** 无官方条目 / 条目崩溃时的回退显示(通常是目录名)。 */
28
+ fallback: string;
29
+ }
30
+
31
+ export function ModelLabel(props: ModelLabelProps): JSX.Element {
32
+ const { provider, model, placement, selected, fallback } = props;
33
+ const entries = useSlotEntries(LABEL_SLOT);
34
+ const entry = useMemo(() => firstComponentEntry(entries), [entries]);
35
+ // 徽章文案在官方插件自己的词典里(ns = deepseek-model-label-1017);
36
+ // 条目没带 locale 就没有可用的 t,按无条目回退。
37
+ const translator = useMemo(
38
+ () => (entry && entry.locale ? translatorFor(entry) : null),
39
+ [entry],
40
+ );
41
+
42
+ let label: React.ReactNode = fallback;
43
+ if (entry && translator) {
44
+ label = createElement(entry.component as React.ComponentType<Record<string, unknown>>, {
45
+ provider,
46
+ model,
47
+ placement,
48
+ selected,
49
+ t: translator,
50
+ });
51
+ }
52
+ return (
53
+ <span data-provider-id={provider.id} data-model-id={model.id}>
54
+ <EntryBoundary fallback={fallback}>{label}</EntryBoundary>
55
+ </span>
56
+ );
57
+ }
@@ -1,15 +1,24 @@
1
1
  /**
2
- * 模型与思考强度菜单:官方 ModelSelection 的触发器与两级 cell 菜单。
2
+ * 模型与思考强度菜单:官方 ModelSelect 座位(conversation.input.model 槽位)
3
+ * 的触发器与两级 cell 菜单复刻。
3
4
  * 数据来自官方 per-session 目录(ctx.modelDirectories.directoryFor(id).store:
4
5
  * { current, groups, status, error }),写入走官方 select(selectModel RPC)。
5
6
  * 服务缺席时触发器禁用。
7
+ *
8
+ * 0.2.1 同步:模型名的显示名经官方 `conversation.input.model.label` 子槽位
9
+ * 渲染(ModelLabel)——桌面版宿主的 deepseek-model-label-1017 插件会给
10
+ * deepseek-flash 追加「1017」发布徽章,web 宿主无条目回退纯名;思考强度
11
+ * 显示对齐官方公式(selection 未显式指定时回落模型 defaultEffort,模型无
12
+ * reasoning 元数据时显示会话 retainedEffort,均无才显示 Default),推理
13
+ * 等级菜单在模型未声明默认强度时补官方的 Default(跟随提供方默认)行。
6
14
  */
7
15
 
8
16
  import { useEffect, useRef, useState } from 'react';
9
- import type { InputActions, ModelCatalogModel, ModelProviderGroup, ModelSelection } from '../host-types';
17
+ import type { ModelCatalogModel, ModelProviderGroup, ModelSelection } from '../host-types';
10
18
  import { getSessionDirectory, type DirectorySnapshot } from '../modeldir';
11
19
  import { stash } from '../services';
12
20
  import { IconChevronOfficial } from '../icons';
21
+ import { ModelLabel } from './ModelLabel';
13
22
  import { MenuCheck, MenuSurface } from './MenuSurface';
14
23
  import { useSnapshot } from './useSnapshot';
15
24
  import { t } from '../i18n';
@@ -41,13 +50,24 @@ function modelLabel(selection: ModelSelection | null, groups: readonly ModelProv
41
50
  return findCurrent(selection, groups)?.model.name ?? selection.model;
42
51
  }
43
52
 
44
- /** 推理等级显示名(目录 efforts[].name,如 High;未热时退回首字母大写 id)。 */
45
- function effortLabel(selection: ModelSelection | null, groups: readonly ModelProviderGroup[]): string {
46
- const effortId = selection?.reasoningEffort;
47
- if (!effortId) return 'Default';
48
- const named = findCurrent(selection, groups)?.model.reasoning?.efforts.find((e) => e.id === effortId);
49
- if (named) return named.name;
50
- return effortId.charAt(0).toUpperCase() + effortId.slice(1);
53
+ /**
54
+ * 思考强度显示(官方 ModelSelect 的 effortLabel 公式):目录里找不到当前
55
+ * 模型 → 无强度段;模型无 reasoning 元数据 → 会话 retainedEffort(官方
56
+ * 已折成显示名);selection 未显式指定 → 模型 defaultEffort 的名字;再
57
+ * 无才显示 Default(effort.providerDefault,官方中英同为「Default」)。
58
+ */
59
+ function effortLabel(
60
+ selection: ModelSelection | null,
61
+ groups: readonly ModelProviderGroup[],
62
+ snapshot: DirectorySnapshot | undefined,
63
+ ): string | undefined {
64
+ const found = findCurrent(selection, groups);
65
+ if (!found) return undefined;
66
+ const reasoning = found.model.reasoning;
67
+ if (!reasoning) return snapshot?.retainedEffort;
68
+ const effective = selection?.reasoningEffort ?? reasoning.defaultEffort;
69
+ if (effective === undefined) return 'Default';
70
+ return reasoning.efforts.find((level) => level.id === effective)?.name ?? effective;
51
71
  }
52
72
 
53
73
  export function ModelMenu(props: ModelMenuProps): JSX.Element {
@@ -97,8 +117,20 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
97
117
 
98
118
  const current = snap?.current ?? null;
99
119
  const groups = snap?.groups ?? [];
120
+ const found = findCurrent(current, groups);
100
121
  const currentModelName = modelLabel(current, groups);
101
- const currentEffort = effortLabel(current, groups);
122
+ const currentEffort = effortLabel(current, groups, snap);
123
+ const triggerTitle = currentEffort === undefined ? currentModelName : `${currentModelName} · ${currentEffort}`;
124
+ /** 官方 renderModelLabel:触发器/根菜单 cell 位置的当前模型显示名。 */
125
+ const renderCurrentName = (placement: 'trigger' | 'menu', target: NonNullable<typeof found>): JSX.Element => (
126
+ <ModelLabel
127
+ provider={{ id: target.group.id, name: target.group.name }}
128
+ model={target.model}
129
+ placement={placement}
130
+ selected={true}
131
+ fallback={currentModelName}
132
+ />
133
+ );
102
134
 
103
135
  const pick = async (group: ModelProviderGroup, model: ModelCatalogModel, effortId: string | undefined): Promise<void> => {
104
136
  if (!directory || busy) return;
@@ -127,15 +159,18 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
127
159
  ref={triggerRef}
128
160
  type="button"
129
161
  className="dci-trigger"
162
+ data-model=""
130
163
  style={{ maxWidth: 'min(360px, 45cqw)' }}
131
164
  data-open={open}
132
165
  onMouseDown={(e) => e.preventDefault()}
133
166
  disabled={!directory}
134
167
  onClick={() => (open ? close() : openMenu())}
135
- aria-label={t("tool.model", { name: `${currentModelName} · ${currentEffort}` })}
136
- title={t("tool.model", { name: `${currentModelName} · ${currentEffort}` })}
168
+ aria-haspopup="menu"
169
+ aria-expanded={open}
170
+ aria-label={t("tool.model", { name: triggerTitle })}
171
+ title={t("tool.model", { name: triggerTitle })}
137
172
  >
138
- <span className="dci-triggerLabel">{currentModelName}</span>
173
+ <span className="dci-triggerLabel">{found ? renderCurrentName('trigger', found) : currentModelName}</span>
139
174
  {currentEffort ? <span className="dci-triggerEffort">{currentEffort}</span> : null}
140
175
  <span className="dci-chev" data-open={open}>
141
176
  <IconChevronOfficial size={14} />
@@ -149,18 +184,22 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
149
184
  <>
150
185
  <button type="button" className="dci-cell" onClick={() => setView('models')}>
151
186
  <span className="dci-cellLabel">{t("menu.model")}</span>
152
- <span className="dci-cellValue">{currentModelName}</span>
153
- <span className="dci-cellChevron" style={{ transform: 'rotate(-90deg)' }} aria-hidden="true">
154
- <IconChevronOfficial size={14} />
187
+ <span className="dci-cellValue">
188
+ {found ? renderCurrentName('menu', found) : currentModelName}
155
189
  </span>
156
- </button>
157
- <button type="button" className="dci-cell" onClick={() => setView('efforts')}>
158
- <span className="dci-cellLabel">{t("menu.reasoning")}</span>
159
- <span className="dci-cellValue">{currentEffort}</span>
160
190
  <span className="dci-cellChevron" style={{ transform: 'rotate(-90deg)' }} aria-hidden="true">
161
191
  <IconChevronOfficial size={14} />
162
192
  </span>
163
193
  </button>
194
+ {found?.model.reasoning ? (
195
+ <button type="button" className="dci-cell" onClick={() => setView('efforts')}>
196
+ <span className="dci-cellLabel">{t("menu.reasoning")}</span>
197
+ <span className="dci-cellValue">{currentEffort}</span>
198
+ <span className="dci-cellChevron" style={{ transform: 'rotate(-90deg)' }} aria-hidden="true">
199
+ <IconChevronOfficial size={14} />
200
+ </span>
201
+ </button>
202
+ ) : null}
164
203
  </>
165
204
  ) : null}
166
205
 
@@ -181,7 +220,15 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
181
220
  title={model.description}
182
221
  onClick={() => void pick(group, model, model.reasoning?.defaultEffort)}
183
222
  >
184
- <span className="dci-itemLabel">{model.name}</span>
223
+ <span className="dci-itemLabel">
224
+ <ModelLabel
225
+ provider={{ id: group.id, name: group.name }}
226
+ model={model}
227
+ placement="option"
228
+ selected={active}
229
+ fallback={model.name}
230
+ />
231
+ </span>
185
232
  {active ? (
186
233
  <span className="dci-check" aria-hidden="true">
187
234
  <MenuCheck size={16} />
@@ -200,21 +247,34 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
200
247
  <>
201
248
  <div className="dci-menuLabel">{t("menu.reasoning")}</div>
202
249
  {(() => {
203
- const found = findCurrent(current, groups);
204
- const efforts = found?.model.reasoning?.efforts ?? [];
205
- if (efforts.length === 0) return <div className="dci-empty">{t("menu.reasoningNone")}</div>;
206
- return efforts.map((eff) => {
207
- const active = current?.reasoningEffort === eff.id;
250
+ const reasoning = found?.model.reasoning;
251
+ if (!reasoning) return <div className="dci-empty">{t("menu.reasoningNone")}</div>;
252
+ const effectiveEffort = current?.reasoningEffort ?? reasoning.defaultEffort;
253
+ // 官方 effortChoices:模型未声明默认强度时补一行 Default
254
+ // (写回时省略 reasoningEffort = 跟随提供方默认)。
255
+ const rows: { key: string; effort: string | undefined; label: string }[] = [
256
+ ...(reasoning.defaultEffort === undefined
257
+ ? [{ key: 'provider-default', effort: undefined as string | undefined, label: 'Default' }]
258
+ : []),
259
+ ...reasoning.efforts.map((eff) => ({
260
+ key: `effort:${eff.id}`,
261
+ effort: eff.id,
262
+ label: eff.name,
263
+ })),
264
+ ];
265
+ return rows.map((row) => {
266
+ const active = effectiveEffort === row.effort;
267
+ const named = row.effort === undefined ? undefined : reasoning.efforts.find((e) => e.id === row.effort);
208
268
  return (
209
269
  <button
210
- key={eff.id}
270
+ key={row.key}
211
271
  type="button"
212
272
  className="dci-item"
213
273
  disabled={busy}
214
- title={eff.description}
215
- onClick={() => found && void pick(found.group, found.model, eff.id)}
274
+ title={named?.description}
275
+ onClick={() => found && void pick(found.group, found.model, row.effort)}
216
276
  >
217
- <span className="dci-itemLabel">{eff.name}</span>
277
+ <span className="dci-itemLabel">{row.label}</span>
218
278
  {active ? (
219
279
  <span className="dci-check" aria-hidden="true">
220
280
  <MenuCheck size={16} />
@@ -114,7 +114,8 @@ export function PopupSelectView(props: PopupSelectViewProps): JSX.Element | null
114
114
  </div>
115
115
  </div>
116
116
  ) : (
117
- <>
117
+ /* 官方两级结构:卡片 overflow:hidden,列表在 .dci-viewport 内滚 */
118
+ <div className="dci-viewport">
118
119
  {state.status === 'pending' ? <div className="dci-empty">{t('menu.loading')}</div> : null}
119
120
  {state.status === 'ready' && filtered.length === 0 ? <div className="dci-empty">{t('menu.empty')}</div> : null}
120
121
  {filtered.map((option, index) => (
@@ -132,7 +133,7 @@ export function PopupSelectView(props: PopupSelectViewProps): JSX.Element | null
132
133
  </span>
133
134
  </button>
134
135
  ))}
135
- </>
136
+ </div>
136
137
  )}
137
138
  {state.error && state.status === 'ready' ? <div className="dci-error">{state.error}</div> : null}
138
139
  </div>
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * 工具行:结构与样式逐条对齐官方 InputBar 工具行——
3
- * 左侧 .dci-tools:指令(plus)、权限(shield 触发器);
3
+ * 左侧 .dci-tools:指令(plus)、权限(shield 触发器),其后是官方左区末位
4
+ * 的 conversation.input.left 座位(第三方条目镜像,见 InputLeftSlot);
4
5
  * 右侧 .dci-trailing:模型与思考强度(触发器)、语音输入(官方 activity 槽位
5
- * 条目,桌面版宿主才有)、发送(.primary 圆钮)。
6
+ * 条目,桌面版宿主才有)、发送(.primary 圆钮);conversation.input.right 座位
7
+ * 在 .dci-standard-controls 内、模型之前(见 InputRightSlot)。
6
8
  * 按钮沿用官方 keepFocus 语义(mousedown 不夺走编辑器焦点)。
7
9
  * 附件无独立按钮(官方已并入 slash「添加 · 文件」),粘贴/拖拽路径保留。
8
10
  * 0.1.7-rc.2 起官方上下文小圈不再在工具行(挪进卡片下方 dock 行,见 StatsRow)。
@@ -18,6 +20,8 @@ import { IconPlusOfficial, IconSendOfficial, IconStopOfficial } from '../icons';
18
20
  import { ModelMenu } from './ModelMenu';
19
21
  import { PermissionMenu } from './PermissionMenu';
20
22
  import { VoiceActivity } from './VoiceActivity';
23
+ import { InputLeftSlot } from './InputLeftSlot';
24
+ import { InputRightSlot } from './InputRightSlot';
21
25
 
22
26
  export interface ToolbarProps {
23
27
  sessionId: string | undefined;
@@ -27,6 +31,13 @@ export interface ToolbarProps {
27
31
  /** caret 偏移(指令按钮以当前 caret 构造 synthetic hit)。 */
28
32
  caretRef: { current: number | null };
29
33
  useProjection: UseProjection | undefined;
34
+ /**
35
+ * conversation.input.left / .right 条目的 standard props 原样面(宿主下发
36
+ * 给本插件 bar/body 条目的 kit 减去 owner props),供两座第三方条目转发
37
+ * 用。它是条目 props 的一部分而非门控:缺席时条目仍按槽位条目渲染,只是
38
+ * 拿不到这组标准面(见 InputSlotSeat)。
39
+ */
40
+ inputStandard?: Record<string, unknown> | undefined;
30
41
  /** owner disabled(权限触发器随之锁住,对齐官方 locked)。 */
31
42
  disabled?: boolean;
32
43
  onNotify?(level: 'info' | 'error', text: string): void;
@@ -45,7 +56,7 @@ function keepFocus(event: React.MouseEvent): void {
45
56
  }
46
57
 
47
58
  export function Toolbar(props: ToolbarProps): JSX.Element {
48
- const { sessionId, state, actions, triggers, caretRef, useProjection, disabled, onNotify } = props;
59
+ const { sessionId, state, actions, triggers, caretRef, useProjection, disabled, onNotify, inputStandard } = props;
49
60
  useT();
50
61
 
51
62
  const attachments: readonly DraftAttachmentId[] = state?.attachmentIds ?? [];
@@ -81,9 +92,19 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
81
92
  disabled={disabled}
82
93
  onNotify={onNotify}
83
94
  />
95
+ {/* 官方左区同构:`.tools` 内 modes(permission→plan)之后是
96
+ renderSlot("conversation.input.left") 的末位。 */}
97
+ {state !== undefined && sessionId !== undefined ? (
98
+ <InputLeftSlot sessionId={sessionId} standard={inputStandard} />
99
+ ) : null}
84
100
  </div>
85
101
  <div className="dci-trailing">
86
102
  <div className="dci-standard-controls">
103
+ {/* 官方同款门槛:input === undefined || sessionId === undefined 时
104
+ 整个 input.right 座位不渲染(renderSlot 调用点的三元条件)。 */}
105
+ {state !== undefined && sessionId !== undefined ? (
106
+ <InputRightSlot sessionId={sessionId} standard={inputStandard} />
107
+ ) : null}
87
108
  <ModelMenu
88
109
  sessionId={sessionId}
89
110
  onNotify={onNotify}