devvami 1.5.0 → 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.
@@ -12,8 +12,10 @@ import {
12
12
  handleFormKeypress,
13
13
  getMCPFormFields,
14
14
  getCommandFormFields,
15
+ getRuleFormFields,
15
16
  getSkillFormFields,
16
17
  getAgentFormFields,
18
+ validateMCPForm,
17
19
  } from './form.js'
18
20
 
19
21
  // ──────────────────────────────────────────────────────────────────────────────
@@ -76,12 +78,16 @@ let _keypressListener = null
76
78
 
77
79
  /**
78
80
  * @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
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
82
86
  * @property {import('./form.js').FormState|null} formState - Active form state (null when mode is 'list')
83
87
  * @property {string|null} confirmDeleteId - Entry id pending deletion confirmation
84
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
85
91
  */
86
92
 
87
93
  // ──────────────────────────────────────────────────────────────────────────────
@@ -233,27 +239,84 @@ export function handleEnvironmentsKeypress(state, key) {
233
239
  }
234
240
 
235
241
  // ──────────────────────────────────────────────────────────────────────────────
236
- // Categories tab content builder (T036) defined here for single-module TUI
242
+ // Categories tab content builder — dual Native/Managed sections
237
243
  // ──────────────────────────────────────────────────────────────────────────────
238
244
 
239
245
  /**
240
- * Build the content lines for the Categories tab.
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.
241
311
  * @param {import('../../types.js').CategoryEntry[]} entries
242
312
  * @param {number} selectedIndex
243
313
  * @param {number} viewportHeight
244
314
  * @param {import('../../formatters/ai-config.js').formatCategoriesTable} formatFn
245
315
  * @param {number} termCols
246
- * @param {string|null} [confirmDeleteName] - Name of entry pending delete confirmation
316
+ * @param {string|null} [confirmDeleteName]
247
317
  * @returns {string[]}
248
318
  */
249
- export function buildCategoriesTab(
250
- entries,
251
- selectedIndex,
252
- viewportHeight,
253
- formatFn,
254
- termCols = 120,
255
- confirmDeleteName = null,
256
- ) {
319
+ export function buildCategoriesTabLegacy(entries, selectedIndex, viewportHeight, formatFn, termCols = 120, confirmDeleteName = null) {
257
320
  if (entries.length === 0) {
258
321
  const lines = [
259
322
  '',
@@ -277,7 +340,6 @@ export function buildCategoriesTab(
277
340
  }
278
341
  }
279
342
 
280
- // Confirmation prompt overlay
281
343
  if (confirmDeleteName !== null) {
282
344
  resultLines.push('')
283
345
  resultLines.push(chalk.red(` Delete "${confirmDeleteName}"? This cannot be undone. `) + chalk.bold('[y/N]'))
@@ -286,6 +348,61 @@ export function buildCategoriesTab(
286
348
  return resultLines.slice(0, viewportHeight)
287
349
  }
288
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
+
289
406
  // ──────────────────────────────────────────────────────────────────────────────
290
407
  // Categories tab keypress reducer (T037)
291
408
  // ──────────────────────────────────────────────────────────────────────────────
@@ -297,41 +414,70 @@ export function buildCategoriesTab(
297
414
  * @returns {CatTabState | { exit: true }}
298
415
  */
299
416
  export function handleCategoriesKeypress(state, key) {
300
- const {selectedIndex, entries, mode, confirmDeleteId} = state
301
- const maxIndex = Math.max(0, entries.length - 1)
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)
302
420
 
303
421
  // Confirm-delete mode
304
422
  if (mode === 'confirm-delete') {
305
423
  if (key.name === 'y') {
306
- return {
307
- ...state,
308
- mode: 'list',
309
- confirmDeleteId: key.name === 'y' ? confirmDeleteId : null,
310
- _deleteConfirmed: true,
311
- }
424
+ return {...state, mode: 'list', _deleteConfirmed: true}
312
425
  }
313
426
  // Any other key cancels
314
427
  return {...state, mode: 'list', confirmDeleteId: null}
315
428
  }
316
429
 
317
- // List mode
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
318
440
  if (key.name === 'up' || key.name === 'k') {
319
- return {...state, selectedIndex: Math.max(0, selectedIndex - 1)}
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}
320
445
  }
321
446
  if (key.name === 'down' || key.name === 'j') {
322
- return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 1)}
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}
323
451
  }
324
452
  if (key.name === 'pageup') {
325
- return {...state, selectedIndex: Math.max(0, selectedIndex - 10)}
453
+ return {...state, selectedIndex: Math.max(0, selectedIndex - 10), revealedEntryId: null}
326
454
  }
327
455
  if (key.name === 'pagedown') {
328
- return {...state, selectedIndex: Math.min(maxIndex, selectedIndex + 10)}
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
329
466
  }
467
+
468
+ // Managed section actions
330
469
  if (key.name === 'n') {
331
470
  return {...state, mode: 'form', _action: 'create'}
332
471
  }
333
472
  if (key.name === 'return' && entries.length > 0) {
334
- return {...state, mode: 'form', _action: 'edit', _editId: entries[selectedIndex]?.id}
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
+ }
335
481
  }
