devvami 1.4.2 → 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.
Files changed (96) hide show
  1. package/README.md +7 -0
  2. package/oclif.manifest.json +129 -89
  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 +143 -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 +127 -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 +318 -0
  62. package/src/services/ai-env-deployer.js +444 -0
  63. package/src/services/ai-env-scanner.js +242 -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 +85 -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 +1006 -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 +800 -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,800 @@
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
+ getSkillFormFields,
16
+ getAgentFormFields,
17
+ } from './form.js'
18
+
19
+ // ──────────────────────────────────────────────────────────────────────────────
20
+ // ANSI escape sequences
21
+ // ──────────────────────────────────────────────────────────────────────────────
22
+
23
+ const ANSI_CLEAR = '\x1b[2J'
24
+ const ANSI_HOME = '\x1b[H'
25
+ const ANSI_ALT_SCREEN_ON = '\x1b[?1049h'
26
+ const ANSI_ALT_SCREEN_OFF = '\x1b[?1049l'
27
+ const ANSI_CURSOR_HIDE = '\x1b[?25l'
28
+ const ANSI_CURSOR_SHOW = '\x1b[?25h'
29
+ const ANSI_INVERSE_ON = '\x1b[7m'
30
+ const ANSI_INVERSE_OFF = '\x1b[27m'
31
+
32
+ // ──────────────────────────────────────────────────────────────────────────────
33
+ // Layout constants
34
+ // ──────────────────────────────────────────────────────────────────────────────
35
+
36
+ const MIN_COLS = 80
37
+ const MIN_ROWS = 24
38
+ const TAB_BAR_LINES = 2 // tab bar line + divider
39
+ const FOOTER_LINES = 2 // empty line + keyboard hints
40
+
41
+ // ──────────────────────────────────────────────────────────────────────────────
42
+ // Module-level terminal session state
43
+ // ──────────────────────────────────────────────────────────────────────────────
44
+
45
+ let _cleanupCalled = false
46
+ let _altScreenActive = false
47
+ let _rawModeActive = false
48
+ /** @type {((...args: unknown[]) => void) | null} */
49
+ let _keypressListener = null
50
+
51
+ // ──────────────────────────────────────────────────────────────────────────────
52
+ // Typedefs
53
+ // ──────────────────────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * @typedef {Object} TabDef
57
+ * @property {string} label - Display label shown in the tab bar
58
+ * @property {string} key - Unique identifier for this tab
59
+ */
60
+
61
+ /**
62
+ * @typedef {Object} TabTUIState
63
+ * @property {TabDef[]} tabs - All tabs
64
+ * @property {number} activeTabIndex - Index of the currently active tab
65
+ * @property {number} termRows - Current terminal height
66
+ * @property {number} termCols - Current terminal width
67
+ * @property {number} contentViewportHeight - Usable content lines (termRows - TAB_BAR_LINES - FOOTER_LINES)
68
+ * @property {boolean} tooSmall - Whether the terminal is below minimum size
69
+ */
70
+
71
+ /**
72
+ * @typedef {Object} EnvTabState
73
+ * @property {import('../../types.js').DetectedEnvironment[]} envs - Detected environments
74
+ * @property {number} selectedIndex - Highlighted row
75
+ */
76
+
77
+ /**
78
+ * @typedef {Object} CatTabState
79
+ * @property {import('../../types.js').CategoryEntry[]} entries - All category entries
80
+ * @property {number} selectedIndex - Highlighted row
81
+ * @property {'list'|'form'|'confirm-delete'} mode - Current sub-mode
82
+ * @property {import('./form.js').FormState|null} formState - Active form state (null when mode is 'list')
83
+ * @property {string|null} confirmDeleteId - Entry id pending deletion confirmation
84
+ * @property {string} chezmoidTip - Footer tip (empty if chezmoi configured)
85
+ */
86
+
87
+ // ──────────────────────────────────────────────────────────────────────────────
88
+ // Internal helpers
89
+ // ──────────────────────────────────────────────────────────────────────────────
90
+
91
+ // ──────────────────────────────────────────────────────────────────────────────
92
+ // T017: buildTabBar — renders horizontal tab bar
93
+ // ──────────────────────────────────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Build the tab bar string (one line of tab labels + a divider line).
97
+ * Active tab is highlighted with inverse video.
98
+ * @param {TabDef[]} tabs
99
+ * @param {number} activeIndex
100
+ * @returns {string[]} Two lines: [tabBarLine, divider]
101
+ */
102
+ export function buildTabBar(tabs, activeIndex) {
103
+ const parts = tabs.map((tab, i) => {
104
+ const label = ` ${tab.label} `
105
+ if (i === activeIndex) {
106
+ return `${ANSI_INVERSE_ON}${label}${ANSI_INVERSE_OFF}`
107
+ }
108
+ return chalk.dim(label)
109
+ })
110
+ const tabBarLine = parts.join(chalk.dim('│'))
111
+ const divider = chalk.dim('─'.repeat(60))
112
+ return [tabBarLine, divider]
113
+ }
114
+
115
+ // ──────────────────────────────────────────────────────────────────────────────
116
+ // T017: buildTabScreen — full screen composition
117
+ // ──────────────────────────────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Compose the full terminal screen from tab bar, content lines, and footer.
121
+ * Prepends ANSI clear + home to replace the previous frame.
122
+ * @param {string[]} tabBarLines - Output of buildTabBar
123
+ * @param {string[]} contentLines - Tab-specific content lines
124
+ * @param {string[]} footerLines - Footer hint lines
125
+ * @param {number} termRows - Terminal height
126
+ * @returns {string}
127
+ */
128
+ export function buildTabScreen(tabBarLines, contentLines, footerLines, termRows) {
129
+ const lines = [...tabBarLines, ...contentLines]
130
+
131
+ // Pad to fill terminal height minus footer
132
+ const targetContentLines = termRows - tabBarLines.length - footerLines.length
133
+ while (lines.length < targetContentLines) {
134
+ lines.push('')
135
+ }
136
+
137
+ lines.push(...footerLines)
138
+ return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
139
+ }
140
+
141
+ // ──────────────────────────────────────────────────────────────────────────────
142
+ // T018: terminal size check
143
+ // ──────────────────────────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Build a "terminal too small" warning screen.
147
+ * @param {number} termRows
148
+ * @param {number} termCols
149
+ * @returns {string}
150
+ */
151
+ export function buildTooSmallScreen(termRows, termCols) {
152
+ const lines = []
153
+ const midRow = Math.floor(termRows / 2)
154
+
155
+ for (let i = 0; i < midRow - 1; i++) lines.push('')
156
+
157
+ lines.push(chalk.red.bold(` Terminal too small (${termCols}×${termRows}, minimum: ${MIN_COLS}×${MIN_ROWS})`))
158
+ lines.push(chalk.dim(' Resize your terminal window and try again.'))
159
+
160
+ return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
161
+ }
162
+
163
+ // ──────────────────────────────────────────────────────────────────────────────
164
+ // T020: buildEnvironmentsTab — content builder
165
+ // ──────────────────────────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Build the content lines for the Environments tab.
169
+ * @param {import('../../types.js').DetectedEnvironment[]} envs - Detected environments
170
+ * @param {number} selectedIndex - Currently highlighted row
171
+ * @param {number} viewportHeight - Available content lines
172
+ * @param {import('../../formatters/ai-config.js').formatEnvironmentsTable} formatFn - Formatter function
173
+ * @param {number} termCols - Terminal width for formatter
174
+ * @returns {string[]}
175
+ */
176
+ export function buildEnvironmentsTab(envs, selectedIndex, viewportHeight, formatFn, termCols = 120) {
177
+ if (envs.length === 0) {
178
+ return [
179
+ '',
180
+ chalk.dim(' No AI coding environments detected.'),
181
+ chalk.dim(' Ensure at least one AI tool is configured in the current project or globally.'),
182
+ ]
183
+ }
184
+
185
+ const tableLines = formatFn(envs, termCols)
186
+
187
+ // Add row highlighting to data rows (skip header lines — first 2 lines are header + divider)
188
+ const HEADER_LINES = 2
189
+ const resultLines = []
190
+
191
+ for (let i = 0; i < tableLines.length; i++) {
192
+ const line = tableLines[i]
193
+ const dataIndex = i - HEADER_LINES
194
+ if (dataIndex >= 0 && dataIndex === selectedIndex) {
195
+ resultLines.push(`${ANSI_INVERSE_ON}${line}${ANSI_INVERSE_OFF}`)
196
+ } else {
197
+ resultLines.push(line)
198
+ }
199
+ }
200
+
201
+ // Viewport: only show lines that fit
202
+ return resultLines.slice(0, viewportHeight)
203
+ }
204
+
205
+ // ──────────────────────────────────────────────────────────────────────────────
206
+ // T021: handleEnvironmentsKeypress — pure reducer
207
+ // ──────────────────────────────────────────────────────────────────────────────
208
+
209
+ /**
210
+ * Pure state reducer for keypresses in the Environments tab.
211
+ * @param {EnvTabState} state
212
+ * @param {{ name: string, ctrl?: boolean }} key
213
+ * @returns {EnvTabState | { exit: true } | { switchTab: number }}
214
+ */
215
+ export function handleEnvironmentsKeypress(state, key) {
216
+ const {selectedIndex, envs} = state
217
+ const maxIndex = Math.max(0, envs.length - 1)
218
+
219
+ if (key.name === 'up' || key.name === 'k') {
220
+ return {...state, selectedIndex: Math.max(0, selectedIndex - 1)}
221
+ }
222
+ if (key.name === 'down' || key.name === 'j') {
223
+ return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 1)}
224
+ }
225
+ if (key.name === 'pageup') {
226
+ return {...state, selectedIndex: Math.max(0, selectedIndex - 10)}
227
+ }
228
+ if (key.name === 'pagedown') {
229
+ return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 10)}
230
+ }
231
+
232
+ return state
233
+ }
234
+
235
+ // ──────────────────────────────────────────────────────────────────────────────
236
+ // Categories tab content builder (T036) — defined here for single-module TUI
237
+ // ──────────────────────────────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * Build the content lines for the Categories tab.
241
+ * @param {import('../../types.js').CategoryEntry[]} entries
242
+ * @param {number} selectedIndex
243
+ * @param {number} viewportHeight
244
+ * @param {import('../../formatters/ai-config.js').formatCategoriesTable} formatFn
245
+ * @param {number} termCols
246
+ * @param {string|null} [confirmDeleteName] - Name of entry pending delete confirmation
247
+ * @returns {string[]}
248
+ */
249
+ export function buildCategoriesTab(
250
+ entries,
251
+ selectedIndex,
252
+ viewportHeight,
253
+ formatFn,
254
+ termCols = 120,
255
+ confirmDeleteName = null,
256
+ ) {
257
+ if (entries.length === 0) {
258
+ const lines = [
259
+ '',
260
+ chalk.dim(' No configuration entries yet.'),
261
+ chalk.dim(' Press ' + chalk.bold('n') + ' to create your first entry.'),
262
+ ]
263
+ if (confirmDeleteName === null) return lines
264
+ }
265
+
266
+ const tableLines = formatFn(entries, termCols)
267
+ const HEADER_LINES = 2
268
+ const resultLines = []
269
+
270
+ for (let i = 0; i < tableLines.length; i++) {
271
+ const line = tableLines[i]
272
+ const dataIndex = i - HEADER_LINES
273
+ if (dataIndex >= 0 && dataIndex === selectedIndex) {
274
+ resultLines.push(`${ANSI_INVERSE_ON}${line}${ANSI_INVERSE_OFF}`)
275
+ } else {
276
+ resultLines.push(line)
277
+ }
278
+ }
279
+
280
+ // Confirmation prompt overlay
281
+ if (confirmDeleteName !== null) {
282
+ resultLines.push('')
283
+ resultLines.push(chalk.red(` Delete "${confirmDeleteName}"? This cannot be undone. `) + chalk.bold('[y/N]'))
284
+ }
285
+
286
+ return resultLines.slice(0, viewportHeight)
287
+ }
288
+
289
+ // ──────────────────────────────────────────────────────────────────────────────
290
+ // Categories tab keypress reducer (T037)
291
+ // ──────────────────────────────────────────────────────────────────────────────
292
+
293
+ /**
294
+ * Pure state reducer for keypresses in the Categories tab list mode.
295
+ * @param {CatTabState} state
296
+ * @param {{ name: string, ctrl?: boolean, sequence?: string }} key
297
+ * @returns {CatTabState | { exit: true }}
298
+ */
299
+ export function handleCategoriesKeypress(state, key) {
300
+ const {selectedIndex, entries, mode, confirmDeleteId} = state
301
+ const maxIndex = Math.max(0, entries.length - 1)
302
+
303
+ // Confirm-delete mode
304
+ if (mode === 'confirm-delete') {
305
+ if (key.name === 'y') {
306
+ return {
307
+ ...state,
308
+ mode: 'list',
309
+ confirmDeleteId: key.name === 'y' ? confirmDeleteId : null,
310
+ _deleteConfirmed: true,
311
+ }
312
+ }
313
+ // Any other key cancels
314
+ return {...state, mode: 'list', confirmDeleteId: null}
315
+ }
316
+
317
+ // List mode
318
+ if (key.name === 'up' || key.name === 'k') {
319
+ return {...state, selectedIndex: Math.max(0, selectedIndex - 1)}
320
+ }
321
+ if (key.name === 'down' || key.name === 'j') {
322
+ return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 1)}
323
+ }
324
+ if (key.name === 'pageup') {
325
+ return {...state, selectedIndex: Math.max(0, selectedIndex - 10)}
326
+ }
327
+ if (key.name === 'pagedown') {
328
+ return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 10)}
329
+ }
330
+ if (key.name === 'n') {
331
+ return {...state, mode: 'form', _action: 'create'}
332
+ }
333
+ if (key.name === 'return' && entries.length > 0) {
334
+ return {...state, mode: 'form', _action: 'edit', _editId: entries[selectedIndex]?.id}
335
+ }
336
+ if (key.name === 'd' && entries.length > 0) {
337
+ return {...state, _toggleId: entries[selectedIndex]?.id}
338
+ }
339
+ if ((key.name === 'delete' || key.name === 'backspace') && entries.length > 0) {
340
+ const entry = entries[selectedIndex]
341
+ if (entry) {
342
+ return {...state, mode: 'confirm-delete', confirmDeleteId: entry.id, _confirmDeleteName: entry.name}
343
+ }
344
+ }
345
+
346
+ return state
347
+ }
348
+
349
+ // ──────────────────────────────────────────────────────────────────────────────
350
+ // Terminal lifecycle management
351
+ // ──────────────────────────────────────────────────────────────────────────────
352
+
353
+ /**
354
+ * Enter the alternate screen buffer, hide the cursor, and enable raw stdin keypresses.
355
+ * @returns {void}
356
+ */
357
+ export function setupTerminal() {
358
+ _cleanupCalled = false
359
+ _altScreenActive = true
360
+ _rawModeActive = true
361
+ process.stdout.write(ANSI_ALT_SCREEN_ON)
362
+ process.stdout.write(ANSI_CURSOR_HIDE)
363
+ readline.emitKeypressEvents(process.stdin)
364
+ if (process.stdin.isTTY) {
365
+ process.stdin.setRawMode(true)
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Restore the terminal to its original state.
371
+ * Idempotent — safe to call multiple times.
372
+ * @returns {void}
373
+ */
374
+ export function cleanupTerminal() {
375
+ if (_cleanupCalled) return
376
+ _cleanupCalled = true
377
+
378
+ if (_keypressListener) {
379
+ process.stdin.removeListener('keypress', _keypressListener)
380
+ _keypressListener = null
381
+ }
382
+ if (_rawModeActive && process.stdin.isTTY) {
383
+ try {
384
+ process.stdin.setRawMode(false)
385
+ } catch {
386
+ /* ignore */
387
+ }
388
+ _rawModeActive = false
389
+ }
390
+ if (_altScreenActive) {
391
+ process.stdout.write(ANSI_CURSOR_SHOW)
392
+ process.stdout.write(ANSI_ALT_SCREEN_OFF)
393
+ _altScreenActive = false
394
+ }
395
+ try {
396
+ process.stdin.pause()
397
+ } catch {
398
+ /* ignore */
399
+ }
400
+ }
401
+
402
+ // ──────────────────────────────────────────────────────────────────────────────
403
+ // T016: startTabTUI — main orchestrator
404
+ // ──────────────────────────────────────────────────────────────────────────────
405
+
406
+ /**
407
+ * @typedef {Object} TabTUIOptions
408
+ * @property {import('../../types.js').DetectedEnvironment[]} envs - Detected environments (from scanner)
409
+ * @property {import('../../types.js').CategoryEntry[]} entries - All category entries (from store)
410
+ * @property {boolean} chezmoiEnabled - Whether chezmoi is configured
411
+ * @property {(action: object) => Promise<void>} onAction - Callback for CRUD actions from category tabs
412
+ * @property {import('../../formatters/ai-config.js').formatEnvironmentsTable} formatEnvs - Environments table formatter
413
+ * @property {import('../../formatters/ai-config.js').formatCategoriesTable} formatCats - Categories table formatter
414
+ * @property {(() => Promise<import('../../types.js').CategoryEntry[]>) | undefined} [refreshEntries] - Reload entries from store after mutations
415
+ */
416
+
417
+ /**
418
+ * Start the interactive tab TUI session.
419
+ * Blocks until the user exits (Esc / q / Ctrl+C).
420
+ * Manages the full TUI lifecycle: terminal setup, keypress loop, tab switching, cleanup.
421
+ *
422
+ * @param {TabTUIOptions} opts
423
+ * @returns {Promise<void>}
424
+ */
425
+ export async function startTabTUI(opts) {
426
+ const {envs, onAction, formatEnvs, formatCats} = opts
427
+ const {entries: initialEntries, chezmoiEnabled} = opts
428
+
429
+ _cleanupCalled = false
430
+
431
+ const sigHandler = () => {
432
+ cleanupTerminal()
433
+ process.exit(0)
434
+ }
435
+ const exitHandler = () => {
436
+ if (!_cleanupCalled) cleanupTerminal()
437
+ }
438
+ process.once('SIGINT', sigHandler)
439
+ process.once('SIGTERM', sigHandler)
440
+ process.once('exit', exitHandler)
441
+
442
+ const tabs = [
443
+ {label: 'Environments', key: 'environments'},
444
+ {label: 'MCPs', key: 'mcp'},
445
+ {label: 'Commands', key: 'command'},
446
+ {label: 'Skills', key: 'skill'},
447
+ {label: 'Agents', key: 'agent'},
448
+ ]
449
+
450
+ const CATEGORY_TYPES = ['mcp', 'command', 'skill', 'agent']
451
+ const chezmoidTip = chezmoiEnabled ? '' : 'Tip: Run `dvmi dotfiles setup` to enable automatic backup of your AI configs'
452
+
453
+ /** @type {TabTUIState} */
454
+ let tuiState = {
455
+ tabs,
456
+ activeTabIndex: 0,
457
+ termRows: process.stdout.rows || 24,
458
+ termCols: process.stdout.columns || 80,
459
+ contentViewportHeight: Math.max(1, (process.stdout.rows || 24) - TAB_BAR_LINES - FOOTER_LINES),
460
+ tooSmall: (process.stdout.columns || 80) < MIN_COLS || (process.stdout.rows || 24) < MIN_ROWS,
461
+ }
462
+
463
+ /** @type {EnvTabState} */
464
+ let envState = {envs, selectedIndex: 0}
465
+
466
+ /** @type {import('../../types.js').CategoryEntry[]} */
467
+ let allEntries = [...initialEntries]
468
+
469
+ /** @type {Record<string, CatTabState>} */
470
+ let catTabStates = Object.fromEntries(
471
+ CATEGORY_TYPES.map((type) => [
472
+ type,
473
+ /** @type {CatTabState} */ ({
474
+ entries: allEntries.filter((e) => e.type === type),
475
+ selectedIndex: 0,
476
+ mode: 'list',
477
+ formState: null,
478
+ confirmDeleteId: null,
479
+ chezmoidTip,
480
+ }),
481
+ ]),
482
+ )
483
+
484
+ /** Push filtered entries into each tab state — call after allEntries changes. */
485
+ function syncTabEntries() {
486
+ for (const type of CATEGORY_TYPES) {
487
+ catTabStates = {
488
+ ...catTabStates,
489
+ [type]: {...catTabStates[type], entries: allEntries.filter((e) => e.type === type)},
490
+ }
491
+ }
492
+ }
493
+
494
+ setupTerminal()
495
+
496
+ /**
497
+ * Build and render the current frame.
498
+ * @returns {void}
499
+ */
500
+ function render() {
501
+ const {termRows, termCols, activeTabIndex, tooSmall, contentViewportHeight} = tuiState
502
+
503
+ if (tooSmall) {
504
+ process.stdout.write(buildTooSmallScreen(termRows, termCols))
505
+ return
506
+ }
507
+
508
+ const tabBarLines = buildTabBar(tabs, activeTabIndex)
509
+ let contentLines
510
+ let hintStr
511
+
512
+ if (activeTabIndex === 0) {
513
+ contentLines = buildEnvironmentsTab(
514
+ envState.envs,
515
+ envState.selectedIndex,
516
+ contentViewportHeight,
517
+ formatEnvs,
518
+ termCols,
519
+ )
520
+ hintStr = chalk.dim(' ↑↓ navigate Tab switch tabs q exit')
521
+ } else {
522
+ const tabKey = tabs[activeTabIndex].key
523
+ const tabState = catTabStates[tabKey]
524
+
525
+ if (tabState.mode === 'form' && tabState.formState) {
526
+ contentLines = buildFormScreen(tabState.formState, contentViewportHeight, termCols)
527
+ hintStr = chalk.dim(' Tab next field Shift+Tab prev Ctrl+S save Esc cancel')
528
+ } else {
529
+ const confirmName =
530
+ tabState.mode === 'confirm-delete' && tabState._confirmDeleteName
531
+ ? /** @type {string} */ (tabState._confirmDeleteName)
532
+ : null
533
+ contentLines = buildCategoriesTab(
534
+ tabState.entries,
535
+ tabState.selectedIndex,
536
+ contentViewportHeight,
537
+ formatCats,
538
+ termCols,
539
+ confirmName,
540
+ )
541
+ hintStr = chalk.dim(' ↑↓ navigate n new Enter edit d toggle Del delete Tab switch q exit')
542
+ }
543
+ }
544
+
545
+ const footerTip = chezmoidTip ? [chalk.dim(chezmoidTip)] : []
546
+ const footerLines = ['', hintStr, ...footerTip]
547
+ process.stdout.write(buildTabScreen(tabBarLines, contentLines, footerLines, termRows))
548
+ }
549
+
550
+ // Resize handler
551
+ function onResize() {
552
+ const newRows = process.stdout.rows || 24
553
+ const newCols = process.stdout.columns || 80
554
+ tuiState = {
555
+ ...tuiState,
556
+ termRows: newRows,
557
+ termCols: newCols,
558
+ contentViewportHeight: Math.max(1, newRows - TAB_BAR_LINES - FOOTER_LINES),
559
+ tooSmall: newCols < MIN_COLS || newRows < MIN_ROWS,
560
+ }
561
+ render()
562
+ }
563
+ process.stdout.on('resize', onResize)
564
+
565
+ render()
566
+
567
+ return new Promise((resolve) => {
568
+ /**
569
+ * @param {string} _str
570
+ * @param {{ name: string, ctrl?: boolean, shift?: boolean, sequence?: string }} key
571
+ */
572
+ const listener = async (_str, key) => {
573
+ if (!key) return
574
+
575
+ // Global keys
576
+ if (key.name === 'escape' || key.name === 'q') {
577
+ process.stdout.removeListener('resize', onResize)
578
+ process.removeListener('SIGINT', sigHandler)
579
+ process.removeListener('SIGTERM', sigHandler)
580
+ process.removeListener('exit', exitHandler)
581
+ cleanupTerminal()
582
+ resolve()
583
+ return
584
+ }
585
+ if (key.ctrl && key.name === 'c') {
586
+ process.stdout.removeListener('resize', onResize)
587
+ process.removeListener('SIGINT', sigHandler)
588
+ process.removeListener('SIGTERM', sigHandler)
589
+ process.removeListener('exit', exitHandler)
590
+ cleanupTerminal()
591
+ resolve()
592
+ return
593
+ }
594
+
595
+ // Tab switching — only when not in form mode (Tab navigates form fields when a form is open)
596
+ const activeTabKey = tuiState.activeTabIndex > 0 ? tabs[tuiState.activeTabIndex].key : null
597
+ const isInFormMode = activeTabKey !== null && catTabStates[activeTabKey]?.mode === 'form'
598
+ if (key.name === 'tab' && !key.shift && !isInFormMode) {
599
+ tuiState = {
600
+ ...tuiState,
601
+ activeTabIndex: (tuiState.activeTabIndex + 1) % tabs.length,
602
+ }
603
+ render()
604
+ return
605
+ }
606
+
607
+ // Delegate to active tab
608
+ if (tuiState.activeTabIndex === 0) {
609
+ // Environments tab — read-only
610
+ const result = handleEnvironmentsKeypress(envState, key)
611
+ envState = /** @type {EnvTabState} */ (result)
612
+ render()
613
+ } else {
614
+ // Category tab (MCPs | Commands | Skills | Agents)
615
+ const tabKey = tabs[tuiState.activeTabIndex].key
616
+ const tabState = catTabStates[tabKey]
617
+
618
+ // Form mode: delegate to form keypress handler
619
+ if (tabState.mode === 'form' && tabState.formState) {
620
+ const formResult = handleFormKeypress(tabState.formState, key)
621
+
622
+ if ('cancelled' in formResult && formResult.cancelled) {
623
+ catTabStates = {
624
+ ...catTabStates,
625
+ [tabKey]: {...tabState, mode: 'list', formState: null, _formAction: null, _editId: null},
626
+ }
627
+ render()
628
+ return
629
+ }
630
+
631
+ if ('submitted' in formResult && formResult.submitted) {
632
+ const formAction = tabState._formAction
633
+ const editId = tabState._editId
634
+ const savedFormState = tabState.formState
635
+ catTabStates = {
636
+ ...catTabStates,
637
+ [tabKey]: {...tabState, mode: 'list', formState: null, _formAction: null, _editId: null},
638
+ }
639
+ render()
640
+ try {
641
+ await onAction({type: formAction, tabKey, values: formResult.values, id: editId})
642
+ if (opts.refreshEntries) {
643
+ allEntries = await opts.refreshEntries()
644
+ syncTabEntries()
645
+ render()
646
+ }
647
+ } catch (err) {
648
+ // Restore form with error message so the user sees what went wrong
649
+ const msg = err instanceof Error ? err.message : String(err)
650
+ catTabStates = {
651
+ ...catTabStates,
652
+ [tabKey]: {
653
+ ...catTabStates[tabKey],
654
+ mode: 'form',
655
+ formState: {...savedFormState, errorMessage: msg},
656
+ _formAction: formAction,
657
+ _editId: editId,
658
+ },
659
+ }
660
+ render()
661
+ }
662
+ return
663
+ }
664
+
665
+ // Still editing — update form state
666
+ catTabStates = {
667
+ ...catTabStates,
668
+ [tabKey]: {...tabState, formState: /** @type {import('./form.js').FormState} */ (formResult)},
669
+ }
670
+ render()
671
+ return
672
+ }
673
+
674
+ // List / confirm-delete mode
675
+ const result = handleCategoriesKeypress(tabState, key)
676
+
677
+ if (result._deleteConfirmed && result.confirmDeleteId) {
678
+ const idToDelete = result.confirmDeleteId
679
+ catTabStates = {
680
+ ...catTabStates,
681
+ [tabKey]: {...result, confirmDeleteId: null, _deleteConfirmed: false},
682
+ }
683
+ render()
684
+ try {
685
+ await onAction({type: 'delete', id: idToDelete})
686
+ if (opts.refreshEntries) {
687
+ allEntries = await opts.refreshEntries()
688
+ syncTabEntries()
689
+ render()
690
+ }
691
+ } catch {
692
+ /* ignore */
693
+ }
694
+ return
695
+ }
696
+
697
+ if (result._toggleId) {
698
+ const idToToggle = result._toggleId
699
+ const entry = tabState.entries.find((e) => e.id === idToToggle)
700
+ catTabStates = {...catTabStates, [tabKey]: {...result, _toggleId: null}}
701
+ render()
702
+ if (entry) {
703
+ try {
704
+ await onAction({type: entry.active ? 'deactivate' : 'activate', id: idToToggle})
705
+ if (opts.refreshEntries) {
706
+ allEntries = await opts.refreshEntries()
707
+ syncTabEntries()
708
+ render()
709
+ }
710
+ } catch {
711
+ /* ignore */
712
+ }
713
+ }
714
+ return
715
+ }
716
+
717
+ if (result._action === 'create') {
718
+ const compatibleEnvs = envs.filter((e) => e.supportedCategories.includes(tabKey))
719
+ const fields =
720
+ tabKey === 'mcp'
721
+ ? getMCPFormFields(null, compatibleEnvs)
722
+ : tabKey === 'command'
723
+ ? getCommandFormFields(null, compatibleEnvs)
724
+ : tabKey === 'skill'
725
+ ? getSkillFormFields(null, compatibleEnvs)
726
+ : getAgentFormFields(null, compatibleEnvs)
727
+ const tabLabel = tabKey === 'mcp' ? 'MCP' : tabKey.charAt(0).toUpperCase() + tabKey.slice(1)
728
+ catTabStates = {
729
+ ...catTabStates,
730
+ [tabKey]: {
731
+ ...result,
732
+ _action: null,
733
+ mode: 'form',
734
+ _formAction: 'create',
735
+ formState: {
736
+ fields,
737
+ focusedFieldIndex: 0,
738
+ title: `Create ${tabLabel}`,
739
+ status: 'editing',
740
+ errorMessage: null,
741
+ },
742
+ },
743
+ }
744
+ render()
745
+ return
746
+ }
747
+
748
+ if (result._action === 'edit' && result._editId) {
749
+ const entry = tabState.entries.find((e) => e.id === result._editId)
750
+ if (entry) {
751
+ const compatibleEnvs = envs.filter((e) => e.supportedCategories.includes(entry.type))
752
+ const fields =
753
+ entry.type === 'mcp'
754
+ ? getMCPFormFields(entry, compatibleEnvs)
755
+ : entry.type === 'command'
756
+ ? getCommandFormFields(entry, compatibleEnvs)
757
+ : entry.type === 'skill'
758
+ ? getSkillFormFields(entry, compatibleEnvs)
759
+ : getAgentFormFields(entry, compatibleEnvs)
760
+ catTabStates = {
761
+ ...catTabStates,
762
+ [tabKey]: {
763
+ ...result,
764
+ _action: null,
765
+ mode: 'form',
766
+ _formAction: 'edit',
767
+ formState: {
768
+ fields,
769
+ focusedFieldIndex: 0,
770
+ title: `Edit ${entry.name}`,
771
+ status: 'editing',
772
+ errorMessage: null,
773
+ },
774
+ },
775
+ }
776
+ render()
777
+ return
778
+ }
779
+ }
780
+
781
+ catTabStates = {...catTabStates, [tabKey]: /** @type {CatTabState} */ (result)}
782
+ render()
783
+ }
784
+ }
785
+
786
+ _keypressListener = listener
787
+ process.stdin.on('keypress', listener)
788
+ process.stdin.resume()
789
+ })
790
+ }
791
+
792
+ /**
793
+ * Update the entries displayed in the Categories tab (called after store mutations).
794
+ * @param {import('../../types.js').CategoryEntry[]} _newEntries
795
+ * @returns {void}
796
+ */
797
+ export function updateTUIEntries(_newEntries) {
798
+ // This is a lightweight state update — the TUI re-renders on next keypress.
799
+ // Callers should call render() manually after this if needed.
800
+ }