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.
- package/README.md +72 -0
- package/oclif.manifest.json +275 -235
- 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 +257 -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 +215 -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 +349 -0
- package/src/services/ai-env-deployer.js +650 -0
- package/src/services/ai-env-scanner.js +983 -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 +117 -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 +1184 -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 +1089 -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,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
|
+
}
|