dsh-taskboard 0.5.4 → 0.6.0

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.
Files changed (42) hide show
  1. package/README.md +27 -160
  2. package/lib/client.js +2564 -678
  3. package/lib/host/execution.js +3 -0
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/routes.js +31 -1
  6. package/lib/host/routes.js.map +1 -1
  7. package/lib/host/session-sync.js +449 -0
  8. package/lib/host/session-sync.js.map +1 -0
  9. package/lib/host/store.js +9 -2
  10. package/lib/host/store.js.map +1 -1
  11. package/lib/index.js +109 -2
  12. package/lib/index.js.map +1 -1
  13. package/lib/shared/api.js.map +1 -1
  14. package/lib/shared/protocol.js +27 -1
  15. package/lib/shared/protocol.js.map +1 -1
  16. package/package.json +75 -75
  17. package/src/client/api.ts +8 -0
  18. package/src/client/board/AlertModal.tsx +3 -1
  19. package/src/client/board/ImportModal.tsx +26 -24
  20. package/src/client/board/SettingsModal.tsx +98 -22
  21. package/src/client/board/SlashPromptInput.tsx +272 -0
  22. package/src/client/board/TaskBoard.tsx +53 -49
  23. package/src/client/board/TaskCard.tsx +33 -21
  24. package/src/client/board/TaskDetail.tsx +169 -104
  25. package/src/client/board/TaskFormModal.tsx +254 -202
  26. package/src/client/board/TemplateManager.tsx +32 -29
  27. package/src/client/board/labels.ts +36 -27
  28. package/src/client/controller.ts +62 -2
  29. package/src/client/i18n/en.ts +455 -0
  30. package/src/client/i18n/runtime.ts +155 -0
  31. package/src/client/i18n/zh.ts +460 -0
  32. package/src/client/index.ts +182 -42
  33. package/src/client/sidebar-entry.ts +13 -3
  34. package/src/client/styles.ts +131 -0
  35. package/src/host/execution.ts +14 -1
  36. package/src/host/routes.ts +49 -1
  37. package/src/host/session-sync.ts +650 -0
  38. package/src/host/store.ts +15 -1
  39. package/src/index.ts +125 -1
  40. package/src/shared/api.ts +49 -0
  41. package/src/shared/protocol.ts +54 -0
  42. package/src/shared/version.ts +1 -1
@@ -10,6 +10,7 @@ import { useRef, useState } from 'react'
10
10
  import type { BoardController } from '../controller.ts'
11
11
  import type { ImportPreviewResponse } from '../../shared/api.ts'
12
12
  import { useAlert } from './AlertModal.tsx'
13
+ import { useT } from '../i18n/runtime.ts'
13
14
 
14
15
  /** One classified row (create / overwrite). */
15
16
  function PlanRow({ row }: { row: { id: string; title: string; status: string } }) {
@@ -26,6 +27,7 @@ function PlanRow({ row }: { row: { id: string; title: string; status: string } }
26
27
  * @param controller - the controller.
27
28
  */
28
29
  export function ImportModal({ controller }: { controller: BoardController }) {
30
+ const t = useT()
29
31
  const [fileName, setFileName] = useState('')
30
32
  const [parsed, setParsed] = useState<unknown>(null)
31
33
  const [parseError, setParseError] = useState<string | undefined>(undefined)
@@ -55,7 +57,7 @@ export function ImportModal({ controller }: { controller: BoardController }) {
55
57
  if (p !== undefined) setPlan(p)
56
58
  })
57
59
  } catch {
58
- setParseError('文件不是合法 JSON')
60
+ setParseError(t('imp.parseError'))
59
61
  }
60
62
  })
61
63
  }
@@ -73,8 +75,8 @@ export function ImportModal({ controller }: { controller: BoardController }) {
73
75
  setConfirmReplace(false)
74
76
  if (r === undefined) return
75
77
  setResult(r.mode === 'replace'
76
- ? `整册替换完成:导入 ${r.created + r.overwritten} 张(原 ${r.replacedTotal} 张已整册备份)`
77
- : `合并完成:新增 ${r.created} 张、覆盖 ${r.overwritten} 张`)
78
+ ? t('imp.result.replace', { n: r.created + r.overwritten, total: r.replacedTotal ?? 0 })
79
+ : t('imp.result.merge', { n: r.created, m: r.overwritten }))
78
80
  })
79
81
  }
80
82
 