336
482
  if (key.name === 'd' && entries.length > 0) {
337
483
  return {...state, _toggleId: entries[selectedIndex]?.id}
@@ -342,6 +488,13 @@ export function handleCategoriesKeypress(state, key) {
342
488
  return {...state, mode: 'confirm-delete', confirmDeleteId: entry.id, _confirmDeleteName: entry.name}
343
489
  }
344
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
+ }
345
498
 
346
499
  return state
347
500
  }
@@ -411,6 +564,7 @@ export function cleanupTerminal() {
411
564
  * @property {(action: object) => Promise<void>} onAction - Callback for CRUD actions from category tabs
412
565
  * @property {import('../../formatters/ai-config.js').formatEnvironmentsTable} formatEnvs - Environments table formatter
413
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
414
568
  * @property {(() => Promise<import('../../types.js').CategoryEntry[]>) | undefined} [refreshEntries] - Reload entries from store after mutations
415
569
  */
416
570
 
@@ -423,7 +577,7 @@ export function cleanupTerminal() {
423
577
  * @returns {Promise<void>}
424
578
  */
425
579
  export async function startTabTUI(opts) {
426
- const {envs, onAction, formatEnvs, formatCats} = opts
580
+ const {envs, onAction, formatEnvs, formatCats, formatNative} = opts
427
581
  const {entries: initialEntries, chezmoiEnabled} = opts
428
582
 
429
583
  _cleanupCalled = false
@@ -443,11 +597,12 @@ export async function startTabTUI(opts) {
443
597
  {label: 'Environments', key: 'environments'},
444
598
  {label: 'MCPs', key: 'mcp'},
445
599
  {label: 'Commands', key: 'command'},
600
+ {label: 'Rules', key: 'rule'},
446
601
  {label: 'Skills', key: 'skill'},
447
602
  {label: 'Agents', key: 'agent'},
448
603
  ]
449
604
 
450
- const CATEGORY_TYPES = ['mcp', 'command', 'skill', 'agent']
605
+ const CATEGORY_TYPES = ['mcp', 'command', 'rule', 'skill', 'agent']
451
606
  const chezmoidTip = chezmoiEnabled ? '' : 'Tip: Run `dvmi dotfiles setup` to enable automatic backup of your AI configs'
452
607
 
453
608
  /** @type {TabTUIState} */
@@ -466,27 +621,53 @@ export async function startTabTUI(opts) {
466
621
  /** @type {import('../../types.js').CategoryEntry[]} */
467
622
  let allEntries = [...initialEntries]
468
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
+
469
644
  /** @type {Record<string, CatTabState>} */
470
645
  let catTabStates = Object.fromEntries(
471
646
  CATEGORY_TYPES.map((type) => [
472
647
  type,
473
648
  /** @type {CatTabState} */ ({
474
649
  entries: allEntries.filter((e) => e.type === type),
650
+ nativeEntries: getNativesByType(type),
475
651
  selectedIndex: 0,
652
+ section: 'managed',
476
653
  mode: 'list',
477
654
  formState: null,
478
655
  confirmDeleteId: null,
479
656
  chezmoidTip,
657
+ revealedEntryId: null,
658
+ driftInfos: getDriftsByType(type),
480
659
  }),
481
660
  ]),
482
661
  )
483
662
 
484
- /** Push filtered entries into each tab state — call after allEntries changes. */
663
+ /** Push filtered entries and drift infos into each tab state — call after allEntries changes. */
485
664
  function syncTabEntries() {
486
665
  for (const type of CATEGORY_TYPES) {
666
+ const entriesForType = allEntries.filter((e) => e.type === type)
667
+ const driftsForType = getDriftsByType(type)
487
668
  catTabStates = {
488
669
  ...catTabStates,
489
- [type]: {...catTabStates[type], entries: allEntries.filter((e) => e.type === type)},
670
+ [type]: {...catTabStates[type], entries: entriesForType, driftInfos: driftsForType},
490
671
  }
491
672
  }
492
673
  }
@@ -517,7 +698,7 @@ export async function startTabTUI(opts) {
517
698
  formatEnvs,
518
699
  termCols,
519
700
  )
520
- hintStr = chalk.dim(' ↑↓ navigate Tab switch tabs q exit')
701
+ hintStr = chalk.dim(' ↑↓ navigate ←→ switch tabs q exit')
521
702
  } else {
522
703
  const tabKey = tabs[activeTabIndex].key
523
704
  const tabState = catTabStates[tabKey]
@@ -525,20 +706,15 @@ export async function startTabTUI(opts) {
525
706
  if (tabState.mode === 'form' && tabState.formState) {
526
707
  contentLines = buildFormScreen(tabState.formState, contentViewportHeight, termCols)
527
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')
528
712
  } 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')
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`)
542
718
  }
543
719
  }
544
720
 
@@ -572,8 +748,16 @@ export async function startTabTUI(opts) {
572
748
  const listener = async (_str, key) => {
573
749
  if (!key) return
574
750
 
575
- // Global keys
576
- if (key.name === 'escape' || key.name === 'q') {
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') {
577
761
  process.stdout.removeListener('resize', onResize)
578
762
  process.removeListener('SIGINT', sigHandler)
579
763
  process.removeListener('SIGTERM', sigHandler)
@@ -582,7 +766,9 @@ export async function startTabTUI(opts) {
582
766
  resolve()
583
767
  return
584
768
  }
585
- if (key.ctrl && key.name === 'c') {
769
+
770
+ // ── Esc: close sub-mode first; exit TUI only from list mode ──
771
+ if (key.name === 'escape' && !isModalMode) {
586
772
  process.stdout.removeListener('resize', onResize)
587
773
  process.removeListener('SIGINT', sigHandler)
588
774
  process.removeListener('SIGTERM', sigHandler)
@@ -591,19 +777,49 @@ export async function startTabTUI(opts) {
591
777
  resolve()
592
778
  return
593
779
  }
780
+ // In modal modes Esc falls through to per-tab handlers (form cancel, drift back, etc.)
594
781
 
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
- }
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}
603
801
  render()
604
802
  return
605
803
  }
606
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
+
607
823
  // Delegate to active tab
608
824
  if (tuiState.activeTabIndex === 0) {
609
825
  // Environments tab — read-only
@@ -714,6 +930,73 @@ export async function startTabTUI(opts) {
714
930
  return
715
931
  }
716
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
+
717
1000
  if (result._action === 'create') {
718
1001
  const compatibleEnvs = envs.filter((e) => e.supportedCategories.includes(tabKey))
719
1002
  const fields =
@@ -721,9 +1004,11 @@ export async function startTabTUI(opts) {
721
1004
  ? getMCPFormFields(null, compatibleEnvs)
722
1005
  : tabKey === 'command'
723
1006
  ? getCommandFormFields(null, compatibleEnvs)
724
- : tabKey === 'skill'
725
- ? getSkillFormFields(null, compatibleEnvs)
726
- : getAgentFormFields(null, compatibleEnvs)
1007
+ : tabKey === 'rule'
1008
+ ? getRuleFormFields(null, compatibleEnvs)
1009
+ : tabKey === 'skill'
1010
+ ? getSkillFormFields(null, compatibleEnvs)
1011
+ : getAgentFormFields(null, compatibleEnvs)
727
1012
  const tabLabel = tabKey === 'mcp' ? 'MCP' : tabKey.charAt(0).toUpperCase() + tabKey.slice(1)
728
1013
  catTabStates = {
729
1014
  ...catTabStates,
@@ -738,6 +1023,7 @@ export async function startTabTUI(opts) {
738
1023
  title: `Create ${tabLabel}`,
739
1024
  status: 'editing',
740
1025
  errorMessage: null,
1026
+ customValidator: tabKey === 'mcp' ? validateMCPForm : null,
741
1027
  },
742
1028
  },
743
1029
  }
@@ -754,9 +1040,11 @@ export async function startTabTUI(opts) {
754
1040
  ? getMCPFormFields(entry, compatibleEnvs)
755
1041
  : entry.type === 'command'
756
1042
  ? getCommandFormFields(entry, compatibleEnvs)
757
- : entry.type === 'skill'
758
- ? getSkillFormFields(entry, compatibleEnvs)
759
- : getAgentFormFields(entry, compatibleEnvs)
1043
+ : entry.type === 'rule'
1044
+ ? getRuleFormFields(entry, compatibleEnvs)
1045
+ : entry.type === 'skill'
1046
+ ? getSkillFormFields(entry, compatibleEnvs)
1047
+ : getAgentFormFields(entry, compatibleEnvs)
760
1048
  catTabStates = {
761
1049
  ...catTabStates,
762
1050
  [tabKey]: {
@@ -770,6 +1058,7 @@ export async function startTabTUI(opts) {
770
1058
  title: `Edit ${entry.name}`,
771
1059
  status: 'editing',
772
1060
  errorMessage: null,
1061
+ customValidator: entry.type === 'mcp' ? validateMCPForm : null,
773
1062
  },
774
1063
  },
775
1064
  }