devvami 1.4.1 → 1.5.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 +7 -0
- package/oclif.manifest.json +41 -1
- package/package.json +2 -1
- package/src/commands/auth/login.js +20 -16
- package/src/commands/changelog.js +12 -12
- package/src/commands/costs/get.js +14 -24
- package/src/commands/costs/trend.js +13 -24
- package/src/commands/create/repo.js +72 -54
- package/src/commands/docs/list.js +29 -25
- package/src/commands/docs/projects.js +58 -24
- package/src/commands/docs/read.js +56 -39
- package/src/commands/docs/search.js +37 -25
- package/src/commands/doctor.js +37 -35
- package/src/commands/dotfiles/add.js +51 -39
- package/src/commands/dotfiles/setup.js +62 -33
- package/src/commands/dotfiles/status.js +18 -18
- package/src/commands/dotfiles/sync.js +62 -46
- package/src/commands/init.js +143 -132
- package/src/commands/logs/index.js +10 -16
- package/src/commands/open.js +12 -12
- package/src/commands/pipeline/logs.js +8 -11
- package/src/commands/pipeline/rerun.js +21 -16
- package/src/commands/pipeline/status.js +28 -24
- package/src/commands/pr/create.js +40 -27
- package/src/commands/pr/detail.js +9 -7
- package/src/commands/pr/review.js +18 -19
- package/src/commands/pr/status.js +27 -21
- package/src/commands/prompts/browse.js +15 -15
- package/src/commands/prompts/download.js +15 -16
- package/src/commands/prompts/install-speckit.js +11 -12
- package/src/commands/prompts/list.js +12 -12
- package/src/commands/prompts/run.js +16 -19
- package/src/commands/repo/list.js +57 -41
- package/src/commands/search.js +20 -18
- package/src/commands/security/setup.js +38 -34
- package/src/commands/sync-config-ai/index.js +143 -0
- package/src/commands/tasks/assigned.js +43 -33
- package/src/commands/tasks/list.js +43 -33
- package/src/commands/tasks/today.js +32 -30
- package/src/commands/upgrade.js +18 -17
- package/src/commands/vuln/detail.js +8 -8
- package/src/commands/vuln/scan.js +95 -21
- package/src/commands/vuln/search.js +23 -18
- package/src/commands/welcome.js +2 -2
- package/src/commands/whoami.js +19 -23
- package/src/formatters/ai-config.js +127 -0
- package/src/formatters/charts.js +6 -23
- package/src/formatters/cost.js +1 -7
- package/src/formatters/dotfiles.js +48 -19
- package/src/formatters/markdown.js +11 -6
- package/src/formatters/openapi.js +7 -9
- package/src/formatters/prompts.js +69 -78
- package/src/formatters/security.js +2 -2
- package/src/formatters/status.js +1 -1
- package/src/formatters/table.js +1 -3
- package/src/formatters/vuln.js +33 -20
- package/src/help.js +162 -164
- package/src/hooks/init.js +1 -3
- package/src/hooks/postrun.js +5 -7
- package/src/index.js +1 -1
- package/src/services/ai-config-store.js +318 -0
- package/src/services/ai-env-deployer.js +444 -0
- package/src/services/ai-env-scanner.js +242 -0
- package/src/services/audit-detector.js +2 -2
- package/src/services/audit-runner.js +40 -31
- package/src/services/auth.js +9 -9
- package/src/services/awesome-copilot.js +7 -4
- package/src/services/aws-costs.js +22 -22
- package/src/services/clickup.js +26 -26
- package/src/services/cloudwatch-logs.js +5 -9
- package/src/services/config.js +13 -13
- package/src/services/docs.js +19 -20
- package/src/services/dotfiles.js +149 -51
- package/src/services/github.js +22 -24
- package/src/services/nvd.js +21 -31
- package/src/services/platform.js +2 -2
- package/src/services/prompts.js +23 -35
- package/src/services/security.js +135 -61
- package/src/services/shell.js +4 -4
- package/src/services/skills-sh.js +3 -9
- package/src/services/speckit.js +4 -7
- package/src/services/version-check.js +10 -10
- package/src/types.js +85 -0
- package/src/utils/aws-vault.js +18 -41
- package/src/utils/banner.js +5 -7
- package/src/utils/errors.js +42 -46
- package/src/utils/frontmatter.js +4 -4
- package/src/utils/gradient.js +18 -16
- package/src/utils/open-browser.js +3 -3
- package/src/utils/tui/form.js +1006 -0
- package/src/utils/tui/modal.js +15 -14
- package/src/utils/tui/navigable-table.js +25 -17
- package/src/utils/tui/tab-tui.js +800 -0
- package/src/utils/typewriter.js +3 -3
- package/src/utils/welcome.js +18 -21
- package/src/validators/repo-name.js +2 -2
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module form
|
|
3
|
+
* Inline form component for the dvmi sync-config-ai TUI.
|
|
4
|
+
* All rendering functions are pure (no terminal side effects).
|
|
5
|
+
* The parent tab-tui.js is responsible for writing rendered lines to the screen.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import chalk from 'chalk'
|
|
9
|
+
|
|
10
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
// Typedefs
|
|
12
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} TextField
|
|
16
|
+
* @property {'text'} type
|
|
17
|
+
* @property {string} label
|
|
18
|
+
* @property {string} value
|
|
19
|
+
* @property {number} cursor - Cursor position (0 = before first char)
|
|
20
|
+
* @property {boolean} required
|
|
21
|
+
* @property {string} placeholder
|
|
22
|
+
* @property {string} [key] - Optional override key for extractValues output
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} SelectorField
|
|
27
|
+
* @property {'selector'} type
|
|
28
|
+
* @property {string} label
|
|
29
|
+
* @property {string[]} options
|
|
30
|
+
* @property {number} selectedIndex
|
|
31
|
+
* @property {boolean} required
|
|
32
|
+
* @property {string} [key]
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {{ id: string, label: string }} MultiSelectOption
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {Object} MultiSelectField
|
|
41
|
+
* @property {'multiselect'} type
|
|
42
|
+
* @property {string} label
|
|
43
|
+
* @property {MultiSelectOption[]} options
|
|
44
|
+
* @property {Set<string>} selected
|
|
45
|
+
* @property {number} focusedOptionIndex
|
|
46
|
+
* @property {boolean} required
|
|
47
|
+
* @property {string} [key]
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {Object} MiniEditorField
|
|
52
|
+
* @property {'editor'} type
|
|
53
|
+
* @property {string} label
|
|
54
|
+
* @property {string[]} lines
|
|
55
|
+
* @property {number} cursorLine
|
|
56
|
+
* @property {number} cursorCol
|
|
57
|
+
* @property {boolean} required
|
|
58
|
+
* @property {string} [key]
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {TextField|SelectorField|MultiSelectField|MiniEditorField} Field
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {Object} FormState
|
|
67
|
+
* @property {Field[]} fields
|
|
68
|
+
* @property {number} focusedFieldIndex
|
|
69
|
+
* @property {string} title
|
|
70
|
+
* @property {'editing'|'submitted'|'cancelled'} status
|
|
71
|
+
* @property {string|null} errorMessage
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @typedef {Object} SubmitResult
|
|
76
|
+
* @property {true} submitted
|
|
77
|
+
* @property {object} values
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @typedef {Object} CancelResult
|
|
82
|
+
* @property {true} cancelled
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
86
|
+
// Internal helpers
|
|
87
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Convert a field label to a plain object key (lowercase, spaces → underscores).
|
|
91
|
+
* If the field has a `key` property, use that instead.
|
|
92
|
+
* @param {Field} field
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
function fieldKey(field) {
|
|
96
|
+
if (field.key) return field.key
|
|
97
|
+
return field.label.toLowerCase().replace(/\s+/g, '_')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Render the text cursor inside a string value at the given position.
|
|
102
|
+
* Inserts a `|` character at the cursor index.
|
|
103
|
+
* @param {string} value
|
|
104
|
+
* @param {number} cursor
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function renderCursor(value, cursor) {
|
|
108
|
+
return value.slice(0, cursor) + chalk.inverse('|') + value.slice(cursor)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
// buildFieldLine
|
|
113
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Render a single form field as a terminal line.
|
|
117
|
+
*
|
|
118
|
+
* - TextField: ` [label]: [value with cursor shown as |]`
|
|
119
|
+
* - SelectorField: ` [label]: < option >`
|
|
120
|
+
* - MultiSelectField: ` [label]: [N/total checked]`
|
|
121
|
+
* - MiniEditorField: ` [label]: [N lines]`
|
|
122
|
+
*
|
|
123
|
+
* When focused, the line is prefixed with a bold `> ` indicator instead of ` `.
|
|
124
|
+
*
|
|
125
|
+
* @param {Field} field
|
|
126
|
+
* @param {boolean} focused
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
export function buildFieldLine(field, focused) {
|
|
130
|
+
const prefix = focused ? chalk.bold('> ') : ' '
|
|
131
|
+
|
|
132
|
+
if (field.type === 'text') {
|
|
133
|
+
const display = focused
|
|
134
|
+
? renderCursor(field.value, field.cursor)
|
|
135
|
+
: field.value || chalk.dim(field.placeholder || '')
|
|
136
|
+
return `${prefix}${chalk.bold(field.label)}: ${display}`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (field.type === 'selector') {
|
|
140
|
+
const option = field.options[field.selectedIndex] ?? ''
|
|
141
|
+
const arrows = focused ? `${chalk.bold('< ')}${chalk.cyan(option)}${chalk.bold(' >')}` : `< ${option} >`
|
|
142
|
+
return `${prefix}${chalk.bold(field.label)}: ${arrows}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (field.type === 'multiselect') {
|
|
146
|
+
const count = field.selected.size
|
|
147
|
+
const total = field.options.length
|
|
148
|
+
const summary = focused ? chalk.cyan(`${count}/${total} selected`) : `${count}/${total} selected`
|
|
149
|
+
return `${prefix}${chalk.bold(field.label)}: ${summary}`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (field.type === 'editor') {
|
|
153
|
+
const lineCount = field.lines.length
|
|
154
|
+
const summary = focused
|
|
155
|
+
? chalk.cyan(`${lineCount} line${lineCount === 1 ? '' : 's'}`)
|
|
156
|
+
: `${lineCount} line${lineCount === 1 ? '' : 's'}`
|
|
157
|
+
return `${prefix}${chalk.bold(field.label)}: ${summary}`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return `${prefix}${chalk.bold(/** @type {any} */ (field).label)}: —`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
164
|
+
// buildMultiSelectLines
|
|
165
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Render expanded MultiSelectField options as multiple lines (shown when focused).
|
|
169
|
+
* Each option shows `[x]` when selected and `[ ]` when not.
|
|
170
|
+
* The option under the cursor is highlighted with chalk.bold.
|
|
171
|
+
*
|
|
172
|
+
* @param {MultiSelectField} field
|
|
173
|
+
* @param {boolean} focused
|
|
174
|
+
* @param {number} maxLines - Maximum number of lines to return
|
|
175
|
+
* @returns {string[]}
|
|
176
|
+
*/
|
|
177
|
+
export function buildMultiSelectLines(field, focused, maxLines) {
|
|
178
|
+
const lines = []
|
|
179
|
+
for (let i = 0; i < field.options.length; i++) {
|
|
180
|
+
const opt = field.options[i]
|
|
181
|
+
const checked = field.selected.has(opt.id) ? chalk.green('[x]') : '[ ]'
|
|
182
|
+
const label = opt.label
|
|
183
|
+
const isCursor = focused && i === field.focusedOptionIndex
|
|
184
|
+
const line = isCursor ? chalk.bold(` ${checked} ${label}`) : ` ${checked} ${label}`
|
|
185
|
+
lines.push(line)
|
|
186
|
+
if (lines.length >= maxLines) break
|
|
187
|
+
}
|
|
188
|
+
return lines
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
192
|
+
// buildMiniEditorLines
|
|
193
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Render MiniEditorField content with line numbers.
|
|
197
|
+
* When focused, inserts `|` at the cursor column on the active line.
|
|
198
|
+
* Returns up to `maxLines` lines.
|
|
199
|
+
*
|
|
200
|
+
* @param {MiniEditorField} field
|
|
201
|
+
* @param {boolean} focused
|
|
202
|
+
* @param {number} maxLines - Maximum number of lines to return
|
|
203
|
+
* @returns {string[]}
|
|
204
|
+
*/
|
|
205
|
+
export function buildMiniEditorLines(field, focused, maxLines) {
|
|
206
|
+
const lines = []
|
|
207
|
+
const numWidth = String(field.lines.length).length
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < field.lines.length; i++) {
|
|
210
|
+
const lineNum = String(i + 1).padStart(numWidth)
|
|
211
|
+
const rawLine = field.lines[i]
|
|
212
|
+
let content
|
|
213
|
+
if (focused && i === field.cursorLine) {
|
|
214
|
+
content = renderCursor(rawLine, field.cursorCol)
|
|
215
|
+
} else {
|
|
216
|
+
content = rawLine
|
|
217
|
+
}
|
|
218
|
+
lines.push(` ${chalk.dim(lineNum + ' │')} ${content}`)
|
|
219
|
+
if (lines.length >= maxLines) break
|
|
220
|
+
}
|
|
221
|
+
return lines
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
225
|
+
// buildFormScreen
|
|
226
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Render all form fields into an array of terminal lines.
|
|
230
|
+
*
|
|
231
|
+
* For the currently focused field:
|
|
232
|
+
* - MultiSelectField: renders expanded options below the field header line
|
|
233
|
+
* - MiniEditorField: renders editor content lines below the field header line
|
|
234
|
+
* - Other types: renders just the single header line
|
|
235
|
+
*
|
|
236
|
+
* Returns an array of lines (no ANSI clear/home — the parent handles that).
|
|
237
|
+
* Includes the form title at the top, an error message if set, all fields,
|
|
238
|
+
* and a footer hint line at the bottom.
|
|
239
|
+
*
|
|
240
|
+
* @param {FormState} formState
|
|
241
|
+
* @param {number} viewportHeight - Available content lines
|
|
242
|
+
* @param {number} termCols - Terminal width
|
|
243
|
+
* @returns {string[]}
|
|
244
|
+
*/
|
|
245
|
+
export function buildFormScreen(formState, viewportHeight, termCols) {
|
|
246
|
+
const lines = []
|
|
247
|
+
|
|
248
|
+
// ── Title ──────────────────────────────────────────────────────────────────
|
|
249
|
+
lines.push('')
|
|
250
|
+
lines.push(` ${chalk.bold.cyan(formState.title)}`)
|
|
251
|
+
lines.push(` ${chalk.dim('─'.repeat(Math.min(termCols - 4, 60)))}`)
|
|
252
|
+
|
|
253
|
+
// ── Error message ─────────────────────────────────────────────────────────
|
|
254
|
+
if (formState.errorMessage) {
|
|
255
|
+
lines.push(` ${chalk.red('✖ ' + formState.errorMessage)}`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
lines.push('')
|
|
259
|
+
|
|
260
|
+
// ── Fields ────────────────────────────────────────────────────────────────
|
|
261
|
+
const FOOTER_RESERVE = 2
|
|
262
|
+
const availableForFields = viewportHeight - lines.length - FOOTER_RESERVE
|
|
263
|
+
|
|
264
|
+
for (let i = 0; i < formState.fields.length; i++) {
|
|
265
|
+
const field = formState.fields[i]
|
|
266
|
+
const isFocused = i === formState.focusedFieldIndex
|
|
267
|
+
|
|
268
|
+
// Header line
|
|
269
|
+
lines.push(buildFieldLine(field, isFocused))
|
|
270
|
+
|
|
271
|
+
// Expanded inline content for focused multiselect / editor
|
|
272
|
+
if (isFocused) {
|
|
273
|
+
const remaining = availableForFields - lines.length
|
|
274
|
+
if (field.type === 'multiselect' && remaining > 0) {
|
|
275
|
+
const expanded = buildMultiSelectLines(field, true, remaining)
|
|
276
|
+
lines.push(...expanded)
|
|
277
|
+
} else if (field.type === 'editor' && remaining > 0) {
|
|
278
|
+
const expanded = buildMiniEditorLines(field, true, remaining)
|
|
279
|
+
lines.push(...expanded)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Footer hint ───────────────────────────────────────────────────────────
|
|
285
|
+
lines.push('')
|
|
286
|
+
lines.push(chalk.dim(' Tab next field Shift+Tab prev Ctrl+S save Esc cancel'))
|
|
287
|
+
|
|
288
|
+
return lines
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
292
|
+
// extractValues
|
|
293
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Extract form field values into a plain object.
|
|
297
|
+
*
|
|
298
|
+
* - TextField → string value
|
|
299
|
+
* - SelectorField → selected option string
|
|
300
|
+
* - MultiSelectField → array of selected ids
|
|
301
|
+
* - MiniEditorField → lines joined with `\n`
|
|
302
|
+
*
|
|
303
|
+
* The key for each field is `field.key` if set, otherwise the label lowercased
|
|
304
|
+
* with spaces replaced by underscores.
|
|
305
|
+
*
|
|
306
|
+
* @param {FormState} formState
|
|
307
|
+
* @returns {object}
|
|
308
|
+
*/
|
|
309
|
+
export function extractValues(formState) {
|
|
310
|
+
/** @type {Record<string, unknown>} */
|
|
311
|
+
const result = {}
|
|
312
|
+
|
|
313
|
+
for (const field of formState.fields) {
|
|
314
|
+
const key = fieldKey(field)
|
|
315
|
+
|
|
316
|
+
if (field.type === 'text') {
|
|
317
|
+
result[key] = field.value
|
|
318
|
+
} else if (field.type === 'selector') {
|
|
319
|
+
result[key] = field.options[field.selectedIndex] ?? ''
|
|
320
|
+
} else if (field.type === 'multiselect') {
|
|
321
|
+
result[key] = Array.from(field.selected)
|
|
322
|
+
} else if (field.type === 'editor') {
|
|
323
|
+
result[key] = field.lines.join('\n')
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return result
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
331
|
+
// Validation helper
|
|
332
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Check that all required fields have a non-empty value.
|
|
336
|
+
* Returns the label of the first invalid field, or null if all are valid.
|
|
337
|
+
* @param {FormState} formState
|
|
338
|
+
* @returns {string|null}
|
|
339
|
+
*/
|
|
340
|
+
function validateForm(formState) {
|
|
341
|
+
for (const field of formState.fields) {
|
|
342
|
+
if (!field.required) continue
|
|
343
|
+
|
|
344
|
+
if (field.type === 'text' && field.value.trim() === '') {
|
|
345
|
+
return field.label
|
|
346
|
+
}
|
|
347
|
+
if (field.type === 'selector' && field.options.length === 0) {
|
|
348
|
+
return field.label
|
|
349
|
+
}
|
|
350
|
+
if (field.type === 'multiselect' && field.selected.size === 0) {
|
|
351
|
+
return field.label
|
|
352
|
+
}
|
|
353
|
+
if (field.type === 'editor') {
|
|
354
|
+
const content = field.lines.join('\n').trim()
|
|
355
|
+
if (content === '') return field.label
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return null
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
362
|
+
// Field-specific keypress handlers (pure)
|
|
363
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Handle a keypress on a focused TextField. Returns updated field.
|
|
367
|
+
* @param {TextField} field
|
|
368
|
+
* @param {{ name: string, sequence?: string, ctrl?: boolean, shift?: boolean }} key
|
|
369
|
+
* @returns {TextField}
|
|
370
|
+
*/
|
|
371
|
+
function handleTextFieldKey(field, key) {
|
|
372
|
+
const {value, cursor} = field
|
|
373
|
+
|
|
374
|
+
if (key.name === 'backspace') {
|
|
375
|
+
if (cursor === 0) return field
|
|
376
|
+
return {
|
|
377
|
+
...field,
|
|
378
|
+
value: value.slice(0, cursor - 1) + value.slice(cursor),
|
|
379
|
+
cursor: cursor - 1,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (key.name === 'delete') {
|
|
384
|
+
if (cursor >= value.length) return field
|
|
385
|
+
return {
|
|
386
|
+
...field,
|
|
387
|
+
value: value.slice(0, cursor) + value.slice(cursor + 1),
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (key.name === 'left') {
|
|
392
|
+
return {...field, cursor: Math.max(0, cursor - 1)}
|
|
393
|
+
}
|
|
394
|
+
if (key.name === 'right') {
|
|
395
|
+
return {...field, cursor: Math.min(value.length, cursor + 1)}
|
|
396
|
+
}
|
|
397
|
+
if (key.name === 'home') {
|
|
398
|
+
return {...field, cursor: 0}
|
|
399
|
+
}
|
|
400
|
+
if (key.name === 'end') {
|
|
401
|
+
return {...field, cursor: value.length}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Printable character
|
|
405
|
+
if (key.sequence && key.sequence.length === 1 && !key.ctrl) {
|
|
406
|
+
const ch = key.sequence
|
|
407
|
+
if (ch >= ' ') {
|
|
408
|
+
return {
|
|
409
|
+
...field,
|
|
410
|
+
value: value.slice(0, cursor) + ch + value.slice(cursor),
|
|
411
|
+
cursor: cursor + 1,
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return field
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Handle a keypress on a focused SelectorField. Returns updated field.
|
|
421
|
+
* @param {SelectorField} field
|
|
422
|
+
* @param {{ name: string }} key
|
|
423
|
+
* @returns {SelectorField}
|
|
424
|
+
*/
|
|
425
|
+
function handleSelectorFieldKey(field, key) {
|
|
426
|
+
const len = field.options.length
|
|
427
|
+
if (len === 0) return field
|
|
428
|
+
|
|
429
|
+
if (key.name === 'left') {
|
|
430
|
+
return {...field, selectedIndex: (field.selectedIndex - 1 + len) % len}
|
|
431
|
+
}
|
|
432
|
+
if (key.name === 'right') {
|
|
433
|
+
return {...field, selectedIndex: (field.selectedIndex + 1) % len}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return field
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Handle a keypress on a focused MultiSelectField.
|
|
441
|
+
* Returns updated field or { advanceField: true } signal object.
|
|
442
|
+
* @param {MultiSelectField} field
|
|
443
|
+
* @param {{ name: string }} key
|
|
444
|
+
* @returns {MultiSelectField | { advanceField: true }}
|
|
445
|
+
*/
|
|
446
|
+
function handleMultiSelectFieldKey(field, key) {
|
|
447
|
+
const len = field.options.length
|
|
448
|
+
|
|
449
|
+
if (key.name === 'up') {
|
|
450
|
+
return {...field, focusedOptionIndex: Math.max(0, field.focusedOptionIndex - 1)}
|
|
451
|
+
}
|
|
452
|
+
if (key.name === 'down') {
|
|
453
|
+
return {...field, focusedOptionIndex: Math.min(len - 1, field.focusedOptionIndex + 1)}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (key.name === 'space') {
|
|
457
|
+
const opt = field.options[field.focusedOptionIndex]
|
|
458
|
+
if (!opt) return field
|
|
459
|
+
const newSelected = new Set(field.selected)
|
|
460
|
+
if (newSelected.has(opt.id)) {
|
|
461
|
+
newSelected.delete(opt.id)
|
|
462
|
+
} else {
|
|
463
|
+
newSelected.add(opt.id)
|
|
464
|
+
}
|
|
465
|
+
return {...field, selected: newSelected}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (key.name === 'return') {
|
|
469
|
+
return {advanceField: /** @type {true} */ (true)}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return field
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Handle a keypress on a focused MiniEditorField.
|
|
477
|
+
* Returns updated field or { advanceField: true } signal object.
|
|
478
|
+
* @param {MiniEditorField} field
|
|
479
|
+
* @param {{ name: string, sequence?: string, ctrl?: boolean }} key
|
|
480
|
+
* @returns {MiniEditorField | { advanceField: true }}
|
|
481
|
+
*/
|
|
482
|
+
function handleEditorFieldKey(field, key) {
|
|
483
|
+
const {lines, cursorLine, cursorCol} = field
|
|
484
|
+
|
|
485
|
+
// Esc exits the editor — move to next field
|
|
486
|
+
if (key.name === 'escape') {
|
|
487
|
+
return {advanceField: /** @type {true} */ (true)}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (key.name === 'left') {
|
|
491
|
+
if (cursorCol > 0) {
|
|
492
|
+
return {...field, cursorCol: cursorCol - 1}
|
|
493
|
+
}
|
|
494
|
+
if (cursorLine > 0) {
|
|
495
|
+
const prevLine = lines[cursorLine - 1]
|
|
496
|
+
return {...field, cursorLine: cursorLine - 1, cursorCol: prevLine.length}
|
|
497
|
+
}
|
|
498
|
+
return field
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (key.name === 'right') {
|
|
502
|
+
const line = lines[cursorLine]
|
|
503
|
+
if (cursorCol < line.length) {
|
|
504
|
+
return {...field, cursorCol: cursorCol + 1}
|
|
505
|
+
}
|
|
506
|
+
if (cursorLine < lines.length - 1) {
|
|
507
|
+
return {...field, cursorLine: cursorLine + 1, cursorCol: 0}
|
|
508
|
+
}
|
|
509
|
+
return field
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (key.name === 'up') {
|
|
513
|
+
if (cursorLine === 0) return field
|
|
514
|
+
const newLine = cursorLine - 1
|
|
515
|
+
const newCol = Math.min(cursorCol, lines[newLine].length)
|
|
516
|
+
return {...field, cursorLine: newLine, cursorCol: newCol}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (key.name === 'down') {
|
|
520
|
+
if (cursorLine >= lines.length - 1) return field
|
|
521
|
+
const newLine = cursorLine + 1
|
|
522
|
+
const newCol = Math.min(cursorCol, lines[newLine].length)
|
|
523
|
+
return {...field, cursorLine: newLine, cursorCol: newCol}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (key.name === 'home') {
|
|
527
|
+
return {...field, cursorCol: 0}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (key.name === 'end') {
|
|
531
|
+
return {...field, cursorCol: lines[cursorLine].length}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (key.name === 'backspace') {
|
|
535
|
+
if (cursorCol > 0) {
|
|
536
|
+
const newLines = [...lines]
|
|
537
|
+
const ln = newLines[cursorLine]
|
|
538
|
+
newLines[cursorLine] = ln.slice(0, cursorCol - 1) + ln.slice(cursorCol)
|
|
539
|
+
return {...field, lines: newLines, cursorCol: cursorCol - 1}
|
|
540
|
+
}
|
|
541
|
+
if (cursorLine > 0) {
|
|
542
|
+
// Merge current line into previous
|
|
543
|
+
const newLines = [...lines]
|
|
544
|
+
const prevLine = newLines[cursorLine - 1]
|
|
545
|
+
const currLine = newLines[cursorLine]
|
|
546
|
+
const mergedCol = prevLine.length
|
|
547
|
+
newLines.splice(cursorLine, 1)
|
|
548
|
+
newLines[cursorLine - 1] = prevLine + currLine
|
|
549
|
+
return {...field, lines: newLines, cursorLine: cursorLine - 1, cursorCol: mergedCol}
|
|
550
|
+
}
|
|
551
|
+
return field
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (key.name === 'delete') {
|
|
555
|
+
const line = lines[cursorLine]
|
|
556
|
+
if (cursorCol < line.length) {
|
|
557
|
+
const newLines = [...lines]
|
|
558
|
+
newLines[cursorLine] = line.slice(0, cursorCol) + line.slice(cursorCol + 1)
|
|
559
|
+
return {...field, lines: newLines}
|
|
560
|
+
}
|
|
561
|
+
if (cursorLine < lines.length - 1) {
|
|
562
|
+
// Merge next line
|
|
563
|
+
const newLines = [...lines]
|
|
564
|
+
newLines[cursorLine] = newLines[cursorLine] + newLines[cursorLine + 1]
|
|
565
|
+
newLines.splice(cursorLine + 1, 1)
|
|
566
|
+
return {...field, lines: newLines}
|
|
567
|
+
}
|
|
568
|
+
return field
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Enter inserts a new line after the cursor position
|
|
572
|
+
if (key.name === 'return') {
|
|
573
|
+
const line = lines[cursorLine]
|
|
574
|
+
const before = line.slice(0, cursorCol)
|
|
575
|
+
const after = line.slice(cursorCol)
|
|
576
|
+
const newLines = [...lines]
|
|
577
|
+
newLines.splice(cursorLine, 1, before, after)
|
|
578
|
+
return {...field, lines: newLines, cursorLine: cursorLine + 1, cursorCol: 0}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Printable character
|
|
582
|
+
if (key.sequence && key.sequence.length === 1 && !key.ctrl) {
|
|
583
|
+
const ch = key.sequence
|
|
584
|
+
if (ch >= ' ') {
|
|
585
|
+
const newLines = [...lines]
|
|
586
|
+
const ln = newLines[cursorLine]
|
|
587
|
+
newLines[cursorLine] = ln.slice(0, cursorCol) + ch + ln.slice(cursorCol)
|
|
588
|
+
return {...field, lines: newLines, cursorCol: cursorCol + 1}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return field
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
596
|
+
// handleFormKeypress
|
|
597
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Pure reducer for form keypresses.
|
|
601
|
+
*
|
|
602
|
+
* Global keys handled regardless of focused field:
|
|
603
|
+
* - Tab: move to next field
|
|
604
|
+
* - Shift+Tab: move to previous field (wraps)
|
|
605
|
+
* - Ctrl+S: validate and submit
|
|
606
|
+
* - Esc: cancel → return `{ cancelled: true }`
|
|
607
|
+
* - Enter on last field: validate and submit
|
|
608
|
+
*
|
|
609
|
+
* Field-specific handling when field is focused:
|
|
610
|
+
* - TextField: printable chars append to value, Backspace deletes, ← → move cursor, Home/End jump
|
|
611
|
+
* - SelectorField: ← → cycle options
|
|
612
|
+
* - MultiSelectField: ↑ ↓ navigate, Space toggle, Enter advances to next field
|
|
613
|
+
* - MiniEditorField: printable chars insert, Enter inserts new line, Esc exits to next field
|
|
614
|
+
*
|
|
615
|
+
* On submit: validates required fields. If invalid, sets `errorMessage` and returns
|
|
616
|
+
* the state. If valid, returns `{ submitted: true, values: extractValues(formState) }`.
|
|
617
|
+
*
|
|
618
|
+
* @param {FormState} formState
|
|
619
|
+
* @param {{ name: string, sequence?: string, ctrl?: boolean, shift?: boolean }} key
|
|
620
|
+
* @returns {FormState | SubmitResult | CancelResult}
|
|
621
|
+
*/
|
|
622
|
+
export function handleFormKeypress(formState, key) {
|
|
623
|
+
const {fields, focusedFieldIndex} = formState
|
|
624
|
+
const lastFieldIndex = fields.length - 1
|
|
625
|
+
|
|
626
|
+
// ── Esc: cancel (unless inside a MiniEditorField) ─────────────────────────
|
|
627
|
+
// For editor fields, Esc is handled inside the field handler to advance focus,
|
|
628
|
+
// not cancel the form. Only cancel when a non-editor field is focused.
|
|
629
|
+
const focusedField = fields[focusedFieldIndex]
|
|
630
|
+
if (key.name === 'escape' && focusedField?.type !== 'editor') {
|
|
631
|
+
return {cancelled: /** @type {true} */ (true)}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ── Ctrl+S: submit ────────────────────────────────────────────────────────
|
|
635
|
+
if (key.ctrl && key.name === 's') {
|
|
636
|
+
return attemptSubmit(formState)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ── Tab: next field ───────────────────────────────────────────────────────
|
|
640
|
+
if (key.name === 'tab' && !key.shift) {
|
|
641
|
+
return {
|
|
642
|
+
...formState,
|
|
643
|
+
focusedFieldIndex: (focusedFieldIndex + 1) % fields.length,
|
|
644
|
+
errorMessage: null,
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ── Shift+Tab: previous field ─────────────────────────────────────────────
|
|
649
|
+
if (key.name === 'tab' && key.shift) {
|
|
650
|
+
return {
|
|
651
|
+
...formState,
|
|
652
|
+
focusedFieldIndex: (focusedFieldIndex - 1 + fields.length) % fields.length,
|
|
653
|
+
errorMessage: null,
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ── Enter on last non-editor field: submit ─────────────────────────────────
|
|
658
|
+
if (
|
|
659
|
+
key.name === 'return' &&
|
|
660
|
+
focusedFieldIndex === lastFieldIndex &&
|
|
661
|
+
focusedField?.type !== 'editor' &&
|
|
662
|
+
focusedField?.type !== 'multiselect'
|
|
663
|
+
) {
|
|
664
|
+
return attemptSubmit(formState)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── Delegate to focused field ─────────────────────────────────────────────
|
|
668
|
+
if (!focusedField) return formState
|
|
669
|
+
|
|
670
|
+
if (focusedField.type === 'text') {
|
|
671
|
+
const updated = handleTextFieldKey(focusedField, key)
|
|
672
|
+
if (updated === focusedField) return formState
|
|
673
|
+
return {
|
|
674
|
+
...formState,
|
|
675
|
+
errorMessage: null,
|
|
676
|
+
fields: replaceAt(fields, focusedFieldIndex, updated),
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (focusedField.type === 'selector') {
|
|
681
|
+
const updated = handleSelectorFieldKey(focusedField, key)
|
|
682
|
+
if (updated === focusedField) return formState
|
|
683
|
+
return {
|
|
684
|
+
...formState,
|
|
685
|
+
fields: replaceAt(fields, focusedFieldIndex, updated),
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (focusedField.type === 'multiselect') {
|
|
690
|
+
const result = handleMultiSelectFieldKey(focusedField, key)
|
|
691
|
+
if ('advanceField' in result) {
|
|
692
|
+
return {
|
|
693
|
+
...formState,
|
|
694
|
+
focusedFieldIndex: Math.min(focusedFieldIndex + 1, lastFieldIndex),
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (result === focusedField) return formState
|
|
698
|
+
return {
|
|
699
|
+
...formState,
|
|
700
|
+
fields: replaceAt(fields, focusedFieldIndex, result),
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (focusedField.type === 'editor') {
|
|
705
|
+
const result = handleEditorFieldKey(focusedField, key)
|
|
706
|
+
if ('advanceField' in result) {
|
|
707
|
+
// Esc in editor cancels the form only if we treat it as a field-level escape.
|
|
708
|
+
// Per spec, Esc in editor moves to next field.
|
|
709
|
+
return {
|
|
710
|
+
...formState,
|
|
711
|
+
focusedFieldIndex: Math.min(focusedFieldIndex + 1, lastFieldIndex),
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (result === focusedField) return formState
|
|
715
|
+
return {
|
|
716
|
+
...formState,
|
|
717
|
+
errorMessage: null,
|
|
718
|
+
fields: replaceAt(fields, focusedFieldIndex, result),
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return formState
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Attempt to submit the form: validate, then return SubmitResult or FormState with error.
|
|
727
|
+
* @param {FormState} formState
|
|
728
|
+
* @returns {FormState | SubmitResult}
|
|
729
|
+
*/
|
|
730
|
+
function attemptSubmit(formState) {
|
|
731
|
+
const invalidLabel = validateForm(formState)
|
|
732
|
+
if (invalidLabel !== null) {
|
|
733
|
+
return {
|
|
734
|
+
...formState,
|
|
735
|
+
errorMessage: `"${invalidLabel}" is required.`,
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return {
|
|
739
|
+
submitted: /** @type {true} */ (true),
|
|
740
|
+
values: extractValues(formState),
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Return a new array with element at `index` replaced by `value`.
|
|
746
|
+
* @template T
|
|
747
|
+
* @param {T[]} arr
|
|
748
|
+
* @param {number} index
|
|
749
|
+
* @param {T} value
|
|
750
|
+
* @returns {T[]}
|
|
751
|
+
*/
|
|
752
|
+
function replaceAt(arr, index, value) {
|
|
753
|
+
return arr.map((item, i) => (i === index ? value : item))
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
757
|
+
// Form field definitions
|
|
758
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Return form fields for creating or editing an MCP entry.
|
|
762
|
+
*
|
|
763
|
+
* Fields: name (text), environments (multiselect), transport (selector), command (text),
|
|
764
|
+
* args (text), url (text), description (text, optional).
|
|
765
|
+
*
|
|
766
|
+
* @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
|
|
767
|
+
* @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
|
|
768
|
+
* @returns {Field[]}
|
|
769
|
+
*/
|
|
770
|
+
export function getMCPFormFields(entry = null, compatibleEnvs = []) {
|
|
771
|
+
/** @type {import('../../types.js').MCPParams|null} */
|
|
772
|
+
const p = entry ? /** @type {import('../../types.js').MCPParams} */ (entry.params) : null
|
|
773
|
+
|
|
774
|
+
const transportOptions = ['stdio', 'sse', 'streamable-http']
|
|
775
|
+
const transportIndex = p ? Math.max(0, transportOptions.indexOf(p.transport)) : 0
|
|
776
|
+
|
|
777
|
+
return [
|
|
778
|
+
/** @type {TextField} */ ({
|
|
779
|
+
type: 'text',
|
|
780
|
+
label: 'Name',
|
|
781
|
+
key: 'name',
|
|
782
|
+
value: entry ? entry.name : '',
|
|
783
|
+
cursor: entry ? entry.name.length : 0,
|
|
784
|
+
required: true,
|
|
785
|
+
placeholder: 'my-mcp-server',
|
|
786
|
+
}),
|
|
787
|
+
/** @type {MultiSelectField} */ ({
|
|
788
|
+
type: 'multiselect',
|
|
789
|
+
label: 'Environments',
|
|
790
|
+
key: 'environments',
|
|
791
|
+
options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})),
|
|
792
|
+
selected: new Set(entry ? entry.environments : []),
|
|
793
|
+
focusedOptionIndex: 0,
|
|
794
|
+
required: true,
|
|
795
|
+
}),
|
|
796
|
+
/** @type {SelectorField} */ ({
|
|
797
|
+
type: 'selector',
|
|
798
|
+
label: 'Transport',
|
|
799
|
+
key: 'transport',
|
|
800
|
+
options: transportOptions,
|
|
801
|
+
selectedIndex: transportIndex,
|
|
802
|
+
required: true,
|
|
803
|
+
}),
|
|
804
|
+
/** @type {TextField} */ ({
|
|
805
|
+
type: 'text',
|
|
806
|
+
label: 'Command',
|
|
807
|
+
key: 'command',
|
|
808
|
+
value: p?.command ?? '',
|
|
809
|
+
cursor: (p?.command ?? '').length,
|
|
810
|
+
required: false,
|
|
811
|
+
placeholder: 'npx my-mcp-server',
|
|
812
|
+
}),
|
|
813
|
+
/** @type {TextField} */ ({
|
|
814
|
+
type: 'text',
|
|
815
|
+
label: 'Args',
|
|
816
|
+
key: 'args',
|
|
817
|
+
value: p?.args ? p.args.join(' ') : '',
|
|
818
|
+
cursor: p?.args ? p.args.join(' ').length : 0,
|
|
819
|
+
required: false,
|
|
820
|
+
placeholder: '--port 3000 --verbose',
|
|
821
|
+
}),
|
|
822
|
+
/** @type {TextField} */ ({
|
|
823
|
+
type: 'text',
|
|
824
|
+
label: 'URL',
|
|
825
|
+
key: 'url',
|
|
826
|
+
value: p?.url ?? '',
|
|
827
|
+
cursor: (p?.url ?? '').length,
|
|
828
|
+
required: false,
|
|
829
|
+
placeholder: 'https://mcp.example.com',
|
|
830
|
+
}),
|
|
831
|
+
/** @type {TextField} */ ({
|
|
832
|
+
type: 'text',
|
|
833
|
+
label: 'Description',
|
|
834
|
+
key: 'description',
|
|
835
|
+
value: p?.description ?? (entry?.params ? /** @type {any} */ ((entry.params).description ?? '') : ''),
|
|
836
|
+
cursor: 0,
|
|
837
|
+
required: false,
|
|
838
|
+
placeholder: 'Optional description',
|
|
839
|
+
}),
|
|
840
|
+
]
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Return form fields for creating or editing a Command entry.
|
|
845
|
+
*
|
|
846
|
+
* Fields: name (text), environments (multiselect), description (text, optional), content (editor).
|
|
847
|
+
*
|
|
848
|
+
* @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
|
|
849
|
+
* @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
|
|
850
|
+
* @returns {Field[]}
|
|
851
|
+
*/
|
|
852
|
+
export function getCommandFormFields(entry = null, compatibleEnvs = []) {
|
|
853
|
+
/** @type {import('../../types.js').CommandParams|null} */
|
|
854
|
+
const p = entry ? /** @type {import('../../types.js').CommandParams} */ (entry.params) : null
|
|
855
|
+
const contentStr = p?.content ?? ''
|
|
856
|
+
const contentLines = contentStr.length > 0 ? contentStr.split('\n') : ['']
|
|
857
|
+
|
|
858
|
+
return [
|
|
859
|
+
/** @type {TextField} */ ({
|
|
860
|
+
type: 'text',
|
|
861
|
+
label: 'Name',
|
|
862
|
+
key: 'name',
|
|
863
|
+
value: entry ? entry.name : '',
|
|
864
|
+
cursor: entry ? entry.name.length : 0,
|
|
865
|
+
required: true,
|
|
866
|
+
placeholder: 'my-command',
|
|
867
|
+
}),
|
|
868
|
+
/** @type {MultiSelectField} */ ({
|
|
869
|
+
type: 'multiselect',
|
|
870
|
+
label: 'Environments',
|
|
871
|
+
key: 'environments',
|
|
872
|
+
options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})),
|
|
873
|
+
selected: new Set(entry ? entry.environments : []),
|
|
874
|
+
focusedOptionIndex: 0,
|
|
875
|
+
required: true,
|
|
876
|
+
}),
|
|
877
|
+
/** @type {TextField} */ ({
|
|
878
|
+
type: 'text',
|
|
879
|
+
label: 'Description',
|
|
880
|
+
key: 'description',
|
|
881
|
+
value: p?.description ?? '',
|
|
882
|
+
cursor: (p?.description ?? '').length,
|
|
883
|
+
required: false,
|
|
884
|
+
placeholder: 'Optional description',
|
|
885
|
+
}),
|
|
886
|
+
/** @type {MiniEditorField} */ ({
|
|
887
|
+
type: 'editor',
|
|
888
|
+
label: 'Content',
|
|
889
|
+
key: 'content',
|
|
890
|
+
lines: contentLines,
|
|
891
|
+
cursorLine: 0,
|
|
892
|
+
cursorCol: 0,
|
|
893
|
+
required: true,
|
|
894
|
+
}),
|
|
895
|
+
]
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Return form fields for creating or editing a Skill entry.
|
|
900
|
+
*
|
|
901
|
+
* Fields: name (text), environments (multiselect), description (text, optional), content (editor).
|
|
902
|
+
*
|
|
903
|
+
* @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
|
|
904
|
+
* @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
|
|
905
|
+
* @returns {Field[]}
|
|
906
|
+
*/
|
|
907
|
+
export function getSkillFormFields(entry = null, compatibleEnvs = []) {
|
|
908
|
+
/** @type {import('../../types.js').SkillParams|null} */
|
|
909
|
+
const p = entry ? /** @type {import('../../types.js').SkillParams} */ (entry.params) : null
|
|
910
|
+
const contentStr = p?.content ?? ''
|
|
911
|
+
const contentLines = contentStr.length > 0 ? contentStr.split('\n') : ['']
|
|
912
|
+
|
|
913
|
+
return [
|
|
914
|
+
/** @type {TextField} */ ({
|
|
915
|
+
type: 'text',
|
|
916
|
+
label: 'Name',
|
|
917
|
+
key: 'name',
|
|
918
|
+
value: entry ? entry.name : '',
|
|
919
|
+
cursor: entry ? entry.name.length : 0,
|
|
920
|
+
required: true,
|
|
921
|
+
placeholder: 'my-skill',
|
|
922
|
+
}),
|
|
923
|
+
/** @type {MultiSelectField} */ ({
|
|
924
|
+
type: 'multiselect',
|
|
925
|
+
label: 'Environments',
|
|
926
|
+
key: 'environments',
|
|
927
|
+
options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})),
|
|
928
|
+
selected: new Set(entry ? entry.environments : []),
|
|
929
|
+
focusedOptionIndex: 0,
|
|
930
|
+
required: true,
|
|
931
|
+
}),
|
|
932
|
+
/** @type {TextField} */ ({
|
|
933
|
+
type: 'text',
|
|
934
|
+
label: 'Description',
|
|
935
|
+
key: 'description',
|
|
936
|
+
value: p?.description ?? '',
|
|
937
|
+
cursor: (p?.description ?? '').length,
|
|
938
|
+
required: false,
|
|
939
|
+
placeholder: 'Optional description',
|
|
940
|
+
}),
|
|
941
|
+
/** @type {MiniEditorField} */ ({
|
|
942
|
+
type: 'editor',
|
|
943
|
+
label: 'Content',
|
|
944
|
+
key: 'content',
|
|
945
|
+
lines: contentLines,
|
|
946
|
+
cursorLine: 0,
|
|
947
|
+
cursorCol: 0,
|
|
948
|
+
required: true,
|
|
949
|
+
}),
|
|
950
|
+
]
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Return form fields for creating or editing an Agent entry.
|
|
955
|
+
*
|
|
956
|
+
* Fields: name (text), environments (multiselect), description (text, optional), instructions (editor).
|
|
957
|
+
*
|
|
958
|
+
* @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
|
|
959
|
+
* @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
|
|
960
|
+
* @returns {Field[]}
|
|
961
|
+
*/
|
|
962
|
+
export function getAgentFormFields(entry = null, compatibleEnvs = []) {
|
|
963
|
+
/** @type {import('../../types.js').AgentParams|null} */
|
|
964
|
+
const p = entry ? /** @type {import('../../types.js').AgentParams} */ (entry.params) : null
|
|
965
|
+
const instructionsStr = p?.instructions ?? ''
|
|
966
|
+
const instructionLines = instructionsStr.length > 0 ? instructionsStr.split('\n') : ['']
|
|
967
|
+
|
|
968
|
+
return [
|
|
969
|
+
/** @type {TextField} */ ({
|
|
970
|
+
type: 'text',
|
|
971
|
+
label: 'Name',
|
|
972
|
+
key: 'name',
|
|
973
|
+
value: entry ? entry.name : '',
|
|
974
|
+
cursor: entry ? entry.name.length : 0,
|
|
975
|
+
required: true,
|
|
976
|
+
placeholder: 'my-agent',
|
|
977
|
+
}),
|
|
978
|
+
/** @type {MultiSelectField} */ ({
|
|
979
|
+
type: 'multiselect',
|
|
980
|
+
label: 'Environments',
|
|
981
|
+
key: 'environments',
|
|
982
|
+
options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})),
|
|
983
|
+
selected: new Set(entry ? entry.environments : []),
|
|
984
|
+
focusedOptionIndex: 0,
|
|
985
|
+
required: true,
|
|
986
|
+
}),
|
|
987
|
+
/** @type {TextField} */ ({
|
|
988
|
+
type: 'text',
|
|
989
|
+
label: 'Description',
|
|
990
|
+
key: 'description',
|
|
991
|
+
value: p?.description ?? '',
|
|
992
|
+
cursor: (p?.description ?? '').length,
|
|
993
|
+
required: false,
|
|
994
|
+
placeholder: 'Optional description',
|
|
995
|
+
}),
|
|
996
|
+
/** @type {MiniEditorField} */ ({
|
|
997
|
+
type: 'editor',
|
|
998
|
+
label: 'Instructions',
|
|
999
|
+
key: 'instructions',
|
|
1000
|
+
lines: instructionLines,
|
|
1001
|
+
cursorLine: 0,
|
|
1002
|
+
cursorCol: 0,
|
|
1003
|
+
required: true,
|
|
1004
|
+
}),
|
|
1005
|
+
]
|
|
1006
|
+
}
|