dsh-skill-picker 0.5.13 → 0.5.18
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 +242 -214
- package/lib/client.js +285 -24
- package/lib/client.js.map +3 -3
- package/lib/index.js +1 -1
- package/lib/index.js.map +2 -2
- package/package.json +76 -76
- package/src/client/index.jsx +109 -46
- package/src/client/slash-source.js +440 -0
- package/src/patch-ui-skill.js +1 -1
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime takeover of the official `/` skill source (issue #14).
|
|
3
|
+
*
|
|
4
|
+
* The picker upgrades the official `/` completion from prefix matching to
|
|
5
|
+
* fuzzy + pinyin matching. Until 0.5.14 that was done by rewriting the official
|
|
6
|
+
* `@deepseek-ai/dsh-client-ui-skill/lib/client.js` **file** on disk — which
|
|
7
|
+
* silently stops working whenever that file is not an ordinary writable file:
|
|
8
|
+
* a packaged desktop build keeps it inside `resources/app.asar`, pnpm
|
|
9
|
+
* hardlinks it into a shared store, or a profile's copy is a dangling symlink
|
|
10
|
+
* after the app moves. Every one of those cases produced the same invisible
|
|
11
|
+
* failure: the patch "succeeded" on a copy nothing was serving.
|
|
12
|
+
*
|
|
13
|
+
* This module takes the other road. `@deepseek-ai/dsh-client-ui-input-trigger`
|
|
14
|
+
* publishes its trigger-source registry through the public `inputTriggers`
|
|
15
|
+
* service:
|
|
16
|
+
*
|
|
17
|
+
* sources: (trigger) => live.sources.filter(s => s.trigger === trigger)...
|
|
18
|
+
* all: () => live.sources
|
|
19
|
+
*
|
|
20
|
+
* Those hand back the **live source objects**, and the slash menu reads
|
|
21
|
+
* `source.candidates` at invocation time rather than capturing it at
|
|
22
|
+
* registration. So the picker can wrap that one method and own the matching
|
|
23
|
+
* behaviour without touching a single file, on every install shape at once.
|
|
24
|
+
*
|
|
25
|
+
* What this module deliberately does NOT do: register a second source for
|
|
26
|
+
* `/skill`. The registry rejects duplicate `(trigger, name)` pairs outright
|
|
27
|
+
* (`slash source "/skill" is already registered`), which is why an earlier
|
|
28
|
+
* version of this plugin tried — and abandoned — the parallel-source approach.
|
|
29
|
+
* Wrapping the existing source is the supported way in.
|
|
30
|
+
*
|
|
31
|
+
* @module dsh-skill-picker/client/slash-source
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Marker recorded on a wrapped source, so re-installing is a no-op. */
|
|
35
|
+
const WRAPPED = '__dshSkillPickerWrappedCandidates'
|
|
36
|
+
|
|
37
|
+
/** Identity of the official skill source inside the trigger registry. */
|
|
38
|
+
export const SKILL_SOURCE = { trigger: '/', name: 'skill' }
|
|
39
|
+
|
|
40
|
+
/** Whether this source already carries the picker's wrapper. */
|
|
41
|
+
export function isSkillSourceWrapped(source) {
|
|
42
|
+
return source !== null && typeof source === 'object' && source[WRAPPED] === true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Set once the runtime takeover is live; read by the ⚡ panel footer. */
|
|
46
|
+
let enhancementMode
|
|
47
|
+
|
|
48
|
+
/** The source the takeover wrapped, so the catalogue can be primed ahead of time. */
|
|
49
|
+
let takenOverSource
|
|
50
|
+
|
|
51
|
+
/** The Session the picker last asked us to prime; consumed when the takeover lands. */
|
|
52
|
+
let pendingSessionId
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Our own copy of the full display list, per Session.
|
|
56
|
+
*
|
|
57
|
+
* Why it exists: the menu highlights **the first group whose `candidates()`
|
|
58
|
+
* promise settles**, and our wrapper used to add an `await` hop of its own on
|
|
59
|
+
* top of the official one — two hops against the command source's chain. A
|
|
60
|
+
* settled-promise `await` is a microtask, so hop count decides, and the menu
|
|
61
|
+
* scrolled down to the command group. Serving the cached list returns without
|
|
62
|
+
* awaiting anything, so this group wins the settle race by construction.
|
|
63
|
+
*
|
|
64
|
+
* Kept fresh by the priming call and by a background refresh on every serve, so
|
|
65
|
+
* the worst case is one menu open behind a very recent skill install.
|
|
66
|
+
*/
|
|
67
|
+
const itemCache = new Map()
|
|
68
|
+
|
|
69
|
+
/** Write a small diagnostic record the maintainer can read off disk. */
|
|
70
|
+
function noteDiag(patch) {
|
|
71
|
+
try {
|
|
72
|
+
const key = 'dsh-skill-picker:diag'
|
|
73
|
+
const previous = JSON.parse(localStorage.getItem(key) ?? '{}')
|
|
74
|
+
localStorage.setItem(key, JSON.stringify({ ...previous, ...patch, at: new Date().toISOString() }))
|
|
75
|
+
} catch {
|
|
76
|
+
/* diagnostics must never break anything */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Refresh our cached display list in the background for the next open.
|
|
82
|
+
* Never awaited, never throws, and uses its own abort signal so the menu's own
|
|
83
|
+
* controller cannot cancel the refresh when it closes.
|
|
84
|
+
*/
|
|
85
|
+
function refreshCache(cacheKey, originalCandidates, self, projection, options) {
|
|
86
|
+
try {
|
|
87
|
+
const signal = typeof AbortController === 'function' ? new AbortController().signal : undefined
|
|
88
|
+
Promise.resolve()
|
|
89
|
+
.then(() => originalCandidates.call(self, projection, { ...options, query: '', signal }))
|
|
90
|
+
.then((items) => {
|
|
91
|
+
if (Array.isArray(items)) itemCache.set(cacheKey, items.slice())
|
|
92
|
+
})
|
|
93
|
+
.catch(() => {})
|
|
94
|
+
} catch {
|
|
95
|
+
/* a refresh that cannot start is not worth reporting */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Which mechanism upgrades the official `/` menu: 'runtime' once installed. */
|
|
100
|
+
export function slashEnhancementMode() {
|
|
101
|
+
return enhancementMode
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Test seam: drop every piece of module-level state.
|
|
106
|
+
*
|
|
107
|
+
* This module deliberately keeps state across calls (the wrapped source, the
|
|
108
|
+
* priming request, the per-Session cache), which makes tests order-dependent
|
|
109
|
+
* unless they start from a clean slate. Not used by the runtime.
|
|
110
|
+
* @internal
|
|
111
|
+
*/
|
|
112
|
+
export function __resetSlashStateForTests() {
|
|
113
|
+
itemCache.clear()
|
|
114
|
+
pendingSessionId = undefined
|
|
115
|
+
takenOverSource = undefined
|
|
116
|
+
enhancementMode = undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Prime the official skill catalogue for a Session, and fill our own cache.
|
|
121
|
+
*
|
|
122
|
+
* The slash menu highlights **the first group that settles** and then scrolls it
|
|
123
|
+
* into view (`scrollIntoView({ block: "nearest" })`), and the highlight sticks
|
|
124
|
+
* once set. The picker keeps the skill group first (`order: -1`), so if the
|
|
125
|
+
* command group settles first the menu opens scrolled down to it — the "menu
|
|
126
|
+
* opens at the bottom" report.
|
|
127
|
+
*
|
|
128
|
+
* Nothing warms the skill catalogue on a normal boot: `input-trigger` calls
|
|
129
|
+
* `source.warm?.()` only from `sourceAdded`, which fires just for a source
|
|
130
|
+
* registered *after* a session controller already exists.
|
|
131
|
+
*
|
|
132
|
+
* Called as soon as the picker knows its Session — which can happen BEFORE the
|
|
133
|
+
* takeover installs, since that retries while the official source registers. The
|
|
134
|
+
* Session id is therefore remembered and the warming is retried from
|
|
135
|
+
* `wrapSkillSource()`, otherwise a mount-time call would be silently lost.
|
|
136
|
+
*
|
|
137
|
+
* Best-effort by design: a failed warm changes nothing.
|
|
138
|
+
*
|
|
139
|
+
* @param sessionId - the active Session's id.
|
|
140
|
+
*/
|
|
141
|
+
export function warmSlashSkill(sessionId) {
|
|
142
|
+
if (typeof sessionId !== 'string' || sessionId === '') return
|
|
143
|
+
// Ready now: prime directly and keep nothing pending. Not ready (the takeover
|
|
144
|
+
// has not installed yet): remember it, and wrapSkillSource() will consume it.
|
|
145
|
+
if (primeNow(sessionId)) pendingSessionId = undefined
|
|
146
|
+
else pendingSessionId = sessionId
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Do the priming work once the takeover can actually serve it.
|
|
151
|
+
* @returns true when the source exists and was asked (or the cache already had it).
|
|
152
|
+
*/
|
|
153
|
+
function primeNow(sessionId) {
|
|
154
|
+
const source = takenOverSource
|
|
155
|
+
if (source === null || source === undefined) return false
|
|
156
|
+
if (itemCache.has(sessionId)) return true
|
|
157
|
+
try {
|
|
158
|
+
// Go through the WRAPPER's own path, never the official `warm()` alone:
|
|
159
|
+
// `warm()` primes only the official module's cache, leaving OUR cache empty,
|
|
160
|
+
// so the menu's own call still misses and still awaits — which is exactly
|
|
161
|
+
// how v0.5.16/v0.5.17 kept losing the settle race. The wrapper stores a
|
|
162
|
+
// snapshot on a miss, so this single call fills both layers.
|
|
163
|
+
const signal = typeof AbortController === 'function' ? new AbortController().signal : undefined
|
|
164
|
+
Promise.resolve(source.candidates({ sessionId }, { query: '', signal })).catch(() => {})
|
|
165
|
+
} catch {
|
|
166
|
+
/* priming is best-effort */
|
|
167
|
+
}
|
|
168
|
+
return true
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
/** Whether an object is the official skill source. */
|
|
173
|
+
function isSkillSource(source) {
|
|
174
|
+
return source !== null && typeof source === 'object'
|
|
175
|
+
&& source.trigger === SKILL_SOURCE.trigger
|
|
176
|
+
&& source.name === SKILL_SOURCE.name
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Every place the live source registry has actually been observed, most precise
|
|
181
|
+
* first. **The service itself does NOT expose `sources()` / `all()`** — those
|
|
182
|
+
* belong to the per-session controller's `roster`, built as
|
|
183
|
+
* `{ sources: (trigger) => live.sources.filter(...), all: () => live.sources }`.
|
|
184
|
+
* The service (`ctx.inputTriggers`, an `InputTriggerService extends Service`) is
|
|
185
|
+
* exactly: `inject, live, constructor, registerSource, sessionOf, sessions` —
|
|
186
|
+
* so `live.sources` is the real registry and the one that matters. The other
|
|
187
|
+
* shapes are kept because reaching for a helper that does not exist is what
|
|
188
|
+
* made the first release of this module silently do nothing.
|
|
189
|
+
*
|
|
190
|
+
* @param service - the injected inputTriggers service.
|
|
191
|
+
* @returns arrays to search, in order.
|
|
192
|
+
*/
|
|
193
|
+
function registryCandidates(service) {
|
|
194
|
+
const lists = []
|
|
195
|
+
const push = (value) => {
|
|
196
|
+
if (Array.isArray(value) && value.length > 0) lists.push(value)
|
|
197
|
+
}
|
|
198
|
+
try { push(service.live?.sources) } catch { /* getter threw */ }
|
|
199
|
+
try { if (typeof service.sources === 'function') push(service.sources(SKILL_SOURCE.trigger)) } catch { /* ignore */ }
|
|
200
|
+
try { if (typeof service.all === 'function') push(service.all()) } catch { /* ignore */ }
|
|
201
|
+
try { push(service.sources) } catch { /* ignore */ }
|
|
202
|
+
try { if (typeof service.roster?.sources === 'function') push(service.roster.sources(SKILL_SOURCE.trigger)) } catch { /* ignore */ }
|
|
203
|
+
try { if (typeof service.roster?.all === 'function') push(service.roster.all()) } catch { /* ignore */ }
|
|
204
|
+
return lists
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Locate the official skill source through the injected `inputTriggers` service.
|
|
209
|
+
* Returns the **live object**, so the caller can take it over in place.
|
|
210
|
+
*
|
|
211
|
+
* @param service - the injected service, if the host provided one.
|
|
212
|
+
* @returns the source object, or undefined while it is not registered yet.
|
|
213
|
+
*/
|
|
214
|
+
export function findSkillSource(service) {
|
|
215
|
+
if (service === null || service === undefined) return undefined
|
|
216
|
+
for (const list of registryCandidates(service)) {
|
|
217
|
+
const found = list.find(isSkillSource)
|
|
218
|
+
if (found !== undefined) return found
|
|
219
|
+
}
|
|
220
|
+
return undefined
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Take the official source over at runtime: matching, group order and pick
|
|
225
|
+
* tracking — the same three upgrades the file patch used to apply, without
|
|
226
|
+
* writing a single byte to disk. Returns a disposer restoring every field it
|
|
227
|
+
* touched, or undefined when nothing could be taken over.
|
|
228
|
+
*
|
|
229
|
+
* 1. `candidates` → the picker's fuzzy + pinyin ranking. The official
|
|
230
|
+
* implementation is handed the live query and returns the **already mapped
|
|
231
|
+
* display items**, having filtered out everything its own matcher did not
|
|
232
|
+
* match — so a fuzzy query can never see the skills it needs. Asking it for
|
|
233
|
+
* an empty query returns the whole visible catalogue instead
|
|
234
|
+
* (`rankByName(items, "")` returns `items` verbatim), which is exactly the
|
|
235
|
+
* input the picker's ranking wants. The official visibility rules
|
|
236
|
+
* (userInvocable filtering, subagent-session exclusion, the "user only"
|
|
237
|
+
* label) are all applied before the list reaches us.
|
|
238
|
+
* 2. `order` → the skill group sorts above the command group (the file patch
|
|
239
|
+
* used to rewrite `order: 2` to `-1`).
|
|
240
|
+
* 3. `onPick` → records the pick, so a slash pick ranks as "recently used" in
|
|
241
|
+
* the ⚡ panel too.
|
|
242
|
+
*
|
|
243
|
+
* @param source - a live source object from the trigger registry.
|
|
244
|
+
* @param hooks - { rank(items, query), order(number), onPick(name) }.
|
|
245
|
+
* @returns a disposer, or undefined when nothing was taken over.
|
|
246
|
+
*/
|
|
247
|
+
export function wrapSkillSource(source, hooks = {}) {
|
|
248
|
+
const { rank, order, onPick } = hooks
|
|
249
|
+
if (source === null || typeof source !== 'object') return undefined
|
|
250
|
+
if (isSkillSourceWrapped(source)) return undefined
|
|
251
|
+
// A frozen or sealed source cannot take the wrapper, and a failed assignment
|
|
252
|
+
// would throw out of `apply` and take the whole plugin (and the web shell)
|
|
253
|
+
// down with it. Decline quietly instead; the file patch remains as fallback.
|
|
254
|
+
if (Object.isFrozen(source)) return undefined
|
|
255
|
+
|
|
256
|
+
const original = {
|
|
257
|
+
candidates: source.candidates,
|
|
258
|
+
order: source.order,
|
|
259
|
+
hadOrder: Object.hasOwn(source, 'order'),
|
|
260
|
+
onPick: source.onPick,
|
|
261
|
+
hadOnPick: Object.hasOwn(source, 'onPick'),
|
|
262
|
+
}
|
|
263
|
+
const taken = []
|
|
264
|
+
|
|
265
|
+
const wrappedCandidates = typeof rank === 'function' && typeof original.candidates === 'function'
|
|
266
|
+
? async function candidates(projection, args) {
|
|
267
|
+
const options = args ?? {}
|
|
268
|
+
const query = String(options.query ?? '').trim()
|
|
269
|
+
const sessionId = projection?.sessionId
|
|
270
|
+
const cacheKey = typeof sessionId === 'string' ? sessionId : undefined
|
|
271
|
+
const cached = cacheKey === undefined ? undefined : itemCache.get(cacheKey)
|
|
272
|
+
|
|
273
|
+
// Cache hit: return WITHOUT awaiting anything. The returned promise is
|
|
274
|
+
// already resolved, so this group settles on the first microtask — ahead
|
|
275
|
+
// of any group whose candidates awaits even a settled promise.
|
|
276
|
+
if (cached !== undefined) {
|
|
277
|
+
noteDiag({ mode: 'runtime', servedFromCache: true, sessionId: cacheKey, query })
|
|
278
|
+
refreshCache(cacheKey, original.candidates, this, projection, options)
|
|
279
|
+
if (query === '') return cached
|
|
280
|
+
try {
|
|
281
|
+
return rank(cached, query)
|
|
282
|
+
} catch {
|
|
283
|
+
return cached
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const everything = await original.candidates.call(this, projection, { ...options, query: '' })
|
|
288
|
+
if (cacheKey !== undefined && Array.isArray(everything)) {
|
|
289
|
+
// Store a SNAPSHOT, never the caller's array: the official source hands
|
|
290
|
+
// back a container it may keep and grow, and aliasing it would make the
|
|
291
|
+
// cache mutate underneath us.
|
|
292
|
+
itemCache.set(cacheKey, everything.slice())
|
|
293
|
+
noteDiag({ mode: 'runtime', servedFromCache: false, sessionId: cacheKey, primed: true, count: everything.length })
|
|
294
|
+
}
|
|
295
|
+
if (query === '' || !Array.isArray(everything)) return everything
|
|
296
|
+
try {
|
|
297
|
+
return rank(everything, query)
|
|
298
|
+
} catch {
|
|
299
|
+
// Ranking is an enhancement: a picker bug must never break the menu.
|
|
300
|
+
return everything
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
: undefined
|
|
304
|
+
|
|
305
|
+
const wrappedPick = typeof onPick === 'function' && typeof original.onPick === 'function'
|
|
306
|
+
? function onPickWrapped(args) {
|
|
307
|
+
try {
|
|
308
|
+
const name = args?.candidate?.name
|
|
309
|
+
if (typeof name === 'string') onPick(name)
|
|
310
|
+
} catch {
|
|
311
|
+
/* usage bookkeeping is best-effort */
|
|
312
|
+
}
|
|
313
|
+
return original.onPick.call(this, args)
|
|
314
|
+
}
|
|
315
|
+
: undefined
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
Object.defineProperty(source, WRAPPED, { value: true, enumerable: false, configurable: true })
|
|
319
|
+
if (wrappedCandidates !== undefined) {
|
|
320
|
+
source.candidates = wrappedCandidates
|
|
321
|
+
taken.push('candidates')
|
|
322
|
+
}
|
|
323
|
+
if (typeof order === 'number' && source.order !== order) {
|
|
324
|
+
source.order = order
|
|
325
|
+
taken.push('order')
|
|
326
|
+
}
|
|
327
|
+
if (wrappedPick !== undefined) {
|
|
328
|
+
source.onPick = wrappedPick
|
|
329
|
+
taken.push('onPick')
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
try {
|
|
333
|
+
delete source[WRAPPED]
|
|
334
|
+
} catch {
|
|
335
|
+
/* nothing left to undo */
|
|
336
|
+
}
|
|
337
|
+
return undefined
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (taken.length === 0) {
|
|
341
|
+
try {
|
|
342
|
+
delete source[WRAPPED]
|
|
343
|
+
} catch {
|
|
344
|
+
/* nothing left to undo */
|
|
345
|
+
}
|
|
346
|
+
return undefined
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
enhancementMode = 'runtime'
|
|
350
|
+
takenOverSource = source
|
|
351
|
+
noteDiag({ mode: 'runtime', installed: true, took: taken.join('+') })
|
|
352
|
+
// A Session may have asked to be primed before the takeover landed (the
|
|
353
|
+
// picker mounts first, the takeover retries until the official source
|
|
354
|
+
// registers); consume that request exactly once.
|
|
355
|
+
if (pendingSessionId !== undefined) {
|
|
356
|
+
const sessionId = pendingSessionId
|
|
357
|
+
pendingSessionId = undefined
|
|
358
|
+
primeNow(sessionId)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return () => {
|
|
362
|
+
try {
|
|
363
|
+
if (taken.includes('candidates')) source.candidates = original.candidates
|
|
364
|
+
if (taken.includes('order')) {
|
|
365
|
+
if (original.hadOrder) source.order = original.order
|
|
366
|
+
else delete source.order
|
|
367
|
+
}
|
|
368
|
+
if (taken.includes('onPick')) {
|
|
369
|
+
if (original.hadOnPick) source.onPick = original.onPick
|
|
370
|
+
else delete source.onPick
|
|
371
|
+
}
|
|
372
|
+
delete source[WRAPPED]
|
|
373
|
+
enhancementMode = undefined
|
|
374
|
+
if (takenOverSource === source) takenOverSource = undefined
|
|
375
|
+
// The cache deliberately SURVIVES a re-install: after a hot reload the
|
|
376
|
+
// first menu open should still be served instantly instead of losing the
|
|
377
|
+
// settle race once more.
|
|
378
|
+
// A disposed takeover must not keep a priming request for a Session that
|
|
379
|
+
// may be gone by the time it is installed again.
|
|
380
|
+
pendingSessionId = undefined
|
|
381
|
+
} catch {
|
|
382
|
+
/* a source that became read-only keeps the wrapper; not fatal */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Install the takeover, retrying while the official plugin has not registered
|
|
389
|
+
* its source yet (both halves load during the same client boot, in an order the
|
|
390
|
+
* picker does not control).
|
|
391
|
+
*
|
|
392
|
+
* @param inputTriggers - the injected service.
|
|
393
|
+
* @param hooks - { rank, order, onPick } passed through to wrapSkillSource.
|
|
394
|
+
* @param options - attempts (default 40) / intervalMs (default 250).
|
|
395
|
+
* @returns a disposer that stops retrying and unwraps.
|
|
396
|
+
*/
|
|
397
|
+
export function installSlashFuzzy(inputTriggers, hooks = {}, options = {}) {
|
|
398
|
+
const maxAttempts = options.attempts ?? 40
|
|
399
|
+
const intervalMs = options.intervalMs ?? 250
|
|
400
|
+
let restore
|
|
401
|
+
let timer
|
|
402
|
+
let attempts = 0
|
|
403
|
+
let stopped = false
|
|
404
|
+
|
|
405
|
+
const attempt = () => {
|
|
406
|
+
if (stopped || restore !== undefined) return
|
|
407
|
+
try {
|
|
408
|
+
const source = findSkillSource(inputTriggers)
|
|
409
|
+
if (source === undefined) {
|
|
410
|
+
if (attempts++ < maxAttempts) {
|
|
411
|
+
timer = setTimeout(attempt, intervalMs)
|
|
412
|
+
} else {
|
|
413
|
+
// Loud on the give-up case. The first release of this module reached
|
|
414
|
+
// for a helper the service does not have and then quietly did nothing
|
|
415
|
+
// for ten seconds; nobody could tell it had never run.
|
|
416
|
+
noteDiag({ mode: 'none', gaveUp: true, attempts: maxAttempts })
|
|
417
|
+
console.warn('[dsh-skill-picker] / takeover: the official "skill" trigger source was not found'
|
|
418
|
+
+ ` after ${maxAttempts} attempts — the / menu keeps the official matcher.`
|
|
419
|
+
+ ' See https://github.com/a735624258/dsh-skill-picker/issues/14')
|
|
420
|
+
}
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
// Already wrapped (a previous install, or an HMR re-apply): stop retrying
|
|
424
|
+
// and leave the existing wrapper in place.
|
|
425
|
+
restore = wrapSkillSource(source, hooks) ?? (() => {})
|
|
426
|
+
} catch {
|
|
427
|
+
// Never let the takeover attempt escape into the client boot.
|
|
428
|
+
restore = () => {}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
attempt()
|
|
433
|
+
|
|
434
|
+
return () => {
|
|
435
|
+
stopped = true
|
|
436
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
437
|
+
restore?.()
|
|
438
|
+
restore = undefined
|
|
439
|
+
}
|
|
440
|
+
}
|
package/src/patch-ui-skill.js
CHANGED
|
@@ -344,7 +344,7 @@ export async function healUiSkillPatches() {
|
|
|
344
344
|
if (files.length === 0) {
|
|
345
345
|
console.warn('[dsh-skill-picker] ui-skill patch: 0 target client.js found under '
|
|
346
346
|
+ `${path.join(dshHome(), 'profiles')} — fuzzy+pinyin matching will NOT be applied.`
|
|
347
|
-
+ `${note} See https://github.com/a735624258/dsh-skill-picker/issues/
|
|
347
|
+
+ `${note} See https://github.com/a735624258/dsh-skill-picker/issues/14`)
|
|
348
348
|
} else if (note !== '') {
|
|
349
349
|
// Targets existed, so the old code stayed silent — but none of them is the
|
|
350
350
|
// copy the host serves. That is issue #9's silent failure: a "successful"
|