dsh-codinput 0.3.5 → 0.3.7

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,9 +1,10 @@
1
1
  {
2
2
  "name": "dsh-codinput",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "DSH WebUI 插件:把聊天输入框变成代码编辑器一样的输入风格(CodeMirror 6 接管 conversation.composer.bar)。",
5
5
  "license": "MIT",
6
6
  "author": "Witherwithwinter",
7
+ "icon": "./assets/icon.png",
7
8
  "keywords": [
8
9
  "dsh",
9
10
  "deepseek",
@@ -27,7 +27,7 @@ const FILE_FOLD =
27
27
  'M26.3909 7.37445L19.0525 0V3.77445C19.0525 4.89271 19.0525 5.45184 19.2352 5.89289C19.4788 6.48096 19.946 6.94818 20.5341 7.19176C20.9751 7.37445 21.5342 7.37445 22.6525 7.37445H26.3909Z';
28
28
 
29
29
  /** 官方 FileTypeIcon 的通用文件 glyph(28×28 视图框,fill 自带轮廓)。 */
30
- function FileGlyph({ size = 28 }: { size?: number }): JSX.Element {
30
+ export function FileGlyph({ size = 28 }: { size?: number }): JSX.Element {
31
31
  return (
32
32
  <svg width={size} height={size} viewBox="0 0 28 28" fill="none" aria-hidden="true">
33
33
  <path d={FILE_BODY} fill="currentColor" />
@@ -37,14 +37,14 @@ function FileGlyph({ size = 28 }: { size?: number }): JSX.Element {
37
37
  }
38
38
 
39
39
  /** 官方 fileExtension:取 basename 最后一个点之后的部分。 */
40
- function fileExtension(name: string): string {
40
+ export function fileExtension(name: string): string {
41
41
  const base = name.split(/[\\/]/).pop() ?? name;
42
42
  const dot = base.lastIndexOf('.');
43
43
  return dot < 0 ? '' : base.slice(dot + 1);
44
44
  }
45
45
 
46
46
  /** 官方 fileSizeText 逐值复刻。 */
47
- function fileSizeText(bytes: number): string {
47
+ export function fileSizeText(bytes: number): string {
48
48
  if (bytes < 1024) return `${bytes}B`;
49
49
  const kb = bytes / 1024;
50
50
  if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)}KB`;
@@ -25,9 +25,8 @@
25
25
  * 私有),现网无此类条目;如出现则降级为无 store props 渲染。
26
26
  */
27
27
 
28
- import { createElement, useMemo } from 'react';
29
- import { diagError } from '../diag';
30
- import { EntryBoundary, entryId, entryInjectProps, listRows, translatorFor, useSlotEntries } from './slotEntry';
28
+ import { useMemo } from 'react';
29
+ import { entryId, listRows, renderSlotEntry, useSlotEntries } from './slotEntry';
31
30
 
32
31
  export interface InputSlotSeatProps {
33
32
  /** 官方子槽位全名(如 conversation.input.left)。 */
@@ -46,35 +45,5 @@ export function InputSlotSeat(props: InputSlotSeatProps): JSX.Element | null {
46
45
  // 官方同款门槛:无会话不渲染(input.left / input.right 都是 strict session 槽位)。
47
46
  if (sessionId === undefined || rows.length === 0) return null;
48
47
 
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
- );
48
+ return <>{rows.map((entry, index) => renderSlotEntry(entry, entryId(entry, index), { sessionId, standard }))}</>;
80
49
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 模型座单值镜像(issue #4):`conversation.input.model`。
3
+ *
4
+ * 座位事实(宿主 0.2.1-rc.2 取证):
5
+ * - `conversation.input.model` 是 `{kind:"single", scope:"session"}` 单值槽,
6
+ * 由官方 InputBar 条目(`conversation.composer.bar` 缺省条目)声明,唯一
7
+ * 渲染点在 InputBar 体内:`input.model → renderSlot(..., { locked })`;
8
+ * 官方占用者是 client-ui-model-selection 的 ModelSelect(替换风险标记
9
+ * `shadows-shipped-ui`,生态替换类插件按契约以 priority 遮蔽它)。
10
+ * - Codinput 以 priority 遮蔽整个 bar 后,该 renderSlot 调用点随官方条目一起
11
+ * 消失;但「遮蔽」不是 dispose——宿主条目仍在 ledger 上,座位声明仍在,
12
+ * 第三方对它的 `slots.inject` 照常触发、`register` 照常成功(单值槽低
13
+ * priority 无人竞争时静默赢)、`entries` 里它确实是赢家,只是没有任何
14
+ * renderSlot 会读它。表现为「插件装了、不报错、永不上屏」。
15
+ *
16
+ * 本组件的收敛口径(0.3.6 起):
17
+ * - **只镜像第三方遮蔽者**(宿主动态包:register 被强制分配负数遮蔽秩、
18
+ * fiber 名带 `dyn/` 前缀),见 `isShadowingOccupant`;
19
+ * - **出厂占用的 ModelSelect 不镜像**——那个座位一直由 Codinput 自绘的模型
20
+ * 芯片代表(模型名显示仍走官方 `conversation.input.model.label` 子座),
21
+ * 镜像出厂条目会与自绘芯片重复成两个模型选择器;
22
+ * - 有第三方遮蔽者时**让位**:镜像其组件、隐藏自绘芯片(同座只留一个控件,
23
+ * 与官方「一个单值座只画一个占用者」一致)。
24
+ *
25
+ * 官方门槛同款复刻:`sessionId === undefined` 时该座位不渲染(此时自绘芯片
26
+ * 保持现状)。owner prop `locked`(官方 `InputControlOwnerProps`:会话是否
27
+ * 拒绝交互)原样下发——生态替换类插件按契约消费它(如 reasoning-slider 的
28
+ * `props.locked === true` 置灰)。
29
+ */
30
+
31
+ import { useMemo } from 'react';
32
+ import type { ReactNode } from 'react';
33
+ import type { SlotEntryLike } from '../host-types';
34
+ import {
35
+ entryId,
36
+ entryInjectFailed,
37
+ firstComponentEntry,
38
+ isShadowingOccupant,
39
+ renderSlotEntry,
40
+ useSlotEntries,
41
+ } from './slotEntry';
42
+
43
+ const MODEL_SLOT = 'conversation.input.model';
44
+
45
+ export interface ModelSlotProps {
46
+ sessionId: string | undefined;
47
+ /** 宿主下发给本插件条目的 standard props 原样面(见 InputSlotSeat)。 */
48
+ standard: Record<string, unknown> | undefined;
49
+ /** 官方 owner prop locked(会话拒绝交互时第三方座条目应置灰)。 */
50
+ locked: boolean;
51
+ /** 无第三方遮蔽者时的自绘模型芯片(保持既有 UX)。 */
52
+ fallback: ReactNode;
53
+ }
54
+
55
+ /** 模型座的第三方遮蔽者(无则 null):单值槽赢家且命中动态包判据。 */
56
+ export function useModelSeatOccupant(): SlotEntryLike | null {
57
+ const entries = useSlotEntries(MODEL_SLOT);
58
+ return useMemo(() => {
59
+ const winner = firstComponentEntry(entries);
60
+ return winner !== null && isShadowingOccupant(winner) ? winner : null;
61
+ }, [entries]);
62
+ }
63
+
64
+ export function ModelSlot(props: ModelSlotProps): JSX.Element | null {
65
+ const { sessionId, standard, locked, fallback } = props;
66
+ const occupant = useModelSeatOccupant();
67
+
68
+ // 官方门槛:无会话不画该座位;自绘芯片(fallback)保持既有行为。
69
+ if (sessionId === undefined || occupant === null) return <>{fallback}</>;
70
+
71
+ // 让位可行性:inject 面必须在宿主 fiber 上下文里才算数(见 slotEntry)。
72
+ // 若该条目的 inject 依赖宿主服务面(插件侧调用会抛 without inject),
73
+ // 让位降级——回退自绘芯片(它本就是官方模型选择的等价物),而不是留空。
74
+ if (entryInjectFailed(occupant, sessionId)) return <>{fallback}</>;
75
+
76
+ return renderSlotEntry(occupant, `model:${entryId(occupant, 0)}`, {
77
+ sessionId,
78
+ standard,
79
+ ownerProps: { locked },
80
+ // 组件本体崩溃同样回退自绘芯片(逐条目边界已在 renderSlotEntry 内)。
81
+ fallback,
82
+ });
83
+ }
@@ -197,7 +197,9 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
197
197
 
198
198
  export function StatsRow(props: StatsRowProps): JSX.Element | null {
199
199
  const { useProjection, sessionId, chatNodes } = props;
200
- const [open, setOpen] = useState<'rounds' | 'tokens' | null>(null);
200
+ // 唯一展开:三枚(轮步/用量/上下文小圈)共用一个 open 态——任意一枚展开
201
+ // 即收起其余(官方语义:详情面板同时只存在一个)。
202
+ const [open, setOpen] = useState<'rounds' | 'tokens' | 'gauge' | null>(null);
201
203
  const rootRef = useRef<HTMLDivElement | null>(null);
202
204
  const stats = useSessionStats(useProjection, sessionId, chatNodes);
203
205
 
@@ -226,7 +228,11 @@ export function StatsRow(props: StatsRowProps): JSX.Element | null {
226
228
  <TokenPill stats={stats} open={open === 'tokens'} onToggle={() => setOpen(open === 'tokens' ? null : 'tokens')} />
227
229
  </>
228
230
  ) : null}
229
- <ContextGauge useProjection={useProjection} />
231
+ <ContextGauge
232
+ useProjection={useProjection}
233
+ open={open === 'gauge'}
234
+ onToggle={() => setOpen(open === 'gauge' ? null : 'gauge')}
235
+ />
230
236
  </div>
231
237
  );
232
238
  }
@@ -252,10 +258,14 @@ function shortTokens(n: number): string {
252
258
  }
253
259
 
254
260
  /** 上下文占用小圈(官方 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);
261
+ * 0.1.7-rc.2 起官方渲染在 dock 行 stats 胶囊之后(原先在工具行 trailing)。
262
+ * 展开态由 StatsRow 持有(三枚详情面板唯一展开)。 */
263
+ export function ContextGauge(props: {
264
+ useProjection: UseProjection | undefined;
265
+ open: boolean;
266
+ onToggle(): void;
267
+ }): JSX.Element | null {
268
+ const { useProjection, open, onToggle } = props;
259
269
  const rootRef = useRef<HTMLSpanElement | null>(null);
260
270
  const pressure = useProjection
261
271
  ? (useProjection('contextPressure') as PressureSnapshot | undefined)
@@ -263,16 +273,6 @@ export function ContextGauge(props: { useProjection: UseProjection | undefined }
263
273
  const breakdown = useProjection
264
274
  ? (useProjection('contextBreakdown') as BreakdownSnapshot | undefined)
265
275
  : 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
276
 
277
277
  if (!pressure || !pressure.contextWindow) return null;
278
278
  const windowTokens = pressure.contextWindow;
@@ -294,7 +294,7 @@ export function ContextGauge(props: { useProjection: UseProjection | undefined }
294
294
  aria-haspopup="dialog"
295
295
  aria-expanded={open}
296
296
  title={t("tool.context", { percent: Math.round(pct) })}
297
- onClick={() => setOpen(!open)}
297
+ onClick={onToggle}
298
298
  >
299
299
  <svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true">
300
300
  <circle className="dci-gauge-track" cx="7" cy="7" r="5.5" />
@@ -18,6 +18,7 @@ import { openCommandMenu } from '../triggers';
18
18
  import { t, useT } from '../i18n';
19
19
  import { IconPlusOfficial, IconSendOfficial, IconStopOfficial } from '../icons';
20
20
  import { ModelMenu } from './ModelMenu';
21
+ import { ModelSlot } from './ModelSlot';
21
22
  import { PermissionMenu } from './PermissionMenu';
22
23
  import { VoiceActivity } from './VoiceActivity';
23
24
  import { InputLeftSlot } from './InputLeftSlot';
@@ -105,9 +106,14 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
105
106
  {state !== undefined && sessionId !== undefined ? (
106
107
  <InputRightSlot sessionId={sessionId} standard={inputStandard} />
107
108
  ) : null}
108
- <ModelMenu
109
+ {/* 官方右区顺序:input.right → input.model。模型座单值槽的第三方
110
+ 遮蔽者(如 reasoning-slider / dsh-model-accordion / effort-dial)
111
+ 由 ModelSlot 镜像并让位;无遮蔽者时回退自绘模型芯片(issue #4)。 */}
112
+ <ModelSlot
109
113
  sessionId={sessionId}
110
- onNotify={onNotify}
114
+ standard={inputStandard}
115
+ locked={disabled === true}
116
+ fallback={<ModelMenu sessionId={sessionId} onNotify={onNotify} />}
111
117
  />
112
118
  </div>
113
119
  <div className="dci-voice-seat">