dsh-codinput 0.1.1 → 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.
@@ -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,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「添加 · 文件」),粘贴/拖拽路径保留。
6
7
  * 0.1.7-rc.2 起官方上下文小圈不再在工具行(挪进卡片下方 dock 行,见 StatsRow)。
7
8
  */
8
9
 
9
- import { useRef } from 'react';
10
10
  import type { DraftAttachmentId, InputActions, InputState, InputTriggerController, UseProjection } from '../host-types';
11
11
  import { openCommandMenu } from '../triggers';
12
- import { stash } from '../services';
13
- import { addFilesToDraft } from '../attachments';
14
12
  import { t, useT } from '../i18n';
15
- import { IconPaperclipOfficial, IconPlusOfficial, IconSendOfficial } from '../icons';
13
+ import { IconPlusOfficial, IconSendOfficial, IconStopOfficial } from '../icons';
16
14
  import { ModelMenu } from './ModelMenu';
17
15
  import { PermissionMenu } from './PermissionMenu';
18
- import { useSnapshot } from './useSnapshot';
19
16
 
20
17
  export interface ToolbarProps {
21
18
  sessionId: string | undefined;
@@ -31,6 +28,10 @@ export interface ToolbarProps {
31
28
  /** 主发送手势(与编辑器发送键同一策略)。 */
32
29
  onSend(): void;
33
30
  sendDisabled?: boolean;
31
+ /** 会话运行中且草稿空(官方 primaryStops):主按钮变停止。 */
32
+ primaryStops?: boolean;
33
+ /** 停止生成(官方 stop:conversation.cancel)。 */
34
+ onStop?(): void;
34
35
  }
35
36
 
36
37
  /** 官方 keepFocus:mousedown 阻止默认,避免编辑器失焦。 */
@@ -41,7 +42,6 @@ function keepFocus(event: React.MouseEvent): void {
41
42
  export function Toolbar(props: ToolbarProps): JSX.Element {
42
43
  const { sessionId, state, actions, triggers, caretRef, useProjection, disabled, onNotify } = props;
43
44
  useT();
44
- const fileRef = useRef<HTMLInputElement | null>(null);
45
45
 
46
46
  const attachments: readonly DraftAttachmentId[] = state?.attachmentIds ?? [];
47
47
 
@@ -50,10 +50,6 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
50
50
  openCommandMenu(triggers, undefined, caretRef.current ?? 0);
51
51
  };
52
52
 
53
- const onAttach = (files: FileList | null): void => {
54
- addFilesToDraft(sessionId, actions, files, onNotify);
55
- };
56
-
57
53
  return (
58
54
  <div className="dci-row">
59
55
  <div className="dci-tools">
@@ -69,26 +65,6 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
69
65
  >
70
66
  <IconPlusOfficial size={14} />
71
67
  </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
68
  <PermissionMenu
93
69
  sessionId={sessionId}
94
70
  actions={actions}
@@ -106,13 +82,13 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
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>
@@ -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
  };
@@ -27,18 +27,24 @@ function svg(paths: JSX.Element, size: number | undefined, className: string | u
27
27
  );
28
28
  }
29
29
 
30
- /** 品牌:lucide file-code-corner。 */
30
+ /**
31
+ * 品牌图形:DSH-Codinput 机型标(代码括号 + 航迹斜杠 + 纸飞机)。
32
+ * 大尺寸原稿见 assets/icon.svg / icon-white.svg;这里是 24 栅格的
33
+ * 小尺寸简化——发卡弯并线、钩脚并入单笔括号,斜杠上收让出机鼻。
34
+ */
35
+ const BRAND_MARK = (
36
+ <>
37
+ <path d="M6.8 5.8 2.9 11l4 4.4" />
38
+ <path d="M17.2 5.8 21.1 11l-3.9 4.4" />
39
+ <path d="M10.7 9.5 12.5 3.9" />
40
+ <path d="M16.6 9.4 6.9 14l2.9 1.3.1 2.5q.1.8.7.3l1-2.3 2.2 1.4Z" />
41
+ <path d="M9.8 15.3 16.6 9.4" />
42
+ </>
43
+ );
44
+
45
+ /** 品牌:DSH-Codinput 机型标(悬浮小球 / 侧栏唤回球)。 */
31
46
  export function IconBrand({ size, className }: IconProps): JSX.Element {
32
- return svg(
33
- <>
34
- <path d="M4 12.15V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-3.35" />
35
- <path d="M14 2v5a1 1 0 0 0 1 1h5" />
36
- <path d="m5 16-3 3 3 3" />
37
- <path d="m9 22 3-3-3-3" />
38
- </>,
39
- size,
40
- className,
41
- );
47
+ return svg(BRAND_MARK, size, className);
42
48
  }
43
49
 
44
50
  export function IconEdit({ size, className }: IconProps): JSX.Element {
@@ -167,7 +173,7 @@ export function IconPlusOfficial({ size }: { size?: number }): JSX.Element {
167
173
  );
168
174
  }
169
175
 
170
- /** 官方 IconPaperclipOutline16(附件按钮;官方为 fill 路径自带描边轮廓,不得再叠 stroke)。 */
176
+ /** 官方 IconPaperclipOutline16(slash 添加·文件 补位条目的 glyph;官方为 fill 路径自带描边轮廓)。 */
171
177
  export function IconPaperclipOfficial({ size }: { size?: number }): JSX.Element {
172
178
  return (
173
179
  <svg width={size ?? 14} height={size ?? 14} viewBox="0 0 16 16" fill="none" aria-hidden="true">
@@ -272,25 +278,25 @@ export function IconChevronOfficial({ size }: { size?: number }): JSX.Element {
272
278
  }
273
279
 
274
280
  /**
275
- * /codinput 命令图标(宿主命令菜单 glyph:文档+代码括号,fill
276
- * currentColor,与 primitives Icon*Outline16 同构——接受 size,命令
281
+ * /codinput 命令图标(宿主命令菜单 glyph:品牌机型标,stroke
282
+ * currentColor,与 primitives Icon*Outline16 同尺度——接受 size,命令
277
283
  * 菜单里按官方语义渲染为 <icon size={16}/>)。
278
284
  */
279
285
  export function IconCodinputCommand16({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
280
286
  return (
281
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
282
- <path
283
- fillRule="evenodd"
284
- clipRule="evenodd"
285
- d="M12.25 2.83422C11.7896 2.75598 11.162 2.75005 10.0298 2.75005C8.11311 2.75005 6.75075 2.75163 5.71785 2.88987C4.70596 3.0253 4.12453 3.27933 3.7019 3.70195C3.27869 4.12516 3.02502 4.70481 2.88976 5.7109C2.75159 6.73856 2.75 8.09323 2.75 10.0001V14.0001C2.75 15.9069 2.75159 17.2615 2.88976 18.2892C3.02502 19.2953 3.27869 19.8749 3.7019 20.2981C4.12511 20.7214 4.70476 20.975 5.71085 21.1103C6.73851 21.2485 8.09318 21.2501 10 21.2501H14C15.9068 21.2501 17.2615 21.2485 18.2892 21.1103C19.2952 20.975 19.8749 20.7214 20.2981 20.2981C20.7213 19.8749 20.975 19.2953 21.1102 18.2892C21.2484 17.2615 21.25 15.9069 21.25 14.0001V13.5629C21.25 12.0269 21.2392 11.2988 21.0762 10.7501H17.9463C16.8135 10.7501 15.8877 10.7501 15.1569 10.6518C14.3929 10.5491 13.7306 10.3268 13.2019 9.79815C12.6732 9.26945 12.4509 8.60712 12.3482 7.84317C12.25 7.1123 12.25 6.18657 12.25 5.05374V2.83422ZM13.75 3.6095V5.00005C13.75 6.19976 13.7516 7.0241 13.8348 7.64329C13.9152 8.24091 14.059 8.53395 14.2626 8.73749C14.4661 8.94103 14.7591 9.08486 15.3568 9.16521C15.976 9.24846 16.8003 9.25005 18 9.25005H20.0195C19.723 8.9625 19.3432 8.61797 18.85 8.17407L14.8912 4.61117C14.4058 4.17433 14.0446 3.85187 13.75 3.6095ZM10.1755 1.25002C11.5601 1.24965 12.4546 1.24942 13.2779 1.56535C14.1012 1.88129 14.7632 2.47735 15.7873 3.39955C15.8226 3.43139 15.8584 3.46361 15.8947 3.49623L19.8534 7.05912C19.8956 7.09705 19.9372 7.1345 19.9783 7.17149C21.162 8.23614 21.9274 8.92458 22.3391 9.84902C22.7508 10.7734 22.7505 11.8029 22.75 13.3949C22.75 13.4502 22.75 13.5062 22.75 13.5629V14.0565C22.75 15.8942 22.75 17.3499 22.5969 18.4891C22.4392 19.6615 22.1071 20.6104 21.3588 21.3588C20.6104 22.1072 19.6614 22.4393 18.489 22.5969C17.3498 22.7501 15.8942 22.7501 14.0564 22.7501H9.94359C8.10583 22.7501 6.65019 22.7501 5.51098 22.5969C4.33856 22.4393 3.38961 22.1072 2.64124 21.3588C1.89288 20.6104 1.56076 19.6615 1.40314 18.4891C1.24997 17.3499 1.24998 15.8942 1.25 14.0565V9.94363C1.24998 8.10587 1.24997 6.65024 1.40314 5.51103C1.56076 4.33861 1.89288 3.38966 2.64124 2.64129C3.39019 1.89235 4.34232 1.56059 5.51887 1.40313C6.66283 1.25002 8.1257 1.25003 9.97352 1.25005L10.0298 1.25005C10.0789 1.25005 10.1275 1.25004 10.1755 1.25002Z"
286
- fill="currentColor"
287
- />
288
- <path
289
- fillRule="evenodd"
290
- clipRule="evenodd"
291
- d="M10.2633 13.2978C10.6512 13.4432 10.8477 13.8756 10.7022 14.2634L9.20225 18.2634C9.05681 18.6512 8.6245 18.8477 8.23666 18.7023C7.84882 18.5569 7.65231 18.1245 7.79775 17.7367L9.29775 13.7367C9.44319 13.3489 9.8755 13.1524 10.2633 13.2978ZM7.53033 13.4697C7.82322 13.7626 7.82322 14.2375 7.53033 14.5304L7.06066 15L7.53033 15.4697C7.82322 15.7626 7.82322 16.2375 7.53033 16.5304C7.23744 16.8233 6.76256 16.8233 6.46967 16.5304L5.46967 15.5304C5.17678 15.2375 5.17678 14.7626 5.46967 14.4697L6.46967 13.4697C6.76256 13.1768 7.23744 13.1768 7.53033 13.4697ZM10.9697 15.4697C11.2626 15.1768 11.7374 15.1768 12.0303 15.4697L13.0303 16.4697C13.3232 16.7626 13.3232 17.2375 13.0303 17.5304L12.0303 18.5304C11.7374 18.8233 11.2626 18.8233 10.9697 18.5304C10.6768 18.2375 10.6768 17.7626 10.9697 17.4697L11.4393 17L10.9697 16.5304C10.6768 16.2375 10.6768 15.7626 10.9697 15.4697Z"
292
- fill="currentColor"
293
- />
287
+ <svg
288
+ width={size}
289
+ height={size}
290
+ viewBox="0 0 24 24"
291
+ fill="none"
292
+ stroke="currentColor"
293
+ strokeWidth={2}
294
+ strokeLinecap="round"
295
+ strokeLinejoin="round"
296
+ className={className}
297
+ aria-hidden="true"
298
+ >
299
+ {BRAND_MARK}
294
300
  </svg>
295
301
  );
296
302
  }
@@ -6,13 +6,17 @@
6
6
  *
7
7
  * 唯一输入组件约定:body **挂载**期间经 sideinput 总线声明承载本会话
8
8
  * 输入(形态 = side),主输入遮蔽条目随之隐藏——收起侧栏不让位(可见性
9
- * 只用于判断「需不需要右上角小球唤出」)。标签关闭(body 卸载)后撤销
10
- * 声明,主输入原样恢复。
9
+ * 只用于判断「需不需要右上角小球唤出」)。声明以 prefs.enabled 为前提:
10
+ * 侧边模式下 /codinput 关闭接管即撤销声明、主输入回到官方输入框,标签
11
+ * 内只出提示;重新开启自动恢复。标签关闭(body 卸载)后撤销声明,主输入
12
+ * 原样恢复。
11
13
  */
12
14
 
13
15
  import { useEffect, useRef, useState } from 'react';
14
16
  import type { SessionStandard, SidebarTabInfo, SlotRegisterOptions } from './host-types';
15
- import { claimSidebarInput, useSidebarCarryOwner, type SidebarCarry } from './sideinput';
17
+ import { claimSidebarInput, subscribeSidebarTabClose, useSidebarCarryOwner, type SidebarCarry } from './sideinput';
18
+ import { loadPrefs, savePrefs, subscribePrefs } from './prefs';
19
+ import { diagError, diagWindow } from './diag';
16
20
  import { CodinputSurface } from './components/CodinputSurface';
17
21
  import { t, useT } from './i18n';
18
22
 
@@ -26,6 +30,10 @@ export function sidebarTabDefinition(): Record<string, unknown> {
26
30
  kind: SIDEBAR_TAB_KIND,
27
31
  priority: 'extension',
28
32
  title: () => 'Codinput',
33
+ // 非激活也保持 body 挂载(官方浏览器标签同款开关):承载声明挂在 body
34
+ // 生命周期上,若切到同栏其他标签(如工作区文件)body 被 dockkit 卸载,
35
+ // 声明随之消失、主输入回来——「标签存在即唯一输入入口」就不成立了。
36
+ keepMounted: true,
29
37
  guide: [
30
38
  {
31
39
  order: 0,
@@ -55,19 +63,62 @@ export function sidebarBodyRegistration(): { options: SlotRegisterOptions; compo
55
63
  * useTabInfo 内部订阅 dockkit store,展开/激活/浮动变化都会重渲染。
56
64
  */
57
65
  export function SidebarCodinputBody(props: SessionStandard & { useTabInfo?: () => SidebarTabInfo }): JSX.Element {
58
- const { sessionId, useInput, inputActions, useProjection, useTabInfo } = props;
66
+ const { sessionId, useInput, inputActions, useProjection, useSession, useTabInfo } = props;
59
67
  const state = useInput((s) => s);
68
+ // 会话运行中:侧栏形态的主按钮同样变停止(官方同款)。
69
+ const running = useSession ? useSession((s: unknown) => (s as { running?: boolean } | undefined)?.running) === true : false;
60
70
  useT();
61
71
  // 注入 hook 的有无逐渲染稳定(渲染器 useMemo 绑定),条件调用合规。
62
72
  const info = useTabInfo ? useTabInfo() : undefined;
63
73
  const visible = info?.tab.visible === true;
64
74
  const expanded = info?.sidebar.expanded === true;
65
75
 
66
- // 承载声明挂在 body 生命周期上(挂载即承载,与可见性无关);可见性与
67
- // 侧栏展开态随后续渲染实时上报(小球据此判断「已收起」)。
76
+ // /codinput 从侧栏退出 → 命令侧广播关闭请求,这里经 tab 域动作面的
77
+ // actions.close() 关掉自己的标签。关闭即卸载 body、撤销承载,主输入按
78
+ // 退出后的 prefs 恢复官方输入框。
79
+ const closeSelf = info?.tab.actions?.close;
80
+ const closeRef = useRef(closeSelf);
81
+ closeRef.current = closeSelf;
82
+ // 诊断:tabId/关闭面是否可得(宿主形状漂移时链路断在哪一目了然)。
83
+ useEffect(() => {
84
+ const shell = diagWindow().__dshCodinputShell;
85
+ shell['sidebar.tabId'] = info?.tab.id ?? 'none';
86
+ shell['sidebar.closeFace'] = closeSelf ? 'yes' : 'none';
87
+ }, [info, closeSelf]);
88
+ useEffect(() => {
89
+ return subscribeSidebarTabClose((requested) => {
90
+ if (requested !== sessionId || !closeRef.current) return;
91
+ try {
92
+ closeRef.current();
93
+ } catch (error) {
94
+ diagError('sidebar.closeTab', error);
95
+ }
96
+ });
97
+ // eslint-disable-next-line react-hooks/exhaustive-deps
98
+ }, [sessionId]);
99
+
100
+ // 接管开关是承载的前提:侧边模式下输入 /codinput(或设置里关闭)要能
101
+ // 退回官方输入框——body 撤销承载声明(主输入位随之恢复),标签里只出
102
+ // 提示;重新开启后自动恢复侧栏形态。
103
+ const [enabled, setEnabled] = useState(() => loadPrefs().enabled);
104
+ useEffect(() => subscribePrefs(() => setEnabled(loadPrefs().enabled)), []);
105
+ // 打开标签即启用:body 挂载(首次激活/重载后激活/关掉重开)时若接管
106
+ // 未开,直接开启——用户点开这个标签就是要用它。body 在 keepMounted 下
107
+ // 常驻,/codinput 退出后不会重挂,不会把退出顶回去。
108
+ const bootRef = useRef(false);
109
+ useEffect(() => {
110
+ if (bootRef.current) return;
111
+ bootRef.current = true;
112
+ if (!loadPrefs().enabled) savePrefs({ enabled: true });
113
+ }, []);
114
+
115
+ // 承载声明挂在 body 生命周期上(挂载即承载,与可见性无关),但以
116
+ // enabled 为前提;可见性与侧栏展开态随后续渲染实时上报(小球据此
117
+ // 判断「已收起」)。
68
118
  const carryRef = useRef<SidebarCarry | null>(null);
69
119
  const [token, setToken] = useState(0);
70
120
  useEffect(() => {
121
+ if (!enabled) return;
71
122
  const carry = claimSidebarInput(sessionId, { visible, expanded });
72
123
  carryRef.current = carry;
73
124
  setToken(carry.token);
@@ -76,7 +127,7 @@ export function SidebarCodinputBody(props: SessionStandard & { useTabInfo?: () =
76
127
  carry.release();
77
128
  };
78
129
  // eslint-disable-next-line react-hooks/exhaustive-deps
79
- }, [sessionId]);
130
+ }, [sessionId, enabled]);
80
131
  useEffect(() => {
81
132
  carryRef.current?.set({ visible, expanded });
82
133
  }, [visible, expanded]);
@@ -84,6 +135,13 @@ export function SidebarCodinputBody(props: SessionStandard & { useTabInfo?: () =
84
135
  // 同一会话被复制出第二个 Codinput 标签(分栏)时,只有最早声明的 body
85
136
  // 渲染编辑面——输入入口唯一,另一个 body 出提示而不是第二个编辑器。
86
137
  const owner = useSidebarCarryOwner(sessionId, token);
138
+ if (!enabled) {
139
+ return (
140
+ <div className="dci-side-notice" role="note">
141
+ {t('side.disabled')}
142
+ </div>
143
+ );
144
+ }
87
145
  if (!owner) {
88
146
  return (
89
147
  <div className="dci-side-notice" role="note">
@@ -107,6 +165,7 @@ export function SidebarCodinputBody(props: SessionStandard & { useTabInfo?: () =
107
165
  state={state}
108
166
  actions={inputActions}
109
167
  useProjection={useProjection}
168
+ running={running}
110
169
  side={true}
111
170
  />
112
171
  </div>