@fonlan/dsh-task-kanban 0.1.0 → 0.1.1

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.
@@ -1,6 +1,13 @@
1
1
  import type { KanbanCard } from '../shared/card.js';
2
2
  /** Refinement session prompt: analyze the repo read-only and write back a plan. */
3
3
  export declare function refinementPrompt(requirement: string, workspacePath: string): string;
4
+ /**
5
+ * Interactive refinement prompt for a card created with a skill (e.g.
6
+ * `/grill-me`). The refinement session is explicit — the user can reply —
7
+ * so instead of deciding autonomously, the agent follows the skill's
8
+ * interview and asks whatever it needs before writing the plan back.
9
+ */
10
+ export declare function interactiveRefinementPrompt(requirement: string, workspacePath: string): string;
4
11
  /** Phase implementation session prompt; identical on retries (the model reads git status/diff itself). */
5
12
  export declare function phasePrompt(card: KanbanCard, phaseIndex: number, workdir: string): string;
6
13
  /** Merge-conflict resolution session prompt: resolve conflicts only, never commit. */
@@ -36,6 +36,7 @@ export declare class KanbanRunner implements KanbanToolResolver {
36
36
  requirement: string;
37
37
  model: string;
38
38
  provider?: string;
39
+ skill?: string;
39
40
  }): Promise<KanbanCard>;
