@mzzsfy/dsh-shell-select 0.1.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.
package/src/client.js ADDED
@@ -0,0 +1,757 @@
1
+ // dsh-shell-select Client 半区:设置页卡片(shells 列表管理 + 默认客户端 + 路径探测)。
2
+ // 以 DSH client-modules 自注册格式发布:__ModuleLoader__.load({id, factory});
3
+ // 数据经 webServer 路由 /api/shell-select/* 读写宿主 settings 节(照 dsh-maintain 双端模式)。
4
+
5
+ window.__ModuleLoader__.load({
6
+ id: '@mzzsfy/dsh-shell-select',
7
+ factory(require) {
8
+ const React = require('react')
9
+ const { useState, useEffect } = React
10
+
11
+ // 官方 primitives 图标/状态点(运行时模块表解析;dsh.client.inject 声明保证在场)
12
+ const primitives = (() => {
13
+ try {
14
+ return require('@deepseek-ai/dsh-client-ui-primitives')
15
+ } catch {
16
+ return null
17
+ }
18
+ })()
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
24
+ }
25
+ const DOT_SVG = { done: '#2e9e5b', error: '#d4553f', warning: '#d9a13b', ongoing: '#7a7f8a' }
26
+ // 降级自绘:模块表缺 primitives 时保持可见性(形态近似官方 StateDot/图标)
27
+ const TOOLVIEW_ICONS = {
28
+ StateDot: createElementOf('StateDot', ({ state }) => h('span', {
29
+ style: {
30
+ width: 8, height: 8, borderRadius: '50%', background: DOT_SVG[state] ?? DOT_SVG.ongoing,
31
+ display: 'inline-block', flex: 'none',
32
+ },
33
+ })),
34
+ IconApi: createElementOf('IconApiOutline14', () => h('span', {
35
+ style: {
36
+ width: 12, height: 12, borderRadius: 3, border: '1.5px solid currentColor',
37
+ opacity: .7, display: 'inline-block', flex: 'none',
38
+ },
39
+ })),
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
+ style: { fontSize: 10, lineHeight: 1, opacity: .8 },
45
+ }, 'ⓘ')),
46
+ }
47
+
48
+ const API = {
49
+ config: '/api/shell-select/config',
50
+ probe: '/api/shell-select/probe',
51
+ detect: '/api/shell-select/detect',
52
+ }
53
+ const KINDS = ['pwsh', 'bash', 'cmd', 'wsl']
54
+ const KIND_LABELS = {
55
+ pwsh: 'PowerShell',
56
+ bash: 'POSIX bash(git bash / msys2 / cygwin)',
57
+ cmd: 'CMD',
58
+ wsl: 'WSL bash',
59
+ }
60
+
61
+ async function api(path, options) {
62
+ const response = await fetch(path, { headers: { 'content-type': 'application/json' }, ...options })
63
+ const payload = await response.json().catch(() => ({}))
64
+ if (!response.ok) throw new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
65
+ return payload
66
+ }
67
+
68
+ const CSS = [
69
+ '.sls-card { display:flex; flex-direction:column; gap:10px; }',
70
+ '.sls-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }',
71
+ '.sls-entry { display:flex; flex-direction:column; gap:6px; padding:10px; border:1px solid var(--sls-border, rgba(128,128,128,.35)); border-radius:8px; }',
72
+ '.sls-entry__head { display:flex; align-items:center; gap:8px; }',
73
+ '.sls-grid { display:grid; grid-template-columns: 90px 1fr; gap:4px 8px; align-items:center; }',
74
+ '.sls-grid__label { opacity:.75; font-size:12px; }',
75
+ '.sls-input { padding:4px 8px; border-radius:6px; border:1px solid var(--sls-border, rgba(128,128,128,.35)); background:transparent; color:inherit; min-width:0; }',
76
+ '.sls-input--wide { width:100%; }',
77
+ '.sls-btn { padding:3px 10px; border-radius:6px; border:1px solid var(--sls-border, rgba(128,128,128,.35)); background:transparent; color:inherit; cursor:pointer; font-size:12px; }',
78
+ '.sls-btn:disabled { opacity:.5; cursor:default; }',
79
+ '.sls-btn--primary { border-color: transparent; background: var(--sls-accent, #4b7bcc); color:#fff; }',
80
+ '.sls-badge { font-size:11px; padding:1px 8px; border-radius:999px; border:1px solid var(--sls-border, rgba(128,128,128,.35)); opacity:.85; }',
81
+ '.sls-badge--ok { color:#2e9e5b; border-color:#2e9e5b; }',
82
+ '.sls-badge--bad { color:#c44; border-color:#c44; }',
83
+ '.sls-badge--default { color:#fff; background:var(--sls-accent, #4b7bcc); border-color:transparent; }',
84
+ '.sls-hint { font-size:12px; opacity:.7; }',
85
+ '.sls-error { font-size:12px; color:#c44; white-space:pre-wrap; }',
86
+ '.sls-args { font-size:12px; font-family:var(--sls-mono, monospace); }',
87
+ // tool.call.toolview 卡片(key 'shell'):官方 terminal 行同构 + 复制/折行增强
88
+ '.sls-tv { font-size:13px; line-height:1.45; }',
89
+ '.sls-tv__row { display:flex; align-items:center; gap:7px; padding:2px 0; cursor:default; }',
90
+ '.sls-tv__row--exp { cursor:pointer; user-select:none; }',
91
+ '.sls-tv__lead { display:flex; align-items:center; gap:4px; color:inherit; }',
92
+ '.sls-tv__chev { opacity:.45; transition:transform .15s ease; }',
93
+ '.sls-tv__row[data-open="1"] .sls-tv__chev { transform:rotate(-90deg); }',
94
+ '.sls-tv__sr { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); }',
95
+ '.sls-tv__title { font-weight:500; }',
96
+ '.sls-tv__sep { width:3px; height:3px; border-radius:50%; background:currentColor; opacity:.35; flex:none; }',
97
+ '.sls-tv__sum { opacity:.6; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
98
+ '.sls-tv__sum--err { color:#d4553f; opacity:.95; }',
99
+ '.sls-tv__body { margin:6px 0 4px; border:1px solid rgba(128,128,128,.28); border-radius:8px; overflow:hidden; }',
100
+ '.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); }',
101
+ '.sls-tv__cwd { font-family:var(--sls-mono, monospace); font-size:12px; opacity:.7; }',
102
+ '.sls-tv__badge { font-size:11px; padding:0 7px; border-radius:999px; border:1px solid rgba(128,128,128,.35); opacity:.85; flex:none; }',
103
+ '.sls-tv__pill { font-size:12px; color:#d4553f; flex:none; }',
104
+ '.sls-tv__sp { flex:1; }',
105
+ '.sls-tv__copy { display:flex; align-items:center; gap:2px; flex:none; }',
106
+ '.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; }',
107
+ '.sls-tv__copyBtn:disabled { opacity:.4; cursor:default; }',
108
+ '.sls-tv__copyBtn:not(:disabled):hover { background:rgba(128,128,128,.12); }',
109
+ '.sls-tv__cmd { padding:8px 10px; font-family:var(--sls-mono, monospace); font-size:12.5px; white-space:pre-wrap; word-break:break-all; display:flex; }',
110
+ '.sls-tv__cmdNo { flex:none; text-align:right; margin-right:10px; opacity:.4; user-select:none; }',
111
+ '.sls-tv__cmdText { flex:1; min-width:0; }',
112
+ '.sls-tv__out { margin:0; padding:8px 10px; border-top:1px solid rgba(128,128,128,.18); font-family:var(--sls-mono, monospace); font-size:12.5px; white-space:pre; overflow-x:auto; max-height:320px; overflow-y:auto; }',
113
+ '.sls-tv__inspect { display:flex; align-items:center; gap:4px; padding:4px 10px; border:none; border-top:1px solid rgba(128,128,128,.18); background:transparent; color:inherit; opacity:.6; cursor:pointer; font-size:12px; }',
114
+ '.sls-tv__inspect:hover { opacity:1; }',
115
+ // 开关:隐藏原生 checkbox,选中态 track 与 thumb 位移用过渡呈现(仓库 client 规约)
116
+ '.sls-switch { display:inline-flex; align-items:center; cursor:pointer; }',
117
+ '.sls-switch input[type="checkbox"] { position:absolute; opacity:0; width:0; height:0; }',
118
+ '.sls-switch__track { position:relative; width:34px; height:19px; border-radius:999px; box-sizing:border-box;',
119
+ ' background:var(--sls-track, rgba(128,128,128,0.35));',
120
+ ' border:1px solid var(--sls-border, rgba(128,128,128,0.35));',
121
+ ' transition:background 0.15s, border-color 0.15s; }',
122
+ '.sls-switch__thumb { position:absolute; top:50%; left:2px; width:13px; height:13px; border-radius:50%;',
123
+ ' background:var(--sls-thumb, rgba(128,128,128,0.6));',
124
+ ' transform:translateY(-50%); transition:left 0.15s, background 0.15s; }',
125
+ '.sls-switch:hover .sls-switch__track { border-color:var(--sls-accent, #4b7bcc); }',
126
+ '.sls-switch input[type="checkbox"]:checked + .sls-switch__track { background:var(--sls-accent, #4b7bcc); border-color:var(--sls-accent, #4b7bcc); }',
127
+ '.sls-switch input[type="checkbox"]:checked + .sls-switch__track .sls-switch__thumb { left:17px; background:var(--sls-bg-base, #fff); }',
128
+ '.sls-switch input[type="checkbox"]:focus-visible + .sls-switch__track { outline:2px solid var(--sls-accent, #4b7bcc); outline-offset:1px; }',
129
+ ].join('\n')
130
+
131
+ // 开关的 checkbox + 轨道对,checkbox 语义保留仅视觉隐藏
132
+ function switchToggle(props) {
133
+ return [
134
+ h('input', { type: 'checkbox', ...props }),
135
+ h('span', { className: 'sls-switch__track' }, h('span', { className: 'sls-switch__thumb' })),
136
+ ]
137
+ }
138
+
139
+ function h(type, props) {
140
+ const children = Array.prototype.slice.call(arguments, 2)
141
+ return React.createElement.apply(React, [type, props || null].concat(children))
142
+ }
143
+
144
+ // 保存负载:仅取设置 schema 字段,剥离探测态
145
+ function toSection(entries, defaultId, denyText) {
146
+ return {
147
+ shells: entries.map((entry) => ({
148
+ id: entry.id.trim(),
149
+ name: entry.name.trim(),
150
+ kind: entry.kind,
151
+ path: entry.path.trim(),
152
+ args: entry.argsText.split(/\s+/).filter((item) => item.length > 0),
153
+ login: entry.kind === 'bash' ? entry.login === true : false,
154
+ distro: entry.distro ?? '',
155
+ env: parseEnvText(entry.envText),
156
+ })),
157
+ default: defaultId,
158
+ deny: splitPatternLines(denyText),
159
+ }
160
+ }
161
+
162
+ // 名单文本(每行一正则)→ 数组;空行与纯空白行忽略
163
+ // LOGIC-BEGIN splitPatternLines
164
+ function splitPatternLines(text) {
165
+ return String(text ?? '').split('\n').map((line) => line.trim()).filter((line) => line.length > 0)
166
+ }
167
+ // LOGIC-END splitPatternLines
168
+
169
+ // 环境文本(每行 K=V)→ 记录;空行与缺 = 的行忽略,值保留原样含空格与 =。
170
+ // LOGIC-BEGIN parseEnvText
171
+ function parseEnvText(text) {
172
+ const env = {}
173
+ for (const line of String(text ?? '').split('\n')) {
174
+ const separator = line.indexOf('=')
175
+ if (separator <= 0) continue
176
+ const key = line.slice(0, separator).trim()
177
+ if (key.length === 0) continue
178
+ env[key] = line.slice(separator + 1)
179
+ }
180
+ return env
181
+ }
182
+ // LOGIC-END parseEnvText
183
+
184
+ // 环境文本中被忽略的行(空行以外):缺 = 的行在保存校验时报错,防静默丢数据
185
+ // LOGIC-BEGIN invalidEnvLines
186
+ function invalidEnvLines(text) {
187
+ return String(text ?? '').split('\n')
188
+ .map((line) => line.trim())
189
+ .filter((line) => line.length > 0 && line.indexOf('=') <= 0)
190
+ }
191
+ // LOGIC-END invalidEnvLines
192
+
193
+ // 服务器清单 → 编辑态(argsText 汇成一串便于编辑;envText 每行 K=V;denyText 每行一正则)
194
+ function toEntries(section) {
195
+ return section.shells.map((entry) => ({
196
+ id: entry.id,
197
+ name: entry.name,
198
+ kind: entry.kind,
199
+ path: entry.path,
200
+ argsText: (entry.args ?? []).join(' '),
201
+ login: entry.login === true,
202
+ distro: entry.distro ?? '',
203
+ envText: Object.entries(entry.env ?? {}).map(([key, value]) => `${key}=${value}`).join('\n'),
204
+ available: entry.available,
205
+ resolved: entry.path,
206
+ }))
207
+ }
208
+
209
+ // 名单数组 → 每行一正则的编辑态文本
210
+ function patternText(list) {
211
+ return (Array.isArray(list) ? list : []).join('\n')
212
+ }
213
+
214
+ function ShellSelectApp() {
215
+ const [entries, setEntries] = useState(null)
216
+ const [defaultId, setDefaultId] = useState('')
217
+ const [dirty, setDirty] = useState(false)
218
+ const [notice, setNotice] = useState(null)
219
+ const [busy, setBusy] = useState(false)
220
+ const [denyText, setDenyText] = useState('')
221
+
222
+ useEffect(() => {
223
+ let disposed = false
224
+ api(API.config).then((section) => {
225
+ if (disposed) return
226
+ setEntries(toEntries(section))
227
+ setDefaultId(section.default)
228
+ setDenyText(patternText(section.deny))
229
+ }).catch((error) => {
230
+ if (!disposed) setNotice('加载失败:' + error.message)
231
+ })
232
+ return () => {
233
+ disposed = true
234
+ }
235
+ }, [])
236
+
237
+ const patchEntry = (index, changes) => {
238
+ setDirty(true)
239
+ setEntries(entries.map((entry, at) => (at === index ? { ...entry, ...changes } : entry)))
240
+ }
241
+
242
+ const addEntry = () => {
243
+ setDirty(true)
244
+ setEntries([...entries, { id: '', name: '', kind: 'bash', path: '', argsText: '', login: false, distro: '', envText: '', available: undefined, resolved: undefined }])
245
+ }
246
+
247
+ const removeEntry = (index) => {
248
+ const target = entries[index]
249
+ setDirty(true)
250
+ setEntries(entries.filter((_, at) => at !== index))
251
+ if (defaultId === target.id) setDefaultId(entries.length > 1 ? entries[0].id : '')
252
+ }
253
+
254
+ const save = async () => {
255
+ setBusy(true)
256
+ setNotice(null)
257
+ try {
258
+ const section = toSection(entries, defaultId, denyText)
259
+ const ids = section.shells.map((entry) => entry.id)
260
+ if (ids.some((id) => id.length === 0)) throw new Error('存在空 id 条目')
261
+ if (new Set(ids).size !== ids.length) throw new Error('id 重复:' + ids.join(', '))
262
+ if (!ids.includes(section.default)) throw new Error('默认客户端不在列表中')
263
+ const invalid = entries.map((entry) => ({ entry, lines: invalidEnvLines(entry.envText) }))
264
+ .find(({ lines }) => lines.length > 0)
265
+ if (invalid !== undefined) throw new Error(`环境变量行缺少 =(条目 ${invalid.entry.id || '(未命名)'}):${invalid.lines.join(' ; ')}`)
266
+ const badPatterns = section.deny.filter((line) => {
267
+ try { new RegExp(line); return false } catch { return true }
268
+ })
269
+ if (badPatterns.length > 0) {
270
+ throw new Error(`拒绝名单正则非法:${badPatterns[0]}`)
271
+ }
272
+ const payload = await api(API.config, { method: 'POST', body: JSON.stringify(section) })
273
+ setEntries(toEntries({ shells: payload.resolved.shells }))
274
+ setDefaultId(payload.resolved.default)
275
+ setDenyText(patternText(payload.resolved.deny))
276
+ setDirty(false)
277
+ setNotice('已保存,工具描述与默认客户端即时生效')
278
+ } catch (error) {
279
+ setNotice('保存失败:' + error.message)
280
+ } finally {
281
+ setBusy(false)
282
+ }
283
+ }
284
+
285
+ const probe = async (index) => {
286
+ const target = entries[index]
287
+ setBusy(true)
288
+ setNotice(null)
289
+ try {
290
+ const payload = await api(API.probe, { method: 'POST', body: JSON.stringify({ path: target.path }) })
291
+ patchEntry(index, { available: payload.exists, resolved: target.path })
292
+ } catch (error) {
293
+ setNotice('探测失败:' + error.message)
294
+ } finally {
295
+ setBusy(false)
296
+ }
297
+ }
298
+
299
+ const detect = async (kind) => {
300
+ setBusy(true)
301
+ setNotice(null)
302
+ try {
303
+ const payload = await api(API.detect, { method: 'POST', body: JSON.stringify({ kinds: [kind] }) })
304
+ setNotice(payload.found.length > 0
305
+ ? kind + ' 候选:' + payload.found.map((item) => item.path).join(' ; ')
306
+ : kind + ' 未发现候选')
307
+ } catch (error) {
308
+ setNotice('探测失败:' + error.message)
309
+ } finally {
310
+ setBusy(false)
311
+ }
312
+ }
313
+
314
+ if (entries === null) {
315
+ return h('div', { className: 'sls-card' },
316
+ h('span', { className: 'sls-hint' }, notice ?? '加载中…'))
317
+ }
318
+
319
+ return h('div', { className: 'sls-card' },
320
+ h('div', { className: 'sls-row' },
321
+ h('span', { className: 'sls-hint' }, '命令行客户端清单。路径留空自动探测;模型可通过工具的 shell 参数选择任一条目,缺省用默认。'),
322
+ ),
323
+ entries.map((entry, index) => h('div', { className: 'sls-entry', key: index },
324
+ h('div', { className: 'sls-entry__head' },
325
+ h('label', { className: 'sls-row' },
326
+ h('input', {
327
+ type: 'radio',
328
+ name: 'sls-default',
329
+ checked: defaultId === entry.id && entry.id.length > 0,
330
+ onChange: () => {
331
+ setDirty(true)
332
+ setDefaultId(entry.id)
333
+ },
334
+ disabled: entry.id.trim().length === 0,
335
+ title: '设为默认客户端(模型缺省 shell 参数时使用)',
336
+ }),
337
+ h('span', { className: 'sls-hint' }, '默认'),
338
+ ),
339
+ h('span', {
340
+ className: 'sls-badge' + (entry.available === true ? ' sls-badge--ok' : entry.available === false ? ' sls-badge--bad' : ''),
341
+ title: entry.resolved !== undefined && entry.resolved.length > 0 ? '解析路径:' + entry.resolved : '路径尚未解析',
342
+ }, entry.available === true ? '可用' : entry.available === false ? '不可用' : '未知'),
343
+ entry.id === defaultId && entry.id.length > 0 ? h('span', { className: 'sls-badge sls-badge--default' }, '默认') : null,
344
+ h('span', { style: { flex: 1 } }),
345
+ h('button', {
346
+ className: 'sls-btn',
347
+ disabled: busy || entry.path.trim().length === 0,
348
+ title: '检查该路径是否存在',
349
+ onClick: () => void probe(index),
350
+ }, '探测路径'),
351
+ h('button', {
352
+ className: 'sls-btn',
353
+ disabled: busy,
354
+ title: '移除该条目',
355
+ onClick: () => removeEntry(index),
356
+ }, '移除'),
357
+ ),
358
+ h('div', { className: 'sls-grid' },
359
+ h('span', { className: 'sls-grid__label' }, 'id'),
360
+ h('input', {
361
+ className: 'sls-input',
362
+ value: entry.id,
363
+ placeholder: 'pwsh / git-bash / …(模型看到的 shell 参数值)',
364
+ onChange: (event) => patchEntry(index, { id: event.target.value }),
365
+ }),
366
+ h('span', { className: 'sls-grid__label' }, '名称'),
367
+ h('input', {
368
+ className: 'sls-input',
369
+ value: entry.name,
370
+ placeholder: '显示名,如 Git Bash',
371
+ onChange: (event) => patchEntry(index, { name: event.target.value }),
372
+ }),
373
+ h('span', { className: 'sls-grid__label' }, '形态'),
374
+ h('div', { className: 'sls-row' },
375
+ h('select', {
376
+ className: 'sls-input',
377
+ value: entry.kind,
378
+ onChange: (event) => patchEntry(index, { kind: event.target.value }),
379
+ }, KINDS.map((kind) => h('option', { value: kind, key: kind }, KIND_LABELS[kind]))),
380
+ h('button', {
381
+ className: 'sls-btn',
382
+ disabled: busy,
383
+ title: '扫描本机常见安装位置',
384
+ onClick: () => void detect(entry.kind),
385
+ }, '扫描本机'),
386
+ ),
387
+ h('span', { className: 'sls-grid__label' }, '路径'),
388
+ h('input', {
389
+ className: 'sls-input sls-input--wide',
390
+ value: entry.path,
391
+ placeholder: '留空自动探测,如 C:\\Program Files\\Git\\bin\\bash.exe',
392
+ onChange: (event) => patchEntry(index, { path: event.target.value }),
393
+ }),
394
+ h('span', { className: 'sls-grid__label' }, '参数'),
395
+ h('input', {
396
+ className: 'sls-input sls-input--wide sls-args',
397
+ value: entry.argsText,
398
+ placeholder: '留空用形态默认;自定义模板以空格分隔,{command} 为命令占位',
399
+ onChange: (event) => patchEntry(index, { argsText: event.target.value }),
400
+ }),
401
+ ...(entry.kind === 'bash' ? [
402
+ h('span', { className: 'sls-grid__label' }, '登录壳'),
403
+ h('label', { className: 'sls-row sls-switch', title: '-lc 登录壳拉起 /etc/profile(只注入 /usr/bin;/mingw64/bin 需在下方的环境里配 MSYSTEM=MINGW64)' },
404
+ ...switchToggle({
405
+ checked: entry.login === true,
406
+ onChange: (event) => patchEntry(index, { login: event.target.checked }),
407
+ }),
408
+ h('span', { className: 'sls-hint' }, '-lc 登录壳'),
409
+ ),
410
+ ] : []),
411
+ ...(entry.kind === 'wsl' ? [
412
+ h('span', { className: 'sls-grid__label' }, '发行版'),
413
+ h('input', {
414
+ className: 'sls-input',
415
+ value: entry.distro ?? '',
416
+ placeholder: '留空用默认发行版,如 Ubuntu-22.04',
417
+ onChange: (event) => patchEntry(index, { distro: event.target.value }),
418
+ }),
419
+ ] : []),
420
+ h('span', { className: 'sls-grid__label' }, '环境'),
421
+ h('textarea', {
422
+ className: 'sls-input sls-args',
423
+ rows: 2,
424
+ value: entry.envText,
425
+ placeholder: '每行 K=V,如 MSYSTEM=MINGW64;wsl 形自动经 WSLENV 透传',
426
+ onChange: (event) => patchEntry(index, { envText: event.target.value }),
427
+ }),
428
+ ),
429
+ )),
430
+ h('div', { className: 'sls-grid' },
431
+ h('span', { className: 'sls-grid__label' }, '拒绝名单'),
432
+ h('textarea', {
433
+ className: 'sls-input sls-args',
434
+ rows: 3,
435
+ value: denyText,
436
+ placeholder: '每行一条正则,命中的命令拒绝执行(绝对,无豁免);精细放行用前瞻,如 rm -rf\\s+(?!\\S*node_modules)',
437
+ onChange: (event) => { setDirty(true); setDenyText(event.target.value) },
438
+ }),
439
+ ),
440
+ h('div', { className: 'sls-row' },
441
+ h('button', { className: 'sls-btn', disabled: busy, onClick: addEntry }, '添加客户端'),
442
+ h('span', { style: { flex: 1 } }),
443
+ dirty ? h('span', { className: 'sls-hint' }, '有未保存改动') : null,
444
+ h('button', {
445
+ className: 'sls-btn sls-btn--primary',
446
+ disabled: busy || !dirty,
447
+ onClick: () => void save(),
448
+ }, '保存'),
449
+ ),
450
+ notice !== null ? h('div', { className: 'sls-hint' }, notice) : null,
451
+ )
452
+ }
453
+
454
+ // ── tool.call.toolview 卡片(key 'shell')──────────────────────────────
455
+ // 官方 BashRow/terminalCardModel 的 shellCall 白名单只认 bash|pwsh,无法复用,
456
+ // 模型派生自带:数据自 argsRaw + 结果文本尾部退出标记派生(官方 block 无
457
+ // callView/resultView 结构化字段),非终端意图(后台 ack/isError/截断)回退简版原文行。
458
+ // 增强面(shell-card-plus 同款):复制命令/复制输出、命令折行+行号、客户端名标注。
459
+
460
+ function detectEnglish() {
461
+ return typeof document !== 'undefined' && (document.documentElement.lang || '').toLowerCase().indexOf('en') === 0
462
+ }
463
+
464
+ async function writeClipboard(text) {
465
+ if (!text) return false
466
+ try {
467
+ if (navigator.clipboard && navigator.clipboard.writeText) {
468
+ await navigator.clipboard.writeText(text)
469
+ return true
470
+ }
471
+ } catch { /* fall through */ }
472
+ try {
473
+ const ta = document.createElement('textarea')
474
+ ta.value = text
475
+ ta.style.position = 'fixed'
476
+ ta.style.opacity = '0'
477
+ document.body.appendChild(ta)
478
+ ta.select()
479
+ const ok = document.execCommand('copy')
480
+ document.body.removeChild(ta)
481
+ return ok
482
+ } catch { return false }
483
+ }
484
+
485
+ // LOGIC-BEGIN lastSegment
486
+ function lastSegment(path) {
487
+ const trimmed = String(path).replace(/[/\\]+$/, '')
488
+ const segment = trimmed.split(/[/\\]/).pop()
489
+ return segment === undefined || segment === '' ? String(path) : segment
490
+ }
491
+ // LOGIC-END lastSegment
492
+
493
+ // 相对 workdir 拼会话工作区(分隔符按会话根形态),返回展示用目录
494
+ // LOGIC-BEGIN displayCwd
495
+ function displayCwd(workdir, sessionCwd) {
496
+ const base = workdir !== undefined && workdir !== '' ? workdir : sessionCwd
497
+ if (base === undefined || base === '') return undefined
498
+ if (workdir !== undefined && workdir !== '' && sessionCwd !== undefined && sessionCwd !== ''
499
+ && !/^[/\\]/.test(workdir) && !/^[A-Za-z]:/.test(workdir)) {
500
+ const separator = sessionCwd.includes('\\') ? '\\' : '/'
501
+ return `${sessionCwd.replace(/[/\\]+$/, '')}${separator}${workdir}`
502
+ }
503
+ return base
504
+ }
505
+ // LOGIC-END displayCwd
506
+
507
+ // 结果文本尾部退出标记(官方 parseExitStatus 逐字同构:无尾标 = exit 0)
508
+ // LOGIC-BEGIN parseExitTail
509
+ function parseExitTail(text) {
510
+ const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
511
+ if (signal !== null && signal[1] !== undefined) return { output: text.slice(0, signal.index), signal: signal[1], exitCode: 0 }
512
+ const exit = /\n\[exit code: (\d+)\]$/.exec(text)
513
+ if (exit !== null && exit[1] !== undefined) return { output: text.slice(0, exit.index), signal: undefined, exitCode: Number(exit[1]) }
514
+ return { output: text, signal: undefined, exitCode: 0 }
515
+ }
516
+ // LOGIC-END parseExitTail
517
+
518
+ // 官方 hasSpillNotice 同构:截断提示会遮蔽尾部退出标记,退回 generic
519
+ // LOGIC-BEGIN hasSpillNotice
520
+ function hasSpillNotice(text) {
521
+ return text.includes('[output truncated; full output:') || text.includes('[some output was dropped from memory; full output:')
522
+ }
523
+ // LOGIC-END hasSpillNotice
524
+
525
+ // block → 卡片模型(官方 terminalCardModel 同构,数据源 argsRaw + content 文本)。
526
+ // generic = 后台 ack / isError / 溢出预览 / persistent 形(无 description,
527
+ // 官方 shellCall persistent→generic 同构),交回退行;terminal = 全量卡。
528
+ // LOGIC-BEGIN shellCardModel
529
+ function shellCardModel(block, sessionCwd) {
530
+ const settled = 'kind' in block
531
+ const call = settled ? block.call : block
532
+ let args = null
533
+ try {
534
+ const parsed = JSON.parse(call !== null && call !== undefined ? call.argsRaw : '')
535
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) args = parsed
536
+ } catch { /* 结构外形态走 generic */ }
537
+ const command = typeof args?.command === 'string' && args.command.trim() !== '' ? args.command : ''
538
+ const description = typeof args?.description === 'string' && args.description.trim() !== '' ? args.description : undefined
539
+ const cwdDir = (() => {
540
+ const dir = displayCwd(typeof args?.workdir === 'string' ? args.workdir : undefined, sessionCwd)
541
+ return dir !== undefined ? lastSegment(dir) : undefined
542
+ })()
543
+ const shellName = typeof args?.shell === 'string' && args.shell !== '' ? args.shell : undefined
544
+ if (command === '') return { kind: 'generic', command: '', summary: undefined, output: null }
545
+
546
+ if (!settled) {
547
+ // running persistent(进行中无描述):回退行但状态点用进行中灰,非 warning 黄
548
+ if (description === undefined) return { kind: 'generic', command, summary: undefined, output: null, running: true }
549
+ return { kind: 'terminal', status: 'running', command, description, cwdDir, shellName, output: undefined, exitCode: undefined, signal: undefined, code: undefined }
550
+ }
551
+
552
+ const contentText = (block.content ?? []).map((part) => (part.type === 'text' ? part.text : '')).filter((text) => text !== '').join('\n')
553
+ // 空结果落 generic:官方 singleResultText 无文本即回通用卡,避免空输出伪 done 终端卡
554
+ if (contentText === '' || block.isError === true || block.error !== undefined || args.run_in_background === true || description === undefined || hasSpillNotice(contentText)) {
555
+ return { kind: 'generic', command, summary: description, output: contentText }
556
+ }
557
+ const tail = parseExitTail(contentText)
558
+ const status = tail.signal !== undefined ? 'signaled' : tail.exitCode !== 0 ? 'failed' : 'done'
559
+ return { kind: 'terminal', status, command, description, cwdDir, shellName, output: tail.output, exitCode: tail.exitCode, signal: tail.signal, code: undefined }
560
+ }
561
+ // LOGIC-END shellCardModel
562
+
563
+ // 官方 leadingFor 同构:失败红点,回退行黄点,进行中灰点,其余工具图标
564
+ function leadingOf(status, icons) {
565
+ if (status === 'failed' || status === 'signaled') return icons.StateDot({ state: 'error' })
566
+ if (status === 'generic-warn') return icons.StateDot({ state: 'warning' })
567
+ if (status === 'running') return icons.StateDot({ state: 'ongoing' })
568
+ return icons.IconApi({ size: 14 })
569
+ }
570
+
571
+ function statusTextOf(status, en) {
572
+ switch (status) {
573
+ case 'running': return en ? 'Running' : '运行中'
574
+ case 'failed': case 'signaled': return en ? 'Failed' : '失败'
575
+ default: return null
576
+ }
577
+ }
578
+
579
+ function headMetaOf(model, en) {
580
+ switch (model.status) {
581
+ case 'running': return { dot: 'ongoing', label: en ? 'Running' : '运行中', pill: undefined }
582
+ case 'done': return { dot: 'done', label: en ? 'Done' : '已完成', pill: undefined }
583
+ case 'failed': return { dot: 'error', label: en ? 'Failed' : '失败', pill: en ? `exit ${model.exitCode}` : `退出码 ${model.exitCode}` }
584
+ case 'signaled': return { dot: 'error', label: en ? 'Failed' : '失败', pill: en ? `signal ${model.signal}` : `信号 ${model.signal}` }
585
+ default: return { dot: 'done', label: '', pill: undefined }
586
+ }
587
+ }
588
+
589
+ function CopyButton({ label, disabled, onClick }) {
590
+ const [done, setDone] = useState(false)
591
+ return h('button', {
592
+ className: 'sls-tv__copyBtn',
593
+ title: label,
594
+ 'aria-label': label,
595
+ disabled: disabled === true,
596
+ onClick: () => {
597
+ if (done) return
598
+ void onClick().then((ok) => {
599
+ if (ok !== true) return
600
+ setDone(true)
601
+ window.setTimeout(() => setDone(false), 1200)
602
+ })
603
+ },
604
+ }, done ? (detectEnglish() ? 'Copied' : '已复制') : label)
605
+ }
606
+
607
+ // 非终端意图回退行(后台 ack / isError / 截断):摘要 + 可展开原文
608
+ function GenericShellRow({ model, inspect, en }) {
609
+ const [open, setOpen] = useState(false)
610
+ const summary = model.summary !== undefined && model.summary !== ''
611
+ ? model.summary.split('\n')[0]
612
+ : (model.output !== null && model.output !== '' ? model.output.split('\n')[0] : '')
613
+ const expandable = model.output !== null && model.output !== ''
614
+ return h('div', { className: 'sls-tv' },
615
+ h('div', {
616
+ className: 'sls-tv__row' + (expandable ? ' sls-tv__row--exp' : ''),
617
+ role: expandable ? 'button' : undefined,
618
+ tabIndex: expandable ? 0 : undefined,
619
+ 'aria-expanded': expandable ? open : undefined,
620
+ onClick: expandable ? () => setOpen((value) => !value) : undefined,
621
+ onKeyDown: expandable ? (event) => {
622
+ if (event.key === 'Enter' || event.key === ' ') {
623
+ event.preventDefault()
624
+ setOpen((value) => !value)
625
+ }
626
+ } : undefined,
627
+ },
628
+ h('span', { className: 'sls-tv__lead' },
629
+ leadingOf(model.running === true ? 'running' : 'generic-warn', TOOLVIEW_ICONS),
630
+ 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,
631
+ ),
632
+ h('span', { className: 'sls-tv__title' }, 'Shell'),
633
+ summary !== '' ? h('span', { className: 'sls-tv__sep', 'aria-hidden': true }) : null,
634
+ summary !== '' ? h('span', { className: 'sls-tv__sum' }, summary) : null,
635
+ ),
636
+ open && expandable ? h('pre', { className: 'sls-tv__out', style: { border: '1px solid rgba(128,128,128,.28)', borderRadius: 8 } }, model.output) : null,
637
+ inspect !== undefined ? h('button', { className: 'sls-tv__inspect', onClick: inspect }, TOOLVIEW_ICONS.IconInspect({}), en ? 'Inspect' : '检查') : null,
638
+ )
639
+ }
640
+
641
+ function CommandBody({ command }) {
642
+ const lines = command === '' ? [''] : command.split('\n')
643
+ const width = String(lines.length).length
644
+ return h('div', { className: 'sls-tv__cmd' },
645
+ h('span', { className: 'sls-tv__cmdNo', style: { width: `${width}ch` } },
646
+ lines.map((_, index) => h('div', { key: index }, index + 1))),
647
+ h('span', { className: 'sls-tv__cmdText' },
648
+ lines.map((line, index) => h('div', { key: index }, line === '' ? ' ' : line))),
649
+ )
650
+ }
651
+
652
+ function ShellCard({ model, inspect, en }) {
653
+ const meta = headMetaOf(model, en)
654
+ const copyCommandLabel = en ? 'Copy command' : '复制命令'
655
+ const copyOutputLabel = en ? 'Copy output' : '复制输出'
656
+ return h('div', { className: 'sls-tv__body', 'data-status': model.status },
657
+ h('div', { className: 'sls-tv__head' },
658
+ h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
659
+ TOOLVIEW_ICONS.StateDot({ state: meta.dot }),
660
+ h('span', { className: 'sls-tv__sr' }, meta.label),
661
+ ),
662
+ model.cwdDir !== undefined ? h('span', { className: 'sls-tv__cwd' }, model.cwdDir) : null,
663
+ model.shellName !== undefined ? h('span', { className: 'sls-tv__badge' }, model.shellName) : null,
664
+ h('span', { className: 'sls-tv__sp' }),
665
+ meta.pill !== undefined ? h('span', { className: 'sls-tv__pill' }, meta.pill) : null,
666
+ h('span', { className: 'sls-tv__copy' },
667
+ h(CopyButton, {
668
+ label: copyCommandLabel,
669
+ disabled: model.command === '',
670
+ onClick: () => writeClipboard(model.command),
671
+ }),
672
+ h(CopyButton, {
673
+ label: copyOutputLabel,
674
+ disabled: model.output === undefined || model.output === '',
675
+ onClick: () => writeClipboard(model.output ?? ''),
676
+ }),
677
+ ),
678
+ ),
679
+ h(CommandBody, { command: model.command }),
680
+ model.output !== undefined && model.output !== ''
681
+ ? h('pre', { className: 'sls-tv__out' }, model.output)
682
+ : null,
683
+ inspect !== undefined ? h('button', { className: 'sls-tv__inspect', onClick: inspect }, TOOLVIEW_ICONS.IconInspect({}), en ? 'Inspect' : '检查') : null,
684
+ )
685
+ }
686
+
687
+ function ShellToolRow(props) {
688
+ const { block, cwd, inspect } = props
689
+ const en = detectEnglish()
690
+ const [open, setOpen] = useState(false)
691
+ const model = shellCardModel(block, cwd)
692
+ if (model.kind === 'generic') return h(GenericShellRow, { model, inspect, en })
693
+ const expandable = true
694
+ const srStatus = statusTextOf(model.status, en)
695
+ const summary = model.description !== undefined ? model.description.split('\n')[0] : (en ? '(no description)' : '(无描述)')
696
+ const failed = model.status === 'failed' || model.status === 'signaled'
697
+ return h('div', { className: 'sls-tv' },
698
+ h('div', {
699
+ className: 'sls-tv__row sls-tv__row--exp',
700
+ role: 'button',
701
+ tabIndex: 0,
702
+ 'aria-expanded': open,
703
+ 'data-open': open ? '1' : '0',
704
+ onClick: () => setOpen((value) => !value),
705
+ onKeyDown: (event) => {
706
+ if (event.key === 'Enter' || event.key === ' ') {
707
+ event.preventDefault()
708
+ setOpen((value) => !value)
709
+ }
710
+ },
711
+ },
712
+ h('span', { className: 'sls-tv__lead' },
713
+ leadingOf(model.status, TOOLVIEW_ICONS),
714
+ h('span', { className: 'sls-tv__chev', style: { display: 'inline-flex', transform: open ? 'rotate(-90deg)' : 'none' } }, TOOLVIEW_ICONS.IconChevron({ size: 14 })),
715
+ ),
716
+ srStatus !== null ? h('span', { className: 'sls-tv__sr' }, srStatus) : null,
717
+ h('span', { className: 'sls-tv__title' }, 'Shell'),
718
+ h('span', { className: 'sls-tv__sep', 'aria-hidden': true }),
719
+ h('span', { className: 'sls-tv__sum' + (failed ? ' sls-tv__sum--err' : '') }, summary),
720
+ ),
721
+ open ? h(ShellCard, { model, inspect, en }) : null,
722
+ )
723
+ }
724
+
725
+ return {
726
+ inject: ['slots'],
727
+ apply(ctx) {
728
+ ctx.effect(() => {
729
+ const style = document.createElement('style')
730
+ // 自带 data-plugin:缺失时宿主 claimStyles 会误收,插件 HMR 重建即误删
731
+ style.setAttribute('data-plugin', '@mzzsfy/dsh-shell-select')
732
+ style.textContent = CSS
733
+ document.head.appendChild(style)
734
+ return () => style.remove()
735
+ }, 'shell-select styles')
736
+ ctx.slots.inject('settings.section', () =>
737
+ ctx.slots.register(
738
+ { name: 'settings.section', id: 'shell-select', order: 82, label: 'Shell 管理' },
739
+ () => React.createElement(ShellSelectApp),
740
+ ))
741
+ // 替换 shell 族工具行渲染:keyed hit 优先于 GenericToolCard 兜底;
742
+ // priority -1 阴影官方同 key 注册(低值先渲染)。pwsh/bash 为官方工具
743
+ // 名(死态窗口 guard 代挂官方 tool-pwsh 时,其调用行同样获得增强卡;
744
+ // 卡片模型按官方 terminalCardModel 同构自 argsRaw+结果文本派生,数据
745
+ // 面对两类工具一致,无需分支)。
746
+ const TOOLVIEW_KEYS = ['shell', 'pwsh', 'bash']
747
+ for (const toolKey of TOOLVIEW_KEYS) {
748
+ ctx.slots.inject('tool.call.toolview', () =>
749
+ ctx.slots.register(
750
+ { name: 'tool.call.toolview', key: toolKey, priority: -1 },
751
+ (props) => React.createElement(ShellToolRow, props),
752
+ ))
753
+ }
754
+ },
755
+ }
756
+ },
757
+ })