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.
- package/README.md +27 -160
- package/lib/client.js +2564 -678
- package/lib/host/execution.js +3 -0
- package/lib/host/execution.js.map +1 -1
- package/lib/host/routes.js +31 -1
- package/lib/host/routes.js.map +1 -1
- package/lib/host/session-sync.js +449 -0
- package/lib/host/session-sync.js.map +1 -0
- package/lib/host/store.js +9 -2
- package/lib/host/store.js.map +1 -1
- package/lib/index.js +109 -2
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +27 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +75 -75
- package/src/client/api.ts +8 -0
- package/src/client/board/AlertModal.tsx +3 -1
- package/src/client/board/ImportModal.tsx +26 -24
- package/src/client/board/SettingsModal.tsx +98 -22
- package/src/client/board/SlashPromptInput.tsx +272 -0
- package/src/client/board/TaskBoard.tsx +53 -49
- package/src/client/board/TaskCard.tsx +33 -21
- package/src/client/board/TaskDetail.tsx +169 -104
- package/src/client/board/TaskFormModal.tsx +254 -202
- package/src/client/board/TemplateManager.tsx +32 -29
- package/src/client/board/labels.ts +36 -27
- package/src/client/controller.ts +62 -2
- package/src/client/i18n/en.ts +455 -0
- package/src/client/i18n/runtime.ts +155 -0
- package/src/client/i18n/zh.ts +460 -0
- package/src/client/index.ts +182 -42
- package/src/client/sidebar-entry.ts +13 -3
- package/src/client/styles.ts +131 -0
- package/src/host/execution.ts +14 -1
- package/src/host/routes.ts +49 -1
- package/src/host/session-sync.ts +650 -0
- package/src/host/store.ts +15 -1
- package/src/index.ts +125 -1
- package/src/shared/api.ts +49 -0
- package/src/shared/protocol.ts +54 -0
- 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('
|
|
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
|
-
?
|
|
77
|
-
:
|
|
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
|
|
90
|
-
<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=
|
|
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"
|
|
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"
|
|
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
|
|
113
|
-
<div className="dsh-atb-imp-stat" data-tone="warn"><b>{plan.overwrite.length}</b><span
|
|
114
|
-
<div className="dsh-atb-imp-stat" data-tone={plan.invalid.length > 0 ? 'bad' : undefined}><b>{plan.invalid.length}</b><span
|
|
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
|
|
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
|
|
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
|
|
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 ?? '
|
|
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"
|
|
146
|
-
<span className="dsh-atb-mode-hint"
|
|
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"
|
|
150
|
-
<span className="dsh-atb-mode-hint"
|
|
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 ? '
|
|
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
|
|
16
|
-
{ value: 'none', name: '
|
|
17
|
-
{ value: 'worktree', name: '
|
|
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
|
|
28
|
-
const
|
|
29
|
-
const
|
|
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({
|
|
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
|
|
44
|
-
<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=
|
|
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
|
|
61
|
+
<h4>{t('set.iso.heading')}</h4>
|
|
52
62
|
<div className="dsh-atb-mode-picker">
|
|
53
|
-
{
|
|
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={
|
|
68
|
+
data-on={draftIso === o.value}
|
|
59
69
|
title={o.hint}
|
|
60
|
-
onClick={() =>
|
|
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
|
-
|
|
69
|
-
|
|
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 ? '
|
|
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()}
|
|
78
|
-
<button type="button" className="dsh-atb-btn" data-primary="true" disabled={!dirty} onClick={save}
|
|
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
|
+
}
|