40
41
  moveTask(cardId: string, toLane: Lane): Promise<{
41
42
  ok: boolean;
@@ -59,6 +60,28 @@ export declare class KanbanRunner implements KanbanToolResolver {
59
60
  private requestStop;
60
61
  private fail;
61
62
  private runRefinement;
63
+ /**
64
+ * Drive the refinement session to a terminal card state. Non-interactive
65
+ * cards follow the original contract: the agent finishes one turn and must
66
+ * have written the plan. Interactive cards (created with a refinement
67
+ * skill) may ask the user clarifying questions — the session is explicit
68
+ * and visible in the workspace chat, so the runner waits for each reply
69
+ * instead of failing, and only settles when the plan is written or the
70
+ * session errors out.
71
+ */
72
+ private driveRefinement;
73
+ /**
74
+ * A promise resolving when the card stops being 'refining' (planned,
75
+ * failed, or gone), plus a disposer stopping the polling interval.
76
+ */
77
+ private waitForCardSettlement;
78
+ /**
79
+ * A promise resolving when a NEW human message enters the given session,
80
+ * plus a disposer detaching the listener. Messages injected by plugins with
81
+ * non-`user` sources (such as our skill-invocation instructions) never
82
+ * resolve it — only real human replies drive the interactive refinement.
83
+ */
84
+ private waitForUserReply;
62
85
  private createAgent;
63
86
  private keepHandle;
64
87
  /**
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Skill wiring for requirement refinement.
3
+ *
4
+ * A task card may carry a skill name (e.g. `grill-me`) chosen in the
5
+ * create-task requirement input via a leading `/skill-name` gesture, matching
6
+ * the harness's user-invocation convention (the same whitespace-bounded token
7
+ * shape `tool-skill` scans for). The server strips the gesture from the stored
8
+ * requirement, records the skill on the card, and injects the canonical
9
+ * `renderSkillContent` block into the refinement session as an instructions-
10
+ * form user message with the harness's `skill-invocation` source, so the
11
+ * transcript UI renders it exactly like a user-invoked skill in chat.
12
+ */
13
+ import type { Context } from '@deepseek-ai/cordis';
14
+ import { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill';
15
+ /** A parsed leading skill gesture: the skill name plus the cleaned requirement. */
16
+ export interface SkillGestureParse {
17
+ /** The skill name from the leading `/name` token, if the input led with one. */
18
+ skill?: string;
19
+ /** The requirement text with the leading gesture removed. */
20
+ requirement: string;
21
+ }
22
+ /**
23
+ * Extract a leading `/skill-name` gesture from raw create-task input. Only a
24
+ * token at the very start selects a skill; tokens later in the text are
25
+ * ordinary requirement prose (the user might legitimately mention paths or
26
+ * fractions). The name is grammar-validated but not registry-checked here —
27
+ * unknown names surface as a refinement error with a clear message.
28
+ */
29
+ export declare function parseSkillGesture(raw: string): SkillGestureParse;
30
+ /**
31
+ * The minimal view of the hosted skill registry the plugin needs: list for a
32
+ * summary lookup and get for a full body, both honoring the calling scope.
33
+ */
34
+ export interface SkillRegistryFace {
35
+ list(options?: {
36
+ cwd?: string;
37
+ scope?: unknown;
38
+ }): Promise<SkillSummary[]>;
39
+ get(name: string, options?: {
40
+ cwd?: string;
41
+ scope?: unknown;
42
+ }): Promise<SkillDefinition | undefined>;
43
+ }
44
+ /** Resolve the host skill registry, or undefined when this profile lacks one. */
45
+ export declare function skillRegistry(ctx: Context): SkillRegistryFace | undefined;
46
+ /** Whether the provided skill name is a syntactically valid skill name. */
47
+ export declare function validSkillName(name: string): boolean;
48
+ /**
49
+ * Build the instructions-form message carrying the rendered skill body, the
50
+ * same shape a user `/grill-me` invocation injects through `tool-skill`. The
51
+ * `skill-invocation` source is what the transcript UI decorates as a skill
52
+ * chip; the content is the canonical `<skill_content>` block.
53
+ */
54
+ export declare function skillInvocationMessage(skill: Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'>): {
55
+ content: {
56
+ type: "text";
57
+ text: string;
58
+ }[];
59
+ source: {
60
+ kind: "skill-invocation";
61
+ name: string;
62
+ form: "instructions";
63
+ };
64
+ } & Pick<import("@deepseek-ai/dsh-llm").UserMessage, "id" | "role">;
65
+ /**
66
+ * Load the skill body the card names, scoped to the refinement agent so
67
+ * preset-layer providers (the filesystem skill roots) are visible. Returns
68
+ * undefined when the skill is unknown, not user-invocable, or the registry is
69
+ * absent — the caller decides how to surface that.
70
+ */
71
+ export declare function loadCardSkill(ctx: Context, name: string | undefined, cwd: string, scope: unknown): Promise<Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'> | undefined>;
@@ -18,6 +18,7 @@ export declare class TaskStore {
18
18
  requirement: string;
19
19
  model: string;
20
20
  provider?: string;
21
+ skill?: string;
21
22
  status?: KanbanCard['status'];
22
23
  }): Promise<KanbanCard>;
23
24
  /** Run a mutation under the card's lock; returns the updated card. */
@@ -47,6 +47,8 @@ export interface KanbanCard {
47
47
  id: string;
48
48
  workspacePath: string;
49
49
  requirement: string;
50
+ /** Optional skill (e.g. `grill-me`) used to drive requirement refinement. */
51
+ skill?: string;
50
52
  model: string;
51
53
  /** Provider route for the model (from the modal selection). */
52
54
  provider?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-task-kanban",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "DSH web plugin: a task kanban that refines requirements into phased plans, auto-executes them in git worktrees (one session per phase), and auto-merges into the main branch on completion.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,6 +39,9 @@
39
39
  "README.md",
40
40
  "LICENSE"
41
41
  ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
42
45
  "scripts": {
43
46
  "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
44
47
  "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
@@ -73,6 +76,7 @@
73
76
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
74
77
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
75
78
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
79
+ "@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
76
80
  "@deepseek-ai/schemastery": "^3.18.1",
77
81
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
78
82
  "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
@@ -94,6 +98,7 @@
94
98
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
95
99
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
96
100
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
101
+ "@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
97
102
  "@deepseek-ai/schemastery": "^3.18.1",
98
103
  "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
99
104
  "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.6",
package/src/client/api.ts CHANGED
@@ -28,10 +28,51 @@ async function call<T>(method: string, payload: Record<string, unknown> = {}): P
28
28
  return parsed.value as T
29
29
  }
30
30
 
31
+ /** One row of the host `skill.list` catalog (same shape DSH's chat autocomplete uses). */
32
+ export interface SkillOption {
33
+ name: string
34
+ description: string
35
+ whenToUse?: string
36
+ modelInvocable: boolean
37
+ }
38
+
39
+ /**
40
+ * Call the host gateway's `skill.list` unary RPC over the same-origin HTTP
41
+ * bridge (the exact wire shape DSH's chat input uses for its /-autocomplete).
42
+ * The requested `sessionId` scopes the catalog to that session's agent
43
+ * preset, which is where the filesystem skill roots are registered.
44
+ */
45
+ export async function gatewaySkillList(sessionId: string): Promise<SkillOption[]> {
46
+ let response: Response
47
+ try {
48
+ response = await fetch('/api/skill.list', {
49
+ method: 'POST',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify({
52
+ type: 'client-request',
53
+ rpcId: `task-kanban-${Math.random().toString(36).slice(2)}`,
54
+ method: 'skill.list',
55
+ payload: { sessionId },
56
+ }),
57
+ })
58
+ } catch (error) {
59
+ throw new KanbanApiError('network', error instanceof Error ? error.message : String(error))
60
+ }
61
+ const parsed: { type?: string; result?: { ok?: boolean; value?: { skills?: SkillOption[] }; error?: { code?: string; message?: string } } } | null
62
+ = await response.json().catch(() => null)
63
+ if (!response.ok || parsed === null || parsed.type !== 'server-response' || parsed.result?.ok !== true) {
64
+ throw new KanbanApiError(
65
+ parsed?.result?.error?.code ?? 'http',
66
+ parsed?.result?.error?.message ?? `HTTP ${response.status}`,
67
+ )
68
+ }
69
+ return parsed.result.value?.skills ?? []
70
+ }
71
+
31
72
  export const api = {
32
73
  list: (workspacePath: string) => call<KanbanCard[]>('list', { workspacePath }),
33
- create: (workspacePath: string, requirement: string, model: string, provider?: string) =>
34
- call<KanbanCard>('create', { workspacePath, requirement, model, provider }),
74
+ create: (workspacePath: string, requirement: string, model: string, provider?: string, skill?: string) =>
75
+ call<KanbanCard>('create', { workspacePath, requirement, model, provider, ...(skill !== undefined && skill !== '' ? { skill } : {}) }),
35
76
  move: (cardId: string, toLane: Lane) => call<{ ok: boolean; message?: string }>('move', { cardId, toLane }),
36
77
  stop: (cardId: string) => call<{ ok: boolean; message?: string }>('stop', { cardId }),
37
78
  retry: (cardId: string) => call<{ ok: boolean; message?: string }>('retry', { cardId }),
@@ -1,4 +1,7 @@
1
- /* @fonlan/dsh-task-kanban board styles (kb- prefixed, plain CSS) */
1
+ /* @fonlan/dsh-task-kanban board styles (kb- prefixed, plain CSS).
2
+ Surfaces use the DSH theme tokens (--dsw-alias-*) so they follow the
3
+ shell's selected light/dark appearance; the var() fallbacks cover shells
4
+ that do not define them. */
2
5
  .kb-board {
3
6
  height: 100%;
4
7
  display: flex;
@@ -6,8 +9,8 @@
6
9
  box-sizing: border-box;
7
10
  padding: 12px 16px;
8
11
  overflow: hidden;
9
- color: var(--dsh-fg, #e6e6e6);
10
- background: var(--dsh-bg, #141418);
12
+ color: var(--dsw-alias-label-primary, var(--dsh-fg, #e6e6e6));
13
+ background: var(--dsw-alias-bg-base, var(--dsh-bg, #141418));
11
14
  font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
12
15
  }
13
16
  .kb-empty {
@@ -81,7 +84,7 @@
81
84
  padding: 8px 10px;
82
85
  font-weight: 600;
83
86
  font-size: 13px;
84
- color: var(--dsh-fg-soft, #b8b8c0);
87
+ color: var(--dsw-alias-label-secondary, var(--dsh-fg-soft, #b8b8c0));
85
88
  border-bottom: 1px solid rgba(128, 128, 128, 0.12);
86
89
  flex: 0 0 auto;
87
90
  }
@@ -121,6 +124,11 @@
121
124
  .kb-card-running .kb-card-badge { background: rgba(59, 130, 246, 0.3); }
122
125
  .kb-card-error .kb-card-badge { background: rgba(239, 68, 68, 0.3); }
123
126
  .kb-card-phase { opacity: 0.8; }
127
+ .kb-card-skill {
128
+ background: rgba(139, 92, 246, 0.22);
129
+ border-radius: 4px;
130
+ padding: 1px 6px;
131
+ }
124
132
  .kb-card-model { opacity: 0.55; }
125
133
  .kb-card-error-text { margin-top: 6px; font-size: 12px; color: #f87171; word-break: break-all; }
126
134
  .kb-card-worktree { margin-top: 4px; font-size: 11px; opacity: 0.5; word-break: break-all; }
@@ -129,9 +137,9 @@
129
137
  left: 50%;
130
138
  bottom: 24px;
131
139
  transform: translateX(-50%);
132
- background: rgba(30, 30, 36, 0.95);
133
- border: 1px solid rgba(128, 128, 128, 0.4);
134
- color: #eee;
140
+ background: var(--dsw-alias-bg-layer-2);
141
+ border: 1px solid var(--dsw-alias-border-l2);
142
+ color: var(--dsw-alias-label-primary);
135
143
  padding: 8px 16px;
136
144
  border-radius: 8px;
137
145
  font-size: 13px;
@@ -152,11 +160,11 @@
152
160
  max-width: 90vw;
153
161
  max-height: 85vh;
154
162
  overflow-y: auto;
155
- background: #1c1c22;
156
- border: 1px solid rgba(128, 128, 128, 0.35);
163
+ background: var(--dsw-alias-bg-layer-1);
164
+ border: 1px solid var(--dsw-alias-border-l1);
157
165
  border-radius: 12px;
158
166
  padding: 18px;
159
- color: #e6e6e6;
167
+ color: var(--dsw-alias-label-primary);
160
168
  }
161
169
  .kb-modal-wide { width: 640px; }
162
170
  /* Task-detail bottom panel: docks at the bottom of the board page, fills the
@@ -168,11 +176,11 @@
168
176
  display: flex;
169
177
  flex-direction: column;
170
178
  margin-top: 10px;
171
- background: #1c1c22;
172
- border: 1px solid rgba(128, 128, 128, 0.35);
179
+ background: var(--dsw-alias-bg-layer-2);
180
+ border: 1px solid var(--dsw-alias-border-l1);
173
181
  border-radius: 12px;
174
182
  overflow: hidden;
175
- color: #e6e6e6;
183
+ color: var(--dsw-alias-label-primary);
176
184
  }
177
185
  .kb-detail-panel-head {
178
186
  display: flex;
@@ -210,6 +218,53 @@
210
218
  .kb-modal-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; gap: 10px; }
211
219
  .kb-field { display: block; margin-bottom: 12px; }
212
220
  .kb-field > span { display: block; font-size: 12px; opacity: 0.75; margin-bottom: 4px; }
221
+ .kb-field-hint {
222
+ display: block;
223
+ font-size: 11px;
224
+ opacity: 0.55;
225
+ margin-top: 4px;
226
+ }
227
+ .kb-field-hint-skill {
228
+ opacity: 0.9;
229
+ color: #a78bfa;
230
+ }
231
+ .kb-skill-menu-wrap { position: relative; }
232
+ .kb-skill-menu {
233
+ position: absolute;
234
+ top: 100%;
235
+ left: 0;
236
+ right: 0;
237
+ z-index: 20;
238
+ margin: 2px 0 0;
239
+ padding: 4px;
240
+ list-style: none;
241
+ max-height: 180px;
242
+ overflow-y: auto;
243
+ border-radius: 8px;
244
+ border: 1px solid var(--dsw-alias-border-l2);
245
+ background: var(--dsw-alias-bg-overlay);
246
+ color: var(--dsw-alias-label-primary);
247
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
248
+ }
249
+ .kb-skill-menu-item {
250
+ display: flex;
251
+ flex-direction: column;
252
+ gap: 2px;
253
+ padding: 6px 8px;
254
+ border-radius: 6px;
255
+ cursor: pointer;
256
+ font-size: 12px;
257
+ color: var(--dsw-alias-label-primary);
258
+ }
259
+ .kb-skill-menu-item-active { background: var(--dsw-alias-interactive-bg-hover); }
260
+ .kb-skill-menu-name { font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
261
+ .kb-skill-menu-desc {
262
+ color: var(--dsw-alias-label-tertiary);
263
+ font-size: 11px;
264
+ white-space: nowrap;
265
+ overflow: hidden;
266
+ text-overflow: ellipsis;
267
+ }
213
268
  .kb-field-row { display: flex; align-items: center; gap: 10px; justify-content: space-between; }
214
269
  .kb-input {
215
270
  width: 100%;
@@ -226,6 +281,14 @@
226
281
  .kb-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
227
282
  .kb-detail-section { margin-bottom: 12px; }
228
283
  .kb-detail-label { font-size: 12px; opacity: 0.65; margin-bottom: 4px; }
284
+ .kb-detail-skill {
285
+ display: inline-block;
286
+ font-size: 11px;
287
+ background: rgba(139, 92, 246, 0.22);
288
+ border-radius: 4px;
289
+ padding: 1px 6px;
290
+ margin-bottom: 4px;
291
+ }
229
292
  .kb-detail-text { white-space: pre-wrap; word-break: break-all; font-size: 13px; }
230
293
  .kb-detail-error .kb-detail-text { color: #f87171; }
231
294
  .kb-detail-status {
@@ -280,11 +343,10 @@
280
343
  font-size: 14px;
281
344
  line-height: 22px;
282
345
  }
283
- @media (prefers-color-scheme: light) {
284
- .kb-board { color: #1c1c22; background: #f5f5f7; }
285
- .kb-modal { background: #ffffff; color: #1c1c22; }
286
- .kb-detail-panel { background: #ffffff; color: #1c1c22; }
287
- }
346
+ /* Theme following is handled by the --dsw-alias-* tokens above: they change
347
+ value with the shell's selected light/dark appearance, so no OS-level
348
+ prefers-color-scheme override is needed (and one would fight the app
349
+ theme when the two disagree). */
288
350
  /* Same-line placement WITHOUT moving any DOM: the sidebar foot area becomes
289
351
  a reversed flex row, so its LAST child (the settings area) renders on the
290
352
  LEFT and the FIRST child (footer actions = the kanban button) on the RIGHT
@@ -1,7 +1,7 @@
1
- import { useCallback, useEffect, useMemo, useState } from 'react'
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import type { KanbanCard, Lane } from '../shared/card'
3
3
  import { DROP_RULES, laneOf } from '../shared/lanes'
4
- import { api } from './api'
4
+ import { api, gatewaySkillList, type SkillOption } from './api'
5
5
  import { exitBoard, getClient } from './kanban-state'
6
6
  import { en, zh } from './locales'
7
7
  import './board.css'
@@ -9,12 +9,28 @@ import './board.css'
9
9
  interface BoardProps {
10
10
  t?: (key: string, params?: Record<string, unknown>) => string
11
11
  useWorkspaces?: (selector: (state: unknown) => unknown) => unknown
12
+ useSessions?: (selector: (state: unknown) => unknown) => unknown
13
+ }
14
+
15
+ /** Session-list snapshot shape the standard `useSessions` feed carries. */
16
+ interface SessionListStateLike {
17
+ current?: string
12
18
  }
13
19
 
14
20
  function firstLine(text: string): string {
15
21
  return text.split('\n').find((l) => l.trim() !== '')?.trim() ?? ''
16
22
  }
17
23
 
24
+ /** The leading `/skill-name` gesture at the start of the requirement input. */
25
+ const SKILL_GESTURE = /^\s*\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/
26
+
27
+ /** Extract a leading `/skill-name` token plus the cleaned remainder. */
28
+ function parseSkillGesture(raw: string): { skill?: string; requirement: string } {
29
+ const m = SKILL_GESTURE.exec(raw)
30
+ if (m === null) return { requirement: raw.trim() }
31
+ return { skill: m[1], requirement: raw.slice(m[0].length).trim() }
32
+ }
33
+
18
34
  function statusLabel(card: KanbanCard, t: (key: string) => string): string {
19
35
  if (card.status === 'error') {
20
36
  switch (card.error?.kind) {
@@ -64,6 +80,9 @@ export function BoardRoot(props: BoardProps): JSX.Element {
64
80
  const useWorkspaces = props.useWorkspaces
65
81
  const wsState = useWorkspaces !== undefined ? (useWorkspaces((s: unknown) => s) as { items?: WorkspaceRow[] } | undefined) : undefined
66
82
  const items: WorkspaceRow[] = wsState?.items ?? []
83
+ const useSessions = props.useSessions
84
+ const sessionState = useSessions !== undefined ? (useSessions((s: unknown) => s) as SessionListStateLike | undefined) : undefined
85
+ const currentSessionId = sessionState?.current
67
86
 
68
87
  const [selectedPath, setSelectedPath] = useState<string>(() => {
69
88
  try {
@@ -211,6 +230,7 @@ export function BoardRoot(props: BoardProps): JSX.Element {
211
230
  t={t}
212
231
  workspaces={items}
213
232
  selectedPath={selectedPath}
233
+ sessionId={currentSessionId}
214
234
  onClose={() => setNewTaskOpen(false)}
215
235
  onCreated={() => {
216
236
  setNewTaskOpen(false)
@@ -264,6 +284,7 @@ function CardView({ card, t, onToggle }: CardViewProps): JSX.Element {
264
284
  <div className="kb-card-meta">
265
285
  <span className="kb-card-badge">{badge}</span>
266
286
  {phaseInfo !== '' && <span className="kb-card-phase">{phaseInfo}</span>}
287
+ {card.skill !== undefined && card.skill !== '' && <span className="kb-card-skill">/ {card.skill}</span>}
267
288
  {card.model !== '' && <span className="kb-card-model">{card.model}</span>}
268
289
  </div>
269
290
  {card.status === 'error' && card.error !== undefined && (
@@ -280,18 +301,27 @@ interface NewTaskModalProps {
280
301
  t: (key: string, params?: Record<string, unknown>) => string
281
302
  workspaces: WorkspaceRow[]
282
303
  selectedPath: string
304
+ /** Current session id, scoping the host skill.list catalog lookup. */
305
+ sessionId?: string
283
306
  onClose: () => void
284
307
  onCreated: () => void
285
308
  onError: (message: string) => void
286
309
  }
287
310
 
288
- function NewTaskModal({ t, workspaces, selectedPath, onClose, onCreated, onError }: NewTaskModalProps): JSX.Element {
311
+ /** An in-progress leading `/query` token (menu-open state). */
312
+ const SKILL_MENU_RE = /^\s*\/([a-z0-9-]*)$/
313
+
314
+ function NewTaskModal({ t, workspaces, selectedPath, sessionId, onClose, onCreated, onError }: NewTaskModalProps): JSX.Element {
289
315
  const [requirement, setRequirement] = useState('')
290
316
  const [project, setProject] = useState(selectedPath)
291
317
  const [model, setModel] = useState('')
292
318
  const [modelProvider, setModelProvider] = useState('')
293
319
  const [models, setModels] = useState<Array<{ provider: string; id: string; name?: string }>>([])
320
+ const [skills, setSkills] = useState<SkillOption[]>([])
321
+ const [menuQuery, setMenuQuery] = useState<string | null>(null)
322
+ const [menuIndex, setMenuIndex] = useState(0)
294
323
  const [busy, setBusy] = useState(false)
324
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null)
295
325
 
296
326
  useEffect(() => {
297
327
  let alive = true
@@ -308,6 +338,73 @@ function NewTaskModal({ t, workspaces, selectedPath, onClose, onCreated, onError
308
338
  return () => { alive = false }
309
339
  }, [])
310
340
 
341
+ // Load the host skill catalog once (scoped to the current session), the
342
+ // same feed DSH's chat input uses for its /-autocomplete.
343
+ useEffect(() => {
344
+ if (sessionId === undefined) return
345
+ let alive = true
346
+ gatewaySkillList(sessionId)
347
+ .then((list) => { if (alive) setSkills(list) })
348
+ .catch(() => { if (alive) setSkills([]) })
349
+ return () => { alive = false }
350
+ }, [sessionId])
351
+
352
+ const matches = skills.filter((s) => s.name.startsWith(menuQuery ?? ''))
353
+ const menuVisible = menuQuery !== null && matches.length > 0
354
+ const selected = menuVisible ? matches[Math.min(menuIndex, matches.length - 1)] : undefined
355
+
356
+ /** Re-evaluate the leading-token menu state from the current value + caret. */
357
+ const syncMenu = (value: string, caret: number): void => {
358
+ const head = value.slice(0, caret)
359
+ const m = SKILL_MENU_RE.exec(head)
360
+ if (m !== null) {
361
+ setMenuQuery(m[1])
362
+ setMenuIndex(0)
363
+ return
364
+ }
365
+ setMenuQuery(null)
366
+ }
367
+
368
+ const selectSkill = (name: string): void => {
369
+ const el = textareaRef.current
370
+ const caret = el?.selectionStart ?? requirement.length
371
+ const head = requirement.slice(0, caret)
372
+ const m = SKILL_MENU_RE.exec(head)
373
+ // Replace the leading `/query` token with `/name `.
374
+ const inserted = `/${name} `
375
+ const newValue = m !== null ? inserted + requirement.slice(caret) : inserted + requirement
376
+ setRequirement(newValue)
377
+ setMenuQuery(null)
378
+ requestAnimationFrame(() => {
379
+ const next = textareaRef.current
380
+ if (next !== null) {
381
+ next.focus()
382
+ const pos = inserted.length
383
+ next.setSelectionRange(pos, pos)
384
+ }
385
+ })
386
+ }
387
+
388
+ const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
389
+ if (!menuVisible) return
390
+ if (e.key === 'ArrowDown') {
391
+ e.preventDefault()
392
+ setMenuIndex((i) => (i + 1) % matches.length)
393
+ } else if (e.key === 'ArrowUp') {
394
+ e.preventDefault()
395
+ setMenuIndex((i) => (i - 1 + matches.length) % matches.length)
396
+ } else if (e.key === 'Enter') {
397
+ e.preventDefault()
398
+ if (selected !== undefined) selectSkill(selected.name)
399
+ } else if (e.key === 'Escape') {
400
+ e.preventDefault()
401
+ setMenuQuery(null)
402
+ } else if (e.key === 'Tab') {
403
+ e.preventDefault()
404
+ if (selected !== undefined) selectSkill(selected.name)
405
+ }
406
+ }
407
+
311
408
  const submit = async (): Promise<void> => {
312
409
  if (requirement.trim() === '') {
313
410
  onError(t('requirementLabel') + ' ' + t('error'))
@@ -317,9 +414,10 @@ function NewTaskModal({ t, workspaces, selectedPath, onClose, onCreated, onError
317
414
  onError(t('noWorkspace'))
318
415
  return
319
416
  }
417
+ const parsed = parseSkillGesture(requirement)
320
418
  setBusy(true)
321
419
  try {
322
- await api.create(project, requirement.trim(), model, modelProvider)
420
+ await api.create(project, parsed.requirement, model, modelProvider, parsed.skill)
323
421
  onCreated()
324
422
  } catch (error) {
325
423
  onError(error instanceof Error ? error.message : String(error))
@@ -327,18 +425,52 @@ function NewTaskModal({ t, workspaces, selectedPath, onClose, onCreated, onError
327
425
  }
328
426
  }
329
427
 
428
+ const activeSkill = parseSkillGesture(requirement).skill
429
+
330
430
  return (
331
431
  <div className="kb-modal-backdrop" onClick={busy ? undefined : onClose}>
332
432
  <div className="kb-modal" onClick={(e) => e.stopPropagation()}>
333
433
  <div className="kb-modal-title">{t('newTask')}</div>
334
434
  <label className="kb-field">
335
435
  <span>{t('requirementLabel')}</span>
336
- <textarea
337
- className="kb-input kb-textarea"
338
- value={requirement}
339
- placeholder={t('requirementPlaceholder')}
340
- onChange={(e) => setRequirement(e.target.value)}
341
- />
436
+ <div className="kb-skill-menu-wrap">
437
+ <textarea
438
+ ref={textareaRef}
439
+ className="kb-input kb-textarea"
440
+ value={requirement}
441
+ placeholder={t('requirementPlaceholder')}
442
+ onChange={(e) => {
443
+ const value = e.target.value
444
+ setRequirement(value)
445
+ syncMenu(value, e.target.selectionStart ?? value.length)
446
+ }}
447
+ onKeyDown={onKeyDown}
448
+ />
449
+ {menuVisible && (
450
+ <ul className="kb-skill-menu" role="listbox">
451
+ {matches.map((s, i) => (
452
+ <li
453
+ key={s.name}
454
+ role="option"
455
+ aria-selected={i === menuIndex}
456
+ className={'kb-skill-menu-item' + (i === menuIndex ? ' kb-skill-menu-item-active' : '')}
457
+ onMouseDown={(e) => { e.preventDefault(); selectSkill(s.name) }}
458
+ onMouseEnter={() => setMenuIndex(i)}
459
+ >
460
+ <span className="kb-skill-menu-name">/{s.name}</span>
461
+ <span className="kb-skill-menu-desc">
462
+ {s.modelInvocable ? s.description : `${t('skillMenuUserOnly')} · ${s.description}`}
463
+ </span>
464
+ </li>
465
+ ))}
466
+ </ul>
467
+ )}
468
+ </div>
469
+ {activeSkill !== undefined ? (
470
+ <span className="kb-field-hint kb-field-hint-skill">{t('skillHint', { skill: activeSkill })}</span>
471
+ ) : (
472
+ <span className="kb-field-hint">{t('skillGestureHint')}</span>
473
+ )}
342
474
  </label>
343
475
  <label className="kb-field">
344
476
  <span>{t('project')}</span>
@@ -419,6 +551,9 @@ function DetailPanel({ card, t, onClose, onChanged, onToast, onOpenSession }: De
419
551
  <div className="kb-detail-panel-body">
420
552
  <div className="kb-detail-section">
421
553
  <div className="kb-detail-label">{t('requirement')}</div>
554
+ {card.skill !== undefined && card.skill !== '' && (
555
+ <div className="kb-detail-skill">{t('skillBadge', { skill: card.skill })}</div>
556
+ )}
422
557
  <div className="kb-detail-text">{card.requirement}</div>
423
558
  </div>
424
559