devvami 1.4.2 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +72 -0
  2. package/oclif.manifest.json +275 -235
  3. package/package.json +2 -1
  4. package/src/commands/auth/login.js +20 -16
  5. package/src/commands/changelog.js +12 -12
  6. package/src/commands/costs/get.js +14 -24
  7. package/src/commands/costs/trend.js +13 -24
  8. package/src/commands/create/repo.js +72 -54
  9. package/src/commands/docs/list.js +29 -25
  10. package/src/commands/docs/projects.js +58 -24
  11. package/src/commands/docs/read.js +56 -39
  12. package/src/commands/docs/search.js +37 -25
  13. package/src/commands/doctor.js +37 -35
  14. package/src/commands/dotfiles/add.js +51 -39
  15. package/src/commands/dotfiles/setup.js +62 -33
  16. package/src/commands/dotfiles/status.js +18 -18
  17. package/src/commands/dotfiles/sync.js +62 -46
  18. package/src/commands/init.js +143 -132
  19. package/src/commands/logs/index.js +10 -16
  20. package/src/commands/open.js +12 -12
  21. package/src/commands/pipeline/logs.js +8 -11
  22. package/src/commands/pipeline/rerun.js +21 -16
  23. package/src/commands/pipeline/status.js +28 -24
  24. package/src/commands/pr/create.js +40 -27
  25. package/src/commands/pr/detail.js +9 -7
  26. package/src/commands/pr/review.js +18 -19
  27. package/src/commands/pr/status.js +27 -21
  28. package/src/commands/prompts/browse.js +15 -15
  29. package/src/commands/prompts/download.js +15 -16
  30. package/src/commands/prompts/install-speckit.js +11 -12
  31. package/src/commands/prompts/list.js +12 -12
  32. package/src/commands/prompts/run.js +16 -19
  33. package/src/commands/repo/list.js +57 -41
  34. package/src/commands/search.js +20 -18
  35. package/src/commands/security/setup.js +38 -34
  36. package/src/commands/sync-config-ai/index.js +257 -0
  37. package/src/commands/tasks/assigned.js +43 -33
  38. package/src/commands/tasks/list.js +43 -33
  39. package/src/commands/tasks/today.js +32 -30
  40. package/src/commands/upgrade.js +18 -17
  41. package/src/commands/vuln/detail.js +8 -8
  42. package/src/commands/vuln/scan.js +39 -20
  43. package/src/commands/vuln/search.js +23 -18
  44. package/src/commands/welcome.js +2 -2
  45. package/src/commands/whoami.js +19 -23
  46. package/src/formatters/ai-config.js +215 -0
  47. package/src/formatters/charts.js +6 -23
  48. package/src/formatters/cost.js +1 -7
  49. package/src/formatters/dotfiles.js +48 -19
  50. package/src/formatters/markdown.js +11 -6
  51. package/src/formatters/openapi.js +7 -9
  52. package/src/formatters/prompts.js +69 -78
  53. package/src/formatters/security.js +2 -2
  54. package/src/formatters/status.js +1 -1
  55. package/src/formatters/table.js +1 -3
  56. package/src/formatters/vuln.js +33 -20
  57. package/src/help.js +162 -164
  58. package/src/hooks/init.js +1 -3
  59. package/src/hooks/postrun.js +5 -7
  60. package/src/index.js +1 -1
  61. package/src/services/ai-config-store.js +349 -0
  62. package/src/services/ai-env-deployer.js +650 -0
  63. package/src/services/ai-env-scanner.js +983 -0
  64. package/src/services/audit-detector.js +2 -2
  65. package/src/services/audit-runner.js +40 -31
  66. package/src/services/auth.js +9 -9
  67. package/src/services/awesome-copilot.js +7 -4
  68. package/src/services/aws-costs.js +22 -22
  69. package/src/services/clickup.js +26 -26
  70. package/src/services/cloudwatch-logs.js +5 -9
  71. package/src/services/config.js +13 -13
  72. package/src/services/docs.js +19 -20
  73. package/src/services/dotfiles.js +149 -51
  74. package/src/services/github.js +22 -24
  75. package/src/services/nvd.js +21 -31
  76. package/src/services/platform.js +2 -2
  77. package/src/services/prompts.js +23 -35
  78. package/src/services/security.js +135 -61
  79. package/src/services/shell.js +4 -4
  80. package/src/services/skills-sh.js +3 -9
  81. package/src/services/speckit.js +4 -7
  82. package/src/services/version-check.js +10 -10
  83. package/src/types.js +117 -0
  84. package/src/utils/aws-vault.js +18 -41
  85. package/src/utils/banner.js +5 -7
  86. package/src/utils/errors.js +42 -46
  87. package/src/utils/frontmatter.js +4 -4
  88. package/src/utils/gradient.js +18 -16
  89. package/src/utils/open-browser.js +3 -3
  90. package/src/utils/tui/form.js +1184 -0
  91. package/src/utils/tui/modal.js +15 -14
  92. package/src/utils/tui/navigable-table.js +16 -16
  93. package/src/utils/tui/tab-tui.js +1089 -0
  94. package/src/utils/typewriter.js +3 -3
  95. package/src/utils/welcome.js +18 -21
  96. package/src/validators/repo-name.js +2 -2
