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.
- package/README.md +7 -0
- package/oclif.manifest.json +129 -89
- 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 +39 -20
- 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 +16 -16
- 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,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
|
+
}
|