@mzzsfy/dsh-shell-select 0.4.1 → 0.4.3

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
@@ -1,6 +1,8 @@
1
- // dsh-shell-select Client 半区:设置页卡片(shells 列表管理 + 默认客户端 + 路径探测)。
1
+ // dsh-shell-select Client 半区:tool.call.toolview shell 族卡片(官方 BashRow/
2
+ // terminalCardModel 同构 + 复制/折行/客户端徽章增强)。
2
3
  // 以 DSH client-modules 自注册格式发布:__ModuleLoader__.load({id, factory});
3
- // 数据经 webServer 路由 /api/shell-select/* 读写宿主 settings 节(照 dsh-maintain 双端模式)。
4
+ // 客户端名册经 webServer 路由 /api/shell-select/config 读取(配置写入走宿主
5
+ // settings JSON,浏览器侧无设置页)。
4
6
 
5
7
  window.__ModuleLoader__.load({
6
8
  id: '@mzzsfy/dsh-shell-select',
@@ -48,15 +50,6 @@ window.__ModuleLoader__.load({
48
50
 
49
51
  const API = {
50
52
  config: '/api/shell-select/config',
51
- probe: '/api/shell-select/probe',
52
- detect: '/api/shell-select/detect',
53
- }
54
- const KINDS = ['pwsh', 'bash', 'cmd', 'wsl']
55
- const KIND_LABELS = {
56
- pwsh: 'PowerShell',
57
- bash: 'POSIX bash(git bash / msys2 / cygwin)',
58
- cmd: 'CMD',
59
- wsl: 'WSL bash',
60
53
  }
61
54
 
62
55
  async function api(path, options) {
@@ -93,37 +86,122 @@ window.__ModuleLoader__.load({
93
86
  }
94
87
  // LOGIC-END clientDisplayName
95
88
 
89
+ // 仅在宿主明确报告 Windows 后,把可识别的 MSYS 路径映射回宿主文件路径。
90
+ // 文件地址本身不经 URL round-trip,避免改写未识别的编码与后缀。
91
+ // LOGIC-BEGIN msysToHostPath
92
+ function msysToHostPath(value, facts) {
93
+ if (typeof value !== 'string' || value === '') return value
94
+ const env = facts ?? {}
95
+ if (env.home && (value === '~' || value.startsWith('~/'))) {
96
+ return value === '~' ? env.home : `${env.home}/${value.slice(2)}`
97
+ }
98
+ const drive = /^\/([a-z])\/(.*)$/i.exec(value)
99
+ if (drive !== null) return `${drive[1].toUpperCase()}:/${drive[2]}`
100
+ const bareDrive = /^\/([a-z])$/i.exec(value)
101
+ if (bareDrive !== null) return `${bareDrive[1].toUpperCase()}:/`
102
+ if (env.tmpDir && (value === '/tmp' || value.startsWith('/tmp/'))) {
103
+ return value === '/tmp' ? env.tmpDir : env.tmpDir + value.slice(4)
104
+ }
105
+ return value
106
+ }
107
+ // LOGIC-END msysToHostPath
108
+
109
+ // LOGIC-BEGIN rescueFileAddress
110
+ function rescueFileAddress(address, msysToHostPath, facts) {
111
+ const prefix = 'dsh-resource://file/'
112
+ if (typeof address !== 'string' || !address.startsWith(prefix)) return address
113
+ const tail = address.slice(prefix.length)
114
+ const cut = tail.search(/[?#]/)
115
+ const suffix = cut === -1 ? '' : tail.slice(cut)
116
+ const parts = (cut === -1 ? tail : tail.slice(0, cut)).split('/')
117
+ const scope = parts.shift()
118
+ if (scope !== 'session' && scope !== 'absolute') return address
119
+ const sessionId = scope === 'session' ? parts.shift() : undefined
120
+ if (scope === 'session' && (!sessionId || parts.length === 0)) return address
121
+ let decodedSessionId = sessionId
122
+ if (scope === 'session') {
123
+ try {
124
+ decodedSessionId = decodeURIComponent(sessionId)
125
+ } catch {
126
+ return address
127
+ }
128
+ }
129
+ let segments
130
+ try {
131
+ segments = parts.map(decodeURIComponent)
132
+ } catch {
133
+ return address
134
+ }
135
+ if (scope === 'absolute' && segments[0] === '' && segments.length > 1) return address
136
+ const path = scope === 'absolute' ? `/${segments.join('/')}` : segments.join('/')
137
+ const translated = msysToHostPath(path, facts)
138
+ if (translated === path) return address
139
+ const encoded = translated.split('/').map((segment) => encodeURIComponent(segment).replace(/%3A/gi, ':')).join('/')
140
+ const rebuilt = scope === 'session'
141
+ ? `${prefix}session/${encodeURIComponent(decodedSessionId)}/${encoded}`
142
+ : `${prefix}absolute/${encoded.replace(/^\/+/, '')}`
143
+ return rebuilt + suffix
144
+ }
145
+ // LOGIC-END rescueFileAddress
146
+
147
+ const PATHMAP_URL = '/api/shell-select/pathmap'
148
+ let pathMap = null
149
+ let pathMapRequested = false
150
+ function loadPathMap() {
151
+ if (pathMapRequested) return
152
+ pathMapRequested = true
153
+ fetch(PATHMAP_URL, { headers: { accept: 'application/json' } })
154
+ .then((response) => response.ok ? response.json() : null)
155
+ .then((facts) => {
156
+ if (facts && typeof facts.platform === 'string') pathMap = facts
157
+ else pathMapRequested = false
158
+ })
159
+ .catch((error) => {
160
+ pathMapRequested = false
161
+ console.warn('shell-select: pathmap fetch failed', error)
162
+ })
163
+ }
164
+ // LOGIC-END loadPathMap
165
+
166
+ function installPathRescue(ctx) {
167
+ if (typeof ctx.inject !== 'function') return
168
+ loadPathMap()
169
+ ctx.inject(['sidebarRight'], (sctx) => {
170
+ const controller = sctx?.sidebarRight
171
+ if (!controller || controller.shellSelectPathRescue === true || typeof controller.openResource !== 'function') return
172
+ if (typeof sctx.effect !== 'function') return
173
+ const openResource = controller.openResource
174
+ const openResourceIn = controller.openResourceIn
175
+ let active = true
176
+ const rescue = (address) => {
177
+ if (!active) return address
178
+ if (pathMap === null) loadPathMap()
179
+ return pathMap?.platform === 'win32'
180
+ ? rescueFileAddress(address, msysToHostPath, pathMap)
181
+ : address
182
+ }
183
+ controller.openResource = function openResourceWithPathRescue(address, options) {
184
+ return openResource.call(this, rescue(address), options)
185
+ }
186
+ const wrappedOpenResource = controller.openResource
187
+ if (typeof openResourceIn === 'function') {
188
+ controller.openResourceIn = function openResourceInWithPathRescue(sessionId, address, options) {
189
+ return openResourceIn.call(this, sessionId, rescue(address), options)
190
+ }
191
+ }
192
+ const wrappedOpenResourceIn = controller.openResourceIn
193
+ controller.shellSelectPathRescue = true
194
+ const restore = () => {
195
+ active = false
196
+ if (controller.openResource === wrappedOpenResource) controller.openResource = openResource
197
+ if (typeof openResourceIn === 'function' && controller.openResourceIn === wrappedOpenResourceIn) controller.openResourceIn = openResourceIn
198
+ if (controller.shellSelectPathRescue === true) delete controller.shellSelectPathRescue
199
+ }
200
+ sctx.effect(() => restore, 'shell-select sidebar path rescue')
201
+ })
202
+ }
203
+
96
204
  const CSS = [
97
- '.sls-card { display:flex; flex-direction:column; gap:10px; }',
98
- '.sls-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }',
99
- '.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; }',
100
- '.sls-entry__head { display:flex; align-items:center; gap:8px; }',
101
- '.sls-grid { display:grid; grid-template-columns: 90px 1fr; gap:4px 8px; align-items:center; }',
102
- '.sls-grid__label { opacity:.75; font-size:12px; }',
103
- '.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; }',
104
- '.sls-input--wide { width:100%; }',
105
- '.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; }',
106
- '.sls-btn:disabled { opacity:.5; cursor:default; }',
107
- '.sls-btn--primary { border-color: transparent; background: var(--sls-accent, #4b7bcc); color:#fff; }',
108
- '.sls-badge { font-size:11px; padding:1px 8px; border-radius:999px; border:1px solid var(--sls-border, rgba(128,128,128,.35)); opacity:.85; }',
109
- '.sls-badge--ok { color:#2e9e5b; border-color:#2e9e5b; }',
110
- '.sls-badge--bad { color:#c44; border-color:#c44; }',
111
- '.sls-badge--default { color:#fff; background:var(--sls-accent, #4b7bcc); border-color:transparent; }',
112
- '.sls-hint { font-size:12px; opacity:.7; }',
113
- '.sls-error { font-size:12px; color:#c44; white-space:pre-wrap; }',
114
- '.sls-args { font-size:12px; font-family:var(--sls-mono, monospace); }',
115
- // 命令黑名单:逐条规则编辑器(每条规则独立行,行级即时校验)
116
- '.sls-rules { display:flex; flex-direction:column; gap:6px; padding:10px; border:1px solid var(--sls-border, rgba(128,128,128,.35)); border-radius:8px; }',
117
- '.sls-rules__head { display:flex; align-items:center; gap:8px; }',
118
- '.sls-rules__title { font-weight:500; }',
119
- '.sls-rules__note { font-size:12px; opacity:.7; }',
120
- '.sls-rule { display:flex; align-items:center; gap:6px; }',
121
- '.sls-rule__no { flex:none; width:18px; text-align:right; font-size:12px; opacity:.45; user-select:none; }',
122
- '.sls-rule__input { flex:1; min-width:0; padding:4px 8px; border-radius:6px; border:1px solid var(--sls-border, rgba(128,128,128,.35)); background:transparent; color:inherit; font-size:12px; font-family:var(--sls-mono, monospace); }',
123
- '.sls-rule__input:focus { outline:none; border-color:var(--sls-accent, #4b7bcc); }',
124
- '.sls-rule--bad .sls-rule__input { border-color:#c44; }',
125
- '.sls-rule__err { flex:none; max-width:40%; font-size:11px; color:#c44; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
126
- '.sls-rule__del { flex:none; padding:2px 8px; }',
127
205
  // tool.call.toolview 卡片(key 'shell'):官方 terminal 行同构 + 复制/折行增强。
128
206
  // 色彩对齐官方 ToolRow:标题 label-secondary、icon 盒 label-tertiary(实证自
129
207
  // 官方行 computed style);token 缺失时回退 inherit 不致不可读
@@ -147,6 +225,7 @@ window.__ModuleLoader__.load({
147
225
  '.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); }',
148
226
  // 头部单行片段禁折行:cwd 行宽不足时收缩出省略号,徽章不收缩不折行
149
227
  '.sls-tv__cwd { font-family:var(--sls-mono, monospace); font-size:12px; opacity:.7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
228
+ '.sls-tv__takeover { font-size:11px; padding:0 7px; border-radius:999px; border:1px solid currentColor; opacity:.75; flex:none; white-space:nowrap; }',
150
229
  '.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
230
  '.sls-tv__pill { font-size:12px; color:#d4553f; flex:none; white-space:nowrap; }',
152
231
  '.sls-tv__pill--bg { color:var(--dsw-alias-label-tertiary, inherit); }',
@@ -165,380 +244,13 @@ window.__ModuleLoader__.load({
165
244
  '.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; }',
166
245
  '.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; }',
167
246
  '.sls-tv__inspect:hover { opacity:1; }',
168
- // 开关:隐藏原生 checkbox,选中态 track 与 thumb 位移用过渡呈现(仓库 client 规约)
169
- '.sls-switch { display:inline-flex; align-items:center; cursor:pointer; }',
170
- '.sls-switch input[type="checkbox"] { position:absolute; opacity:0; width:0; height:0; }',
171
- '.sls-switch__track { position:relative; width:34px; height:19px; border-radius:999px; box-sizing:border-box;',
172
- ' background:var(--sls-track, rgba(128,128,128,0.35));',
173
- ' border:1px solid var(--sls-border, rgba(128,128,128,0.35));',
174
- ' transition:background 0.15s, border-color 0.15s; }',
175
- '.sls-switch__thumb { position:absolute; top:50%; left:2px; width:13px; height:13px; border-radius:50%;',
176
- ' background:var(--sls-thumb, rgba(128,128,128,0.6));',
177
- ' transform:translateY(-50%); transition:left 0.15s, background 0.15s; }',
178
- '.sls-switch:hover .sls-switch__track { border-color:var(--sls-accent, #4b7bcc); }',
179
- '.sls-switch input[type="checkbox"]:checked + .sls-switch__track { background:var(--sls-accent, #4b7bcc); border-color:var(--sls-accent, #4b7bcc); }',
180
- '.sls-switch input[type="checkbox"]:checked + .sls-switch__track .sls-switch__thumb { left:17px; background:var(--sls-bg-base, #fff); }',
181
- '.sls-switch input[type="checkbox"]:focus-visible + .sls-switch__track { outline:2px solid var(--sls-accent, #4b7bcc); outline-offset:1px; }',
182
247
  ].join('\n')
183
248
 
184
- // 开关的 checkbox + 轨道对,checkbox 语义保留仅视觉隐藏
185
- function switchToggle(props) {
186
- return [
187
- h('input', { type: 'checkbox', ...props }),
188
- h('span', { className: 'sls-switch__track' }, h('span', { className: 'sls-switch__thumb' })),
189
- ]
190
- }
191
-
192
249
  function h(type, props) {
193
250
  const children = Array.prototype.slice.call(arguments, 2)
194
251
  return React.createElement.apply(React, [type, props || null].concat(children))
195
252
  }
196
253
 
197
- // 保存负载:仅取设置 schema 字段,剥离探测态
198
- function toSection(entries, defaultId, denyRules) {
199
- return {
200
- shells: entries.map((entry) => ({
201
- id: entry.id.trim(),
202
- kind: entry.kind,
203
- path: entry.path.trim(),
204
- args: entry.argsText.split(/\s+/).filter((item) => item.length > 0),
205
- login: entry.kind === 'bash' ? entry.login === true : false,
206
- distro: entry.distro ?? '',
207
- env: parseEnvText(entry.envText),
208
- })),
209
- default: defaultId,
210
- deny: denyRules.filter((rule) => rule.length > 0),
211
- }
212
- }
213
-
214
- // 逐条规则的正则校验:返回非法条目 [{index, error}](index 为规则序,供行级标错与保存拦截复用)
215
- // LOGIC-BEGIN denyPatternIssues
216
- function denyPatternIssues(rules) {
217
- const issues = []
218
- for (let index = 0; index < rules.length; index += 1) {
219
- if (rules[index].length === 0) continue
220
- try {
221
- new RegExp(rules[index])
222
- } catch (error) {
223
- issues.push({ index, error: String(error?.message ?? error) })
224
- }
225
- }
226
- return issues
227
- }
228
- // LOGIC-END denyPatternIssues
229
-
230
- // 环境文本(每行 K=V)→ 记录;空行与缺 = 的行忽略,值保留原样含空格与 =。
231
- // LOGIC-BEGIN parseEnvText
232
- function parseEnvText(text) {
233
- const env = {}
234
- for (const line of String(text ?? '').split('\n')) {
235
- const separator = line.indexOf('=')
236
- if (separator <= 0) continue
237
- const key = line.slice(0, separator).trim()
238
- if (key.length === 0) continue
239
- env[key] = line.slice(separator + 1)
240
- }
241
- return env
242
- }
243
- // LOGIC-END parseEnvText
244
-
245
- // 环境文本中被忽略的行(空行以外):缺 = 的行在保存校验时报错,防静默丢数据
246
- // LOGIC-BEGIN invalidEnvLines
247
- function invalidEnvLines(text) {
248
- return String(text ?? '').split('\n')
249
- .map((line) => line.trim())
250
- .filter((line) => line.length > 0 && line.indexOf('=') <= 0)
251
- }
252
- // LOGIC-END invalidEnvLines
253
-
254
- // 服务器清单 → 编辑态(argsText 汇成一串便于编辑;envText 每行 K=V;deny 为逐条规则数组)
255
- function toEntries(section) {
256
- return section.shells.map((entry) => ({
257
- id: entry.id,
258
- kind: entry.kind,
259
- path: entry.path,
260
- argsText: (entry.args ?? []).join(' '),
261
- login: entry.login === true,
262
- distro: entry.distro ?? '',
263
- envText: Object.entries(entry.env ?? {}).map(([key, value]) => `${key}=${value}`).join('\n'),
264
- available: entry.available,
265
- resolved: entry.path,
266
- }))
267
- }
268
-
269
- // 名单数组 → 编辑态数组(去空白行;加载侧解析)
270
- function patternText(list) {
271
- return (Array.isArray(list) ? list : []).map((rule) => String(rule).trim()).filter((rule) => rule.length > 0)
272
- }
273
-
274
- function ShellSelectApp() {
275
- const [entries, setEntries] = useState(null)
276
- const [defaultId, setDefaultId] = useState('')
277
- const [dirty, setDirty] = useState(false)
278
- const [notice, setNotice] = useState(null)
279
- const [busy, setBusy] = useState(false)
280
- const [denyRules, setDenyRules] = useState([])
281
-
282
- useEffect(() => {
283
- let disposed = false
284
- api(API.config).then((section) => {
285
- if (disposed) return
286
- setEntries(toEntries(section))
287
- setDefaultId(section.default)
288
- setDenyRules(patternText(section.deny))
289
- }).catch((error) => {
290
- if (!disposed) setNotice('加载失败:' + error.message)
291
- })
292
- return () => {
293
- disposed = true
294
- }
295
- }, [])
296
-
297
- const patchEntry = (index, changes) => {
298
- setDirty(true)
299
- setEntries(entries.map((entry, at) => (at === index ? { ...entry, ...changes } : entry)))
300
- }
301
-
302
- const addEntry = () => {
303
- setDirty(true)
304
- setEntries([...entries, { id: '', kind: 'bash', path: '', argsText: '', login: false, distro: '', envText: '', available: undefined, resolved: undefined }])
305
- }
306
-
307
- const removeEntry = (index) => {
308
- const target = entries[index]
309
- setDirty(true)
310
- setEntries(entries.filter((_, at) => at !== index))
311
- if (defaultId === target.id) setDefaultId(entries.length > 1 ? entries[0].id : '')
312
- }
313
-
314
- const patchRule = (index, value) => {
315
- setDirty(true)
316
- setDenyRules(denyRules.map((rule, at) => (at === index ? value : rule)))
317
- }
318
-
319
- const addRule = () => {
320
- setDirty(true)
321
- setDenyRules([...denyRules, ''])
322
- }
323
-
324
- const removeRule = (index) => {
325
- setDirty(true)
326
- setDenyRules(denyRules.filter((_, at) => at !== index))
327
- }
328
-
329
- const save = async () => {
330
- setBusy(true)
331
- setNotice(null)
332
- try {
333
- const section = toSection(entries, defaultId, denyRules)
334
- const ids = section.shells.map((entry) => entry.id)
335
- if (ids.some((id) => id.length === 0)) throw new Error('存在空 id 条目')
336
- if (new Set(ids).size !== ids.length) throw new Error('id 重复:' + ids.join(', '))
337
- if (!ids.includes(section.default)) throw new Error('默认客户端不在列表中')
338
- const invalid = entries.map((entry) => ({ entry, lines: invalidEnvLines(entry.envText) }))
339
- .find(({ lines }) => lines.length > 0)
340
- if (invalid !== undefined) throw new Error(`环境变量行缺少 =(条目 ${invalid.entry.id || '(未命名)'}):${invalid.lines.join(' ; ')}`)
341
- const issue = denyPatternIssues(section.deny)[0]
342
- if (issue !== undefined) {
343
- throw new Error(`命令黑名单第 ${issue.index + 1} 条正则非法:${issue.error}`)
344
- }
345
- const payload = await api(API.config, { method: 'POST', body: JSON.stringify(section) })
346
- setEntries(toEntries({ shells: payload.resolved.shells }))
347
- setDefaultId(payload.resolved.default)
348
- setDenyRules(patternText(payload.resolved.deny))
349
- setDirty(false)
350
- setNotice('已保存,工具描述与默认客户端即时生效')
351
- } catch (error) {
352
- setNotice('保存失败:' + error.message)
353
- } finally {
354
- setBusy(false)
355
- }
356
- }
357
-
358
- const probe = async (index) => {
359
- const target = entries[index]
360
- setBusy(true)
361
- setNotice(null)
362
- try {
363
- const payload = await api(API.probe, { method: 'POST', body: JSON.stringify({ path: target.path }) })
364
- patchEntry(index, { available: payload.exists, resolved: target.path })
365
- } catch (error) {
366
- setNotice('探测失败:' + error.message)
367
- } finally {
368
- setBusy(false)
369
- }
370
- }
371
-
372
- const detect = async (kind) => {
373
- setBusy(true)
374
- setNotice(null)
375
- try {
376
- const payload = await api(API.detect, { method: 'POST', body: JSON.stringify({ kinds: [kind] }) })
377
- setNotice(payload.found.length > 0
378
- ? kind + ' 候选:' + payload.found.map((item) => item.path).join(' ; ')
379
- : kind + ' 未发现候选')
380
- } catch (error) {
381
- setNotice('探测失败:' + error.message)
382
- } finally {
383
- setBusy(false)
384
- }
385
- }
386
-
387
- if (entries === null) {
388
- return h('div', { className: 'sls-card' },
389
- h('span', { className: 'sls-hint' }, notice ?? '加载中…'))
390
- }
391
-
392
- return h('div', { className: 'sls-card' },
393
- h('div', { className: 'sls-row' },
394
- h('span', { className: 'sls-hint' }, '命令行客户端清单。路径留空自动探测;模型可通过工具的 shell 参数选择任一条目,缺省用默认。'),
395
- ),
396
- entries.map((entry, index) => h('div', { className: 'sls-entry', key: index },
397
- h('div', { className: 'sls-entry__head' },
398
- h('label', { className: 'sls-row' },
399
- h('input', {
400
- type: 'radio',
401
- name: 'sls-default',
402
- checked: defaultId === entry.id && entry.id.length > 0,
403
- onChange: () => {
404
- setDirty(true)
405
- setDefaultId(entry.id)
406
- },
407
- disabled: entry.id.trim().length === 0,
408
- title: '设为默认客户端(模型缺省 shell 参数时使用)',
409
- }),
410
- h('span', { className: 'sls-hint' }, '默认'),
411
- ),
412
- h('span', {
413
- className: 'sls-badge' + (entry.available === true ? ' sls-badge--ok' : entry.available === false ? ' sls-badge--bad' : ''),
414
- title: entry.resolved !== undefined && entry.resolved.length > 0 ? '解析路径:' + entry.resolved : '路径尚未解析',
415
- }, entry.available === true ? '可用' : entry.available === false ? '不可用' : '未知'),
416
- entry.id === defaultId && entry.id.length > 0 ? h('span', { className: 'sls-badge sls-badge--default' }, '默认') : null,
417
- h('span', { style: { flex: 1 } }),
418
- h('button', {
419
- className: 'sls-btn',
420
- disabled: busy || entry.path.trim().length === 0,
421
- title: '检查该路径是否存在',
422
- onClick: () => void probe(index),
423
- }, '探测路径'),
424
- h('button', {
425
- className: 'sls-btn',
426
- disabled: busy,
427
- title: '移除该条目',
428
- onClick: () => removeEntry(index),
429
- }, '移除'),
430
- ),
431
- h('div', { className: 'sls-grid' },
432
- h('span', { className: 'sls-grid__label' }, 'id'),
433
- h('input', {
434
- className: 'sls-input',
435
- value: entry.id,
436
- placeholder: 'pwsh / git-bash / …(模型看到的 shell 参数值,亦作显示名)',
437
- onChange: (event) => patchEntry(index, { id: event.target.value }),
438
- }),
439
- h('span', { className: 'sls-grid__label' }, '形态'),
440
- h('div', { className: 'sls-row' },
441
- h('select', {
442
- className: 'sls-input',
443
- value: entry.kind,
444
- onChange: (event) => patchEntry(index, { kind: event.target.value }),
445
- }, KINDS.map((kind) => h('option', { value: kind, key: kind }, KIND_LABELS[kind]))),
446
- h('button', {
447
- className: 'sls-btn',
448
- disabled: busy,
449
- title: '扫描本机常见安装位置',
450
- onClick: () => void detect(entry.kind),
451
- }, '扫描本机'),
452
- ),
453
- h('span', { className: 'sls-grid__label' }, '路径'),
454
- h('input', {
455
- className: 'sls-input sls-input--wide',
456
- value: entry.path,
457
- placeholder: '留空自动探测,如 C:\\Program Files\\Git\\bin\\bash.exe',
458
- onChange: (event) => patchEntry(index, { path: event.target.value }),
459
- }),
460
- h('span', { className: 'sls-grid__label' }, '参数'),
461
- h('input', {
462
- className: 'sls-input sls-input--wide sls-args',
463
- value: entry.argsText,
464
- placeholder: '留空用形态默认;自定义模板以空格分隔,{command} 为命令占位',
465
- onChange: (event) => patchEntry(index, { argsText: event.target.value }),
466
- }),
467
- ...(entry.kind === 'bash' ? [
468
- h('span', { className: 'sls-grid__label' }, '登录壳'),
469
- h('label', { className: 'sls-row sls-switch', title: '-lc 登录壳拉起 /etc/profile(只注入 /usr/bin;/mingw64/bin 需在下方的环境里配 MSYSTEM=MINGW64)' },
470
- ...switchToggle({
471
- checked: entry.login === true,
472
- onChange: (event) => patchEntry(index, { login: event.target.checked }),
473
- }),
474
- h('span', { className: 'sls-hint' }, '-lc 登录壳'),
475
- ),
476
- ] : []),
477
- ...(entry.kind === 'wsl' ? [
478
- h('span', { className: 'sls-grid__label' }, '发行版'),
479
- h('input', {
480
- className: 'sls-input',
481
- value: entry.distro ?? '',
482
- placeholder: '留空用默认发行版,如 Ubuntu-22.04',
483
- onChange: (event) => patchEntry(index, { distro: event.target.value }),
484
- }),
485
- ] : []),
486
- h('span', { className: 'sls-grid__label' }, '环境'),
487
- h('textarea', {
488
- className: 'sls-input sls-args',
489
- rows: 2,
490
- value: entry.envText,
491
- placeholder: '每行 K=V,如 MSYSTEM=MINGW64;wsl 形自动经 WSLENV 透传',
492
- onChange: (event) => patchEntry(index, { envText: event.target.value }),
493
- }),
494
- ),
495
- )),
496
- h('div', { className: 'sls-rules' },
497
- h('div', { className: 'sls-rules__head' },
498
- h('span', { className: 'sls-rules__title' }, '命令黑名单'),
499
- h('span', { className: 'sls-badge' + (denyRules.length > 0 ? ' sls-badge--ok' : ' sls-badge--bad') },
500
- denyRules.length > 0 ? denyRules.length + ' 条规则' : '未生效'),
501
- ),
502
- h('span', { className: 'sls-rules__note' },
503
- '每条规则为一条正则,对整条命令全文匹配(大小写不敏感),命中即拒,无豁免;精细放行写在模式内用前瞻,如 rm -rf\\s+(?!\\S*node_modules)。'),
504
- denyRules.length === 0
505
- ? h('span', { className: 'sls-rules__note' }, '未配置,命中拦截不生效,模型可执行任意命令。')
506
- : denyRules.map((rule, index) => {
507
- const issue = denyPatternIssues([rule])[0]
508
- return h('div', { className: 'sls-rule' + (issue !== undefined ? ' sls-rule--bad' : ''), key: index },
509
- h('span', { className: 'sls-rule__no' }, index + 1),
510
- h('input', {
511
- className: 'sls-rule__input',
512
- value: rule,
513
- placeholder: '正则,如 ^format\\s',
514
- onChange: (event) => patchRule(index, event.target.value),
515
- }),
516
- issue !== undefined ? h('span', { className: 'sls-rule__err', title: issue.error }, issue.error) : null,
517
- h('button', {
518
- className: 'sls-btn sls-rule__del',
519
- title: '删除该条规则',
520
- onClick: () => removeRule(index),
521
- }, '删除'),
522
- )
523
- }),
524
- h('div', { className: 'sls-row' },
525
- h('button', { className: 'sls-btn', onClick: addRule }, '添加规则'),
526
- ),
527
- ),
528
- h('div', { className: 'sls-row' },
529
- h('button', { className: 'sls-btn', disabled: busy, onClick: addEntry }, '添加客户端'),
530
- h('span', { style: { flex: 1 } }),
531
- dirty ? h('span', { className: 'sls-hint' }, '有未保存改动') : null,
532
- h('button', {
533
- className: 'sls-btn sls-btn--primary',
534
- disabled: busy || !dirty,
535
- onClick: () => void save(),
536
- }, '保存'),
537
- ),
538
- notice !== null ? h('div', { className: 'sls-hint' }, notice) : null,
539
- )
540
- }
541
-
542
254
  // ── tool.call.toolview 卡片(key 'shell')──────────────────────────────
543
255
  // 官方 BashRow/terminalCardModel 的 shellCall 白名单只认 bash|pwsh,无法复用,
544
256
  // 模型派生自带:数据自 argsRaw + 结果文本尾部退出标记派生(官方 block 无
@@ -657,22 +369,23 @@ window.__ModuleLoader__.load({
657
369
  }
658
370
 
659
371
  const contentText = (block.content ?? []).map((part) => (part.type === 'text' ? part.text : '')).filter((text) => text !== '').join('\n')
372
+ const intercepted = toolKey === 'pwsh' && (block.content ?? []).some((part) => part.type === 'shell-select-takeover' && part.tool === 'pwsh')
660
373
  const shellName = shellNameOf(parseShellMark(contentText))
661
374
  const outputText = stripShellMark(contentText)
662
375
  // 后台 ack:独立卡呈现命令全文与任务号徽标,ack 原文作输出(不再落 generic 简版行)
663
376
  if (args.run_in_background === true) {
664
377
  const jobId = /started background job (\S+)/.exec(contentText)?.[1]
665
- return { kind: 'background', status: 'background', command, description, cwdDir, cwdFull, shellName, output: outputText, jobId }
378
+ return { kind: 'background', status: 'background', command, description, cwdDir, cwdFull, shellName, intercepted, output: outputText, jobId }
666
379
  }
667
380
  // 空结果落 generic:官方 singleResultText 无文本即回通用卡,避免空输出伪 done 终端卡;
668
381
  // isError/error 块带 alert(错误红点语义),其余(空输出/无描述/截断)中性无状态点
669
382
  if (outputText === '' || block.isError === true || block.error !== undefined || description === undefined || hasSpillNotice(contentText)) {
670
383
  const alert = block.isError === true || block.error !== undefined ? 'error' : undefined
671
- return { kind: 'generic', command, summary: description, output: outputText, running: false, alert }
384
+ return { kind: 'generic', command, summary: description, output: outputText, running: false, alert, intercepted }
672
385
  }
673
386
  const tail = parseExitTail(outputText)
674
387
  const status = tail.signal !== undefined ? 'signaled' : tail.exitCode !== 0 ? 'failed' : 'done'
675
- return { kind: 'terminal', status, command, description, cwdDir, cwdFull, shellName, output: tail.output, exitCode: tail.exitCode, signal: tail.signal, code: undefined }
388
+ return { kind: 'terminal', status, command, description, cwdDir, cwdFull, shellName, intercepted, output: tail.output, exitCode: tail.exitCode, signal: tail.signal, code: undefined }
676
389
  }
677
390
  // LOGIC-END shellCardModel
678
391
 
@@ -789,6 +502,7 @@ window.__ModuleLoader__.load({
789
502
  h('span', { className: 'sls-tv__title' }, 'Shell'),
790
503
  summary !== '' ? h('span', { className: 'sls-tv__sep', 'aria-hidden': true }) : null,
791
504
  summary !== '' ? h('span', { className: 'sls-tv__sum' + (status === 'generic-error' ? ' sls-tv__sum--err' : '') }, summary) : null,
505
+ model.intercepted ? h(TakeoverBadge, { en }) : null,
792
506
  status === 'running' ? h('span', { className: 'sls-tv__pulse' }, en ? 'running…' : '运行中…') : null,
793
507
  ),
794
508
  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,
@@ -796,6 +510,10 @@ window.__ModuleLoader__.load({
796
510
  )
797
511
  }
798
512
 
513
+ function TakeoverBadge({ en }) {
514
+ return h('span', { className: 'sls-tv__takeover', title: en ? 'Direct pwsh call handled by shell-select' : '直接 pwsh 调用由 shell-select 接管' }, en ? 'Intercepted' : '插件接管')
515
+ }
516
+
799
517
  function CommandBody({ command }) {
800
518
  const lines = command === '' ? [''] : command.split('\n')
801
519
  const width = String(lines.length).length
@@ -823,6 +541,7 @@ window.__ModuleLoader__.load({
823
541
  ),
824
542
  model.cwdDir !== undefined ? h('span', { className: 'sls-tv__cwd', title: 'pwd: ' + (model.cwdFull ?? model.cwdDir) }, model.cwdDir) : null,
825
543
  model.shellName !== undefined ? h('span', { className: 'sls-tv__badge', title: en ? 'Shell client' : 'Shell 客户端' }, model.shellName) : null,
544
+ model.intercepted ? h(TakeoverBadge, { en }) : null,
826
545
  h('span', { className: 'sls-tv__sp' }),
827
546
  pill !== undefined ? h('span', { className: 'sls-tv__pill' + (meta.pillTone === 'bg' ? ' sls-tv__pill--bg' : '') }, pill) : null,
828
547
  h('span', { className: 'sls-tv__copy' },
@@ -885,6 +604,7 @@ window.__ModuleLoader__.load({
885
604
  h('span', { className: 'sls-tv__title' }, 'Shell'),
886
605
  h('span', { className: 'sls-tv__sep', 'aria-hidden': true }),
887
606
  h('span', { className: 'sls-tv__sum' + (failed ? ' sls-tv__sum--err' : '') }, summary),
607
+ model.intercepted ? h(TakeoverBadge, { en }) : null,
888
608
  model.status === 'running' ? h(RunningDuration, { startedAt: model.startedAt, en }) : null,
889
609
  model.status === 'background' && model.jobId !== undefined
890
610
  ? h('span', { className: 'sls-tv__badge', title: en ? 'Background job' : '后台任务' }, en ? `bg ${model.jobId}` : `后台 ${model.jobId}`)
@@ -897,6 +617,7 @@ window.__ModuleLoader__.load({
897
617
  return {
898
618
  inject: ['slots'],
899
619
  apply(ctx) {
620
+ installPathRescue(ctx)
900
621
  ctx.effect(() => {
901
622
  const style = document.createElement('style')
902
623
  // 自带 data-plugin:缺失时宿主 claimStyles 会误收,插件 HMR 重建即误删
@@ -905,11 +626,6 @@ window.__ModuleLoader__.load({
905
626
  document.head.appendChild(style)
906
627
  return () => style.remove()
907
628
  }, 'shell-select styles')
908
- ctx.slots.inject('settings.section', () =>
909
- ctx.slots.register(
910
- { name: 'settings.section', id: 'shell-select', order: 82, label: 'Shell 管理' },
911
- () => React.createElement(ShellSelectApp),
912
- ))
913
629
  // 替换 shell 族工具行渲染:keyed hit 优先于 GenericToolCard 兜底;
914
630
  // priority -1 阴影官方同 key 注册(低值先渲染)。pwsh/bash 为官方工具
915
631
  // 名(死态窗口 guard 代挂官方 tool-pwsh 时,其调用行同样获得增强卡);