dsh-skill-hub 0.2.2 → 0.2.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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Slash-menu skill dots: source lookup + candidates wrapping. The wrapper's
3
+ * contract is pure and injectable — fake api (catalog) and fake settings scope
4
+ * (getSnapshot), no DOM — so it tests cleanly in the node vitest environment.
5
+ * The module caches the catalog map; resetModelCache keeps tests independent.
6
+ */
7
+
8
+ import { beforeEach, describe, expect, it } from 'vitest'
9
+ import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
10
+ import type { InputTriggerCandidate, InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
11
+ import type { HubSettingsValue } from '../protocol.ts'
12
+ import type { SkillHubApi } from './api.ts'
13
+ import { findSkillSource, resetModelCache, wrapSkillSource } from './slash-dots.tsx'
14
+
15
+ beforeEach(() => {
16
+ resetModelCache()
17
+ })
18
+
19
+ /** A `/` skill source stub with the core ui-skill shape. */
20
+ function skillSource(candidates = async (): Promise<InputTriggerCandidate[]> => []): InputTriggerSource {
21
+ return {
22
+ trigger: '/',
23
+ name: 'skill',
24
+ order: 2,
25
+ candidates,
26
+ onPick: () => undefined,
27
+ }
28
+ }
29
+
30
+ /** Fake registry exposing `live.sources` the way the running service does. */
31
+ function registry(...sources: InputTriggerSource[]): InputTriggerServiceContract {
32
+ return { live: { sources }, registerSource: () => () => {}, sessionOf: () => { throw new Error('not used in tests') } } as unknown as InputTriggerServiceContract
33
+ }
34
+
35
+ /** Fake settings scope snapshotting a fixed HubSettingsValue. */
36
+ function scopeWith(value: HubSettingsValue): SettingsScope<HubSettingsValue> {
37
+ return {
38
+ getSnapshot: () => ({ status: 'ready', value, base: undefined, user: undefined, revision: 1, writable: true, mode: 'host' }),
39
+ subscribe: () => () => {},
40
+ set: async () => {},
41
+ unset: async () => {},
42
+ } as SettingsScope<HubSettingsValue>
43
+ }
44
+
45
+ /** Fake hub api whose catalog lists the given skills. */
46
+ function apiWith(skills: Array<{ name: string; modelInvocable: boolean }>): SkillHubApi {
47
+ return {
48
+ catalog: async () => ({
49
+ ok: true,
50
+ complete: true,
51
+ skills: skills.map((s) => ({
52
+ name: s.name,
53
+ description: '',
54
+ invocation: { modelInvocable: s.modelInvocable, userInvocable: true },
55
+ provider: 'filesystem',
56
+ source: 'user-dsh',
57
+ writable: true,
58
+ })),
59
+ disabled: [],
60
+ diagnostics: [],
61
+ }),
62
+ } as unknown as SkillHubApi
63
+ }
64
+
65
+ describe('findSkillSource', () => {
66
+ it('finds the core /skill source among other sources', () => {
67
+ const source = skillSource()
68
+ const service = registry(
69
+ { trigger: '/', name: 'command', candidates: async () => [], onPick: () => undefined },
70
+ source,
71
+ )
72
+ expect(findSkillSource(service)).toBe(source)
73
+ })
74
+
75
+ it('returns undefined when the skill source is absent', () => {
76
+ const service = registry({ trigger: '/', name: 'command', candidates: async () => [], onPick: () => undefined })
77
+ expect(findSkillSource(service)).toBeUndefined()
78
+ })
79
+
80
+ it('never throws on a reshaped registry', () => {
81
+ expect(findSkillSource({} as InputTriggerServiceContract)).toBeUndefined()
82
+ expect(findSkillSource({ live: {} } as unknown as InputTriggerServiceContract)).toBeUndefined()
83
+ })
84
+ })
85
+
86
+ describe('wrapSkillSource', () => {
87
+ it('adds a model-colored dot to model-callable skills and a user-colored dot to user-only ones', async () => {
88
+ const source = skillSource(async () => [
89
+ { name: 'code-review', description: 'review code' },
90
+ { name: 'personal-note', description: 'only me' },
91
+ { name: 'known-default', description: 'fallback unknown' },
92
+ ])
93
+ const api = apiWith([
94
+ { name: 'code-review', modelInvocable: true },
95
+ { name: 'personal-note', modelInvocable: false },
96
+ ])
97
+ const scope = scopeWith({ enabled: true, announceToAgent: true, showUseCount: true, showUseTime: true, showGroupSummary: true, dotModelColor: '#112233', dotUserColor: '#445566' })
98
+
99
+ const restore = wrapSkillSource(source, api, scope)
100
+ try {
101
+ const rows = await source.candidates({ sessionId: 's1' as never }, { query: '', position: 'leading', signal: new AbortController().signal })
102
+
103
+ expect(rows).toHaveLength(3)
104
+ // Model-callable → model color.
105
+ expect((rows[0].icon as unknown as { props: { style: { background: string } } }).props.style.background).toBe('#112233')
106
+ // User-only → user color.
107
+ expect((rows[1].icon as unknown as { props: { style: { background: string } } }).props.style.background).toBe('#445566')
108
+ // Unknown name → model default (still a dot, safer than no dot).
109
+ expect(rows[2].icon).toBeDefined()
110
+ // Original fields pass through untouched.
111
+ expect(rows[0].name).toBe('code-review')
112
+ expect(rows[0].description).toBe('review code')
113
+ } finally {
114
+ restore()
115
+ }
116
+ })
117
+
118
+ it('falls back to the default colors when settings omit them and catalog fails', async () => {
119
+ const source = skillSource(async () => [{ name: 'lonely', description: '' }])
120
+ const api = { catalog: async () => { throw new Error('route down') } } as unknown as SkillHubApi
121
+ const scope = scopeWith({ enabled: true, announceToAgent: true, showUseCount: true, showUseTime: true, showGroupSummary: true })
122
+
123
+ const restore = wrapSkillSource(source, api, scope)
124
+ try {
125
+ const rows = await source.candidates({ sessionId: 's1' as never }, { query: '', position: 'leading', signal: new AbortController().signal })
126
+ // Catalog failed → unknown name → model default dot still renders.
127
+ expect(rows[0].icon).toBeDefined()
128
+ expect((rows[0].icon as unknown as { props: { style: { background: string } } }).props.style.background).toBe('#2f81f7')
129
+ } finally {
130
+ restore()
131
+ }
132
+ })
133
+
134
+ it('restores the original candidates on dispose', async () => {
135
+ const originalCandidates = async (): Promise<InputTriggerCandidate[]> => [{ name: 'plain', description: '' }]
136
+ const source = skillSource(originalCandidates)
137
+ const api = apiWith([])
138
+ const scope = scopeWith({ enabled: true, announceToAgent: true, showUseCount: true, showUseTime: true, showGroupSummary: true })
139
+
140
+ const restore = wrapSkillSource(source, api, scope)
141
+ expect(source.candidates).not.toBe(originalCandidates)
142
+ restore()
143
+ expect(source.candidates).toBe(originalCandidates)
144
+ })
145
+ })
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Slash-menu skill dots: puts the invocation-status dot (model-callable blue /
3
+ * user-only green) in front of every skill candidate in the chat `/` menu.
4
+ *
5
+ * Mechanism (mirrors how dsh-at-file fills the menu icon slot): the candidate
6
+ * menu's rows already render an optional `icon` slot (`MenuView` renders
7
+ * `item.icon` in a 16×16 leading span when it's defined), but the core `/skill`
8
+ * source (`dsh-client-ui-skill`) returns candidates without `icon`. This module
9
+ * wraps that source's `candidates` and stamps each row with a colored dot,
10
+ * reusing the same settings (dotModelColor / dotUserColor) and the same
11
+ * `modelInvocable` classification the panel legend uses — so the chat menu and
12
+ * the Settings → 技能 panel stay in sync, and editing the color updates both.
13
+ *
14
+ * The skill source is registered by the core plugin under the `name` "skill"
15
+ * on the `/` trigger; re-registering the same name would throw, so this wraps
16
+ * the already-registered source object instead (found through the runtime
17
+ * source registry, which the frozen contract exposes only as `registerSource`
18
+ * / `sessionOf` — the lookup below is defensive: it never throws).
19
+ */
20
+
21
+ import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
22
+ // Type-only: pulls the Context merge for ctx.inputTriggers.
23
+ import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
24
+ import type { InputTriggerCandidate, InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
25
+ import { createElement } from 'react'
26
+ import type { HubSettingsValue } from '../protocol.ts'
27
+ import type { SkillHubApi } from './api.ts'
28
+ import { DEFAULT_DOT_MODEL_COLOR, DEFAULT_DOT_USER_COLOR } from './panel/format.ts'
29
+
30
+ /** The core plugin's skill source identity on the '/' trigger. */
31
+ const SKILL_SOURCE = { trigger: '/', name: 'skill' } as const
32
+
33
+ /** How long one catalog-derived modelInvocable map stays hot before refresh. */
34
+ const MODEL_TTL_MS = 60_000
35
+
36
+ /** Runtime view of the source registry (the contract keeps `live` private). */
37
+ type SourceRegistry = {
38
+ live?: { sources?: InputTriggerSource[] }
39
+ }
40
+
41
+ /**
42
+ * Find the core `/skill` source through the runtime registry, or undefined.
43
+ * The lookup never throws: a missing/reshaped registry just means no dots. Exported
44
+ * for unit tests; the apply path only calls it indirectly through setupSkillSlashDots.
45
+ * @param service - the ctx.inputTriggers service face.
46
+ * @returns the registered skill source, or undefined.
47
+ */
48
+ export function findSkillSource(service: InputTriggerServiceContract): InputTriggerSource | undefined {
49
+ const live = (service as unknown as SourceRegistry).live
50
+ if (live?.sources === undefined) return undefined
51
+ return live.sources.find((source) => source.trigger === SKILL_SOURCE.trigger && source.name === SKILL_SOURCE.name)
52
+ }
53
+
54
+ /** One catalog-derived name → modelInvocable snapshot with a load timestamp. */
55
+ let modelCache: { at: number; map: Map<string, boolean> } | { at: number; failed: true } | undefined
56
+
57
+ /**
58
+ * Clear the modelInvocable cache. Called on connection/reset so a fresh
59
+ * catalog wins after reconnect; exported for deterministic unit tests.
60
+ */
61
+ export function resetModelCache(): void {
62
+ modelCache = undefined
63
+ }
64
+
65
+ /**
66
+ * Resolve name → modelInvocable from the hub's catalog, cached for
67
+ * MODEL_TTL_MS. A failed load caches the failure briefly so a downed route
68
+ * doesn't hammer the host on every keystroke; the returned map is empty then
69
+ * (callers fall back to the model dot for unknown names).
70
+ * @param api - the hub browser API.
71
+ * @returns name → whether the model may call the skill.
72
+ */
73
+ async function modelInvocableMap(api: SkillHubApi): Promise<Map<string, boolean>> {
74
+ const now = Date.now()
75
+ const cached = modelCache
76
+ if (cached !== undefined && now - cached.at < MODEL_TTL_MS) {
77
+ return 'failed' in cached ? new Map() : cached.map
78
+ }
79
+ try {
80
+ const catalog = await api.catalog()
81
+ const map = new Map<string, boolean>(catalog.skills.map((skill) => [skill.name, skill.invocation.modelInvocable]))
82
+ modelCache = { at: now, map }
83
+ return map
84
+ } catch (error) {
85
+ console.error('[dsh-skill-hub] slash dot color lookup failed:', error)
86
+ modelCache = { at: now, failed: true }
87
+ return new Map()
88
+ }
89
+ }
90
+
91
+ /**
92
+ * One menu-row dot element. Inline span so it needs no CSS module; the
93
+ * candidate menu centers it inside its 16×16 leading icon slot.
94
+ * @param color - the dot's background color.
95
+ * @returns a React node (memory-only, never crosses the Host boundary).
96
+ */
97
+ function dotIcon(color: string): InputTriggerCandidate['icon'] {
98
+ return createElement('span', {
99
+ 'aria-hidden': true,
100
+ style: {
101
+ display: 'inline-block',
102
+ width: 6,
103
+ height: 6,
104
+ borderRadius: 3,
105
+ background: color,
106
+ flex: 'none',
107
+ },
108
+ }) as unknown as InputTriggerCandidate['icon']
109
+ }
110
+
111
+ /**
112
+ * Wrap the core skill source so every menu row carries the invocation dot.
113
+ * Exported for unit tests; production wiring goes through setupSkillSlashDots.
114
+ * @param source - the registered `/skill` source.
115
+ * @param api - hub browser API for the modelInvocable lookup.
116
+ * @param scope - hub settings scope for the dot colors.
117
+ * @returns a disposer restoring the original candidates.
118
+ */
119
+ export function wrapSkillSource(source: InputTriggerSource, api: SkillHubApi, scope: SettingsScope<HubSettingsValue>): () => void {
120
+ const original = source.candidates
121
+ source.candidates = async (session, req) => {
122
+ const items = await original(session, req)
123
+ if (req.signal.aborted) return items
124
+ const modelByName = await modelInvocableMap(api)
125
+ if (req.signal.aborted) return items
126
+ const snapshot = scope.getSnapshot()
127
+ const modelColor = snapshot.value?.dotModelColor ?? DEFAULT_DOT_MODEL_COLOR
128
+ const userColor = snapshot.value?.dotUserColor ?? DEFAULT_DOT_USER_COLOR
129
+ return items.map((item) => ({
130
+ ...item,
131
+ icon: dotIcon((modelByName.get(item.name) ?? true) ? modelColor : userColor),
132
+ }))
133
+ }
134
+ return () => {
135
+ source.candidates = original
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Mount the slash-menu dots on the registered `/skill` source. Idempotent and
141
+ * defensive: if the core source isn't registered yet (or the registry shape
142
+ * changes), it retries briefly and then gives up silently — the chat keeps
143
+ * working, it simply shows no dots. The returned disposer restores the
144
+ * original candidates and clears the model cache.
145
+ * @param ctx - the client root context (inputTriggers + events).
146
+ * @param api - hub browser API.
147
+ * @param scope - hub settings scope for dot colors.
148
+ * @returns a cleanup function for `ctx.effect`.
149
+ */
150
+ export function setupSkillSlashDots(
151
+ ctx: ClientContext,
152
+ api: SkillHubApi,
153
+ scope: SettingsScope<HubSettingsValue>,
154
+ ): () => void {
155
+ const inputTriggers = ctx.get('inputTriggers')
156
+ if (inputTriggers === undefined) return () => {}
157
+
158
+ let disposed = false
159
+ let restore: (() => void) | undefined
160
+ let attempts = 0
161
+
162
+ // The core ui-skill source registers early in the bundle, but not strictly
163
+ // before this apply runs — poll briefly so order never matters.
164
+ const attempt = (): void => {
165
+ if (disposed) return
166
+ const source = findSkillSource(inputTriggers)
167
+ if (source === undefined) {
168
+ if (attempts < 10) {
169
+ attempts += 1
170
+ setTimeout(attempt, 100)
171
+ }
172
+ return
173
+ }
174
+ restore = wrapSkillSource(source, api, scope)
175
+ }
176
+ attempt()
177
+
178
+ const clearCache = (): void => {
179
+ resetModelCache()
180
+ }
181
+ // ctx.on returns the listener disposer; cordis has no ctx.off.
182
+ const offReset = ctx.on('connection/reset', clearCache)
183
+
184
+ return () => {
185
+ disposed = true
186
+ offReset()
187
+ restore?.()
188
+ restore = undefined
189
+ }
190
+ }