@@ -0,0 +1,1089 @@
1
+ /**
2
+ * @module tab-tui
3
+ * Tab-based full-screen TUI framework for dvmi sync-config-ai.
4
+ * Follows the same ANSI + readline + chalk pattern as navigable-table.js.
5
+ * Zero new dependencies — uses only Node.js built-ins + chalk.
6
+ */
7
+
8
+ import readline from 'node:readline'
9
+ import chalk from 'chalk'
10
+ import {
11
+ buildFormScreen,
12
+ handleFormKeypress,
13
+ getMCPFormFields,
14
+ getCommandFormFields,
15
+ getRuleFormFields,
16
+ getSkillFormFields,
17
+ getAgentFormFields,
18
+ validateMCPForm,
19
+ } from './form.js'
20
+
21
+ // ──────────────────────────────────────────────────────────────────────────────
22
+ // ANSI escape sequences
23
+ // ──────────────────────────────────────────────────────────────────────────────
24
+
25
+ const ANSI_CLEAR = '\x1b[2J'
26
+ const ANSI_HOME = '\x1b[H'
27
+ const ANSI_ALT_SCREEN_ON = '\x1b[?1049h'
28
+ const ANSI_ALT_SCREEN_OFF = '\x1b[?1049l'
29
+ const ANSI_CURSOR_HIDE = '\x1b[?25l'
30
+ const ANSI_CURSOR_SHOW = '\x1b[?25h'
31
+ const ANSI_INVERSE_ON = '\x1b[7m'
32
+ const ANSI_INVERSE_OFF = '\x1b[27m'
33
+
34
+ // ──────────────────────────────────────────────────────────────────────────────
35
+ // Layout constants
36
+ // ──────────────────────────────────────────────────────────────────────────────
37
+
38
+ const MIN_COLS = 80
39
+ const MIN_ROWS = 24
40
+ const TAB_BAR_LINES = 2 // tab bar line + divider
41
+ const FOOTER_LINES = 2 // empty line + keyboard hints
42
+
43
+ // ──────────────────────────────────────────────────────────────────────────────
44
+ // Module-level terminal session state
45
+ // ──────────────────────────────────────────────────────────────────────────────
46
+
47
+ let _cleanupCalled = false
48
+ let _altScreenActive = false
49
+ let _rawModeActive = false
50
+ /** @type {((...args: unknown[]) => void) | null} */
51
+ let _keypressListener = null
52
+
53
+ // ──────────────────────────────────────────────────────────────────────────────
54
+ // Typedefs
55
+ // ──────────────────────────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * @typedef {Object} TabDef
59
+ * @property {string} label - Display label shown in the tab bar
60
+ * @property {string} key - Unique identifier for this tab
61
+ */
62
+
63
+ /**
64
+ * @typedef {Object} TabTUIState
65
+ * @property {TabDef[]} tabs - All tabs
66
+ * @property {number} activeTabIndex - Index of the currently active tab
67
+ * @property {number} termRows - Current terminal height
68
+ * @property {number} termCols - Current terminal width
69
+ * @property {number} contentViewportHeight - Usable content lines (termRows - TAB_BAR_LINES - FOOTER_LINES)
70
+ * @property {boolean} tooSmall - Whether the terminal is below minimum size
71
+ */
72
+
73
+ /**
74
+ * @typedef {Object} EnvTabState
75
+ * @property {import('../../types.js').DetectedEnvironment[]} envs - Detected environments
76
+ * @property {number} selectedIndex - Highlighted row
77
+ */
78
+
79
+ /**
80
+ * @typedef {Object} CatTabState
81
+ * @property {import('../../types.js').CategoryEntry[]} entries - Managed entries for this category type
82
+ * @property {import('../../types.js').NativeEntry[]} nativeEntries - Native (unmanaged) entries for this category type
83
+ * @property {number} selectedIndex - Highlighted row in the active section
84
+ * @property {'native'|'managed'} section - Which section is focused
85
+ * @property {'list'|'form'|'confirm-delete'|'drift'} mode - Current sub-mode
86
+ * @property {import('./form.js').FormState|null} formState - Active form state (null when mode is 'list')
87
+ * @property {string|null} confirmDeleteId - Entry id pending deletion confirmation
88
+ * @property {string} chezmoidTip - Footer tip (empty if chezmoi configured)
89
+ * @property {string|null} revealedEntryId - Entry id whose env vars are currently revealed
90
+ * @property {import('../../types.js').DriftInfo[]} driftInfos - All drift infos for this category
91
+ */
92
+
93
+ // ──────────────────────────────────────────────────────────────────────────────
94
+ // Internal helpers
95
+ // ──────────────────────────────────────────────────────────────────────────────
96
+
97
+ // ──────────────────────────────────────────────────────────────────────────────
98
+ // T017: buildTabBar — renders horizontal tab bar
99
+ // ──────────────────────────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Build the tab bar string (one line of tab labels + a divider line).
103
+ * Active tab is highlighted with inverse video.
104
+ * @param {TabDef[]} tabs
105
+ * @param {number} activeIndex
106
+ * @returns {string[]} Two lines: [tabBarLine, divider]
107
+ */
108
+ export function buildTabBar(tabs, activeIndex) {
109
+ const parts = tabs.map((tab, i) => {
110
+ const label = ` ${tab.label} `
111
+ if (i === activeIndex) {
112
+ return `${ANSI_INVERSE_ON}${label}${ANSI_INVERSE_OFF}`
113
+ }
114
+ return chalk.dim(label)
115
+ })
116
+ const tabBarLine = parts.join(chalk.dim('│'))
117
+ const divider = chalk.dim('─'.repeat(60))
118
+ return [tabBarLine, divider]
119
+ }
120
+
121
+ // ──────────────────────────────────────────────────────────────────────────────
122
+ // T017: buildTabScreen — full screen composition
123
+ // ──────────────────────────────────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Compose the full terminal screen from tab bar, content lines, and footer.
127
+ * Prepends ANSI clear + home to replace the previous frame.
128
+ * @param {string[]} tabBarLines - Output of buildTabBar
129
+ * @param {string[]} contentLines - Tab-specific content lines
130
+ * @param {string[]} footerLines - Footer hint lines
131
+ * @param {number} termRows - Terminal height
132
+ * @returns {string}
133
+ */
134
+ export function buildTabScreen(tabBarLines, contentLines, footerLines, termRows) {
135
+ const lines = [...tabBarLines, ...contentLines]
136
+
137
+ // Pad to fill terminal height minus footer
138
+ const targetContentLines = termRows - tabBarLines.length - footerLines.length
139
+ while (lines.length < targetContentLines) {
140
+ lines.push('')
141
+ }
142
+
143
+ lines.push(...footerLines)
144
+ return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
145
+ }
146
+
147
+ // ──────────────────────────────────────────────────────────────────────────────
148
+ // T018: terminal size check
149
+ // ──────────────────────────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * Build a "terminal too small" warning screen.
153
+ * @param {number} termRows
154
+ * @param {number} termCols
155
+ * @returns {string}
156
+ */
157
+ export function buildTooSmallScreen(termRows, termCols) {
158
+ const lines = []
159
+ const midRow = Math.floor(termRows / 2)
160
+
161
+ for (let i = 0; i < midRow - 1; i++) lines.push('')
162
+
163
+ lines.push(chalk.red.bold(` Terminal too small (${termCols}×${termRows}, minimum: ${MIN_COLS}×${MIN_ROWS})`))
164
+ lines.push(chalk.dim(' Resize your terminal window and try again.'))
165
+
166
+ return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
167
+ }
168
+
169
+ // ──────────────────────────────────────────────────────────────────────────────
170
+ // T020: buildEnvironmentsTab — content builder
171
+ // ──────────────────────────────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Build the content lines for the Environments tab.
175
+ * @param {import('../../types.js').DetectedEnvironment[]} envs - Detected environments
176
+ * @param {number} selectedIndex - Currently highlighted row
177
+ * @param {number} viewportHeight - Available content lines
178
+ * @param {import('../../formatters/ai-config.js').formatEnvironmentsTable} formatFn - Formatter function
179
+ * @param {number} termCols - Terminal width for formatter
180
+ * @returns {string[]}
181
+ */
182
+ export function buildEnvironmentsTab(envs, selectedIndex, viewportHeight, formatFn, termCols = 120) {
183
+ if (envs.length === 0) {
184
+ return [
185
+ '',
186
+ chalk.dim(' No AI coding environments detected.'),
187
+ chalk.dim(' Ensure at least one AI tool is configured in the current project or globally.'),
188
+ ]
189
+ }
190
+
191
+ const tableLines = formatFn(envs, termCols)
192
+
193
+ // Add row highlighting to data rows (skip header lines — first 2 lines are header + divider)
194
+ const HEADER_LINES = 2
195
+ const resultLines = []
196
+
197
+ for (let i = 0; i < tableLines.length; i++) {
198
+ const line = tableLines[i]
199
+ const dataIndex = i - HEADER_LINES
200
+ if (dataIndex >= 0 && dataIndex === selectedIndex) {
201
+ resultLines.push(`${ANSI_INVERSE_ON}${line}${ANSI_INVERSE_OFF}`)
202
+ } else {
203
+ resultLines.push(line)
204
+ }
205
+ }
206
+
207
+ // Viewport: only show lines that fit
208
+ return resultLines.slice(0, viewportHeight)
209
+ }
210
+
211
+ // ──────────────────────────────────────────────────────────────────────────────
212
+ // T021: handleEnvironmentsKeypress — pure reducer
213
+ // ──────────────────────────────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * Pure state reducer for keypresses in the Environments tab.
217
+ * @param {EnvTabState} state
218
+ * @param {{ name: string, ctrl?: boolean }} key
219
+ * @returns {EnvTabState | { exit: true } | { switchTab: number }}
220
+ */
221
+ export function handleEnvironmentsKeypress(state, key) {
222
+ const {selectedIndex, envs} = state
223
+ const maxIndex = Math.max(0, envs.length - 1)
224
+
225
+ if (key.name === 'up' || key.name === 'k') {
226
+ return {...state, selectedIndex: Math.max(0, selectedIndex - 1)}
227
+ }
228
+ if (key.name === 'down' || key.name === 'j') {
229
+ return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 1)}
230
+ }
231
+ if (key.name === 'pageup') {
232
+ return {...state, selectedIndex: Math.max(0, selectedIndex - 10)}
233
+ }
234
+ if (key.name === 'pagedown') {
235
+ return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 10)}
236
+ }
237
+
238
+ return state
239
+ }
240
+
241
+ // ──────────────────────────────────────────────────────────────────────────────
242
+ // Categories tab content builder — dual Native/Managed sections
243
+ // ──────────────────────────────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * Build the content lines for a category tab with Native and Managed sections.
247
+ * @param {CatTabState} tabState
248
+ * @param {number} viewportHeight
249
+ * @param {import('../../formatters/ai-config.js').formatCategoriesTable} formatManaged
250
+ * @param {import('../../formatters/ai-config.js').formatNativeEntriesTable} formatNative
251
+ * @param {number} termCols
252
+ * @returns {string[]}
253
+ */
254
+ export function buildCategoriesTab(tabState, viewportHeight, formatManaged, formatNative, termCols = 120) {
255
+ const {entries, nativeEntries = [], selectedIndex, section = 'managed', mode} = tabState
256
+ const confirmDeleteName = tabState._confirmDeleteName ?? null
257
+ const driftedIds = new Set((tabState.driftInfos ?? []).map((d) => d.entryId))
258
+
259
+ const lines = []
260
+
261
+ // ── Native section ──
262
+ if (nativeEntries.length > 0) {
263
+ lines.push(chalk.bold.cyan(' ── Native (read-only) ──────────────────────────────'))
264
+
265
+ const nativeLines = formatNative(nativeEntries, termCols)
266
+ const HEADER = 2
267
+ for (let i = 0; i < nativeLines.length; i++) {
268
+ const dataIndex = i - HEADER
269
+ if (section === 'native' && dataIndex >= 0 && dataIndex === selectedIndex) {
270
+ lines.push(`${ANSI_INVERSE_ON}${nativeLines[i]}${ANSI_INVERSE_OFF}`)
271
+ } else {
272
+ lines.push(chalk.dim(nativeLines[i]))
273
+ }
274
+ }
275
+ lines.push('')
276
+ }
277
+
278
+ // ── Managed section ──
279
+ lines.push(chalk.bold.white(' ── Managed ────────────────────────────────────────'))
280
+
281
+ if (entries.length === 0) {
282
+ lines.push(chalk.dim(' No managed entries yet.'))
283
+ lines.push(chalk.dim(' Press ' + chalk.bold('n') + ' to create your first entry.'))
284
+ } else {
285
+ // Annotate entries with drift flag for formatter
286
+ const annotated = entries.map((e) => ({...e, drifted: driftedIds.has(e.id)}))
287
+ const tableLines = formatManaged(annotated, termCols)
288
+ const HEADER_LINES = 2
289
+ for (let i = 0; i < tableLines.length; i++) {
290
+ const dataIndex = i - HEADER_LINES
291
+ if (section === 'managed' && dataIndex >= 0 && dataIndex === selectedIndex) {
292
+ lines.push(`${ANSI_INVERSE_ON}${tableLines[i]}${ANSI_INVERSE_OFF}`)
293
+ } else {
294
+ lines.push(tableLines[i])
295
+ }
296
+ }
297
+ }
298
+
299
+ // Confirmation prompt
300
+ if (mode === 'confirm-delete' && confirmDeleteName) {
301
+ lines.push('')
302
+ lines.push(chalk.red(` Delete "${confirmDeleteName}"? This cannot be undone. `) + chalk.bold('[y/N]'))
303
+ }
304
+
305
+ return lines.slice(0, viewportHeight)
306
+ }
307
+
308
+ /**
309
+ * Build content lines for the legacy single-section categories tab.
310
+ * Kept for backward compatibility in tests.
311
+ * @param {import('../../types.js').CategoryEntry[]} entries
312
+ * @param {number} selectedIndex
313
+ * @param {number} viewportHeight
314
+ * @param {import('../../formatters/ai-config.js').formatCategoriesTable} formatFn
315
+ * @param {number} termCols
316
+ * @param {string|null} [confirmDeleteName]
317
+ * @returns {string[]}
318
+ */
319
+ export function buildCategoriesTabLegacy(entries, selectedIndex, viewportHeight, formatFn, termCols = 120, confirmDeleteName = null) {
320
+ if (entries.length === 0) {
321
+ const lines = [
322
+ '',
323
+ chalk.dim(' No configuration entries yet.'),
324
+ chalk.dim(' Press ' + chalk.bold('n') + ' to create your first entry.'),
325
+ ]
326
+ if (confirmDeleteName === null) return lines
327
+ }
328
+
329
+ const tableLines = formatFn(entries, termCols)
330
+ const HEADER_LINES = 2
331
+ const resultLines = []
332
+
333
+ for (let i = 0; i < tableLines.length; i++) {
334
+ const line = tableLines[i]
335
+ const dataIndex = i - HEADER_LINES
336
+ if (dataIndex >= 0 && dataIndex === selectedIndex) {
337
+ resultLines.push(`${ANSI_INVERSE_ON}${line}${ANSI_INVERSE_OFF}`)
338
+ } else {
339
+ resultLines.push(line)
340
+ }
341
+ }
342
+
343
+ if (confirmDeleteName !== null) {
344
+ resultLines.push('')
345
+ resultLines.push(chalk.red(` Delete "${confirmDeleteName}"? This cannot be undone. `) + chalk.bold('[y/N]'))
346
+ }
347
+
348
+ return resultLines.slice(0, viewportHeight)
349
+ }
350
+
351
+ // ──────────────────────────────────────────────────────────────────────────────
352
+ // Drift resolution screen
353
+ // ──────────────────────────────────────────────────────────────────────────────
354
+
355
+ /**
356
+ * Build the drift resolution screen for a drifted managed entry.
357
+ * @param {CatTabState} tabState
358
+ * @param {number} viewportHeight
359
+ * @param {number} termCols
360
+ * @returns {string[]}
361
+ */
362
+ export function buildDriftScreen(tabState, viewportHeight, termCols) {
363
+ const entryId = tabState._driftEntryId
364
+ const drift = (tabState.driftInfos ?? []).find((d) => d.entryId === entryId)
365
+ const entry = (tabState.entries ?? []).find((e) => e.id === entryId)
366
+
367
+ if (!drift || !entry) {
368
+ return [chalk.dim(' No drift info available.')]
369
+ }
370
+
371
+ const lines = []
372
+ lines.push(chalk.bold.yellow(` ⚠ Drift detected: ${entry.name}`))
373
+ lines.push(chalk.dim('─'.repeat(Math.min(termCols, 70))))
374
+ lines.push('')
375
+ lines.push(chalk.bold(' Expected (managed):'))
376
+ const expected = JSON.stringify(drift.expected, null, 2)
377
+ for (const l of expected.split('\n').slice(0, 8)) {
378
+ lines.push(chalk.green(` ${l}`))
379
+ }
380
+ lines.push('')
381
+ lines.push(chalk.bold(' Actual (on disk):'))
382
+ const actual = JSON.stringify(drift.actual, null, 2)
383
+ for (const l of actual.split('\n').slice(0, 8)) {
384
+ lines.push(chalk.red(` ${l}`))
385
+ }
386
+ lines.push('')
387
+ lines.push(chalk.dim(' Press r to re-deploy (overwrite file) a to accept changes (update store) Esc to go back'))
388
+
389
+ return lines.slice(0, viewportHeight)
390
+ }
391
+
392
+ /**
393
+ * Minimal fallback formatter for native entries (no chalk dependency at module load).
394
+ * Used only when formatNative is not provided to startTabTUI.
395
+ * @param {import('../../types.js').NativeEntry[]} entries
396
+ * @returns {string[]}
397
+ */
398
+ function formatNativeEntriesTableFallback(entries) {
399
+ const lines = [' Name Environment Level Config', ' ' + '─'.repeat(60)]
400
+ for (const e of entries) {
401
+ lines.push(` ${e.name.padEnd(25)} ${e.environmentId.padEnd(13)} ${e.level.padEnd(8)} ${e.sourcePath}`)
402
+ }
403
+ return lines
404
+ }
405
+
406
+ // ──────────────────────────────────────────────────────────────────────────────
407
+ // Categories tab keypress reducer (T037)
408
+ // ──────────────────────────────────────────────────────────────────────────────
409
+
410
+ /**
411
+ * Pure state reducer for keypresses in the Categories tab list mode.
412
+ * @param {CatTabState} state
413
+ * @param {{ name: string, ctrl?: boolean, sequence?: string }} key
414
+ * @returns {CatTabState | { exit: true }}
415
+ */
416
+ export function handleCategoriesKeypress(state, key) {
417
+ const {selectedIndex, entries, nativeEntries = [], section = 'managed', mode} = state
418
+ const activeList = section === 'native' ? nativeEntries : entries
419
+ const maxIndex = Math.max(0, activeList.length - 1)
420
+
421
+ // Confirm-delete mode
422
+ if (mode === 'confirm-delete') {
423
+ if (key.name === 'y') {
424
+ return {...state, mode: 'list', _deleteConfirmed: true}
425
+ }
426
+ // Any other key cancels
427
+ return {...state, mode: 'list', confirmDeleteId: null}
428
+ }
429
+
430
+ // Drift resolution mode
431
+ if (mode === 'drift') {
432
+ if (key.name === 'escape') return {...state, mode: 'list'}
433
+ if (key.name === 'r') return {...state, mode: 'list', _redeploy: state._driftEntryId, _driftEntryId: null}
434
+ if (key.name === 'a') return {...state, mode: 'list', _acceptDrift: state._driftEntryId, _driftEntryId: null}
435
+ return state
436
+ }
437
+
438
+ // Navigation — clears env var reveal on any movement
439
+ // Cross-section: up from top of managed goes to last native; down from bottom of native goes to first managed
440
+ if (key.name === 'up' || key.name === 'k') {
441
+ if (selectedIndex === 0 && section === 'managed' && nativeEntries.length > 0) {
442
+ return {...state, section: 'native', selectedIndex: nativeEntries.length - 1, revealedEntryId: null}
443
+ }
444
+ return {...state, selectedIndex: Math.max(0, selectedIndex - 1), revealedEntryId: null}
445
+ }
446
+ if (key.name === 'down' || key.name === 'j') {
447
+ if (selectedIndex >= maxIndex && section === 'native' && entries.length > 0) {
448
+ return {...state, section: 'managed', selectedIndex: 0, revealedEntryId: null}
449
+ }
450
+ return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 1), revealedEntryId: null}
451
+ }
452
+ if (key.name === 'pageup') {
453
+ return {...state, selectedIndex: Math.max(0, selectedIndex - 10), revealedEntryId: null}
454
+ }
455
+ if (key.name === 'pagedown') {
456
+ return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 10), revealedEntryId: null}
457
+ }
458
+
459
+ // Native section actions
460
+ if (section === 'native') {
461
+ if (key.name === 'i' && nativeEntries.length > 0) {
462
+ const nativeEntry = nativeEntries[selectedIndex]
463
+ if (nativeEntry) return {...state, _importNative: nativeEntry}
464
+ }
465
+ return state
466
+ }
467
+
468
+ // Managed section actions
469
+ if (key.name === 'n') {
470
+ return {...state, mode: 'form', _action: 'create'}
471
+ }
472
+ if (key.name === 'return' && entries.length > 0) {
473
+ const entry = entries[selectedIndex]
474
+ if (entry) {
475
+ const driftedIds = new Set((state.driftInfos ?? []).map((d) => d.entryId))
476
+ if (driftedIds.has(entry.id)) {
477
+ return {...state, mode: 'drift', _driftEntryId: entry.id}
478
+ }
479
+ return {...state, mode: 'form', _action: 'edit', _editId: entry.id}
480
+ }
481
+ }
482
+ if (key.name === 'd' && entries.length > 0) {
483
+ return {...state, _toggleId: entries[selectedIndex]?.id}
484
+ }
485
+ if ((key.name === 'delete' || key.name === 'backspace') && entries.length > 0) {
486
+ const entry = entries[selectedIndex]
487
+ if (entry) {
488
+ return {...state, mode: 'confirm-delete', confirmDeleteId: entry.id, _confirmDeleteName: entry.name}
489
+ }
490
+ }
491
+ // r — reveal/hide env vars for the selected MCP entry
492
+ if (key.name === 'r' && entries.length > 0) {
493
+ const entry = entries[selectedIndex]
494
+ if (entry?.type === 'mcp') {
495
+ return {...state, revealedEntryId: state.revealedEntryId === entry.id ? null : entry.id}
496
+ }
497
+ }
498
+
499
+ return state
500
+ }
501
+
502
+ // ──────────────────────────────────────────────────────────────────────────────
503
+ // Terminal lifecycle management
504
+ // ──────────────────────────────────────────────────────────────────────────────
505
+
506
+ /**
507
+ * Enter the alternate screen buffer, hide the cursor, and enable raw stdin keypresses.
508
+ * @returns {void}
509
+ */
510
+ export function setupTerminal() {
511
+ _cleanupCalled = false
512
+ _altScreenActive = true
513
+ _rawModeActive = true
514
+ process.stdout.write(ANSI_ALT_SCREEN_ON)
515
+ process.stdout.write(ANSI_CURSOR_HIDE)
516
+ readline.emitKeypressEvents(process.stdin)
517
+ if (process.stdin.isTTY) {
518
+ process.stdin.setRawMode(true)
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Restore the terminal to its original state.
524
+ * Idempotent — safe to call multiple times.
525
+ * @returns {void}
526
+ */
527
+ export function cleanupTerminal() {
528
+ if (_cleanupCalled) return
529
+ _cleanupCalled = true
530
+
531
+ if (_keypressListener) {
532
+ process.stdin.removeListener('keypress', _keypressListener)
533
+ _keypressListener = null
534
+ }
535
+ if (_rawModeActive && process.stdin.isTTY) {
536
+ try {
537
+ process.stdin.setRawMode(false)
538
+ } catch {
539
+ /* ignore */
540
+ }
541
+ _rawModeActive = false
542
+ }
543
+ if (_altScreenActive) {
544
+ process.stdout.write(ANSI_CURSOR_SHOW)
545
+ process.stdout.write(ANSI_ALT_SCREEN_OFF)
546
+ _altScreenActive = false
547
+ }
548
+ try {
549
+ process.stdin.pause()
550
+ } catch {
551
+ /* ignore */
552
+ }
553
+ }
554
+
555
+ // ──────────────────────────────────────────────────────────────────────────────
556
+ // T016: startTabTUI — main orchestrator
557
+ // ──────────────────────────────────────────────────────────────────────────────
558
+
559
+ /**
560
+ * @typedef {Object} TabTUIOptions
561
+ * @property {import('../../types.js').DetectedEnvironment[]} envs - Detected environments (from scanner)
562
+ * @property {import('../../types.js').CategoryEntry[]} entries - All category entries (from store)
563
+ * @property {boolean} chezmoiEnabled - Whether chezmoi is configured
564
+ * @property {(action: object) => Promise<void>} onAction - Callback for CRUD actions from category tabs
565
+ * @property {import('../../formatters/ai-config.js').formatEnvironmentsTable} formatEnvs - Environments table formatter
566
+ * @property {import('../../formatters/ai-config.js').formatCategoriesTable} formatCats - Categories table formatter
567
+ * @property {import('../../formatters/ai-config.js').formatNativeEntriesTable} [formatNative] - Native entries table formatter
568
+ * @property {(() => Promise<import('../../types.js').CategoryEntry[]>) | undefined} [refreshEntries] - Reload entries from store after mutations
569
+ */
570
+
571
+ /**
572
+ * Start the interactive tab TUI session.
573
+ * Blocks until the user exits (Esc / q / Ctrl+C).
574
+ * Manages the full TUI lifecycle: terminal setup, keypress loop, tab switching, cleanup.
575
+ *
576
+ * @param {TabTUIOptions} opts
577
+ * @returns {Promise<void>}
578
+ */
579
+ export async function startTabTUI(opts) {
580
+ const {envs, onAction, formatEnvs, formatCats, formatNative} = opts
581
+ const {entries: initialEntries, chezmoiEnabled} = opts
582
+
583
+ _cleanupCalled = false
584
+
585
+ const sigHandler = () => {
586
+ cleanupTerminal()
587
+ process.exit(0)
588
+ }
589
+ const exitHandler = () => {
590
+ if (!_cleanupCalled) cleanupTerminal()
591
+ }
592
+ process.once('SIGINT', sigHandler)
593
+ process.once('SIGTERM', sigHandler)
594
+ process.once('exit', exitHandler)
595
+
596
+ const tabs = [
597
+ {label: 'Environments', key: 'environments'},
598
+ {label: 'MCPs', key: 'mcp'},
599
+ {label: 'Commands', key: 'command'},
600
+ {label: 'Rules', key: 'rule'},
601
+ {label: 'Skills', key: 'skill'},
602
+ {label: 'Agents', key: 'agent'},
603
+ ]
604
+
605
+ const CATEGORY_TYPES = ['mcp', 'command', 'rule', 'skill', 'agent']
606
+ const chezmoidTip = chezmoiEnabled ? '' : 'Tip: Run `dvmi dotfiles setup` to enable automatic backup of your AI configs'
607
+
608
+ /** @type {TabTUIState} */
609
+ let tuiState = {
610
+ tabs,
611
+ activeTabIndex: 0,
612
+ termRows: process.stdout.rows || 24,
613
+ termCols: process.stdout.columns || 80,
614
+ contentViewportHeight: Math.max(1, (process.stdout.rows || 24) - TAB_BAR_LINES - FOOTER_LINES),
615
+ tooSmall: (process.stdout.columns || 80) < MIN_COLS || (process.stdout.rows || 24) < MIN_ROWS,
616
+ }
617
+
618
+ /** @type {EnvTabState} */
619
+ let envState = {envs, selectedIndex: 0}
620
+
621
+ /** @type {import('../../types.js').CategoryEntry[]} */
622
+ let allEntries = [...initialEntries]
623
+
624
+ /** Aggregate all drift infos from detected envs (flat list). */
625
+ const allDriftInfos = envs.flatMap((e) => e.driftedEntries ?? [])
626
+
627
+ /**
628
+ * @param {string} type
629
+ * @returns {import('../../types.js').NativeEntry[]}
630
+ */
631
+ function getNativesByType(type) {
632
+ return envs.flatMap((e) => (e.nativeEntries ?? []).filter((ne) => ne.type === type))
633
+ }
634
+
635
+ /**
636
+ * @param {string} type
637
+ * @returns {import('../../types.js').DriftInfo[]}
638
+ */
639
+ function getDriftsByType(type) {
640
+ const ids = new Set(allEntries.filter((e) => e.type === type).map((e) => e.id))
641
+ return allDriftInfos.filter((d) => ids.has(d.entryId))
642
+ }
643
+
644
+ /** @type {Record<string, CatTabState>} */
645
+ let catTabStates = Object.fromEntries(
646
+ CATEGORY_TYPES.map((type) => [
647
+ type,
648
+ /** @type {CatTabState} */ ({
649
+ entries: allEntries.filter((e) => e.type === type),
650
+ nativeEntries: getNativesByType(type),
651
+ selectedIndex: 0,
652
+ section: 'managed',
653
+ mode: 'list',
654
+ formState: null,
655
+ confirmDeleteId: null,
656
+ chezmoidTip,
657
+ revealedEntryId: null,
658
+ driftInfos: getDriftsByType(type),
659
+ }),
660
+ ]),
661
+ )
662
+
663
+ /** Push filtered entries and drift infos into each tab state — call after allEntries changes. */
664
+ function syncTabEntries() {
665
+ for (const type of CATEGORY_TYPES) {
666
+ const entriesForType = allEntries.filter((e) => e.type === type)
667
+ const driftsForType = getDriftsByType(type)
668
+ catTabStates = {
669
+ ...catTabStates,
670
+ [type]: {...catTabStates[type], entries: entriesForType, driftInfos: driftsForType},
671
+ }
672
+ }
673
+ }
674
+
675
+ setupTerminal()
676
+
677
+ /**
678
+ * Build and render the current frame.
679
+ * @returns {void}
680
+ */
681
+ function render() {
682
+ const {termRows, termCols, activeTabIndex, tooSmall, contentViewportHeight} = tuiState
683
+
684
+ if (tooSmall) {
685
+ process.stdout.write(buildTooSmallScreen(termRows, termCols))
686
+ return
687
+ }
688
+
689
+ const tabBarLines = buildTabBar(tabs, activeTabIndex)
690
+ let contentLines
691
+ let hintStr
692
+
693
+ if (activeTabIndex === 0) {
694
+ contentLines = buildEnvironmentsTab(
695
+ envState.envs,
696
+ envState.selectedIndex,
697
+ contentViewportHeight,
698
+ formatEnvs,
699
+ termCols,
700
+ )
701
+ hintStr = chalk.dim(' ↑↓ navigate ←→ switch tabs q exit')
702
+ } else {
703
+ const tabKey = tabs[activeTabIndex].key
704
+ const tabState = catTabStates[tabKey]
705
+
706
+ if (tabState.mode === 'form' && tabState.formState) {
707
+ contentLines = buildFormScreen(tabState.formState, contentViewportHeight, termCols)
708
+ hintStr = chalk.dim(' Tab next field Shift+Tab prev Ctrl+S save Esc cancel')
709
+ } else if (tabState.mode === 'drift') {
710
+ contentLines = buildDriftScreen(tabState, contentViewportHeight, termCols)
711
+ hintStr = chalk.dim(' r re-deploy a accept changes Esc back')
712
+ } else {
713
+ const nativeFmt = formatNative ?? formatNativeEntriesTableFallback
714
+ contentLines = buildCategoriesTab(tabState, contentViewportHeight, formatCats, nativeFmt, termCols)
715
+ const sectionHint = tabState.nativeEntries.length > 0 ? ' Tab section' : ''
716
+ const nativeHint = tabState.section === 'native' ? ' i import' : ' n new Enter edit d toggle Del delete r reveal'
717
+ hintStr = chalk.dim(` ↑↓ navigate ←→ tabs${nativeHint}${sectionHint} q exit`)
718
+ }
719
+ }
720
+
721
+ const footerTip = chezmoidTip ? [chalk.dim(chezmoidTip)] : []
722
+ const footerLines = ['', hintStr, ...footerTip]
723
+ process.stdout.write(buildTabScreen(tabBarLines, contentLines, footerLines, termRows))
724
+ }
725
+
726
+ // Resize handler
727
+ function onResize() {
728
+ const newRows = process.stdout.rows || 24
729
+ const newCols = process.stdout.columns || 80
730
+ tuiState = {
731
+ ...tuiState,
732
+ termRows: newRows,
733
+ termCols: newCols,
734
+ contentViewportHeight: Math.max(1, newRows - TAB_BAR_LINES - FOOTER_LINES),
735
+ tooSmall: newCols < MIN_COLS || newRows < MIN_ROWS,
736
+ }
737
+ render()
738
+ }
739
+ process.stdout.on('resize', onResize)
740
+
741
+ render()
742
+
743
+ return new Promise((resolve) => {
744
+ /**
745
+ * @param {string} _str
746
+ * @param {{ name: string, ctrl?: boolean, shift?: boolean, sequence?: string }} key
747
+ */
748
+ const listener = async (_str, key) => {
749
+ if (!key) return
750
+
751
+ // ── Compute mode guards ──
752
+ const activeTabKey = tuiState.activeTabIndex > 0 ? tabs[tuiState.activeTabIndex].key : null
753
+ const activeCatState = activeTabKey ? catTabStates[activeTabKey] : null
754
+ const isInFormMode = activeCatState?.mode === 'form'
755
+ const isInDriftMode = activeCatState?.mode === 'drift'
756
+ const isInConfirmDelete = activeCatState?.mode === 'confirm-delete'
757
+ const isModalMode = isInFormMode || isInDriftMode || isInConfirmDelete
758
+
759
+ // ── Ctrl+C: always exit ──
760
+ if (key.ctrl && key.name === 'c') {
761
+ process.stdout.removeListener('resize', onResize)
762
+ process.removeListener('SIGINT', sigHandler)
763
+ process.removeListener('SIGTERM', sigHandler)
764
+ process.removeListener('exit', exitHandler)
765
+ cleanupTerminal()
766
+ resolve()
767
+ return
768
+ }
769
+
770
+ // ── Esc: close sub-mode first; exit TUI only from list mode ──
771
+ if (key.name === 'escape' && !isModalMode) {
772
+ process.stdout.removeListener('resize', onResize)
773
+ process.removeListener('SIGINT', sigHandler)
774
+ process.removeListener('SIGTERM', sigHandler)
775
+ process.removeListener('exit', exitHandler)
776
+ cleanupTerminal()
777
+ resolve()
778
+ return
779
+ }
780
+ // In modal modes Esc falls through to per-tab handlers (form cancel, drift back, etc.)
781
+
782
+ // ── q: exit TUI only from list mode (in forms q is a regular character) ──
783
+ if (key.name === 'q' && !isModalMode) {
784
+ process.stdout.removeListener('resize', onResize)
785
+ process.removeListener('SIGINT', sigHandler)
786
+ process.removeListener('SIGTERM', sigHandler)
787
+ process.removeListener('exit', exitHandler)
788
+ cleanupTerminal()
789
+ resolve()
790
+ return
791
+ }
792
+
793
+ // ── Left/Right arrows: tab switching (blocked in modal sub-modes) ──
794
+ if ((key.name === 'right' || key.name === 'l') && !isModalMode) {
795
+ tuiState = {...tuiState, activeTabIndex: (tuiState.activeTabIndex + 1) % tabs.length}
796
+ render()
797
+ return
798
+ }
799
+ if ((key.name === 'left' || key.name === 'h') && !isModalMode) {
800
+ tuiState = {...tuiState, activeTabIndex: (tuiState.activeTabIndex - 1 + tabs.length) % tabs.length}
801
+ render()
802
+ return
803
+ }
804
+
805
+ // ── Tab: toggle Native/Managed section within category tabs ──
806
+ if (key.name === 'tab' && !key.shift && !isInFormMode) {
807
+ if (activeCatState && activeCatState.nativeEntries?.length > 0 && !isInDriftMode) {
808
+ catTabStates = {
809
+ ...catTabStates,
810
+ [activeTabKey]: {
811
+ ...activeCatState,
812
+ section: activeCatState.section === 'managed' ? 'native' : 'managed',
813
+ selectedIndex: 0,
814
+ revealedEntryId: null,
815
+ },
816
+ }
817
+ render()
818
+ return
819
+ }
820
+ // No native entries or Environments tab — Tab is a no-op (use ←/→)
821
+ }
822
+
823
+ // Delegate to active tab
824
+ if (tuiState.activeTabIndex === 0) {
825
+ // Environments tab — read-only
826
+ const result = handleEnvironmentsKeypress(envState, key)
827
+ envState = /** @type {EnvTabState} */ (result)
828
+ render()
829
+ } else {
830
+ // Category tab (MCPs | Commands | Skills | Agents)
831
+ const tabKey = tabs[tuiState.activeTabIndex].key
832
+ const tabState = catTabStates[tabKey]
833
+
834
+ // Form mode: delegate to form keypress handler
835
+ if (tabState.mode === 'form' && tabState.formState) {
836
+ const formResult = handleFormKeypress(tabState.formState, key)
837
+
838
+ if ('cancelled' in formResult && formResult.cancelled) {
839
+ catTabStates = {
840
+ ...catTabStates,
841
+ [tabKey]: {...tabState, mode: 'list', formState: null, _formAction: null, _editId: null},
842
+ }
843
+ render()
844
+ return
845
+ }
846
+
847
+ if ('submitted' in formResult && formResult.submitted) {
848
+ const formAction = tabState._formAction
849
+ const editId = tabState._editId
850
+ const savedFormState = tabState.formState
851
+ catTabStates = {
852
+ ...catTabStates,
853
+ [tabKey]: {...tabState, mode: 'list', formState: null, _formAction: null, _editId: null},
854
+ }
855
+ render()
856
+ try {
857
+ await onAction({type: formAction, tabKey, values: formResult.values, id: editId})
858
+ if (opts.refreshEntries) {
859
+ allEntries = await opts.refreshEntries()
860
+ syncTabEntries()
861
+ render()
862
+ }
863
+ } catch (err) {
864
+ // Restore form with error message so the user sees what went wrong
865
+ const msg = err instanceof Error ? err.message : String(err)
866
+ catTabStates = {
867
+ ...catTabStates,
868
+ [tabKey]: {
869
+ ...catTabStates[tabKey],
870
+ mode: 'form',
871
+ formState: {...savedFormState, errorMessage: msg},
872
+ _formAction: formAction,
873
+ _editId: editId,
874
+ },
875
+ }
876
+ render()
877
+ }
878
+ return
879
+ }
880
+
881
+ // Still editing — update form state
882
+ catTabStates = {
883
+ ...catTabStates,
884
+ [tabKey]: {...tabState, formState: /** @type {import('./form.js').FormState} */ (formResult)},
885
+ }
886
+ render()
887
+ return
888
+ }
889
+
890
+ // List / confirm-delete mode
891
+ const result = handleCategoriesKeypress(tabState, key)
892
+
893
+ if (result._deleteConfirmed && result.confirmDeleteId) {
894
+ const idToDelete = result.confirmDeleteId
895
+ catTabStates = {
896
+ ...catTabStates,
897
+ [tabKey]: {...result, confirmDeleteId: null, _deleteConfirmed: false},
898
+ }
899
+ render()
900
+ try {
901
+ await onAction({type: 'delete', id: idToDelete})
902
+ if (opts.refreshEntries) {
903
+ allEntries = await opts.refreshEntries()
904
+ syncTabEntries()
905
+ render()
906
+ }
907
+ } catch {
908
+ /* ignore */
909
+ }
910
+ return
911
+ }
912
+
913
+ if (result._toggleId) {
914
+ const idToToggle = result._toggleId
915
+ const entry = tabState.entries.find((e) => e.id === idToToggle)
916
+ catTabStates = {...catTabStates, [tabKey]: {...result, _toggleId: null}}
917
+ render()
918
+ if (entry) {
919
+ try {
920
+ await onAction({type: entry.active ? 'deactivate' : 'activate', id: idToToggle})
921
+ if (opts.refreshEntries) {
922
+ allEntries = await opts.refreshEntries()
923
+ syncTabEntries()
924
+ render()
925
+ }
926
+ } catch {
927
+ /* ignore */
928
+ }
929
+ }
930
+ return
931
+ }
932
+
933
+ // T017: Import native entry into managed sync
934
+ if (result._importNative) {
935
+ const nativeEntry = result._importNative
936
+ catTabStates = {...catTabStates, [tabKey]: {...result, _importNative: null}}
937
+ render()
938
+ try {
939
+ await onAction({type: 'import-native', nativeEntry})
940
+ if (opts.refreshEntries) {
941
+ allEntries = await opts.refreshEntries()
942
+ syncTabEntries()
943
+ // Update native entries: remove imported entry from native list
944
+ catTabStates = {
945
+ ...catTabStates,
946
+ [tabKey]: {
947
+ ...catTabStates[tabKey],
948
+ nativeEntries: catTabStates[tabKey].nativeEntries.filter(
949
+ (ne) => !(ne.name === nativeEntry.name && ne.environmentId === nativeEntry.environmentId),
950
+ ),
951
+ section: 'managed',
952
+ selectedIndex: 0,
953
+ },
954
+ }
955
+ render()
956
+ }
957
+ } catch {
958
+ // Import failed — re-render so user sees the entry stayed in native
959
+ render()
960
+ }
961
+ return
962
+ }
963
+
964
+ // T018: Re-deploy after drift resolution
965
+ if (result._redeploy) {
966
+ const idToRedeploy = result._redeploy
967
+ catTabStates = {...catTabStates, [tabKey]: {...result, _redeploy: null}}
968
+ render()
969
+ try {
970
+ await onAction({type: 'redeploy', id: idToRedeploy})
971
+ if (opts.refreshEntries) {
972
+ allEntries = await opts.refreshEntries()
973
+ syncTabEntries()
974
+ render()
975
+ }
976
+ } catch {
977
+ /* ignore */
978
+ }
979
+ return
980
+ }
981
+
982
+ // T018: Accept drift (update store from file)
983
+ if (result._acceptDrift) {
984
+ const idToAccept = result._acceptDrift
985
+ catTabStates = {...catTabStates, [tabKey]: {...result, _acceptDrift: null}}
986
+ render()
987
+ try {
988
+ await onAction({type: 'accept-drift', id: idToAccept})
989
+ if (opts.refreshEntries) {
990
+ allEntries = await opts.refreshEntries()
991
+ syncTabEntries()
992
+ render()
993
+ }
994
+ } catch {
995
+ /* ignore */
996
+ }
997
+ return
998
+ }
999
+
1000
+ if (result._action === 'create') {
1001
+ const compatibleEnvs = envs.filter((e) => e.supportedCategories.includes(tabKey))
1002
+ const fields =
1003
+ tabKey === 'mcp'
1004
+ ? getMCPFormFields(null, compatibleEnvs)
1005
+ : tabKey === 'command'
1006
+ ? getCommandFormFields(null, compatibleEnvs)
1007
+ : tabKey === 'rule'
1008
+ ? getRuleFormFields(null, compatibleEnvs)
1009
+ : tabKey === 'skill'
1010
+ ? getSkillFormFields(null, compatibleEnvs)
1011
+ : getAgentFormFields(null, compatibleEnvs)
1012
+ const tabLabel = tabKey === 'mcp' ? 'MCP' : tabKey.charAt(0).toUpperCase() + tabKey.slice(1)
1013
+ catTabStates = {
1014
+ ...catTabStates,
1015
+ [tabKey]: {
1016
+ ...result,
1017
+ _action: null,
1018
+ mode: 'form',
1019
+ _formAction: 'create',
1020
+ formState: {
1021
+ fields,
1022
+ focusedFieldIndex: 0,
1023
+ title: `Create ${tabLabel}`,
1024
+ status: 'editing',
1025
+ errorMessage: null,
1026
+ customValidator: tabKey === 'mcp' ? validateMCPForm : null,
1027
+ },
1028
+ },
1029
+ }
1030
+ render()
1031
+ return
1032
+ }
1033
+
1034
+ if (result._action === 'edit' && result._editId) {
1035
+ const entry = tabState.entries.find((e) => e.id === result._editId)
1036
+ if (entry) {
1037
+ const compatibleEnvs = envs.filter((e) => e.supportedCategories.includes(entry.type))
1038
+ const fields =
1039
+ entry.type === 'mcp'
1040
+ ? getMCPFormFields(entry, compatibleEnvs)
1041
+ : entry.type === 'command'
1042
+ ? getCommandFormFields(entry, compatibleEnvs)
1043
+ : entry.type === 'rule'
1044
+ ? getRuleFormFields(entry, compatibleEnvs)
1045
+ : entry.type === 'skill'
1046
+ ? getSkillFormFields(entry, compatibleEnvs)
1047
+ : getAgentFormFields(entry, compatibleEnvs)
1048
+ catTabStates = {
1049
+ ...catTabStates,
1050
+ [tabKey]: {
1051
+ ...result,
1052
+ _action: null,
1053
+ mode: 'form',
1054
+ _formAction: 'edit',
1055
+ formState: {
1056
+ fields,
1057
+ focusedFieldIndex: 0,
1058
+ title: `Edit ${entry.name}`,
1059
+ status: 'editing',
1060
+ errorMessage: null,
1061
+ customValidator: entry.type === 'mcp' ? validateMCPForm : null,
1062
+ },
1063
+ },
1064
+ }
1065
+ render()
1066
+ return
1067
+ }
1068
+ }
1069
+
1070
+ catTabStates = {...catTabStates, [tabKey]: /** @type {CatTabState} */ (result)}
1071
+ render()
1072
+ }
1073
+ }
1074
+
1075
+ _keypressListener = listener
1076
+ process.stdin.on('keypress', listener)
1077
+ process.stdin.resume()
1078
+ })
1079
+ }
1080
+
1081
+ /**
1082
+ * Update the entries displayed in the Categories tab (called after store mutations).
1083
+ * @param {import('../../types.js').CategoryEntry[]} _newEntries
1084
+ * @returns {void}
1085
+ */
1086
+ export function updateTUIEntries(_newEntries) {
1087
+ // This is a lightweight state update — the TUI re-renders on next keypress.
1088
+ // Callers should call render() manually after this if needed.
1089
+ }