@mzzsfy/dsh-shell-select 0.4.1 → 0.4.4
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/README.md +13 -20
- package/cordis.patch.yml +3 -189
- package/package.json +10 -4
- package/src/api.mjs +7 -1
- package/src/client.js +439 -389
- package/src/executor.mjs +31 -7
- package/src/prompt-projection.mjs +34 -0
- package/src/pwsh-takeover.mjs +23 -0
- package/src/resolve.mjs +35 -23
- package/src/tool.mjs +58 -25
- package/src/web-routes.mjs +3 -0
- package/test/background-owner.test.mjs +14 -0
- package/test/client-card.test.mjs +0 -21
- package/test/client-id.test.mjs +0 -56
- package/test/client-render.test.mjs +38 -12
- package/test/client-settings.test.mjs +251 -0
- package/test/config-entry.test.mjs +2 -1
- package/test/deny-output-schema.test.mjs +53 -0
- package/test/executor-seam.test.mjs +2 -1
- package/test/executor.test.mjs +23 -3
- package/test/official-contract.test.mjs +208 -0
- package/test/path-guidance.test.mjs +77 -0
- package/test/path-rescue.test.mjs +173 -0
- package/test/preset-drift.test.mjs +30 -139
- package/test/prompt-projection.test.mjs +120 -0
- package/test/pwsh-takeover.test.mjs +41 -0
- package/test/resolve.test.mjs +43 -13
- package/test/web-routes.test.mjs +15 -2
- package/test/client-drift.test.mjs +0 -27
- package/test/switch-guard.test.mjs +0 -37
package/src/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// dsh-shell-select Client
|
|
1
|
+
// dsh-shell-select Client 半区:tool.call.toolview shell 族卡片(官方 BashRow/
|
|
2
|
+
// terminalCardModel 同构 + 复制/折行/客户端徽章增强)。
|
|
2
3
|
// 以 DSH client-modules 自注册格式发布:__ModuleLoader__.load({id, factory});
|
|
3
|
-
//
|
|
4
|
+
// 客户端名册与 Shell 管理设置页经 /api/shell-select/* 读写宿主配置。
|
|
4
5
|
|
|
5
6
|
window.__ModuleLoader__.load({
|
|
6
7
|
id: '@mzzsfy/dsh-shell-select',
|
|
@@ -51,13 +52,6 @@ window.__ModuleLoader__.load({
|
|
|
51
52
|
probe: '/api/shell-select/probe',
|
|
52
53
|
detect: '/api/shell-select/detect',
|
|
53
54
|
}
|
|
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
|
-
}
|
|
61
55
|
|
|
62
56
|
async function api(path, options) {
|
|
63
57
|
const response = await fetch(path, { headers: { 'content-type': 'application/json' }, ...options })
|
|
@@ -93,6 +87,121 @@ window.__ModuleLoader__.load({
|
|
|
93
87
|
}
|
|
94
88
|
// LOGIC-END clientDisplayName
|
|
95
89
|
|
|
90
|
+
// 仅在宿主明确报告 Windows 后,把可识别的 MSYS 路径映射回宿主文件路径。
|
|
91
|
+
// 文件地址本身不经 URL round-trip,避免改写未识别的编码与后缀。
|
|
92
|
+
// LOGIC-BEGIN msysToHostPath
|
|
93
|
+
function msysToHostPath(value, facts) {
|
|
94
|
+
if (typeof value !== 'string' || value === '') return value
|
|
95
|
+
const env = facts ?? {}
|
|
96
|
+
if (env.home && (value === '~' || value.startsWith('~/'))) {
|
|
97
|
+
return value === '~' ? env.home : `${env.home}/${value.slice(2)}`
|
|
98
|
+
}
|
|
99
|
+
const drive = /^\/([a-z])\/(.*)$/i.exec(value)
|
|
100
|
+
if (drive !== null) return `${drive[1].toUpperCase()}:/${drive[2]}`
|
|
101
|
+
const bareDrive = /^\/([a-z])$/i.exec(value)
|
|
102
|
+
if (bareDrive !== null) return `${bareDrive[1].toUpperCase()}:/`
|
|
103
|
+
if (env.tmpDir && (value === '/tmp' || value.startsWith('/tmp/'))) {
|
|
104
|
+
return value === '/tmp' ? env.tmpDir : env.tmpDir + value.slice(4)
|
|
105
|
+
}
|
|
106
|
+
return value
|
|
107
|
+
}
|
|
108
|
+
// LOGIC-END msysToHostPath
|
|
109
|
+
|
|
110
|
+
// LOGIC-BEGIN rescueFileAddress
|
|
111
|
+
function rescueFileAddress(address, msysToHostPath, facts) {
|
|
112
|
+
const prefix = 'dsh-resource://file/'
|
|
113
|
+
if (typeof address !== 'string' || !address.startsWith(prefix)) return address
|
|
114
|
+
const tail = address.slice(prefix.length)
|
|
115
|
+
const cut = tail.search(/[?#]/)
|
|
116
|
+
const suffix = cut === -1 ? '' : tail.slice(cut)
|
|
117
|
+
const parts = (cut === -1 ? tail : tail.slice(0, cut)).split('/')
|
|
118
|
+
const scope = parts.shift()
|
|
119
|
+
if (scope !== 'session' && scope !== 'absolute') return address
|
|
120
|
+
const sessionId = scope === 'session' ? parts.shift() : undefined
|
|
121
|
+
if (scope === 'session' && (!sessionId || parts.length === 0)) return address
|
|
122
|
+
let decodedSessionId = sessionId
|
|
123
|
+
if (scope === 'session') {
|
|
124
|
+
try {
|
|
125
|
+
decodedSessionId = decodeURIComponent(sessionId)
|
|
126
|
+
} catch {
|
|
127
|
+
return address
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let segments
|
|
131
|
+
try {
|
|
132
|
+
segments = parts.map(decodeURIComponent)
|
|
133
|
+
} catch {
|
|
134
|
+
return address
|
|
135
|
+
}
|
|
136
|
+
if (scope === 'absolute' && segments[0] === '' && segments.length > 1) return address
|
|
137
|
+
const path = scope === 'absolute' ? `/${segments.join('/')}` : segments.join('/')
|
|
138
|
+
const translated = msysToHostPath(path, facts)
|
|
139
|
+
if (translated === path) return address
|
|
140
|
+
const encoded = translated.split('/').map((segment) => encodeURIComponent(segment).replace(/%3A/gi, ':')).join('/')
|
|
141
|
+
const rebuilt = scope === 'session'
|
|
142
|
+
? `${prefix}session/${encodeURIComponent(decodedSessionId)}/${encoded}`
|
|
143
|
+
: `${prefix}absolute/${encoded.replace(/^\/+/, '')}`
|
|
144
|
+
return rebuilt + suffix
|
|
145
|
+
}
|
|
146
|
+
// LOGIC-END rescueFileAddress
|
|
147
|
+
|
|
148
|
+
const PATHMAP_URL = '/api/shell-select/pathmap'
|
|
149
|
+
let pathMap = null
|
|
150
|
+
let pathMapRequested = false
|
|
151
|
+
function loadPathMap() {
|
|
152
|
+
if (pathMapRequested) return
|
|
153
|
+
pathMapRequested = true
|
|
154
|
+
fetch(PATHMAP_URL, { headers: { accept: 'application/json' } })
|
|
155
|
+
.then((response) => response.ok ? response.json() : null)
|
|
156
|
+
.then((facts) => {
|
|
157
|
+
if (facts && typeof facts.platform === 'string') pathMap = facts
|
|
158
|
+
else pathMapRequested = false
|
|
159
|
+
})
|
|
160
|
+
.catch((error) => {
|
|
161
|
+
pathMapRequested = false
|
|
162
|
+
console.warn('shell-select: pathmap fetch failed', error)
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
// LOGIC-END loadPathMap
|
|
166
|
+
|
|
167
|
+
function installPathRescue(ctx) {
|
|
168
|
+
if (typeof ctx.inject !== 'function') return
|
|
169
|
+
loadPathMap()
|
|
170
|
+
ctx.inject(['sidebarRight'], (sctx) => {
|
|
171
|
+
const controller = sctx?.sidebarRight
|
|
172
|
+
if (!controller || controller.shellSelectPathRescue === true || typeof controller.openResource !== 'function') return
|
|
173
|
+
if (typeof sctx.effect !== 'function') return
|
|
174
|
+
const openResource = controller.openResource
|
|
175
|
+
const openResourceIn = controller.openResourceIn
|
|
176
|
+
let active = true
|
|
177
|
+
const rescue = (address) => {
|
|
178
|
+
if (!active) return address
|
|
179
|
+
if (pathMap === null) loadPathMap()
|
|
180
|
+
return pathMap?.platform === 'win32'
|
|
181
|
+
? rescueFileAddress(address, msysToHostPath, pathMap)
|
|
182
|
+
: address
|
|
183
|
+
}
|
|
184
|
+
controller.openResource = function openResourceWithPathRescue(address, options) {
|
|
185
|
+
return openResource.call(this, rescue(address), options)
|
|
186
|
+
}
|
|
187
|
+
const wrappedOpenResource = controller.openResource
|
|
188
|
+
if (typeof openResourceIn === 'function') {
|
|
189
|
+
controller.openResourceIn = function openResourceInWithPathRescue(sessionId, address, options) {
|
|
190
|
+
return openResourceIn.call(this, sessionId, rescue(address), options)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const wrappedOpenResourceIn = controller.openResourceIn
|
|
194
|
+
controller.shellSelectPathRescue = true
|
|
195
|
+
const restore = () => {
|
|
196
|
+
active = false
|
|
197
|
+
if (controller.openResource === wrappedOpenResource) controller.openResource = openResource
|
|
198
|
+
if (typeof openResourceIn === 'function' && controller.openResourceIn === wrappedOpenResourceIn) controller.openResourceIn = openResourceIn
|
|
199
|
+
if (controller.shellSelectPathRescue === true) delete controller.shellSelectPathRescue
|
|
200
|
+
}
|
|
201
|
+
sctx.effect(() => restore, 'shell-select sidebar path rescue')
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
96
205
|
const CSS = [
|
|
97
206
|
'.sls-card { display:flex; flex-direction:column; gap:10px; }',
|
|
98
207
|
'.sls-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }',
|
|
@@ -101,7 +210,7 @@ window.__ModuleLoader__.load({
|
|
|
101
210
|
'.sls-grid { display:grid; grid-template-columns: 90px 1fr; gap:4px 8px; align-items:center; }',
|
|
102
211
|
'.sls-grid__label { opacity:.75; font-size:12px; }',
|
|
103
212
|
'.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%; }',
|
|
213
|
+
'.sls-input--wide { width:100%; box-sizing:border-box; }',
|
|
105
214
|
'.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
215
|
'.sls-btn:disabled { opacity:.5; cursor:default; }',
|
|
107
216
|
'.sls-btn--primary { border-color: transparent; background: var(--sls-accent, #4b7bcc); color:#fff; }',
|
|
@@ -109,21 +218,29 @@ window.__ModuleLoader__.load({
|
|
|
109
218
|
'.sls-badge--ok { color:#2e9e5b; border-color:#2e9e5b; }',
|
|
110
219
|
'.sls-badge--bad { color:#c44; border-color:#c44; }',
|
|
111
220
|
'.sls-badge--default { color:#fff; background:var(--sls-accent, #4b7bcc); border-color:transparent; }',
|
|
112
|
-
'.sls-hint { font-size:12px; opacity:.7; }',
|
|
221
|
+
'.sls-hint { font-size:12px; opacity:.7; white-space:pre-wrap; overflow-wrap:anywhere; }',
|
|
113
222
|
'.sls-error { font-size:12px; color:#c44; white-space:pre-wrap; }',
|
|
114
223
|
'.sls-args { font-size:12px; font-family:var(--sls-mono, monospace); }',
|
|
115
|
-
// 命令黑名单:逐条规则编辑器(每条规则独立行,行级即时校验)
|
|
116
224
|
'.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
225
|
'.sls-rules__head { display:flex; align-items:center; gap:8px; }',
|
|
118
226
|
'.sls-rules__title { font-weight:500; }',
|
|
119
227
|
'.sls-rules__note { font-size:12px; opacity:.7; }',
|
|
120
228
|
'.sls-rule { display:flex; align-items:center; gap:6px; }',
|
|
121
229
|
'.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:
|
|
230
|
+
'.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); resize:vertical; }',
|
|
231
|
+
'.sls-rule__input:focus-visible { outline:2px solid var(--sls-accent, #4b7bcc); outline-offset:1px; }',
|
|
124
232
|
'.sls-rule--bad .sls-rule__input { border-color:#c44; }',
|
|
125
233
|
'.sls-rule__err { flex:none; max-width:40%; font-size:11px; color:#c44; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
|
|
126
234
|
'.sls-rule__del { flex:none; padding:2px 8px; }',
|
|
235
|
+
'.sls-switch { display:inline-flex; align-items:center; cursor:pointer; }',
|
|
236
|
+
'.sls-switch input[type="checkbox"] { position:absolute; opacity:0; width:1px; height:1px; }',
|
|
237
|
+
'.sls-switch__track { position:relative; width:34px; height:19px; border-radius:999px; box-sizing:border-box; background:var(--sls-track, rgba(128,128,128,.35)); border:1px solid var(--sls-border, rgba(128,128,128,.35)); transition:background .15s, border-color .15s; }',
|
|
238
|
+
'.sls-switch__thumb { position:absolute; top:50%; left:2px; width:13px; height:13px; border-radius:50%; background:var(--sls-thumb, rgba(128,128,128,.6)); transform:translateY(-50%); transition:left .15s, background .15s; }',
|
|
239
|
+
'.sls-switch:hover .sls-switch__track { border-color:var(--sls-accent, #4b7bcc); }',
|
|
240
|
+
'.sls-switch input[type="checkbox"]:checked + .sls-switch__track { background:var(--sls-accent, #4b7bcc); border-color:var(--sls-accent, #4b7bcc); }',
|
|
241
|
+
'.sls-switch input[type="checkbox"]:checked + .sls-switch__track .sls-switch__thumb { left:17px; background:var(--sls-bg-base, #fff); }',
|
|
242
|
+
'.sls-switch input[type="checkbox"]:focus-visible + .sls-switch__track { outline:2px solid var(--sls-accent, #4b7bcc); outline-offset:1px; }',
|
|
243
|
+
'.sls-switch input[type="checkbox"]:disabled + .sls-switch__track { opacity:.5; }',
|
|
127
244
|
// tool.call.toolview 卡片(key 'shell'):官方 terminal 行同构 + 复制/折行增强。
|
|
128
245
|
// 色彩对齐官方 ToolRow:标题 label-secondary、icon 盒 label-tertiary(实证自
|
|
129
246
|
// 官方行 computed style);token 缺失时回退 inherit 不致不可读
|
|
@@ -147,6 +264,7 @@ window.__ModuleLoader__.load({
|
|
|
147
264
|
'.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
265
|
// 头部单行片段禁折行:cwd 行宽不足时收缩出省略号,徽章不收缩不折行
|
|
149
266
|
'.sls-tv__cwd { font-family:var(--sls-mono, monospace); font-size:12px; opacity:.7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
|
|
267
|
+
'.sls-tv__takeover { font-size:11px; padding:0 7px; border-radius:999px; border:1px solid currentColor; opacity:.75; flex:none; white-space:nowrap; }',
|
|
150
268
|
'.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
269
|
'.sls-tv__pill { font-size:12px; color:#d4553f; flex:none; white-space:nowrap; }',
|
|
152
270
|
'.sls-tv__pill--bg { color:var(--dsw-alias-label-tertiary, inherit); }',
|
|
@@ -165,380 +283,13 @@ window.__ModuleLoader__.load({
|
|
|
165
283
|
'.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
284
|
'.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
285
|
'.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
286
|
].join('\n')
|
|
183
287
|
|
|
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
288
|
function h(type, props) {
|
|
193
289
|
const children = Array.prototype.slice.call(arguments, 2)
|
|
194
290
|
return React.createElement.apply(React, [type, props || null].concat(children))
|
|
195
291
|
}
|
|
196
292
|
|
|
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
293
|
// ── tool.call.toolview 卡片(key 'shell')──────────────────────────────
|
|
543
294
|
// 官方 BashRow/terminalCardModel 的 shellCall 白名单只认 bash|pwsh,无法复用,
|
|
544
295
|
// 模型派生自带:数据自 argsRaw + 结果文本尾部退出标记派生(官方 block 无
|
|
@@ -657,22 +408,23 @@ window.__ModuleLoader__.load({
|
|
|
657
408
|
}
|
|
658
409
|
|
|
659
410
|
const contentText = (block.content ?? []).map((part) => (part.type === 'text' ? part.text : '')).filter((text) => text !== '').join('\n')
|
|
411
|
+
const intercepted = toolKey === 'pwsh' && (block.content ?? []).some((part) => part.type === 'shell-select-takeover' && part.tool === 'pwsh')
|
|
660
412
|
const shellName = shellNameOf(parseShellMark(contentText))
|
|
661
413
|
const outputText = stripShellMark(contentText)
|
|
662
414
|
// 后台 ack:独立卡呈现命令全文与任务号徽标,ack 原文作输出(不再落 generic 简版行)
|
|
663
415
|
if (args.run_in_background === true) {
|
|
664
416
|
const jobId = /started background job (\S+)/.exec(contentText)?.[1]
|
|
665
|
-
return { kind: 'background', status: 'background', command, description, cwdDir, cwdFull, shellName, output: outputText, jobId }
|
|
417
|
+
return { kind: 'background', status: 'background', command, description, cwdDir, cwdFull, shellName, intercepted, output: outputText, jobId }
|
|
666
418
|
}
|
|
667
419
|
// 空结果落 generic:官方 singleResultText 无文本即回通用卡,避免空输出伪 done 终端卡;
|
|
668
420
|
// isError/error 块带 alert(错误红点语义),其余(空输出/无描述/截断)中性无状态点
|
|
669
421
|
if (outputText === '' || block.isError === true || block.error !== undefined || description === undefined || hasSpillNotice(contentText)) {
|
|
670
422
|
const alert = block.isError === true || block.error !== undefined ? 'error' : undefined
|
|
671
|
-
return { kind: 'generic', command, summary: description, output: outputText, running: false, alert }
|
|
423
|
+
return { kind: 'generic', command, summary: description, output: outputText, running: false, alert, intercepted }
|
|
672
424
|
}
|
|
673
425
|
const tail = parseExitTail(outputText)
|
|
674
426
|
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 }
|
|
427
|
+
return { kind: 'terminal', status, command, description, cwdDir, cwdFull, shellName, intercepted, output: tail.output, exitCode: tail.exitCode, signal: tail.signal, code: undefined }
|
|
676
428
|
}
|
|
677
429
|
// LOGIC-END shellCardModel
|
|
678
430
|
|
|
@@ -789,6 +541,7 @@ window.__ModuleLoader__.load({
|
|
|
789
541
|
h('span', { className: 'sls-tv__title' }, 'Shell'),
|
|
790
542
|
summary !== '' ? h('span', { className: 'sls-tv__sep', 'aria-hidden': true }) : null,
|
|
791
543
|
summary !== '' ? h('span', { className: 'sls-tv__sum' + (status === 'generic-error' ? ' sls-tv__sum--err' : '') }, summary) : null,
|
|
544
|
+
model.intercepted ? h(TakeoverBadge, { en }) : null,
|
|
792
545
|
status === 'running' ? h('span', { className: 'sls-tv__pulse' }, en ? 'running…' : '运行中…') : null,
|
|
793
546
|
),
|
|
794
547
|
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 +549,10 @@ window.__ModuleLoader__.load({
|
|
|
796
549
|
)
|
|
797
550
|
}
|
|
798
551
|
|
|
552
|
+
function TakeoverBadge({ en }) {
|
|
553
|
+
return h('span', { className: 'sls-tv__takeover', title: en ? 'Direct pwsh call handled by shell-select' : '直接 pwsh 调用由 shell-select 接管' }, en ? 'Intercepted' : '插件接管')
|
|
554
|
+
}
|
|
555
|
+
|
|
799
556
|
function CommandBody({ command }) {
|
|
800
557
|
const lines = command === '' ? [''] : command.split('\n')
|
|
801
558
|
const width = String(lines.length).length
|
|
@@ -823,6 +580,7 @@ window.__ModuleLoader__.load({
|
|
|
823
580
|
),
|
|
824
581
|
model.cwdDir !== undefined ? h('span', { className: 'sls-tv__cwd', title: 'pwd: ' + (model.cwdFull ?? model.cwdDir) }, model.cwdDir) : null,
|
|
825
582
|
model.shellName !== undefined ? h('span', { className: 'sls-tv__badge', title: en ? 'Shell client' : 'Shell 客户端' }, model.shellName) : null,
|
|
583
|
+
model.intercepted ? h(TakeoverBadge, { en }) : null,
|
|
826
584
|
h('span', { className: 'sls-tv__sp' }),
|
|
827
585
|
pill !== undefined ? h('span', { className: 'sls-tv__pill' + (meta.pillTone === 'bg' ? ' sls-tv__pill--bg' : '') }, pill) : null,
|
|
828
586
|
h('span', { className: 'sls-tv__copy' },
|
|
@@ -885,6 +643,7 @@ window.__ModuleLoader__.load({
|
|
|
885
643
|
h('span', { className: 'sls-tv__title' }, 'Shell'),
|
|
886
644
|
h('span', { className: 'sls-tv__sep', 'aria-hidden': true }),
|
|
887
645
|
h('span', { className: 'sls-tv__sum' + (failed ? ' sls-tv__sum--err' : '') }, summary),
|
|
646
|
+
model.intercepted ? h(TakeoverBadge, { en }) : null,
|
|
888
647
|
model.status === 'running' ? h(RunningDuration, { startedAt: model.startedAt, en }) : null,
|
|
889
648
|
model.status === 'background' && model.jobId !== undefined
|
|
890
649
|
? h('span', { className: 'sls-tv__badge', title: en ? 'Background job' : '后台任务' }, en ? `bg ${model.jobId}` : `后台 ${model.jobId}`)
|
|
@@ -894,9 +653,305 @@ window.__ModuleLoader__.load({
|
|
|
894
653
|
)
|
|
895
654
|
}
|
|
896
655
|
|
|
656
|
+
const SHELL_KINDS = { pwsh: 'PowerShell', bash: 'POSIX bash(git bash / msys2 / cygwin)', cmd: 'CMD', wsl: 'WSL bash' }
|
|
657
|
+
|
|
658
|
+
function argsTextOf(args) {
|
|
659
|
+
return args.map((arg) => {
|
|
660
|
+
if (/[\u0000-\u001f\u007f]/.test(arg)) {
|
|
661
|
+
const body = arg.replace(/[\u0000-\u001f\u007f'\\]/g, (char) => {
|
|
662
|
+
if (char === "'" || char === '\\') return '\\' + char
|
|
663
|
+
return '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0')
|
|
664
|
+
})
|
|
665
|
+
return "$'" + body + "'"
|
|
666
|
+
}
|
|
667
|
+
return arg !== '' && !/[\s'"\\]/.test(arg) ? arg : '"' + arg.replace(/["\\]/g, (char) => '\\' + char) + '"'
|
|
668
|
+
}).join(' ')
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// 普通引号只转义当前引号与反斜线;控制字符仅在显式 ANSI-C 引号内解码,不执行 shell。
|
|
672
|
+
function parseArgsText(text) {
|
|
673
|
+
const args = []
|
|
674
|
+
const controls = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v' }
|
|
675
|
+
let token = '', started = false
|
|
676
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
677
|
+
const char = text[index]
|
|
678
|
+
if (/\s/.test(char)) {
|
|
679
|
+
if (started) args.push(token)
|
|
680
|
+
token = ''
|
|
681
|
+
started = false
|
|
682
|
+
continue
|
|
683
|
+
}
|
|
684
|
+
started = true
|
|
685
|
+
const ansi = char === '$' && text[index + 1] === "'"
|
|
686
|
+
if (!ansi && char !== '"' && char !== "'") {
|
|
687
|
+
token += char
|
|
688
|
+
continue
|
|
689
|
+
}
|
|
690
|
+
if (ansi) index += 1
|
|
691
|
+
const quote = text[index]
|
|
692
|
+
index += 1
|
|
693
|
+
for (; index < text.length && text[index] !== quote; index += 1) {
|
|
694
|
+
const current = text[index]
|
|
695
|
+
const next = text[index + 1]
|
|
696
|
+
if (current === '\\' && (next === quote || next === '\\')) {
|
|
697
|
+
token += next
|
|
698
|
+
index += 1
|
|
699
|
+
} else if (ansi && current === '\\' && Object.hasOwn(controls, next)) {
|
|
700
|
+
token += controls[next]
|
|
701
|
+
index += 1
|
|
702
|
+
} else if (ansi && current === '\\' && next === 'u' && /^[\da-f]{4}$/i.test(text.slice(index + 2, index + 6))) {
|
|
703
|
+
token += String.fromCharCode(parseInt(text.slice(index + 2, index + 6), 16))
|
|
704
|
+
index += 5
|
|
705
|
+
} else {
|
|
706
|
+
token += current
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (index >= text.length) throw new Error('参数引号未闭合')
|
|
710
|
+
}
|
|
711
|
+
if (started) args.push(token)
|
|
712
|
+
return args
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function envTextOf(env) {
|
|
716
|
+
return Object.entries(env).map(([key, value]) => `${key}=${/[\r\n]|^"/.test(value) ? JSON.stringify(value) : value}`).join('\n')
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function parseEnvText(text) {
|
|
720
|
+
const pairs = []
|
|
721
|
+
for (const line of text.split('\n')) {
|
|
722
|
+
if (line.trim() === '') continue
|
|
723
|
+
const separator = line.indexOf('=')
|
|
724
|
+
const key = separator < 0 ? '' : line.slice(0, separator).trim()
|
|
725
|
+
if (key === '') throw new Error('环境变量每行必须为 K=V')
|
|
726
|
+
const value = line.slice(separator + 1)
|
|
727
|
+
const parsed = value.startsWith('"') ? JSON.parse(value) : value
|
|
728
|
+
if (typeof parsed !== 'string') throw new Error('环境变量双引号值必须是字符串')
|
|
729
|
+
pairs.push([key, parsed])
|
|
730
|
+
}
|
|
731
|
+
return Object.fromEntries(pairs)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function denyPatternIssue(rule) {
|
|
735
|
+
if (rule === '') return undefined
|
|
736
|
+
try { new RegExp(rule, 'i') } catch (error) { return error.message }
|
|
737
|
+
return undefined
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function editableSection(section) {
|
|
741
|
+
const { resolved, ...config } = section
|
|
742
|
+
return {
|
|
743
|
+
config,
|
|
744
|
+
entries: config.shells.map((entry, index) => {
|
|
745
|
+
const argsText = argsTextOf(entry.args ?? [])
|
|
746
|
+
const envText = envTextOf(entry.env ?? {})
|
|
747
|
+
const fact = resolved?.shells.find((item) => item.id === entry.id)
|
|
748
|
+
return { ...entry, key: `loaded-${index}`, argsText, originalArgsText: argsText,
|
|
749
|
+
envText, originalEnvText: envText, available: fact?.available, resolved: fact?.path }
|
|
750
|
+
}),
|
|
751
|
+
nextKey: config.shells.length,
|
|
752
|
+
defaultId: config.default,
|
|
753
|
+
rules: (config.deny ?? []).map((value, index) => ({ key: `rule-${index}`, value })),
|
|
754
|
+
nextRuleKey: (config.deny ?? []).length,
|
|
755
|
+
dirty: false,
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function ShellSelectApp() {
|
|
760
|
+
const [form, setForm] = useState(null)
|
|
761
|
+
const [busy, setBusy] = useState(false)
|
|
762
|
+
const [notice, setNotice] = useState('')
|
|
763
|
+
const [revision, setRevision] = useState(0)
|
|
764
|
+
|
|
765
|
+
useEffect(() => {
|
|
766
|
+
let disposed = false
|
|
767
|
+
setNotice('')
|
|
768
|
+
api(API.config).then((section) => {
|
|
769
|
+
if (!disposed) setForm(editableSection(section))
|
|
770
|
+
}).catch((error) => { if (!disposed) setNotice('加载失败:' + error.message) })
|
|
771
|
+
return () => { disposed = true }
|
|
772
|
+
}, [revision])
|
|
773
|
+
|
|
774
|
+
const editForm = (update) => {
|
|
775
|
+
if (busy) return
|
|
776
|
+
setForm((current) => ({ ...update(current), dirty: true }))
|
|
777
|
+
}
|
|
778
|
+
const patchEntry = (key, changes) => editForm((current) => ({
|
|
779
|
+
...current,
|
|
780
|
+
defaultId: changes.id !== undefined && current.entries.find((entry) => entry.key === key)?.id === current.defaultId
|
|
781
|
+
? changes.id : current.defaultId,
|
|
782
|
+
entries: current.entries.map((entry) => entry.key === key ? { ...entry, ...changes, available: undefined, resolved: undefined } : entry),
|
|
783
|
+
}))
|
|
784
|
+
const removeEntry = (key) => editForm((current) => {
|
|
785
|
+
const entries = current.entries.filter((entry) => entry.key !== key)
|
|
786
|
+
return { ...current, entries, defaultId: entries.some((entry) => entry.id === current.defaultId) ? current.defaultId : entries[0]?.id ?? '' }
|
|
787
|
+
})
|
|
788
|
+
const addEntry = () => editForm((current) => ({
|
|
789
|
+
...current, nextKey: current.nextKey + 1,
|
|
790
|
+
entries: [...current.entries, { key: `added-${current.nextKey}`, id: '', kind: 'bash', path: '', argsText: '', envText: '', login: false, distro: '' }],
|
|
791
|
+
}))
|
|
792
|
+
const patchRule = (key, value) => editForm((current) => ({ ...current,
|
|
793
|
+
rules: current.rules.map((rule) => rule.key === key ? { ...rule, value } : rule),
|
|
794
|
+
}))
|
|
795
|
+
const addRule = () => editForm((current) => ({ ...current, nextRuleKey: current.nextRuleKey + 1,
|
|
796
|
+
rules: [...current.rules, { key: `rule-${current.nextRuleKey}`, value: '' }],
|
|
797
|
+
}))
|
|
798
|
+
const removeRule = (key) => editForm((current) => ({ ...current, rules: current.rules.filter((rule) => rule.key !== key) }))
|
|
799
|
+
const save = async () => {
|
|
800
|
+
if (busy || !form.dirty) return
|
|
801
|
+
setBusy(true)
|
|
802
|
+
setNotice('')
|
|
803
|
+
try {
|
|
804
|
+
const shells = form.entries.map((entry) => {
|
|
805
|
+
// 显示未变时直接复用原值,避免表单控件归一换行等行为改写已有配置。
|
|
806
|
+
const args = entry.argsText === entry.originalArgsText ? entry.args ?? [] : parseArgsText(entry.argsText)
|
|
807
|
+
const env = entry.envText === entry.originalEnvText ? entry.env ?? {} : parseEnvText(entry.envText)
|
|
808
|
+
return { id: entry.id.trim(), kind: entry.kind, path: entry.path.trim(), args, login: entry.login === true, distro: entry.distro ?? '', env }
|
|
809
|
+
})
|
|
810
|
+
const ids = shells.map((entry) => entry.id)
|
|
811
|
+
if (ids.length === 0 || ids.some((id) => id === '')) throw new Error('至少保留一个客户端,id 不能为空')
|
|
812
|
+
if (new Set(ids).size !== ids.length) throw new Error('客户端 id 重复')
|
|
813
|
+
const defaultId = form.defaultId.trim()
|
|
814
|
+
if (!ids.includes(defaultId)) throw new Error('默认客户端不在列表中')
|
|
815
|
+
for (const [index, rule] of form.rules.entries()) {
|
|
816
|
+
const issue = denyPatternIssue(rule.value)
|
|
817
|
+
if (issue !== undefined) throw new Error(`第 ${index + 1} 条正则非法:${issue}`)
|
|
818
|
+
}
|
|
819
|
+
const deny = form.rules.map((rule) => rule.value).filter((rule) => rule !== '')
|
|
820
|
+
const result = await api(API.config, { method: 'POST', body: JSON.stringify({ ...form.config, shells, default: defaultId, deny }) })
|
|
821
|
+
clientCatalog = { default: result.resolved.default, byId: Object.fromEntries(result.resolved.shells.map((entry) => [entry.id, entry.id])) }
|
|
822
|
+
setForm((current) => ({ ...current, dirty: false }))
|
|
823
|
+
setNotice('已保存,默认客户端与工具描述即时生效')
|
|
824
|
+
try {
|
|
825
|
+
const section = await api(API.config)
|
|
826
|
+
setForm(editableSection(section))
|
|
827
|
+
} catch (error) { setNotice('已保存,但刷新失败:' + error.message + ';请重新加载') }
|
|
828
|
+
} catch (error) {
|
|
829
|
+
setNotice('保存失败:' + error.message)
|
|
830
|
+
} finally { setBusy(false) }
|
|
831
|
+
}
|
|
832
|
+
const probe = async (entry) => {
|
|
833
|
+
if (busy) return
|
|
834
|
+
setBusy(true)
|
|
835
|
+
setNotice('')
|
|
836
|
+
try {
|
|
837
|
+
const result = await api(API.probe, { method: 'POST', body: JSON.stringify({ path: entry.path }) })
|
|
838
|
+
setForm((current) => ({ ...current, entries: current.entries.map((item) => item.key === entry.key
|
|
839
|
+
? { ...item, available: result.exists, resolved: entry.path } : item) }))
|
|
840
|
+
setNotice(result.exists ? '路径存在' : '路径不存在')
|
|
841
|
+
} catch (error) { setNotice('探测失败:' + error.message) }
|
|
842
|
+
finally { setBusy(false) }
|
|
843
|
+
}
|
|
844
|
+
const detect = async (kind) => {
|
|
845
|
+
if (busy) return
|
|
846
|
+
setBusy(true)
|
|
847
|
+
setNotice('')
|
|
848
|
+
try {
|
|
849
|
+
const result = await api(API.detect, { method: 'POST', body: JSON.stringify({ kinds: [kind] }) })
|
|
850
|
+
setNotice(result.found.length > 0 ? kind + ' 候选:' + result.found.map((item) => item.path).join(' ; ') : kind + ' 未发现候选')
|
|
851
|
+
} catch (error) { setNotice('探测失败:' + error.message) }
|
|
852
|
+
finally { setBusy(false) }
|
|
853
|
+
}
|
|
854
|
+
const reload = h('button', { type: 'button', className: 'sls-btn', disabled: busy, onClick: () => {
|
|
855
|
+
if (busy) return
|
|
856
|
+
setForm(null)
|
|
857
|
+
setNotice('')
|
|
858
|
+
setRevision((value) => value + 1)
|
|
859
|
+
} }, '重新加载')
|
|
860
|
+
if (form === null) return h('div', { className: 'sls-card', 'aria-busy': !notice },
|
|
861
|
+
h('div', { role: notice ? 'alert' : 'status', className: 'sls-hint' }, notice || '加载中…'), notice ? reload : null)
|
|
862
|
+
|
|
863
|
+
const activeRules = form.rules.filter((rule) => rule.value !== '').length
|
|
864
|
+
return h('div', { className: 'sls-card', 'aria-busy': busy },
|
|
865
|
+
h('div', { className: 'sls-row' }, h('span', { className: 'sls-hint' },
|
|
866
|
+
'命令行客户端清单。路径留空自动探测;模型可通过工具的 shell 参数选择任一条目,缺省用默认。')),
|
|
867
|
+
form.entries.map((entry, index) => {
|
|
868
|
+
const input = (label, name, placeholder, type = 'input') => h(type, {
|
|
869
|
+
className: 'sls-input sls-input--wide' + (name === 'argsText' || name === 'envText' ? ' sls-args' : ''),
|
|
870
|
+
'aria-label': `客户端 ${index + 1} ${label}`, value: entry[name] ?? '', disabled: busy,
|
|
871
|
+
rows: type === 'textarea' ? 2 : undefined, placeholder,
|
|
872
|
+
onChange: (event) => patchEntry(entry.key, { [name]: event.target.value }),
|
|
873
|
+
})
|
|
874
|
+
return h('div', { className: 'sls-entry', key: entry.key },
|
|
875
|
+
h('div', { className: 'sls-entry__head' },
|
|
876
|
+
h('label', { className: 'sls-row' },
|
|
877
|
+
h('input', { type: 'radio', name: 'sls-default', 'aria-label': `客户端 ${index + 1} 默认`,
|
|
878
|
+
checked: form.defaultId === entry.id && entry.id.length > 0, disabled: busy || entry.id.trim() === '',
|
|
879
|
+
title: '设为默认客户端(模型缺省 shell 参数时使用)',
|
|
880
|
+
onChange: () => editForm((current) => ({ ...current, defaultId: entry.id })),
|
|
881
|
+
}), h('span', { className: 'sls-hint' }, '默认')),
|
|
882
|
+
h('span', { className: 'sls-badge' + (entry.available === true ? ' sls-badge--ok' : entry.available === false ? ' sls-badge--bad' : ''),
|
|
883
|
+
title: entry.resolved ? '解析路径:' + entry.resolved : '路径尚未解析',
|
|
884
|
+
}, entry.available === true ? '可用' : entry.available === false ? '不可用' : '未知'),
|
|
885
|
+
entry.id === form.defaultId && entry.id !== '' ? h('span', { className: 'sls-badge sls-badge--default' }, '默认') : null,
|
|
886
|
+
h('span', { style: { flex: 1 } }),
|
|
887
|
+
h('button', { type: 'button', className: 'sls-btn', disabled: busy || entry.path.trim() === '', title: '检查该路径是否存在', onClick: () => probe(entry) }, '探测路径'),
|
|
888
|
+
h('button', { type: 'button', className: 'sls-btn', disabled: busy, 'aria-label': `移除客户端 ${index + 1}`, title: '移除该条目', onClick: () => removeEntry(entry.key) }, '移除')),
|
|
889
|
+
h('div', { className: 'sls-grid' },
|
|
890
|
+
h('span', { className: 'sls-grid__label' }, 'id'),
|
|
891
|
+
input('id', 'id', 'pwsh / git-bash / …(模型看到的 shell 参数值,亦作显示名)'),
|
|
892
|
+
h('span', { className: 'sls-grid__label' }, '形态'),
|
|
893
|
+
h('div', { className: 'sls-row' },
|
|
894
|
+
h('select', { className: 'sls-input', 'aria-label': `客户端 ${index + 1} 形态`, value: entry.kind, disabled: busy,
|
|
895
|
+
onChange: (event) => patchEntry(entry.key, { kind: event.target.value }),
|
|
896
|
+
}, Object.entries(SHELL_KINDS).map(([kind, label]) => h('option', { key: kind, value: kind }, label))),
|
|
897
|
+
h('button', { type: 'button', className: 'sls-btn', disabled: busy, title: '扫描本机常见安装位置', onClick: () => detect(entry.kind) }, '扫描本机')),
|
|
898
|
+
h('span', { className: 'sls-grid__label' }, '路径'),
|
|
899
|
+
input('路径', 'path', '留空自动探测,如 C:\\Program Files\\Git\\bin\\bash.exe'),
|
|
900
|
+
h('span', { className: 'sls-grid__label' }, '参数'),
|
|
901
|
+
input('参数', 'argsText', '留空用形态默认;空格分隔,引号包住含空格参数,{command} 为命令占位'),
|
|
902
|
+
h('span', { className: 'sls-hint', style: { gridColumn: '2' } }, "空格参数加引号;控制字符用 $'…' 转义,仅解析不执行。"),
|
|
903
|
+
...(entry.kind === 'bash' ? [
|
|
904
|
+
h('span', { className: 'sls-grid__label' }, '登录壳'),
|
|
905
|
+
h('label', { className: 'sls-row sls-switch', title: '-lc 登录壳拉起 /etc/profile(只注入 /usr/bin;/mingw64/bin 需在下方的环境里配 MSYSTEM=MINGW64)' },
|
|
906
|
+
h('input', { type: 'checkbox', 'aria-label': `客户端 ${index + 1} 登录壳`, checked: entry.login === true, disabled: busy,
|
|
907
|
+
onChange: (event) => patchEntry(entry.key, { login: event.target.checked }),
|
|
908
|
+
}), h('span', { className: 'sls-switch__track' }, h('span', { className: 'sls-switch__thumb' })),
|
|
909
|
+
h('span', { className: 'sls-hint' }, '-lc 登录壳')),
|
|
910
|
+
] : []),
|
|
911
|
+
...(entry.kind === 'wsl' ? [h('span', { className: 'sls-grid__label' }, '发行版'), input('发行版', 'distro', '留空用默认发行版,如 Ubuntu-22.04')] : []),
|
|
912
|
+
h('span', { className: 'sls-grid__label' }, '环境'),
|
|
913
|
+
input('环境', 'envText', '每行 K=V,如 MSYSTEM=MINGW64;wsl 形自动经 WSLENV 透传', 'textarea'),
|
|
914
|
+
h('span', { className: 'sls-hint', style: { gridColumn: '2' } }, '每行 K=V;换行值用双引号及 \\n,双引号内反斜线写 \\\\。')))
|
|
915
|
+
}),
|
|
916
|
+
h('div', { className: 'sls-rules' },
|
|
917
|
+
h('div', { className: 'sls-rules__head' }, h('span', { className: 'sls-rules__title' }, '命令黑名单'),
|
|
918
|
+
h('span', { className: 'sls-badge' + (activeRules > 0 ? ' sls-badge--ok' : ' sls-badge--bad') }, activeRules > 0 ? activeRules + ' 条规则' : '未生效')),
|
|
919
|
+
h('span', { className: 'sls-rules__note' }, '每条规则为一条正则,对整条命令全文匹配(大小写不敏感),命中即拒,无豁免;精细放行写在模式内用前瞻,如 rm -rf\\s+(?!\\S*node_modules)。'),
|
|
920
|
+
form.rules.length === 0 ? h('span', { className: 'sls-rules__note' }, '未配置,命中拦截不生效,模型可执行任意命令。') : form.rules.map((rule, index) => {
|
|
921
|
+
const issue = denyPatternIssue(rule.value)
|
|
922
|
+
const errorId = `sls-${rule.key}-error`
|
|
923
|
+
return h('div', { className: 'sls-rule' + (issue !== undefined ? ' sls-rule--bad' : ''), key: rule.key },
|
|
924
|
+
h('span', { className: 'sls-rule__no' }, index + 1),
|
|
925
|
+
h('textarea', { className: 'sls-rule__input', rows: Math.max(1, rule.value.split('\n').length), 'aria-label': `黑名单第 ${index + 1} 条`,
|
|
926
|
+
'aria-invalid': issue !== undefined, 'aria-describedby': issue !== undefined ? errorId : undefined,
|
|
927
|
+
value: rule.value, disabled: busy, placeholder: '正则,如 ^format\\s', onChange: (event) => patchRule(rule.key, event.target.value),
|
|
928
|
+
}),
|
|
929
|
+
issue !== undefined ? h('span', { id: errorId, className: 'sls-rule__err', title: issue }, issue) : null,
|
|
930
|
+
h('button', { type: 'button', className: 'sls-btn sls-rule__del', disabled: busy, 'aria-label': `删除规则 ${index + 1}`, title: '删除该条规则', onClick: () => removeRule(rule.key) }, '删除'))
|
|
931
|
+
}),
|
|
932
|
+
h('div', { className: 'sls-row' }, h('button', { type: 'button', className: 'sls-btn', disabled: busy, onClick: addRule }, '添加规则'))),
|
|
933
|
+
h('div', { className: 'sls-row' },
|
|
934
|
+
h('button', { type: 'button', className: 'sls-btn', disabled: busy, onClick: addEntry }, '添加客户端'),
|
|
935
|
+
h('span', { style: { flex: 1 } }),
|
|
936
|
+
form.dirty ? h('span', { className: 'sls-hint' }, '有未保存改动') : null,
|
|
937
|
+
reload,
|
|
938
|
+
h('button', { type: 'button', className: 'sls-btn sls-btn--primary', disabled: busy || !form.dirty, onClick: save }, '保存')),
|
|
939
|
+
notice ? h('div', { role: notice.includes('失败') ? 'alert' : 'status', className: 'sls-hint' }, notice) : null,
|
|
940
|
+
)
|
|
941
|
+
}
|
|
942
|
+
|
|
897
943
|
return {
|
|
898
944
|
inject: ['slots'],
|
|
899
945
|
apply(ctx) {
|
|
946
|
+
const NAV_ICON = { 'Shell 管理': 'terminal' }
|
|
947
|
+
if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
|
|
948
|
+
else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
|
|
949
|
+
else window.__navicIconQueue = [NAV_ICON]
|
|
950
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register(
|
|
951
|
+
{ name: 'settings.section', id: 'shell-select', order: 82, label: 'Shell 管理' },
|
|
952
|
+
() => React.createElement(ShellSelectApp),
|
|
953
|
+
))
|
|
954
|
+
installPathRescue(ctx)
|
|
900
955
|
ctx.effect(() => {
|
|
901
956
|
const style = document.createElement('style')
|
|
902
957
|
// 自带 data-plugin:缺失时宿主 claimStyles 会误收,插件 HMR 重建即误删
|
|
@@ -905,11 +960,6 @@ window.__ModuleLoader__.load({
|
|
|
905
960
|
document.head.appendChild(style)
|
|
906
961
|
return () => style.remove()
|
|
907
962
|
}, '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
963
|
// 替换 shell 族工具行渲染:keyed hit 优先于 GenericToolCard 兜底;
|
|
914
964
|
// priority -1 阴影官方同 key 注册(低值先渲染)。pwsh/bash 为官方工具
|
|
915
965
|
// 名(死态窗口 guard 代挂官方 tool-pwsh 时,其调用行同样获得增强卡);
|