@@ -82,14 +84,14 @@ export function ImportModal({ controller }: { controller: BoardController }) {
82
84
 
83
85
  return (
84
86
  <div className="dsh-atb-modal-backdrop" onClick={e => { if (e.target === e.currentTarget) close() }}>
85
- <div className="dsh-atb-modal dsh-atb-imp" role="dialog" aria-modal="true" aria-label="导入台账">
87
+ <div className="dsh-atb-modal dsh-atb-imp" role="dialog" aria-modal="true" aria-label={t('imp.aria')}>
86
88
  <div className="dsh-atb-modal-head">
87
89
  <span className="dsh-atb-modal-headicon">⬆</span>
88
90
  <div className="dsh-atb-modal-headtext">
89
- <h3>导入台账</h3>
90
- <p>选择导出的 JSON 备份文件:先预览、再合并或整册替换</p>
91
+ <h3>{t('imp.title')}</h3>
92
+ <p>{t('imp.subtitle')}</p>
91
93
  </div>
92
- <button type="button" className="dsh-atb-modal-close" aria-label="关闭" onClick={close}>✕</button>
94
+ <button type="button" className="dsh-atb-modal-close" aria-label={t('shared.close')} onClick={close}>✕</button>
93
95
  </div>
94
96
  <div className="dsh-atb-modal-body">
95
97
  <div className="dsh-atb-imp-picker">
@@ -101,38 +103,38 @@ export function ImportModal({ controller }: { controller: BoardController }) {
101
103
  />
102
104
  {fileName.length > 0 && <span className="dsh-atb-imp-filename">{fileName}</span>}
103
105
  </div>
104
- <div className="dsh-atb-imp-note">⬇ JSON 导出的文件即为同格式备份,可直接导入恢复;导入文件的 schemaVersion 必须与当前版本一致。</div>
106
+ <div className="dsh-atb-imp-note">{t('imp.note')}</div>
105
107
 
106
108
  {parseError !== undefined && <div className="dsh-atb-imp-error">{parseError}</div>}
107
- {plan === undefined && parseError === undefined && fileName.length > 0 && <div className="dsh-atb-empty2">预览中…</div>}
109
+ {plan === undefined && parseError === undefined && fileName.length > 0 && <div className="dsh-atb-empty2">{t('imp.previewing')}</div>}
108
110
 
109
111
  {plan !== undefined && (
110
112
  <>
111
113
  <div className="dsh-atb-imp-stats">
112
- <div className="dsh-atb-imp-stat" data-tone="ok"><b>{plan.create.length}</b><span>新增</span></div>
113
- <div className="dsh-atb-imp-stat" data-tone="warn"><b>{plan.overwrite.length}</b><span>覆盖(同 id)</span></div>
114
- <div className="dsh-atb-imp-stat" data-tone={plan.invalid.length > 0 ? 'bad' : undefined}><b>{plan.invalid.length}</b><span>无效(跳过)</span></div>
114
+ <div className="dsh-atb-imp-stat" data-tone="ok"><b>{plan.create.length}</b><span>{t('imp.stat.create')}</span></div>
115
+ <div className="dsh-atb-imp-stat" data-tone="warn"><b>{plan.overwrite.length}</b><span>{t('imp.stat.overwrite')}</span></div>
116
+ <div className="dsh-atb-imp-stat" data-tone={plan.invalid.length > 0 ? 'bad' : undefined}><b>{plan.invalid.length}</b><span>{t('imp.stat.invalid')}</span></div>
115
117
  </div>
116
118
 
117
119
  {plan.create.length > 0 && (
118
120
  <div className="dsh-atb-imp-sec">
119
- <h4>新增任务</h4>
121
+ <h4>{t('imp.sec.create')}</h4>
120
122
  <div className="dsh-atb-imp-list">{plan.create.map(r => <PlanRow key={r.id} row={r} />)}</div>
121
123
  </div>
122
124
  )}
123
125
  {plan.overwrite.length > 0 && (
124
126
  <div className="dsh-atb-imp-sec">
125
- <h4>覆盖任务(整卡替换,含执行历史与评论)</h4>
127
+ <h4>{t('imp.sec.overwrite')}</h4>
126
128
  <div className="dsh-atb-imp-list">{plan.overwrite.map(r => <PlanRow key={r.id} row={r} />)}</div>
127
129
  </div>
128
130
  )}
129
131
  {plan.invalid.length > 0 && (
130
132
  <div className="dsh-atb-imp-sec">
131
- <h4>无效条目(不会导入)</h4>
133
+ <h4>{t('imp.sec.invalid')}</h4>
132
134
  <div className="dsh-atb-imp-list">
133
135
  {plan.invalid.map((r, i) => (
134
136
  <div key={r.id ?? `invalid-${i}`} className="dsh-atb-imp-row" data-tone="bad" title={r.id ?? ''}>
135
- <span className="dsh-atb-imp-row-title">{r.id ?? '(无 id)'}</span>
137
+ <span className="dsh-atb-imp-row-title">{r.id ?? t('imp.noId')}</span>
136
138
  <span className="dsh-atb-imp-row-status">{r.reason}</span>
137
139
  </div>
138
140
  ))}
@@ -142,12 +144,12 @@ export function ImportModal({ controller }: { controller: BoardController }) {
142
144
 
143
145
  <div className="dsh-atb-mode-picker">
144
146
  <button type="button" className="dsh-atb-mode-opt" data-on={mode === 'merge'} onClick={() => { setMode('merge'); setConfirmReplace(false) }}>
145
- <span className="dsh-atb-mode-name">⊕ 合并</span>
146
- <span className="dsh-atb-mode-hint">新增 + 按 id 覆盖,其余不动</span>
147
+ <span className="dsh-atb-mode-name">{t('imp.mode.merge')}</span>
148
+ <span className="dsh-atb-mode-hint">{t('imp.mode.mergeHint')}</span>
147
149
  </button>
148
150
  <button type="button" className="dsh-atb-mode-opt" data-on={mode === 'replace'} onClick={() => setMode('replace')}>
149
- <span className="dsh-atb-mode-name">💣 整册替换</span>
150
- <span className="dsh-atb-mode-hint">清空当前台账,以导入文件为准(先自动备份)</span>
151
+ <span className="dsh-atb-mode-name">{t('imp.mode.replace')}</span>
152
+ <span className="dsh-atb-mode-hint">{t('imp.mode.replaceHint')}</span>
151
153
  </button>
152
154
  </div>
153
155
 
@@ -158,11 +160,11 @@ export function ImportModal({ controller }: { controller: BoardController }) {
158
160
  <div className="dsh-atb-modal-foot">
159
161
  <span className="dsh-atb-modal-hint">
160
162
  {mode === 'replace'
161
- ? confirmReplace ? '⚠ 再次点击确认执行整册替换(不可撤销,已自动备份)' : '整册替换需要二次确认'
162
- : '合并只写入预览中列出的任务'}
163
+ ? confirmReplace ? t('imp.foot.replaceConfirm') : t('imp.foot.replaceNeedConfirm')
164
+ : t('imp.foot.mergeHint')}
163
165
  </span>
164
166
  <span className="dsh-atb-modal-footbtns">
165
- <button type="button" className="dsh-atb-btn" onClick={close}>{result !== undefined ? '关闭' : '取消'}</button>
167
+ <button type="button" className="dsh-atb-btn" onClick={close}>{result !== undefined ? t('shared.close') : t('shared.cancel')}</button>
166
168
  <button
167
169
  type="button"
168
170
  className="dsh-atb-btn"
@@ -171,7 +173,7 @@ export function ImportModal({ controller }: { controller: BoardController }) {
171
173
  disabled={plan === undefined || busy}
172
174
  onClick={commit}
173
175
  >
174
- {mode === 'replace' && confirmReplace ? '确认整册替换' : '执行导入'}
176
+ {mode === 'replace' && confirmReplace ? t('imp.action.confirmReplace') : t('imp.action.run')}
175
177
  </button>
176
178
  </span>
177
179
  </div>
@@ -9,12 +9,13 @@
9
9
  */
10
10
  import { useState } from 'react'
11
11
  import type { BoardController } from '../controller.ts'
12
- import { DEFAULT_ISOLATION, type IsolationMode } from '../../shared/protocol.ts'
12
+ import { DEFAULT_ISOLATION, defaultPermissionOf, defaultSyncExternalSessionsOf, type IsolationMode, type PermissionMode } from '../../shared/protocol.ts'
13
+ import { useT, type Translate } from '../i18n/runtime.ts'
13
14
 
14
- /** The isolation options with one-line hints (mirrors the task form). */
15
- const ISOLATION_OPTIONS: ReadonlyArray<{ value: IsolationMode; name: string; hint: string }> = [
16
- { value: 'none', name: '📁 原目录执行', hint: '不使用 git,直接在项目目录工作(出厂默认)' },
17
- { value: 'worktree', name: '🌿 Worktree 隔离', hint: '每次执行在独立 worktree 分支上进行(task/标题+ID),互不污染' },
15
+ /** The isolation options with one-line hints (mirrors the task form; translated per render). */
16
+ const isolationOptions = (t: Translate): ReadonlyArray<{ value: IsolationMode; name: string; hint: string }> => [
17
+ { value: 'none', name: t('form.iso.none'), hint: t('set.iso.noneHint') },
18
+ { value: 'worktree', name: t('form.iso.worktree'), hint: t('set.iso.worktreeHint') },
18
19
  ]
19
20
 
20
21
  /**
@@ -23,41 +24,50 @@ const ISOLATION_OPTIONS: ReadonlyArray<{ value: IsolationMode; name: string; hin
23
24
  * @param controller - the board controller.
24
25
  */
25
26
  export function SettingsModal({ controller }: { controller: BoardController }) {
27
+ const t = useT()
26
28
  const state = controller.getSnapshot()
27
- const current = state.ledger.settings?.defaultIsolation ?? DEFAULT_ISOLATION
28
- const [draft, setDraft] = useState<IsolationMode>(current)
29
- const dirty = draft !== current
29
+ const currentIso = state.ledger.settings?.defaultIsolation ?? DEFAULT_ISOLATION
30
+ const currentSync = defaultSyncExternalSessionsOf(state.ledger.settings)
31
+ const currentPerm = defaultPermissionOf(state.ledger.settings)
32
+ const [draftIso, setDraftIso] = useState<IsolationMode>(currentIso)
33
+ const [draftSync, setDraftSync] = useState<boolean>(currentSync)
34
+ const [draftPerm, setDraftPerm] = useState<PermissionMode>(currentPerm)
35
+ const dirty = draftIso !== currentIso || draftSync !== currentSync || draftPerm !== currentPerm
30
36
 
31
37
  const save = (): void => {
32
- void controller.updateSettings({ defaultIsolation: draft }).then(ok => {
38
+ void controller.updateSettings({
39
+ defaultIsolation: draftIso,
40
+ syncExternalSessions: draftSync,
41
+ defaultPermission: draftPerm,
42
+ }).then(ok => {
33
43
  if (ok) controller.closeSettings()
34
44
  })
35
45
  }
36
46
 
37
47
  return (
38
48
  <div className="dsh-atb-modal-backdrop" onClick={e => { if (e.target === e.currentTarget) controller.closeSettings() }}>
39
- <div className="dsh-atb-modal dsh-atb-set" role="dialog" aria-modal="true" aria-label="看板设置">
49
+ <div className="dsh-atb-modal dsh-atb-set" role="dialog" aria-modal="true" aria-label={t('set.aria')}>
40
50
  <div className="dsh-atb-modal-head">
41
51
  <span className="dsh-atb-modal-headicon">🛠</span>
42
52
  <div className="dsh-atb-modal-headtext">
43
- <h3>看板设置</h3>
44
- <p>新建任务时应用的默认值(不影响已有任务)</p>
53
+ <h3>{t('set.title')}</h3>
54
+ <p>{t('set.subtitle')}</p>
45
55
  </div>
46
- <button type="button" className="dsh-atb-modal-close" aria-label="关闭" onClick={() => controller.closeSettings()}>✕</button>
56
+ <button type="button" className="dsh-atb-modal-close" aria-label={t('shared.close')} onClick={() => controller.closeSettings()}>✕</button>
47
57
  </div>
48
58
 
49
59
  <div className="dsh-atb-modal-body">
50
60
  <section className="dsh-atb-diag-sec">
51
- <h4>默认执行隔离</h4>
61
+ <h4>{t('set.iso.heading')}</h4>
52
62
  <div className="dsh-atb-mode-picker">
53
- {ISOLATION_OPTIONS.map(o => (
63
+ {isolationOptions(t).map(o => (
54
64
  <button
55
65
  key={o.value}
56
66
  type="button"
57
67
  className="dsh-atb-mode-opt"
58
- data-on={draft === o.value}
68
+ data-on={draftIso === o.value}
59
69
  title={o.hint}
60
- onClick={() => setDraft(o.value)}
70
+ onClick={() => setDraftIso(o.value)}
61
71
  >
62
72
  <span className="dsh-atb-mode-name">{o.name}</span>
63
73
  <span className="dsh-atb-mode-hint">{o.hint}</span>
@@ -65,17 +75,83 @@ export function SettingsModal({ controller }: { controller: BoardController }) {
65
75
  ))}
66
76
  </div>
67
77
  <span className="dsh-atb-isolation-note">
68
- 当前保存的默认:{current === 'worktree' ? '🌿 Worktree 隔离' : '📁 原目录执行'}
69
- 仅影响之后新建的任务;已有任务保持创建时的选择,非 git 项目运行时仍自动降级原目录。
78
+ {t('set.iso.current', { current: currentIso === 'worktree' ? t('form.iso.worktree') : t('form.iso.none') })}
79
+ </span>
80
+ </section>
81
+
82
+ <section className="dsh-atb-diag-sec">
83
+ <h4>{t('set.sync.heading')}</h4>
84
+ <div className="dsh-atb-mode-picker">
85
+ <button
86
+ type="button"
87
+ className="dsh-atb-mode-opt"
88
+ data-on={!draftSync}
89
+ title={t('set.sync.off.title')}
90
+ onClick={() => setDraftSync(false)}
91
+ >
92
+ <span className="dsh-atb-mode-name">{t('set.sync.off.name')}</span>
93
+ <span className="dsh-atb-mode-hint">{t('set.sync.off.hint')}</span>
94
+ </button>
95
+ <button
96
+ type="button"
97
+ className="dsh-atb-mode-opt"
98
+ data-on={draftSync}
99
+ title={t('set.sync.on.title')}
100
+ onClick={() => setDraftSync(true)}
101
+ >
102
+ <span className="dsh-atb-mode-name">{t('set.sync.on.name')}</span>
103
+ <span className="dsh-atb-mode-hint">{t('set.sync.on.hint')}</span>
104
+ </button>
105
+ </div>
106
+ <span className="dsh-atb-isolation-note">
107
+ {currentSync
108
+ ? t('set.sync.stateOn')
109
+ : t('set.sync.stateOff')}
110
+ </span>
111
+ </section>
112
+
113
+ <section className="dsh-atb-diag-sec">
114
+ <h4>{t('set.perm.heading')}</h4>
115
+ <div className="dsh-atb-perm-picker">
116
+ <button
117
+ type="button"
118
+ className="dsh-atb-perm-opt"
119
+ data-on={draftPerm === 'workspace-write'}
120
+ onClick={() => setDraftPerm('workspace-write')}
121
+ >
122
+ <span className="dsh-atb-perm-name">{t('set.perm.writeName')}</span>
123
+ <span className="dsh-atb-perm-hint">{t('set.perm.writeHint')}</span>
124
+ </button>
125
+ <button
126
+ type="button"
127
+ className="dsh-atb-perm-opt"
128
+ data-on={draftPerm === 'read-only'}
129
+ onClick={() => setDraftPerm('read-only')}
130
+ >
131
+ <span className="dsh-atb-perm-name">{t('set.perm.readOnlyName')}</span>
132
+ <span className="dsh-atb-perm-hint">{t('set.perm.readOnlyHint')}</span>
133
+ </button>
134
+ <button
135
+ type="button"
136
+ className="dsh-atb-perm-opt"
137
+ data-on={draftPerm === 'danger-full-access'}
138
+ onClick={() => setDraftPerm('danger-full-access')}
139
+ >
140
+ <span className="dsh-atb-perm-name">{t('set.perm.fullName')}</span>
141
+ <span className="dsh-atb-perm-hint">{t('set.perm.fullHint')}</span>
142
+ </button>
143
+ </div>
144
+ <span className="dsh-atb-isolation-note">
145
+ {t('set.perm.current', { current: currentPerm === 'read-only' ? t('set.perm.readOnlyName') : currentPerm === 'danger-full-access' ? t('set.perm.fullName') : t('set.perm.writeName') })}
70
146
  </span>
71
147
  </section>
72
148
  </div>
73
149
 
74
150
  <div className="dsh-atb-modal-foot">
75
- <span className="dsh-atb-modal-hint">{dirty ? '有未保存的修改' : '与看板当前设置一致'}</span>
151
+ <span className="dsh-atb-modal-hint">{dirty ? t('set.foot.dirty') : t('set.foot.clean')}</span>
76
152
  <span className="dsh-atb-modal-footbtns">
77
- <button type="button" className="dsh-atb-btn" onClick={() => controller.closeSettings()}>取消</button>
78
- <button type="button" className="dsh-atb-btn" data-primary="true" disabled={!dirty} onClick={save}>保存设置</button>
153
+ <button type="button" className="dsh-atb-btn" onClick={() => controller.closeSettings()}>{t('shared.cancel')}</button>
154
+ <button type="button" className="dsh-atb-btn" data-primary="true" disabled={!dirty} onClick={save}>{t('set.action.save')}</button>
79
155
  </span>
80
156
  </div>
81
157
  </div>
@@ -0,0 +1,272 @@
1
+ /**
2
+ * SlashPromptInput: Rich text input component for task description & execution prompt.
3
+ * Features:
4
+ * - Slash autocomplete popup for commands and skills with keyboard navigation.
5
+ * - Clean text editing without image base64 pollution.
6
+ *
7
+ * @module dsh-taskboard/client/board/SlashPromptInput
8
+ */
9
+ import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
10
+ import type { BoardController } from '../controller.ts'
11
+ import type { PromptCompletionItem } from '../../shared/api.ts'
12
+ import { useT, type Translate } from '../i18n/runtime.ts'
13
+
14
+ /** Default built-in slash commands (descriptions resolve through t at render,
15
+ * so they follow the GUI language live; host-provided items override by name). */
16
+ export const defaultCommands = (t: Translate): PromptCompletionItem[] => [
17
+ { name: 'goal', kind: 'command', description: t('slash.cmd.goal.desc'), hint: t('slash.cmd.goal.hint') },
18
+ { name: 'schedule', kind: 'command', description: t('slash.cmd.schedule.desc'), hint: t('slash.cmd.schedule.hint') },
19
+ { name: 'plan', kind: 'command', description: t('slash.cmd.plan.desc') },
20
+ { name: 'browser', kind: 'command', description: t('slash.cmd.browser.desc') },
21
+ { name: 'grill-me', kind: 'command', description: t('slash.cmd.grill-me.desc') },
22
+ { name: 'teamwork-preview', kind: 'command', description: t('slash.cmd.teamwork-preview.desc') },
23
+ { name: 'learn', kind: 'command', description: t('slash.cmd.learn.desc') },
24
+ { name: 'review', kind: 'command', description: t('slash.cmd.review.desc') },
25
+ { name: 'security', kind: 'command', description: t('slash.cmd.security.desc') },
26
+ { name: 'permission', kind: 'command', description: t('slash.cmd.permission.desc'), hint: t('slash.cmd.permission.hint') },
27
+ ]
28
+
29
+ /** Default built-in skills (descriptions resolve through t at render). */
30
+ export const defaultSkills = (t: Translate): PromptCompletionItem[] => [
31
+ { name: 'frontend-ui-engineering', kind: 'skill', description: t('slash.skill.frontend-ui-engineering') },
32
+ { name: 'api-and-interface-design', kind: 'skill', description: t('slash.skill.api-and-interface-design') },
33
+ { name: 'test-driven-development', kind: 'skill', description: t('slash.skill.test-driven-development') },
34
+ { name: 'debugging-and-error-recovery', kind: 'skill', description: t('slash.skill.debugging-and-error-recovery') },
35
+ { name: 'performance-optimization', kind: 'skill', description: t('slash.skill.performance-optimization') },
36
+ { name: 'ci-cd-and-automation', kind: 'skill', description: t('slash.skill.ci-cd-and-automation') },
37
+ { name: 'code-review-and-quality', kind: 'skill', description: t('slash.skill.code-review-and-quality') },
38
+ { name: 'code-simplification', kind: 'skill', description: t('slash.skill.code-simplification') },
39
+ { name: 'context-engineering', kind: 'skill', description: t('slash.skill.context-engineering') },
40
+ { name: 'doubt-driven-development', kind: 'skill', description: t('slash.skill.doubt-driven-development') },
41
+ { name: 'git-workflow-and-versioning', kind: 'skill', description: t('slash.skill.git-workflow-and-versioning') },
42
+ { name: 'idea-refine', kind: 'skill', description: t('slash.skill.idea-refine') },
43
+ { name: 'incremental-implementation', kind: 'skill', description: t('slash.skill.incremental-implementation') },
44
+ { name: 'interview-me', kind: 'skill', description: t('slash.skill.interview-me') },
45
+ { name: 'memory-leak-debugging', kind: 'skill', description: t('slash.skill.memory-leak-debugging') },
46
+ { name: 'observability-and-instrumentation', kind: 'skill', description: t('slash.skill.observability-and-instrumentation') },
47
+ { name: 'planning-and-task-breakdown', kind: 'skill', description: t('slash.skill.planning-and-task-breakdown') },
48
+ { name: 'security-and-hardening', kind: 'skill', description: t('slash.skill.security-and-hardening') },
49
+ { name: 'shipping-and-launch', kind: 'skill', description: t('slash.skill.shipping-and-launch') },
50
+ { name: 'source-driven-development', kind: 'skill', description: t('slash.skill.source-driven-development') },
51
+ { name: 'spec-driven-development', kind: 'skill', description: t('slash.skill.spec-driven-development') },
52
+ { name: 'using-agent-skills', kind: 'skill', description: t('slash.skill.using-agent-skills') },
53
+ ]
54
+
55
+ /** Props for SlashPromptInput. */
56
+ export interface SlashPromptInputProps {
57
+ value: string
58
+ onChange: (value: string) => void
59
+ controller?: BoardController
60
+ placeholder?: string
61
+ rows?: number
62
+ maxLength?: number
63
+ disabled?: boolean
64
+ autoFocus?: boolean
65
+ className?: string
66
+ ariaLabel?: string
67
+ }
68
+
69
+ /**
70
+ * Rich prompt textarea with / autocomplete for slash commands & skills.
71
+ */
72
+ export function SlashPromptInput({
73
+ value,
74
+ onChange,
75
+ controller,
76
+ placeholder,
77
+ rows = 4,
78
+ maxLength = 8000,
79
+ disabled = false,
80
+ autoFocus = false,
81
+ className,
82
+ ariaLabel,
83
+ }: SlashPromptInputProps) {
84
+ const t = useT()
85
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
86
+
87
+ // Autocomplete state: only HOST-provided items are stateful; the built-in
88
+ // defaults are re-derived per render so their descriptions follow the
89
+ // active locale live (host items override defaults by name).
90
+ const [hostCompletions, setHostCompletions] = useState<{ commands: PromptCompletionItem[]; skills: PromptCompletionItem[] } | undefined>(undefined)
91
+ const completions = useMemo<{ commands: PromptCompletionItem[]; skills: PromptCompletionItem[] }>(() => {
92
+ const merge = (defaults: PromptCompletionItem[], host: PromptCompletionItem[] | undefined): PromptCompletionItem[] => {
93
+ const map = new Map<string, PromptCompletionItem>()
94
+ for (const d of defaults) map.set(d.name, d)
95
+ for (const h of host ?? []) map.set(h.name, h)
96
+ return Array.from(map.values())
97
+ }
98
+ return { commands: merge(defaultCommands(t), hostCompletions?.commands), skills: merge(defaultSkills(t), hostCompletions?.skills) }
99
+ }, [t, hostCompletions])
100
+ const [popupOpen, setPopupOpen] = useState(false)
101
+ const [slashQuery, setSlashQuery] = useState('')
102
+ const [slashStart, setSlashStart] = useState(-1)
103
+ const [selectedIndex, setSelectedIndex] = useState(0)
104
+
105
+ // Fetch host completions if controller provided
106
+ useEffect(() => {
107
+ if (controller === undefined) return
108
+ let alive = true
109
+ void controller.fetchPromptCompletions().then(res => {
110
+ if (!alive || res === undefined) return
111
+ setHostCompletions({
112
+ commands: res.commands.map(c => ({ ...c, kind: 'command' })),
113
+ skills: res.skills.map(s => ({ ...s, kind: 'skill' })),
114
+ })
115
+ })
116
+ return () => { alive = false }
117
+ }, [controller])
118
+
119
+ // Filter items based on query
120
+ const filteredItems = useMemo<PromptCompletionItem[]>(() => {
121
+ const q = slashQuery.toLowerCase().trim()
122
+ const all = [...completions.commands, ...completions.skills]
123
+ if (q.length === 0) return all
124
+ return all.filter(item => item.name.toLowerCase().includes(q) || (item.description !== undefined && item.description.toLowerCase().includes(q)))
125
+ }, [completions, slashQuery])
126
+
127
+ // Keep selected index in bounds
128
+ useEffect(() => {
129
+ if (selectedIndex >= filteredItems.length) {
130
+ setSelectedIndex(Math.max(0, filteredItems.length - 1))
131
+ }
132
+ }, [filteredItems.length, selectedIndex])
133
+
134
+ // Detect slash typing on cursor movement or text change
135
+ const checkSlashTrigger = (): void => {
136
+ const el = textareaRef.current
137
+ if (el === null) return
138
+ const pos = el.selectionStart
139
+ const currentText = el.value.slice(0, pos)
140
+
141
+ // Check if cursor is right after a word starting with /
142
+ const lastSlash = currentText.lastIndexOf('/')
143
+ if (lastSlash >= 0) {
144
+ const charBefore = lastSlash > 0 ? (currentText[lastSlash - 1] ?? '\n') : '\n'
145
+ const isWordStart = /\s/.test(charBefore) || lastSlash === 0
146
+ const queryPart = currentText.slice(lastSlash + 1)
147
+ const noWhitespaceInQuery = !/\s/.test(queryPart)
148
+
149
+ if (isWordStart && noWhitespaceInQuery) {
150
+ setSlashStart(lastSlash)
151
+ setSlashQuery(queryPart)
152
+ setPopupOpen(true)
153
+ return
154
+ }
155
+ }
156
+ setPopupOpen(false)
157
+ }
158
+
159
+ // Insert picked completion item
160
+ const applyCompletion = (item: PromptCompletionItem): void => {
161
+ const el = textareaRef.current
162
+ if (el === null || slashStart < 0) return
163
+ const pos = el.selectionStart
164
+ const before = value.slice(0, slashStart)
165
+ const after = value.slice(pos)
166
+ const inserted = `/${item.name} `
167
+ const nextText = before + inserted + after
168
+ onChange(nextText)
169
+ setPopupOpen(false)
170
+
171
+ // Restore focus & cursor position
172
+ setTimeout(() => {
173
+ if (textareaRef.current !== null) {
174
+ const nextPos = slashStart + inserted.length
175
+ textareaRef.current.focus()
176
+ textareaRef.current.setSelectionRange(nextPos, nextPos)
177
+ }
178
+ }, 0)
179
+ }
180
+
181
+ // Keyboard navigation for slash popup
182
+ const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
183
+ if (popupOpen && filteredItems.length > 0) {
184
+ if (e.key === 'ArrowDown') {
185
+ e.preventDefault()
186
+ setSelectedIndex(prev => (prev + 1) % filteredItems.length)
187
+ return
188
+ }
189
+ if (e.key === 'ArrowUp') {
190
+ e.preventDefault()
191
+ setSelectedIndex(prev => (prev - 1 + filteredItems.length) % filteredItems.length)
192
+ return
193
+ }
194
+ if (e.key === 'Enter' || e.key === 'Tab') {
195
+ const picked = filteredItems[selectedIndex]
196
+ if (picked !== undefined) {
197
+ e.preventDefault()
198
+ applyCompletion(picked)
199
+ return
200
+ }
201
+ }
202
+ if (e.key === 'Escape') {
203
+ e.preventDefault()
204
+ setPopupOpen(false)
205
+ return
206
+ }
207
+ }
208
+ }
209
+
210
+ return (
211
+ <div className={`dsh-atb-prompt-wrap ${className ?? ''}`}>
212
+ <div className="dsh-atb-prompt-inner">
213
+ <textarea
214
+ ref={textareaRef}
215
+ className="dsh-atb-prompt-input"
216
+ value={value}
217
+ rows={rows}
218
+ maxLength={maxLength}
219
+ disabled={disabled}
220
+ autoFocus={autoFocus}
221
+ placeholder={placeholder}
222
+ aria-label={ariaLabel}
223
+ onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
224
+ onChange(e.target.value)
225
+ checkSlashTrigger()
226
+ }}
227
+ onKeyUp={checkSlashTrigger}
228
+ onClick={checkSlashTrigger}
229
+ onKeyDown={handleKeyDown}
230
+ />
231
+
232
+ {/* Slash Autocomplete Popup */}
233
+ {popupOpen && filteredItems.length > 0 && (
234
+ <div className="dsh-atb-slash-popup" role="listbox" aria-label={t('slash.aria')}>
235
+ <div className="dsh-atb-slash-head">
236
+ <span className="dsh-atb-slash-title">{t('slash.title')}</span>
237
+ <span className="dsh-atb-slash-hint">{t('slash.hint')}</span>
238
+ </div>
239
+ <div className="dsh-atb-slash-list">
240
+ {filteredItems.map((item, idx) => (
241
+ <div
242
+ key={`${item.kind}-${item.name}`}
243
+ role="option"
244
+ aria-selected={idx === selectedIndex}
245
+ className="dsh-atb-slash-item"
246
+ data-active={idx === selectedIndex ? 'true' : undefined}
247
+ data-kind={item.kind}
248
+ onClick={() => applyCompletion(item)}
249
+ onMouseEnter={() => setSelectedIndex(idx)}
250
+ >
251
+ <span className="dsh-atb-slash-badge" data-kind={item.kind}>
252
+ {item.kind === 'command' ? t('slash.badge.command') : t('slash.badge.skill')}
253
+ </span>
254
+ <span className="dsh-atb-slash-name">/{item.name}</span>
255
+ {item.hint && <span className="dsh-atb-slash-param">{item.hint}</span>}
256
+ {item.description && <span className="dsh-atb-slash-desc">{item.description}</span>}
257
+ </div>
258
+ ))}
259
+ </div>
260
+ </div>
261
+ )}
262
+ </div>
263
+
264
+ {/* Bottom helper toolbar */}
265
+ <div className="dsh-atb-prompt-foot">
266
+ <span className="dsh-atb-prompt-tip">
267
+ {t('slash.tipA')} <code>/</code> {t('slash.tipB')}
268
+ </span>
269
+ </div>
270
+ </div>
271
+ )
272
+ }