@mzzsfy/dsh-shell-select 0.3.1 → 0.4.1

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/src/client.js CHANGED
@@ -8,7 +8,8 @@ window.__ModuleLoader__.load({
8
8
  const React = require('react')
9
9
  const { useState, useEffect } = React
10
10
 
11
- // 官方 primitives 图标/状态点(运行时模块表解析;dsh.client.inject 声明保证在场)
11
+ // 官方 primitives 图标/状态点(运行时模块表解析;dsh.client.inject 声明保证在场)。
12
+ // 0.1.7 起图标名带字重后缀(Regular/Medium),尺寸是 prop 不在名字里
12
13
  const primitives = (() => {
13
14
  try {
14
15
  return require('@deepseek-ai/dsh-client-ui-primitives')
@@ -16,31 +17,31 @@ window.__ModuleLoader__.load({
16
17
  return null
17
18
  }
18
19
  })()
19
- const createElementOf = (name, fallback) => {
20
- const Component = primitives !== null ? primitives[name] : undefined
21
- return typeof Component === 'function' || typeof Component === 'object'
22
- ? (props) => React.createElement(Component, props ?? null)
23
- : fallback
20
+ const createElementOf = (names, fallback) => {
21
+ for (const name of names) {
22
+ const Component = primitives !== null ? primitives[name] : undefined
23
+ if (typeof Component === 'function' || typeof Component === 'object') {
24
+ return (props) => React.createElement(Component, props ?? null)
25
+ }
26
+ }
27
+ return fallback
24
28
  }
25
29
  const DOT_SVG = { done: '#2e9e5b', error: '#d4553f', warning: '#d9a13b', ongoing: '#7a7f8a' }
26
30
  // 降级自绘:模块表缺 primitives 时保持可见性(形态近似官方 StateDot/图标)
27
31
  const TOOLVIEW_ICONS = {
28
- StateDot: createElementOf('StateDot', ({ state }) => h('span', {
32
+ StateDot: createElementOf(['StateDot'], ({ state }) => h('span', {
29
33
  style: {
30
34
  width: 8, height: 8, borderRadius: '50%', background: DOT_SVG[state] ?? DOT_SVG.ongoing,
31
35
  display: 'inline-block', flex: 'none',
32
36
  },
33
37
  })),
34
- IconApi: createElementOf('IconApiOutline14', () => h('span', {
38
+ IconApi: createElementOf(['IconApiOutlineRegular', 'IconApiOutlineMedium', 'IconApiOutline14'], () => h('span', {
35
39
  style: {
36
40
  width: 12, height: 12, borderRadius: 3, border: '1.5px solid currentColor',
37
41
  opacity: .7, display: 'inline-block', flex: 'none',
38
42
  },
39
43
  })),
40
- IconChevron: createElementOf('IconChevronDownOutline14', ({ size }) => h('span', {
41
- style: { fontSize: (size ?? 14) - 3, lineHeight: 1, userSelect: 'none' },
42
- }, '▾')),
43
- IconInspect: createElementOf('IconInspectOutline12', () => h('span', {
44
+ IconInspect: createElementOf(['IconInspectOutlineRegular', 'IconInspectOutlineMedium', 'IconInspectOutline12'], () => h('span', {
44
45
  style: { fontSize: 10, lineHeight: 1, opacity: .8 },
45
46
  }, 'ⓘ')),
46
47
  }
@@ -66,7 +67,7 @@ window.__ModuleLoader__.load({
66
67
  }
67
68
 
68
69
  // 工具卡客户端名册缓存:GET /config 单飞拉取,shell 徽章按 id→用户命名解析。
69
- // 失败置 null(徽章不渲染),不重试——下次页面加载自然重取
70
+ // 失败复位单飞(宿主启动窗口路由未就绪的 404 在下次卡渲染自然重试)
70
71
  let clientCatalog = null
71
72
  let clientCatalogPromise = null
72
73
  // LOGIC-BEGIN ensureClientCatalog
@@ -74,9 +75,11 @@ window.__ModuleLoader__.load({
74
75
  if (clientCatalogPromise === null) {
75
76
  clientCatalogPromise = api(API.config).then((section) => {
76
77
  const byId = {}
77
- for (const entry of section.resolved?.shells ?? []) byId[entry.id] = entry.name
78
+ for (const entry of section.resolved?.shells ?? []) byId[entry.id] = entry.id
78
79
  clientCatalog = { default: section.resolved?.default, byId }
79
- }).catch(() => { })
80
+ }).catch(() => {
81
+ clientCatalogPromise = null
82
+ })
80
83
  }
81
84
  return clientCatalogPromise
82
85
  }
@@ -128,8 +131,13 @@ window.__ModuleLoader__.load({
128
131
  '.sls-tv__row { display:flex; align-items:center; gap:7px; padding:2px 0; cursor:default; }',
129
132
  '.sls-tv__row--exp { cursor:pointer; user-select:none; }',
130
133
  '.sls-tv__lead { display:flex; align-items:center; gap:4px; color:var(--dsw-alias-label-tertiary, inherit); }',
131
- '.sls-tv__chev { opacity:.45; transition:transform .15s ease; }',
132
- '.sls-tv__row[data-open="1"] .sls-tv__chev { transform:rotate(-90deg); }',
134
+ // 官方 ToolRow 同构:行首仅 icon,无展开箭头(可展开性由指针与整行点击承载);
135
+ // 错误/进行中状态点绝对定位覆盖 icon(红点掩盖 icon)
136
+ '.sls-tv__leadStack { position:relative; width:16px; height:16px; display:inline-flex; align-items:center; justify-content:center; flex:none; }',
137
+ '.sls-tv__iconIdle { display:inline-flex; }',
138
+ '.sls-tv__stateCover { position:absolute; inset:0; margin:auto; display:inline-flex; align-items:center; justify-content:center; background:transparent; }',
139
+ '.sls-tv__stateCover::before { content:""; position:absolute; inset:-2px; border-radius:50%; background:var(--dsw-alias-bg-primary, #fff); }',
140
+ '.sls-tv__stateCover > span { position:relative; }',
133
141
  '.sls-tv__sr { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); }',
134
142
  '.sls-tv__title { font-weight:400; }',
135
143
  '.sls-tv__sep { width:2px; height:2px; border-radius:1px; background:var(--dsw-alias-label-caption, currentColor); opacity:.8; flex:none; }',
@@ -137,10 +145,15 @@ window.__ModuleLoader__.load({
137
145
  '.sls-tv__sum--err { color:var(--dsw-alias-state-error-primary, #d4553f); }',
138
146
  '.sls-tv__body { margin:6px 0 4px; border:1px solid rgba(128,128,128,.28); border-radius:8px; overflow:hidden; }',
139
147
  '.sls-tv__head { display:flex; align-items:center; gap:8px; padding:6px 10px; border-bottom:1px solid rgba(128,128,128,.18); background:rgba(128,128,128,.05); }',
140
- '.sls-tv__cwd { font-family:var(--sls-mono, monospace); font-size:12px; opacity:.7; }',
141
- '.sls-tv__badge { font-size:11px; padding:0 7px; border-radius:999px; border:1px solid rgba(128,128,128,.35); opacity:.85; flex:none; }',
142
- '.sls-tv__pill { font-size:12px; color:#d4553f; flex:none; }',
148
+ // 头部单行片段禁折行:cwd 行宽不足时收缩出省略号,徽章不收缩不折行
149
+ '.sls-tv__cwd { font-family:var(--sls-mono, monospace); font-size:12px; opacity:.7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
150
+ '.sls-tv__badge { font-size:11px; padding:0 7px; border-radius:999px; border:1px solid rgba(128,128,128,.35); opacity:.85; flex:none; white-space:nowrap; }',
151
+ '.sls-tv__pill { font-size:12px; color:#d4553f; flex:none; white-space:nowrap; }',
143
152
  '.sls-tv__pill--bg { color:var(--dsw-alias-label-tertiary, inherit); }',
153
+ '.sls-tv__duration { font-family:var(--sls-mono, monospace); font-size:12px; color:var(--dsw-alias-label-tertiary, inherit); flex:none; white-space:nowrap; font-variant-numeric:tabular-nums; }',
154
+ // 运行中脉冲文字:零信息窗口(参数未到)的活体反馈
155
+ '.sls-tv__pulse { font-size:12px; color:var(--dsw-alias-label-tertiary, inherit); flex:none; white-space:nowrap; animation:sls-pulse 1.5s ease-in-out infinite; }',
156
+ '@keyframes sls-pulse { 0%,100% { opacity:.35; } 50% { opacity:1; } }',
144
157
  '.sls-tv__sp { flex:1; }',
145
158
  '.sls-tv__copy { display:flex; align-items:center; gap:2px; flex:none; }',
146
159
  '.sls-tv__copyBtn { padding:2px 8px; border:1px solid rgba(128,128,128,.35); border-radius:6px; background:transparent; color:inherit; cursor:pointer; font-size:12px; }',
@@ -186,7 +199,6 @@ window.__ModuleLoader__.load({
186
199
  return {
187
200
  shells: entries.map((entry) => ({
188
201
  id: entry.id.trim(),
189
- name: entry.name.trim(),
190
202
  kind: entry.kind,
191
203
  path: entry.path.trim(),
192
204
  args: entry.argsText.split(/\s+/).filter((item) => item.length > 0),
@@ -243,7 +255,6 @@ window.__ModuleLoader__.load({
243
255
  function toEntries(section) {
244
256
  return section.shells.map((entry) => ({
245
257
  id: entry.id,
246
- name: entry.name,
247
258
  kind: entry.kind,
248
259
  path: entry.path,
249
260
  argsText: (entry.args ?? []).join(' '),
@@ -290,7 +301,7 @@ window.__ModuleLoader__.load({
290
301
 
291
302
  const addEntry = () => {
292
303
  setDirty(true)
293
- setEntries([...entries, { id: '', name: '', kind: 'bash', path: '', argsText: '', login: false, distro: '', envText: '', available: undefined, resolved: undefined }])
304
+ setEntries([...entries, { id: '', kind: 'bash', path: '', argsText: '', login: false, distro: '', envText: '', available: undefined, resolved: undefined }])
294
305
  }
295
306
 
296
307
  const removeEntry = (index) => {
@@ -422,16 +433,9 @@ window.__ModuleLoader__.load({
422
433
  h('input', {
423
434
  className: 'sls-input',
424
435
  value: entry.id,
425
- placeholder: 'pwsh / git-bash / …(模型看到的 shell 参数值)',
436
+ placeholder: 'pwsh / git-bash / …(模型看到的 shell 参数值,亦作显示名)',
426
437
  onChange: (event) => patchEntry(index, { id: event.target.value }),
427
438
  }),
428
- h('span', { className: 'sls-grid__label' }, '名称'),
429
- h('input', {
430
- className: 'sls-input',
431
- value: entry.name,
432
- placeholder: '显示名,如 Git Bash',
433
- onChange: (event) => patchEntry(index, { name: event.target.value }),
434
- }),
435
439
  h('span', { className: 'sls-grid__label' }, '形态'),
436
440
  h('div', { className: 'sls-row' },
437
441
  h('select', {
@@ -606,11 +610,25 @@ window.__ModuleLoader__.load({
606
610
  }
607
611
  // LOGIC-END hasSpillNotice
608
612
 
613
+ // 执行事实标记(host renderResult 落,格式见 render.mjs):卡片徽章第一优先级,
614
+ // 配置事后变更/跨宿主查看不溯往改写历史调用
615
+ // LOGIC-BEGIN parseShellMark
616
+ function parseShellMark(text) {
617
+ return /\[shell: ([^\]\n]+)\]/.exec(text)?.[1]
618
+ }
619
+ // LOGIC-END parseShellMark
620
+
621
+ // LOGIC-BEGIN stripShellMark
622
+ function stripShellMark(text) {
623
+ return text.split('\n').filter((line) => !/^\[shell: [^\]]+\]$/.test(line)).join('\n')
624
+ }
625
+ // LOGIC-END stripShellMark
626
+
609
627
  // block → 卡片模型(官方 terminalCardModel 同构,数据源 argsRaw + content 文本)。
610
628
  // generic = 后台 ack / isError / 溢出预览 / persistent 形(无 description,
611
629
  // 官方 shellCall persistent→generic 同构),交回退行;terminal = 全量卡。
612
630
  // LOGIC-BEGIN shellCardModel
613
- function shellCardModel(block, sessionCwd) {
631
+ function shellCardModel(block, sessionCwd, toolKey) {
614
632
  const settled = 'kind' in block
615
633
  const call = settled ? block.call : block
616
634
  let args = null
@@ -622,40 +640,80 @@ window.__ModuleLoader__.load({
622
640
  const description = typeof args?.description === 'string' && args.description.trim() !== '' ? args.description : undefined
623
641
  const cwdFull = displayCwd(typeof args?.workdir === 'string' ? args.workdir : undefined, sessionCwd)
624
642
  const cwdDir = cwdFull !== undefined ? lastSegment(cwdFull) : undefined
625
- // shell 名 = 用户在配置页起的条目名;args.shell 缺省时按 default 客户端解析(配置当前读数)
626
- const shellName = clientDisplayName(typeof args?.shell === 'string' ? args.shell : undefined)
627
- if (command === '') return { kind: 'generic', command: '', summary: undefined, output: null }
643
+ // 官方钉死客户端工具(bash/pwsh)徽章即工具名:其输出无标记、参数无 shell
644
+ // 字段,落默认读数会误标(官方 pwsh 调用显示当前默认客户端的事故根因)
645
+ const PINNED_CLIENT = { pwsh: 'pwsh', bash: 'bash' }
646
+ // shell 工具徽章优先级:结果标记(执行事实)→ 显式参数 → default 读数(旧块兜底,
647
+ // 配置即当前解析事实);读数推导仅兜无标记历史块,不覆盖事实
648
+ const requested = typeof args?.shell === 'string' ? args.shell : undefined
649
+ const shellNameOf = (mark) => PINNED_CLIENT[toolKey] ?? mark ?? clientDisplayName(requested)
650
+ if (command === '') return { kind: 'generic', command: '', summary: undefined, output: null, running: !settled, alert: undefined }
628
651
 
629
652
  if (!settled) {
630
- // running persistent(进行中无描述):回退行但状态点用进行中灰,非 warning 黄
631
- if (description === undefined) return { kind: 'generic', command, summary: undefined, output: null, running: true }
632
- return { kind: 'terminal', status: 'running', command, description, cwdDir, cwdFull, shellName, output: undefined, exitCode: undefined, signal: undefined, code: undefined }
653
+ const startedAt = typeof block.time === 'number' ? block.time : undefined
654
+ // 运行中一律全量卡:阻塞时展开即可见命令与时长,不落无命令的 generic 回退行;
655
+ // 无结果文本,徽章按读数推导,落定后由标记校正
656
+ return { kind: 'terminal', status: 'running', command, description, cwdDir, cwdFull, shellName: shellNameOf(undefined), output: undefined, exitCode: undefined, signal: undefined, code: undefined, startedAt }
633
657
  }
634
658
 
635
659
  const contentText = (block.content ?? []).map((part) => (part.type === 'text' ? part.text : '')).filter((text) => text !== '').join('\n')
660
+ const shellName = shellNameOf(parseShellMark(contentText))
661
+ const outputText = stripShellMark(contentText)
636
662
  // 后台 ack:独立卡呈现命令全文与任务号徽标,ack 原文作输出(不再落 generic 简版行)
637
663
  if (args.run_in_background === true) {
638
664
  const jobId = /started background job (\S+)/.exec(contentText)?.[1]
639
- return { kind: 'background', status: 'background', command, description, cwdDir, cwdFull, shellName, output: contentText, jobId }
665
+ return { kind: 'background', status: 'background', command, description, cwdDir, cwdFull, shellName, output: outputText, jobId }
640
666
  }
641
- // 空结果落 generic:官方 singleResultText 无文本即回通用卡,避免空输出伪 done 终端卡
642
- if (contentText === '' || block.isError === true || block.error !== undefined || description === undefined || hasSpillNotice(contentText)) {
643
- return { kind: 'generic', command, summary: description, output: contentText }
667
+ // 空结果落 generic:官方 singleResultText 无文本即回通用卡,避免空输出伪 done 终端卡;
668
+ // isError/error 块带 alert(错误红点语义),其余(空输出/无描述/截断)中性无状态点
669
+ if (outputText === '' || block.isError === true || block.error !== undefined || description === undefined || hasSpillNotice(contentText)) {
670
+ const alert = block.isError === true || block.error !== undefined ? 'error' : undefined
671
+ return { kind: 'generic', command, summary: description, output: outputText, running: false, alert }
644
672
  }
645
- const tail = parseExitTail(contentText)
673
+ const tail = parseExitTail(outputText)
646
674
  const status = tail.signal !== undefined ? 'signaled' : tail.exitCode !== 0 ? 'failed' : 'done'
647
675
  return { kind: 'terminal', status, command, description, cwdDir, cwdFull, shellName, output: tail.output, exitCode: tail.exitCode, signal: tail.signal, code: undefined }
648
676
  }
649
677
  // LOGIC-END shellCardModel
650
678
 
651
- // 官方 leadingFor 同构:失败红点,回退行黄点,进行中灰点,其余工具图标
652
- function leadingOf(status, icons) {
653
- if (status === 'failed' || status === 'signaled') return icons.StateDot({ state: 'error' })
654
- if (status === 'generic-warn') return icons.StateDot({ state: 'warning' })
655
- if (status === 'running') return icons.StateDot({ state: 'ongoing' })
679
+ // 官方 ToolRow leading 同构:行首仅 icon;错误/进行中状态红点叠 icon 之上
680
+ // (掩盖 icon,官方状态点语义)
681
+ function leadingStack({ status, icons }) {
682
+ const failed = status === 'failed' || status === 'signaled' || status === 'generic-error'
683
+ const dot = failed ? icons.StateDot({ state: 'error' }) : status === 'generic-warn' ? icons.StateDot({ state: 'warning' }) : status === 'running' ? icons.StateDot({ state: 'ongoing' }) : null
684
+ if (dot !== null) {
685
+ return h('span', { className: 'sls-tv__leadStack' },
686
+ h('span', { className: 'sls-tv__iconIdle' }, icons.IconApi({ size: 14 })),
687
+ h('span', { className: 'sls-tv__stateCover' }, dot),
688
+ )
689
+ }
656
690
  return icons.IconApi({ size: 14 })
657
691
  }
658
692
 
693
+ // 时长格式:秒 → m:ss;超一小时 → h:mm:ss
694
+ // LOGIC-BEGIN formatDuration
695
+ function formatDuration(elapsedMs) {
696
+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000))
697
+ const seconds = totalSeconds % 60
698
+ const minutes = Math.floor(totalSeconds / 60) % 60
699
+ const hours = Math.floor(totalSeconds / 3600)
700
+ const two = (value) => (value < 10 ? '0' + value : String(value))
701
+ return hours > 0 ? `${hours}:${two(minutes)}:${two(seconds)}` : `${minutes}:${two(seconds)}`
702
+ }
703
+ // LOGIC-END formatDuration
704
+
705
+ // 运行中实时时长:每秒 tick;秒级粒度对"阻塞了多久"足够
706
+ function RunningDuration({ startedAt, en }) {
707
+ const [, setTick] = useState(0)
708
+ useEffect(() => {
709
+ const timer = setInterval(() => setTick((value) => value + 1), 1000)
710
+ return () => clearInterval(timer)
711
+ }, [])
712
+ const elapsed = startedAt !== undefined ? Date.now() - startedAt : undefined
713
+ const text = elapsed !== undefined && elapsed >= 0 ? formatDuration(elapsed) : en ? 'running' : '运行中'
714
+ return h('span', { className: 'sls-tv__duration', title: en ? 'Elapsed' : '已运行' }, text)
715
+ }
716
+
659
717
  function statusTextOf(status, en) {
660
718
  switch (status) {
661
719
  case 'running': return en ? 'Running' : '运行中'
@@ -668,9 +726,9 @@ window.__ModuleLoader__.load({
668
726
  function headMetaOf(model, en) {
669
727
  switch (model.status) {
670
728
  case 'running': return { dot: 'ongoing', label: en ? 'Running' : '运行中', pill: undefined }
671
- // ack 块静态不反映 job 生命周期,状态点用中性工具图标,文案不带"运行中"
729
+ // ack 块静态不反映 job 生命周期,状态点缺省(后台任务由 bg pill 标识)
672
730
  case 'background': return {
673
- dot: 'none',
731
+ dot: undefined,
674
732
  label: en ? 'Background' : '后台',
675
733
  pill: model.jobId !== undefined ? (en ? `bg ${model.jobId}` : `后台 ${model.jobId}`) : undefined,
676
734
  pillTone: 'bg',
@@ -700,13 +758,17 @@ window.__ModuleLoader__.load({
700
758
  }, done ? (detectEnglish() ? 'Copied' : '已复制') : label)
701
759
  }
702
760
 
703
- // 非终端意图回退行(后台 ack / isError / 截断):摘要 + 可展开原文
761
+ // 非终端意图回退行(后台 ack / isError / 截断 / 参数未到):摘要 + 可展开原文。
762
+ // 状态语义对齐官方:运行中灰 spinner,settled 错误红点,其余中性 icon——
763
+ // 黄点(warning)仅真实告警,禁止挪用作"进行中"(用户把黄点当卡死)。
764
+ // 运行中且零信息(参数未到)显示脉冲「运行中…」,给用户活着的感觉。
704
765
  function GenericShellRow({ model, inspect, en }) {
705
766
  const [open, setOpen] = useState(false)
767
+ const status = model.running === true ? 'running' : model.alert === 'error' ? 'generic-error' : undefined
706
768
  const summary = model.summary !== undefined && model.summary !== ''
707
769
  ? model.summary.split('\n')[0]
708
770
  : (model.output !== null && model.output !== '' ? model.output.split('\n')[0] : '')
709
- const expandable = model.output !== null && model.output !== ''
771
+ const expandable = (model.output !== null && model.output !== '') || model.command !== ''
710
772
  return h('div', { className: 'sls-tv' },
711
773
  h('div', {
712
774
  className: 'sls-tv__row' + (expandable ? ' sls-tv__row--exp' : ''),
@@ -722,14 +784,14 @@ window.__ModuleLoader__.load({
722
784
  } : undefined,
723
785
  },
724
786
  h('span', { className: 'sls-tv__lead' },
725
- leadingOf(model.running === true ? 'running' : 'generic-warn', TOOLVIEW_ICONS),
726
- expandable ? h('span', { className: 'sls-tv__chev', 'data-open': open ? '1' : '0', style: { display: 'inline-flex', transform: open ? 'rotate(-90deg)' : 'none' } }, TOOLVIEW_ICONS.IconChevron({ size: 14 })) : null,
787
+ leadingStack({ status, icons: TOOLVIEW_ICONS }),
727
788
  ),
728
789
  h('span', { className: 'sls-tv__title' }, 'Shell'),
729
790
  summary !== '' ? h('span', { className: 'sls-tv__sep', 'aria-hidden': true }) : null,
730
- summary !== '' ? h('span', { className: 'sls-tv__sum' }, summary) : null,
791
+ summary !== '' ? h('span', { className: 'sls-tv__sum' + (status === 'generic-error' ? ' sls-tv__sum--err' : '') }, summary) : null,
792
+ status === 'running' ? h('span', { className: 'sls-tv__pulse' }, en ? 'running…' : '运行中…') : null,
731
793
  ),
732
- open && expandable ? h('pre', { className: 'sls-tv__out', style: { border: '1px solid rgba(128,128,128,.28)', borderRadius: 8 } }, model.output) : null,
794
+ open && expandable ? h('pre', { className: 'sls-tv__out', style: { border: '1px solid rgba(128,128,128,.28)', borderRadius: 8 } }, model.command + (model.output !== null && model.output !== '' ? `\n${model.output}` : '')) : null,
733
795
  inspect !== undefined ? h('button', { className: 'sls-tv__inspect', onClick: inspect }, TOOLVIEW_ICONS.IconInspect({}), en ? 'Inspect' : '检查') : null,
734
796
  )
735
797
  }
@@ -749,16 +811,20 @@ window.__ModuleLoader__.load({
749
811
  const meta = headMetaOf(model, en)
750
812
  const copyCommandLabel = en ? 'Copy command' : '复制命令'
751
813
  const copyOutputLabel = en ? 'Copy output' : '复制输出'
814
+ // 运行中头部 pill = 实时时长(组件,hook 内每秒自刷新)
815
+ const pill = model.status === 'running'
816
+ ? h(RunningDuration, { startedAt: model.startedAt, en })
817
+ : meta.pill
752
818
  return h('div', { className: 'sls-tv__body', 'data-status': model.status },
753
819
  h('div', { className: 'sls-tv__head' },
754
820
  h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
755
- TOOLVIEW_ICONS.StateDot({ state: meta.dot }),
821
+ meta.dot !== undefined ? TOOLVIEW_ICONS.StateDot({ state: meta.dot }) : null,
756
822
  h('span', { className: 'sls-tv__sr' }, meta.label),
757
823
  ),
758
824
  model.cwdDir !== undefined ? h('span', { className: 'sls-tv__cwd', title: 'pwd: ' + (model.cwdFull ?? model.cwdDir) }, model.cwdDir) : null,
759
825
  model.shellName !== undefined ? h('span', { className: 'sls-tv__badge', title: en ? 'Shell client' : 'Shell 客户端' }, model.shellName) : null,
760
826
  h('span', { className: 'sls-tv__sp' }),
761
- meta.pill !== undefined ? h('span', { className: 'sls-tv__pill' + (meta.pillTone === 'bg' ? ' sls-tv__pill--bg' : '') }, meta.pill) : null,
827
+ pill !== undefined ? h('span', { className: 'sls-tv__pill' + (meta.pillTone === 'bg' ? ' sls-tv__pill--bg' : '') }, pill) : null,
762
828
  h('span', { className: 'sls-tv__copy' },
763
829
  h(CopyButton, {
764
830
  label: copyCommandLabel,
@@ -781,8 +847,12 @@ window.__ModuleLoader__.load({
781
847
  }
782
848
 
783
849
  function ShellToolRow(props) {
784
- const { block, cwd, inspect } = props
850
+ const { block, cwd, inspect, toolKey } = props
785
851
  const en = detectEnglish()
852
+ const model = shellCardModel(block, cwd, toolKey)
853
+ // hooks 恒序:必须在任何提前 return 之前完整执行(真实 React 的 hooks
854
+ // 链表按调用序对位,generic 行与 terminal 行的提前 return 分叉会让
855
+ // 后续 hook 错位,行组件树被整棵卸载——卡片全消失事故根因)
786
856
  const [open, setOpen] = useState(false)
787
857
  const [, setCatalogReady] = useState(false)
788
858
  useEffect(() => {
@@ -791,7 +861,6 @@ window.__ModuleLoader__.load({
791
861
  return () => { disposed = true }
792
862
  }, [])
793
863
  if (model.kind === 'generic') return h(GenericShellRow, { model, inspect, en })
794
- const expandable = true
795
864
  const srStatus = statusTextOf(model.status, en)
796
865
  const summary = model.description !== undefined ? model.description.split('\n')[0] : (en ? '(no description)' : '(无描述)')
797
866
  const failed = model.status === 'failed' || model.status === 'signaled'
@@ -801,7 +870,6 @@ window.__ModuleLoader__.load({
801
870
  role: 'button',
802
871
  tabIndex: 0,
803
872
  'aria-expanded': open,
804
- 'data-open': open ? '1' : '0',
805
873
  onClick: () => setOpen((value) => !value),
806
874
  onKeyDown: (event) => {
807
875
  if (event.key === 'Enter' || event.key === ' ') {
@@ -811,13 +879,13 @@ window.__ModuleLoader__.load({
811
879
  },
812
880
  },
813
881
  h('span', { className: 'sls-tv__lead' },
814
- leadingOf(model.status, TOOLVIEW_ICONS),
815
- h('span', { className: 'sls-tv__chev', style: { display: 'inline-flex', transform: open ? 'rotate(-90deg)' : 'none' } }, TOOLVIEW_ICONS.IconChevron({ size: 14 })),
882
+ leadingStack({ status: model.status, icons: TOOLVIEW_ICONS }),
816
883
  ),
817
884
  srStatus !== null ? h('span', { className: 'sls-tv__sr' }, srStatus) : null,
818
885
  h('span', { className: 'sls-tv__title' }, 'Shell'),
819
886
  h('span', { className: 'sls-tv__sep', 'aria-hidden': true }),
820
887
  h('span', { className: 'sls-tv__sum' + (failed ? ' sls-tv__sum--err' : '') }, summary),
888
+ model.status === 'running' ? h(RunningDuration, { startedAt: model.startedAt, en }) : null,
821
889
  model.status === 'background' && model.jobId !== undefined
822
890
  ? h('span', { className: 'sls-tv__badge', title: en ? 'Background job' : '后台任务' }, en ? `bg ${model.jobId}` : `后台 ${model.jobId}`)
823
891
  : null,
@@ -844,15 +912,15 @@ window.__ModuleLoader__.load({
844
912
  ))
845
913
  // 替换 shell 族工具行渲染:keyed hit 优先于 GenericToolCard 兜底;
846
914
  // priority -1 阴影官方同 key 注册(低值先渲染)。pwsh/bash 为官方工具
847
- // 名(死态窗口 guard 代挂官方 tool-pwsh 时,其调用行同样获得增强卡;
848
- // 卡片模型按官方 terminalCardModel 同构自 argsRaw+结果文本派生,数据
849
- // 面对两类工具一致,无需分支)。
915
+ // 名(死态窗口 guard 代挂官方 tool-pwsh 时,其调用行同样获得增强卡);
916
+ // key 以 toolKey 注入卡片,官方钉死客户端工具的徽章按工具名钉死,
917
+ // 不落默认客户端读数。
850
918
  const TOOLVIEW_KEYS = ['shell', 'pwsh', 'bash']
851
919
  for (const toolKey of TOOLVIEW_KEYS) {
852
920
  ctx.slots.inject('tool.call.toolview', () =>
853
921
  ctx.slots.register(
854
922
  { name: 'tool.call.toolview', key: toolKey, priority: -1 },
855
- (props) => React.createElement(ShellToolRow, props),
923
+ (props) => React.createElement(ShellToolRow, { ...props, toolKey }),
856
924
  ))
857
925
  }
858
926
  },
package/src/config.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // cmd /d /s /c 与 wsl --exec 为本包定义的保守形态。
4
4
 
5
5
  import z from '@deepseek-ai/schemastery'
6
+ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
6
7
 
7
8
  // 支持的 shell 形态
8
9
  export const KINDS = ['pwsh', 'bash', 'cmd', 'wsl']
@@ -29,7 +30,6 @@ const EXECUTOR_DEFAULTS = {
29
30
 
30
31
  const shellEntry = z.object({
31
32
  id: z.string().required(),
32
- name: z.string().required(),
33
33
  kind: z.union(KINDS).required(),
34
34
  path: z.string().default(RESOLVED_AUTO),
35
35
  args: z.array(z.string()).default([]),
@@ -39,27 +39,187 @@ const shellEntry = z.object({
39
39
  })
40
40
 
41
41
  export const Config = z.object({
42
+ // 0.1.7 设置页写路径(settings.write)只接受 volatile 字段(免重挂载的活
43
+ // 字段,官方 dsh-bash-local Config 逐字段 volatile 同构);缺声明即
44
+ // "has no volatile fields" 保存失败
42
45
  shells: z.array(shellEntry).default([
43
- { id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: RESOLVED_AUTO, args: [] },
44
- { id: 'git-bash', name: 'Git Bash', kind: 'bash', path: RESOLVED_AUTO, args: [] },
45
- { id: 'cmd', name: 'CMD', kind: 'cmd', path: RESOLVED_AUTO, args: [] },
46
- ]),
47
- default: z.string().default('pwsh'),
46
+ { id: 'pwsh', kind: 'pwsh', path: RESOLVED_AUTO, args: [] },
47
+ { id: 'git-bash', kind: 'bash', path: RESOLVED_AUTO, args: [] },
48
+ { id: 'cmd', kind: 'cmd', path: RESOLVED_AUTO, args: [] },
49
+ ]).volatile(),
50
+ default: z.string().default('pwsh').volatile(),
48
51
  // 命令黑名单(整文本正则,大小写不敏感):命中即拒,deny 绝对无豁免;
49
52
  // 精细放行用正则前瞻在模式内表达。空数组 = 不拦截。
50
53
  // 护栏防误触,非安全边界(真边界是沙箱与访问模式)
51
- deny: z.array(z.string()).default([]),
52
- cwd: z.string(),
53
- timeoutMs: z.number().default(EXECUTOR_DEFAULTS.timeoutMs),
54
- maxTimeoutMs: z.number().default(EXECUTOR_DEFAULTS.maxTimeoutMs),
55
- maxOutputBytes: z.number().default(EXECUTOR_DEFAULTS.maxOutputBytes),
56
- maxSpillBytes: z.number().default(EXECUTOR_DEFAULTS.maxSpillBytes),
57
- graceMs: z.number().default(EXECUTOR_DEFAULTS.graceMs),
54
+ deny: z.array(z.string()).default([]).volatile(),
55
+ cwd: z.string().volatile(),
56
+ timeoutMs: z.number().default(EXECUTOR_DEFAULTS.timeoutMs).volatile(),
57
+ maxTimeoutMs: z.number().default(EXECUTOR_DEFAULTS.maxTimeoutMs).volatile(),
58
+ maxOutputBytes: z.number().default(EXECUTOR_DEFAULTS.maxOutputBytes).volatile(),
59
+ maxSpillBytes: z.number().default(EXECUTOR_DEFAULTS.maxSpillBytes).volatile(),
60
+ graceMs: z.number().default(EXECUTOR_DEFAULTS.graceMs).volatile(),
58
61
  })
59
62
 
63
+ // volatile 字段解箱:schema 声明 .volatile() 后(0.1.7 设置页写路径只接受
64
+ // volatile 字段,缺声明即 "has no volatile fields" 拒写),Config 产物字段为
65
+ // boxed ref(读值须 .get(),官方 dsh-bash-local 同构)。统一在此解箱,消费者
66
+ // (executor/web-routes/测试)一律拿普通对象。
67
+ // structuredClone 深拷贝双向:输入侧(sanitize 产物)切断与调用方对象的耦合——
68
+ // volatile ref .get() 产物是 cosmokit snapshot 深冻结对象,schemastery resolve
69
+ // 原地改写入参,冻结 env 直接炸挂载;输出侧:schemastery resolve 会原地改写入参
70
+ // (填默认/键归一)并把产出对象置为不可扩展,二轮 resolve(快照回灌,web 写路径
71
+ // current+patch 合并常态)对不可扩展对象赋值即炸;clone 切断产物与 schema 的原地
72
+ // 耦合,保证 resolveConfig 幂等且对输入不可变。
73
+ export function unwrapConfig(value) {
74
+ return Object.fromEntries(Object.entries(value).map(([key, field]) => {
75
+ const raw = typeof field?.get === 'function' ? field.get() : field
76
+ return [key, raw !== null && typeof raw === 'object' ? structuredClone(raw) : raw]
77
+ }))
78
+ }
79
+
80
+ /** schema 应用 + 解箱 + 输入深拷:全部消费者经此入口,不直接触 Config 产物。 */
81
+ export function resolveConfig(entry) {
82
+ const sanitized = sanitizeConfigEntry(entry ?? {})
83
+ return unwrapConfig(Config(structuredClone(sanitized)))
84
+ }
85
+
86
+ /** 执行面严格解析:输入键被类型对账丢弃时抛。执行面(挂载/refresh)输入是
87
+ * 宿主刚 resolve 的产物,形态非法 = 宿主契约变化或调用方未解箱,必须炸在
88
+ * 挂载点(接管序列有回滚告警)而非静默回落出厂默认——静默曾让行 config
89
+ * 长期失效(0.2.0-rc.2 真机实证)。持久化读回路径(落盘写坏降级、行不死
90
+ * 可保存修复)仍用 resolveConfig 的宽容语义。 */
91
+ export function resolveConfigStrict(entry) {
92
+ const input = entry ?? {}
93
+ const sanitized = sanitizeConfigEntry(input)
94
+ const dropped = Object.keys(FIELD_TYPES)
95
+ .filter((key) => input[key] !== undefined && !(key in sanitized))
96
+ if (dropped.length > 0) {
97
+ throw new Error(`shell-select: config 字段形态非法(执行面输入须先解箱为普通对象): ${dropped.join(', ')}`)
98
+ }
99
+ return unwrapConfig(Config(structuredClone(sanitized)))
100
+ }
101
+
102
+ /** 用户声明值挂载前 coercing:类型清洗(sanitize)+ 语义兜底,任意用户输入
103
+ * 产出可服务配置——插件本体不因配置死(行活、工具活,坏字段告警后降默认)。
104
+ * 语义兜底:shells 清洗后为空(含条目缺 id/kind 或 kind 非法被剔除)补出厂
105
+ * 默认;default 不在 shells id 集归 shells[0];预算数字非正数或超 graceMs
106
+ * 上限降 schema 默认;deny 剔除非字符串与不可编译条目。 */
107
+ export function coerceServiceable(entry) {
108
+ const warnings = []
109
+ const cleaned = sanitizeConfigEntry(entry ?? {})
110
+ const rawShells = Array.isArray(cleaned.shells) ? cleaned.shells : []
111
+ const usable = rawShells.filter((item) => item !== null && typeof item === 'object'
112
+ && typeof item.id === 'string' && item.id !== ''
113
+ && typeof item.kind === 'string' && KINDS.includes(item.kind))
114
+ if (usable.length !== rawShells.length) warnings.push(`shells 剔除 ${rawShells.length - usable.length} 个缺 id/kind 或 kind 非法的条目`)
115
+ const shells = usable.length > 0 ? usable : defaultConfig().shells
116
+ if (usable.length === 0) warnings.push('shells 为空或全部非法,回落出厂默认客户端清单')
117
+ let defaultId = cleaned.default
118
+ if (typeof defaultId !== 'string' || !shells.some((item) => item.id === defaultId)) {
119
+ warnings.push(`default ${JSON.stringify(defaultId)} 不在 shells 清单,归 shells[0]`)
120
+ defaultId = shells[0].id
121
+ }
122
+ const rawDeny = Array.isArray(cleaned.deny) ? cleaned.deny : undefined
123
+ const deny = rawDeny?.filter((pattern) => {
124
+ if (typeof pattern !== 'string' || pattern === '') return false
125
+ try {
126
+ new RegExp(pattern, 'i')
127
+ return true
128
+ } catch {
129
+ return false
130
+ }
131
+ })
132
+ if (rawDeny !== undefined && deny.length !== rawDeny.length) warnings.push(`deny 剔除 ${rawDeny.length - deny.length} 个非字符串或不可编译正则的条目`)
133
+ const budgets = {}
134
+ for (const key of ['timeoutMs', 'maxTimeoutMs', 'maxOutputBytes', 'maxSpillBytes', 'graceMs']) {
135
+ const value = cleaned[key]
136
+ if (value === undefined) continue
137
+ const overCeiling = key === 'graceMs' && value > MAX_TIMER_DELAY_MS
138
+ if (!Number.isFinite(value) || value <= 0 || overCeiling) {
139
+ warnings.push(`${key} 必须为正有限数${key === 'graceMs' ? `且不大于 ${MAX_TIMER_DELAY_MS}` : ''},降 schema 默认`)
140
+ // 显式置空覆盖 cleaned 原值,交 schema 默认兜底
141
+ budgets[key] = undefined
142
+ continue
143
+ }
144
+ budgets[key] = value
145
+ }
146
+ return {
147
+ config: { ...cleaned, shells, default: defaultId, deny, ...budgets },
148
+ warnings,
149
+ }
150
+ }
151
+
152
+ // 落盘行 config 的类型防御:设置写路径(原生页/mutate)可能把 volatile 字段
153
+ // 以非预期形态(ref 序列化产物 {})持久化,冷启动 Config 直接抛 → 行死 →
154
+ // settings describe 无此节 → 设置页保存 "no longer configurable" 死锁,
155
+ // 只能手工改 patch 解锁。类型不符的键降级为 schema 默认:行活、设置页活、
156
+ // 数据可经保存覆盖修复(官方 schema default 兜底同语义)。
157
+ // 字段类型契约:与 schema 声明一一对应,清洗按此对账
158
+ const FIELD_TYPES = {
159
+ shells: 'array',
160
+ deny: 'array',
161
+ default: 'string',
162
+ cwd: 'string',
163
+ timeoutMs: 'number',
164
+ maxTimeoutMs: 'number',
165
+ maxOutputBytes: 'number',
166
+ maxSpillBytes: 'number',
167
+ graceMs: 'number',
168
+ }
169
+ export function sanitizeConfigEntry(entry) {
170
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return {}
171
+ const out = {}
172
+ for (const [key, kind] of Object.entries(FIELD_TYPES)) {
173
+ const value = entry[key]
174
+ if (value === undefined) continue
175
+ const valid = kind === 'array' ? Array.isArray(value) : typeof value === kind
176
+ if (valid) out[key] = value
177
+ }
178
+ return out
179
+ }
180
+
60
181
  /** 出厂默认配置(供文档与测试比对;schema 内 default 为同构静态值)。 */
61
182
  export function defaultConfig() {
62
- return Config({})
183
+ return resolveConfig({})
184
+ }
185
+
186
+ // yaml 无引号标量会把 `\` 字面落盘,任何一层再转义都让路径翻倍(C:\\ 实测):
187
+ // 入口统一归一,保证进 schema 的 path 就是干净值。
188
+ // 仅 Windows 宿主生效:posix 路径以 / 分隔,归一会把合法路径毁成反斜杠字面(CI linux 实测)
189
+ export function normalizeWin32Path(path) {
190
+ if (process.platform !== 'win32') return path
191
+ if (typeof path !== 'string' || path.length === 0) return path
192
+ return path.replace(/\\{2,}/g, '\\').replace(/\//g, '\\')
193
+ }
194
+
195
+ // 更新负载深归一:shells[].path 与任意层字符串值只处理 path 键,避免误伤 args 模板
196
+ export function normalizeConfigPaths(patch) {
197
+ if (typeof patch !== 'object' || patch === null || !Array.isArray(patch.shells)) return patch
198
+ return {
199
+ ...patch,
200
+ shells: patch.shells.map((entry) => (typeof entry?.path === 'string' ? { ...entry, path: normalizeWin32Path(entry.path) } : entry)),
201
+ }
202
+ }
203
+
204
+ function assertPositiveFinite(name, value) {
205
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`shell-select: ${name} must be a positive finite number`)
206
+ }
207
+
208
+ /** 拒绝无法运行的已解析配置节(schema 之外的正数/时限/清单约束,官方 assertServiceable 同构)。 */
209
+ export function assertServiceableConfig(config) {
210
+ assertPositiveFinite('timeoutMs', config.timeoutMs)
211
+ assertPositiveFinite('maxTimeoutMs', config.maxTimeoutMs)
212
+ assertPositiveFinite('maxOutputBytes', config.maxOutputBytes)
213
+ assertPositiveFinite('maxSpillBytes', config.maxSpillBytes)
214
+ assertPositiveFinite('graceMs', config.graceMs)
215
+ if (config.graceMs > MAX_TIMER_DELAY_MS) throw new Error(`shell-select: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
216
+ if (!Array.isArray(config.shells) || config.shells.length === 0) throw new Error('shell-select: shells must not be empty')
217
+ const ids = new Set()
218
+ for (const entry of config.shells) {
219
+ if (ids.has(entry.id)) throw new Error(`shell-select: duplicate shell id "${entry.id}"`)
220
+ ids.add(entry.id)
221
+ }
222
+ if (!ids.has(config.default)) throw new Error(`shell-select: default "${config.default}" is not a configured shell id`)
63
223
  }
64
224
 
65
225
  /**