@contentful/experience-design-system-cli 2.14.2 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +64 -2
  2. package/dist/package.json +3 -2
  3. package/dist/src/analyze/command.d.ts +11 -0
  4. package/dist/src/analyze/command.js +298 -3
  5. package/dist/src/analyze/composite-closure.d.ts +25 -0
  6. package/dist/src/analyze/composite-closure.js +0 -0
  7. package/dist/src/analyze/composition/agent-parser/author-prompt.d.ts +4 -0
  8. package/dist/src/analyze/composition/agent-parser/author-prompt.js +22 -0
  9. package/dist/src/analyze/composition/agent-parser/extract-parser.d.ts +8 -0
  10. package/dist/src/analyze/composition/agent-parser/extract-parser.js +44 -0
  11. package/dist/src/analyze/composition/agent-parser/load-prompt.d.ts +7 -0
  12. package/dist/src/analyze/composition/agent-parser/load-prompt.js +31 -0
  13. package/dist/src/analyze/composition/agent-parser/resolve-via-parser.d.ts +46 -0
  14. package/dist/src/analyze/composition/agent-parser/resolve-via-parser.js +88 -0
  15. package/dist/src/analyze/composition/agent-parser/sandbox.d.ts +45 -0
  16. package/dist/src/analyze/composition/agent-parser/sandbox.js +187 -0
  17. package/dist/src/analyze/composition/apply-mapping.d.ts +22 -0
  18. package/dist/src/analyze/composition/apply-mapping.js +67 -0
  19. package/dist/src/analyze/composition/candidate-critic-agent.d.ts +7 -0
  20. package/dist/src/analyze/composition/candidate-critic-agent.js +39 -0
  21. package/dist/src/analyze/composition/candidate-critic.d.ts +32 -0
  22. package/dist/src/analyze/composition/candidate-critic.js +63 -0
  23. package/dist/src/analyze/composition/candidate-files.d.ts +34 -0
  24. package/dist/src/analyze/composition/candidate-files.js +131 -0
  25. package/dist/src/analyze/composition/composition-cache-key.d.ts +19 -0
  26. package/dist/src/analyze/composition/composition-cache-key.js +19 -0
  27. package/dist/src/analyze/composition/interchange-schema.d.ts +37 -0
  28. package/dist/src/analyze/composition/interchange-schema.js +61 -0
  29. package/dist/src/analyze/composition/merge-edges.d.ts +19 -0
  30. package/dist/src/analyze/composition/merge-edges.js +50 -0
  31. package/dist/src/analyze/composition/parse-map-edges.d.ts +16 -0
  32. package/dist/src/analyze/composition/parse-map-edges.js +55 -0
  33. package/dist/src/analyze/composition/resolve-mapping-cli.d.ts +23 -0
  34. package/dist/src/analyze/composition/resolve-mapping-cli.js +29 -0
  35. package/dist/src/analyze/composition/resolve-mapping.d.ts +59 -0
  36. package/dist/src/analyze/composition/resolve-mapping.js +94 -0
  37. package/dist/src/analyze/cycle-detection.d.ts +27 -0
  38. package/dist/src/analyze/cycle-detection.js +234 -0
  39. package/dist/src/analyze/cycle-view.d.ts +7 -0
  40. package/dist/src/analyze/cycle-view.js +28 -0
  41. package/dist/src/analyze/fuzzy-search.d.ts +3 -0
  42. package/dist/src/analyze/fuzzy-search.js +55 -0
  43. package/dist/src/analyze/issue-inheritance.d.ts +12 -0
  44. package/dist/src/analyze/issue-inheritance.js +108 -0
  45. package/dist/src/analyze/lineage.d.ts +29 -0
  46. package/dist/src/analyze/lineage.js +0 -0
  47. package/dist/src/analyze/scope-gate-cascade.d.ts +11 -0
  48. package/dist/src/analyze/scope-gate-cascade.js +170 -0
  49. package/dist/src/analyze/search-neighborhood.d.ts +3 -0
  50. package/dist/src/analyze/search-neighborhood.js +56 -0
  51. package/dist/src/analyze/select/tui/App.js +5 -16
  52. package/dist/src/analyze/select/tui/components/ComponentRationalePanel.d.ts +0 -4
  53. package/dist/src/analyze/select/tui/components/ComponentRationalePanel.js +3 -15
  54. package/dist/src/analyze/select/tui/components/FieldEditor.d.ts +29 -38
  55. package/dist/src/analyze/select/tui/components/FieldEditor.js +309 -185
  56. package/dist/src/analyze/select/tui/components/FinalizeDialog.d.ts +13 -1
  57. package/dist/src/analyze/select/tui/components/FinalizeDialog.js +23 -2
  58. package/dist/src/analyze/select/tui/components/GotoBanner.d.ts +31 -0
  59. package/dist/src/analyze/select/tui/components/GotoBanner.js +30 -0
  60. package/dist/src/analyze/select/tui/components/GroupedSidebar.d.ts +91 -0
  61. package/dist/src/analyze/select/tui/components/GroupedSidebar.js +497 -0
  62. package/dist/src/analyze/select/tui/components/HelpOverlay.d.ts +13 -1
  63. package/dist/src/analyze/select/tui/components/HelpOverlay.js +9 -3
  64. package/dist/src/analyze/select/tui/components/JsonEditor.js +3 -3
  65. package/dist/src/analyze/select/tui/components/LineagePanel.d.ts +14 -0
  66. package/dist/src/analyze/select/tui/components/LineagePanel.js +22 -0
  67. package/dist/src/analyze/select/tui/components/RationalePanel.d.ts +0 -7
  68. package/dist/src/analyze/select/tui/components/RationalePanel.js +3 -15
  69. package/dist/src/analyze/select/tui/components/Sidebar.d.ts +0 -6
  70. package/dist/src/analyze/select/tui/components/Sidebar.js +11 -27
  71. package/dist/src/analyze/select/tui/components/StatusBar.js +2 -1
  72. package/dist/src/analyze/select/tui/components/removed-components-text.d.ts +7 -0
  73. package/dist/src/analyze/select/tui/components/removed-components-text.js +11 -0
  74. package/dist/src/analyze/select/tui/hooks/useImmediateInput.d.ts +1 -5
  75. package/dist/src/analyze/select/tui/hooks/useImmediateInput.js +8 -10
  76. package/dist/src/analyze/select/tui/theme.d.ts +15 -0
  77. package/dist/src/analyze/select/tui/theme.js +28 -0
  78. package/dist/src/analyze/selection-cascade.d.ts +3 -0
  79. package/dist/src/analyze/selection-cascade.js +19 -0
  80. package/dist/src/analyze/slot-graph.d.ts +11 -0
  81. package/dist/src/analyze/slot-graph.js +17 -0
  82. package/dist/src/apply/api-client.d.ts +2 -1
  83. package/dist/src/apply/api-client.js +40 -6
  84. package/dist/src/apply/command.d.ts +17 -1
  85. package/dist/src/apply/command.js +69 -11
  86. package/dist/src/apply/error-parser.d.ts +16 -0
  87. package/dist/src/apply/error-parser.js +117 -0
  88. package/dist/src/apply/tui/ServerPreviewView.js +3 -1
  89. package/dist/src/credentials-store.d.ts +6 -0
  90. package/dist/src/credentials-store.js +5 -1
  91. package/dist/src/generate/agent-runner.d.ts +6 -0
  92. package/dist/src/generate/agent-runner.js +24 -8
  93. package/dist/src/import/command.js +69 -31
  94. package/dist/src/import/cycle-auto-reject.d.ts +4 -0
  95. package/dist/src/import/cycle-auto-reject.js +14 -0
  96. package/dist/src/import/orchestrator.d.ts +20 -7
  97. package/dist/src/import/orchestrator.js +139 -35
  98. package/dist/src/import/strip-allowed-components.d.ts +18 -0
  99. package/dist/src/import/strip-allowed-components.js +22 -0
  100. package/dist/src/import/tui/CustomPromptBanner.d.ts +0 -5
  101. package/dist/src/import/tui/CustomPromptBanner.js +2 -1
  102. package/dist/src/import/tui/WizardApp.d.ts +10 -74
  103. package/dist/src/import/tui/WizardApp.js +234 -246
  104. package/dist/src/import/tui/ai-flag.d.ts +5 -0
  105. package/dist/src/import/tui/ai-flag.js +6 -0
  106. package/dist/src/import/tui/autocomplete.d.ts +5 -0
  107. package/dist/src/import/tui/autocomplete.js +32 -0
  108. package/dist/src/import/tui/components/AutoFilterBanner.d.ts +10 -0
  109. package/dist/src/import/tui/components/AutoFilterBanner.js +19 -0
  110. package/dist/src/import/tui/components/CounterStrip.d.ts +12 -0
  111. package/dist/src/import/tui/components/CounterStrip.js +12 -0
  112. package/dist/src/import/tui/components/LegendEntry.d.ts +2 -0
  113. package/dist/src/import/tui/components/LegendEntry.js +6 -0
  114. package/dist/src/import/tui/cycle-panel-scroll.d.ts +8 -0
  115. package/dist/src/import/tui/cycle-panel-scroll.js +33 -0
  116. package/dist/src/import/tui/final-review-host.d.ts +3 -7
  117. package/dist/src/import/tui/final-review-host.js +9 -4
  118. package/dist/src/import/tui/group-collapse.d.ts +2 -0
  119. package/dist/src/import/tui/group-collapse.js +11 -0
  120. package/dist/src/import/tui/history.d.ts +22 -0
  121. package/dist/src/import/tui/history.js +53 -0
  122. package/dist/src/import/tui/hooks/useLineage.d.ts +32 -0
  123. package/dist/src/import/tui/hooks/useLineage.js +50 -0
  124. package/dist/src/import/tui/hooks/useOverlayPanel.d.ts +13 -0
  125. package/dist/src/import/tui/hooks/useOverlayPanel.js +27 -0
  126. package/dist/src/import/tui/lineage-layout.d.ts +75 -0
  127. package/dist/src/import/tui/lineage-layout.js +82 -0
  128. package/dist/src/import/tui/runLivePreview.d.ts +11 -0
  129. package/dist/src/import/tui/runLivePreview.js +7 -1
  130. package/dist/src/import/tui/scope-gate-columns.d.ts +31 -0
  131. package/dist/src/import/tui/scope-gate-columns.js +94 -0
  132. package/dist/src/import/tui/scope-gate-host.d.ts +3 -1
  133. package/dist/src/import/tui/scope-gate-host.js +11 -3
  134. package/dist/src/import/tui/sidebar-width.d.ts +1 -0
  135. package/dist/src/import/tui/sidebar-width.js +3 -0
  136. package/dist/src/import/tui/step-filters.d.ts +19 -0
  137. package/dist/src/import/tui/step-filters.js +42 -0
  138. package/dist/src/import/tui/steps/AtomicGenerateReviewStep.d.ts +42 -0
  139. package/dist/src/import/tui/steps/AtomicGenerateReviewStep.js +726 -0
  140. package/dist/src/import/tui/steps/AtomicScopeGateStep.d.ts +3 -0
  141. package/dist/src/import/tui/steps/AtomicScopeGateStep.js +211 -0
  142. package/dist/src/import/tui/steps/CredentialsStep.js +5 -18
  143. package/dist/src/import/tui/steps/DoneStep.d.ts +0 -2
  144. package/dist/src/import/tui/steps/DoneStep.js +3 -2
  145. package/dist/src/import/tui/steps/ErrorStep.js +2 -1
  146. package/dist/src/import/tui/steps/GenerateReviewStep.d.ts +19 -25
  147. package/dist/src/import/tui/steps/GenerateReviewStep.js +1258 -229
  148. package/dist/src/import/tui/steps/PathValidationStep.js +3 -2
  149. package/dist/src/import/tui/steps/PreviewStep.js +2 -1
  150. package/dist/src/import/tui/steps/PushDecisionGateStep.d.ts +0 -7
  151. package/dist/src/import/tui/steps/PushDecisionGateStep.js +3 -6
  152. package/dist/src/import/tui/steps/PushingStep.js +2 -1
  153. package/dist/src/import/tui/steps/RunningStep.d.ts +4 -1
  154. package/dist/src/import/tui/steps/RunningStep.js +3 -2
  155. package/dist/src/import/tui/steps/ScopeGateStep.d.ts +24 -0
  156. package/dist/src/import/tui/steps/ScopeGateStep.js +850 -193
  157. package/dist/src/import/tui/steps/TokenInputStep.js +2 -1
  158. package/dist/src/import/tui/steps/WelcomeStep.js +2 -1
  159. package/dist/src/import/tui/steps/WizardPreviewStep.d.ts +6 -0
  160. package/dist/src/import/tui/steps/WizardPreviewStep.js +95 -56
  161. package/dist/src/import/tui/steps/auto-reject-decision.d.ts +7 -0
  162. package/dist/src/import/tui/steps/auto-reject-decision.js +9 -0
  163. package/dist/src/import/tui/steps/breaking-change-format.d.ts +2 -0
  164. package/dist/src/import/tui/steps/breaking-change-format.js +24 -0
  165. package/dist/src/import/tui/steps/enumerate-cycle-breaks.d.ts +14 -0
  166. package/dist/src/import/tui/steps/enumerate-cycle-breaks.js +0 -0
  167. package/dist/src/import/tui/steps/preview-diff.js +48 -5
  168. package/dist/src/import/tui/useFinalizePreview.d.ts +34 -0
  169. package/dist/src/import/tui/useFinalizePreview.js +0 -0
  170. package/dist/src/import/tui/useLivePreview.d.ts +3 -0
  171. package/dist/src/import/tui/useLivePreview.js +1 -0
  172. package/dist/src/import/tui/wizard-state-transitions.d.ts +5 -59
  173. package/dist/src/import/tui/wizard-state-transitions.js +5 -60
  174. package/dist/src/lib/composition-mode.d.ts +28 -0
  175. package/dist/src/lib/composition-mode.js +37 -0
  176. package/dist/src/lib/prompt-overrides.d.ts +38 -0
  177. package/dist/src/lib/prompt-overrides.js +69 -0
  178. package/dist/src/print/command.js +19 -1
  179. package/dist/src/runs/modify-launcher.d.ts +3 -0
  180. package/dist/src/runs/modify-launcher.js +2 -0
  181. package/dist/src/runs/replay-helpers.js +1 -0
  182. package/dist/src/runs/run-picker.js +2 -18
  183. package/dist/src/runs/store.d.ts +4 -0
  184. package/dist/src/session/db.d.ts +23 -40
  185. package/dist/src/session/db.js +110 -138
  186. package/dist/src/setup/composition-mode-prompt.d.ts +14 -0
  187. package/dist/src/setup/composition-mode-prompt.js +37 -0
  188. package/package.json +5 -4
  189. package/prompts/composition-dir-critic.md +7 -0
  190. package/prompts/composition-edges.md +8 -0
  191. package/prompts/composition-parser-repair-empty.md +1 -0
  192. package/prompts/composition-parser-repair-error.md +2 -0
  193. package/prompts/composition-parser.md +42 -0
@@ -1,86 +1,267 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from 'react';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { PALETTE } from '../../../analyze/select/tui/theme.js';
3
4
  import { Box, Text, useStdout } from 'ink';
4
- import { Sidebar } from '../../../analyze/select/tui/components/Sidebar.js';
5
+ import { GroupedSidebar, buildVisibleRows, } from '../../../analyze/select/tui/components/GroupedSidebar.js';
6
+ import { computeAllClosures } from '../../../analyze/composite-closure.js';
7
+ import { buildComponentGraph } from '../../../analyze/slot-graph.js';
8
+ import { computeCycleView } from '../../../analyze/cycle-view.js';
9
+ import { computeRenderStatuses, pickDrillTarget } from '../../../analyze/issue-inheritance.js';
5
10
  import { JsonPanel } from '../../../analyze/select/tui/components/JsonPanel.js';
6
11
  import { FieldEditor } from '../../../analyze/select/tui/components/FieldEditor.js';
7
12
  import { StatusBar } from '../../../analyze/select/tui/components/StatusBar.js';
8
13
  import { FinalizeDialog } from '../../../analyze/select/tui/components/FinalizeDialog.js';
14
+ import { removedComponentsHeader, removedComponentLine, } from '../../../analyze/select/tui/components/removed-components-text.js';
9
15
  import { QuitDialog } from '../../../analyze/select/tui/components/QuitDialog.js';
10
16
  import { useImmediateInput } from '../../../analyze/select/tui/hooks/useImmediateInput.js';
11
- import { openPipelineDb, loadCDFComponents, storeCDFComponents, loadComponentReviewMetadata, loadComponentRationale, } from '../../../session/db.js';
17
+ import { openPipelineDb, loadCDFComponents, storeCDFComponents, loadComponentReviewMetadata, loadComponentRationale, loadSlotCycles, storeSlotCycles, } from '../../../session/db.js';
18
+ import { formatCyclePathSegments, findSlotCycles, suggestCycleBreakEdge } from '../../../analyze/cycle-detection.js';
19
+ import { followCycleScroll } from '../cycle-panel-scroll.js';
12
20
  import { RationalePanel } from '../../../analyze/select/tui/components/RationalePanel.js';
13
21
  import { ComponentRationalePanel } from '../../../analyze/select/tui/components/ComponentRationalePanel.js';
14
22
  import { applyPreviewAnnotations } from '../../../analyze/select/preview-annotations.js';
15
23
  import { useLivePreview } from '../useLivePreview.js';
24
+ import { useFinalizePreview } from '../useFinalizePreview.js';
16
25
  import { computeNextScrollOffset } from '../../../analyze/select/tui/hooks/scroll-offset.js';
17
- /**
18
- * Sort components for the final-review sidebar so the underlying data array
19
- * matches the visual order. Empty components (zero classified $properties)
20
- * surface at the top via the warning-tier path in Sidebar.tsx; we mirror that
21
- * here so `selectedIdx` indexes into the same order the user sees. Without
22
- * this, j/k navigation lands on different rows than the visually-selected
23
- * one (INTEG-4259).
24
- *
25
- * Within each tier (empty / non-empty) we tie-break alphabetically by `key`.
26
- */
27
- export function sortComponentsForSidebar(components) {
26
+ import { fuzzyMatches } from '../../../analyze/fuzzy-search.js';
27
+ import { computeDirectNeighborhood, findAllAncestors as findAllAncestorsInclusive, } from '../../../analyze/search-neighborhood.js';
28
+ import { computeSidebarWidth } from '../sidebar-width.js';
29
+ import { computeAcceptCascade, computeRejectCascade } from '../../../analyze/selection-cascade.js';
30
+ import { useLineage } from '../hooks/useLineage.js';
31
+ import { computeCycleAutoRejectTargets } from '../../cycle-auto-reject.js';
32
+ import { useOverlayPanel } from '../hooks/useOverlayPanel.js';
33
+ import { LineagePanel } from '../../../analyze/select/tui/components/LineagePanel.js';
34
+ import { GotoBanner } from '../../../analyze/select/tui/components/GotoBanner.js';
35
+ import { computeSidebarBudget, FALLBACK_ROWS } from '../lineage-layout.js';
36
+ import { HelpOverlay } from '../../../analyze/select/tui/components/HelpOverlay.js';
37
+ import { legendEntry } from '../components/LegendEntry.js';
38
+ import { computeAutoRejectDecision } from './auto-reject-decision.js';
39
+ import { formatBreakingChange } from './breaking-change-format.js';
40
+ import { enumerateCycleBreaks, shouldBreakOverlayGoFullScreen } from './enumerate-cycle-breaks.js';
41
+ import { createHistoryStack } from '../history.js';
42
+ import { computeAutocomplete } from '../autocomplete.js';
43
+ import { resolveGroupRoot } from '../group-collapse.js';
44
+ import { buildFlatDimPredicate, computeFilterKeys, intersectFilterKeys } from '../step-filters.js';
45
+ export function sortComponentsForSidebar(components, cycleParticipants) {
46
+ const isEmpty = (entry) => Object.keys(entry.$properties ?? {}).length === 0 && Object.keys(entry.$slots ?? {}).length === 0;
47
+ const tier = (c) => {
48
+ if (cycleParticipants?.has(c.key))
49
+ return 0;
50
+ if (isEmpty(c.entry))
51
+ return 1;
52
+ return 2;
53
+ };
28
54
  return [...components].sort((a, b) => {
29
- const aEmpty = Object.keys(a.entry.$properties ?? {}).length === 0;
30
- const bEmpty = Object.keys(b.entry.$properties ?? {}).length === 0;
31
- if (aEmpty !== bEmpty)
32
- return aEmpty ? -1 : 1;
55
+ const at = tier(a);
56
+ const bt = tier(b);
57
+ if (at !== bt)
58
+ return at - bt;
33
59
  return a.key.localeCompare(b.key);
34
60
  });
35
61
  }
36
- const VISIBLE_COUNT = 20;
37
62
  const PANEL_HEIGHT = 22;
63
+ const HELP_SECTIONS = [
64
+ {
65
+ title: 'Navigation',
66
+ entries: [
67
+ { keys: 'j / k / ↑ / ↓', label: 'Move cursor' },
68
+ { keys: 'Tab', label: 'Toggle sidebar/panel' },
69
+ { keys: 'Enter', label: 'Drill to source' },
70
+ ],
71
+ },
72
+ {
73
+ title: 'Selection',
74
+ entries: [
75
+ { keys: 'a', label: 'Accept' },
76
+ { keys: 'r', label: 'Reject' },
77
+ { keys: 'A', label: 'Accept all' },
78
+ { keys: 'F', label: 'Finalize' },
79
+ ],
80
+ },
81
+ {
82
+ title: 'Sidebar views',
83
+ entries: [
84
+ { keys: 'L', label: 'Flat view' },
85
+ { keys: 'l', label: 'Lineage' },
86
+ { keys: 'i', label: 'Focus lineage' },
87
+ { keys: 'w', label: 'Only breaking' },
88
+ { keys: 'o', label: 'Only cycles' },
89
+ { keys: 'space', label: 'Expand/collapse group' },
90
+ { keys: 'E / C', label: 'Expand/collapse all' },
91
+ ],
92
+ },
93
+ {
94
+ title: 'Panels',
95
+ entries: [
96
+ { keys: 'c', label: 'Cycle list' },
97
+ { keys: 'p', label: 'Prop rationale' },
98
+ { keys: 'P', label: 'Component rationale' },
99
+ { keys: 's', label: 'Source' },
100
+ { keys: 'J', label: 'Toggle JSON' },
101
+ ],
102
+ },
103
+ {
104
+ title: 'Resolving cycles',
105
+ entries: [
106
+ { keys: 'r', label: 'Reject a cycle member (drops it from the push), or' },
107
+ { keys: '', label: "break the cycle by removing a slot's" },
108
+ { keys: '', label: '$allowedComponents edge (see [c] suggested fix).' },
109
+ { keys: 'x', label: 'Break cycle (from [c]): delete a slot edge.' },
110
+ ],
111
+ },
112
+ {
113
+ title: 'Search',
114
+ entries: [{ keys: '/', label: 'Search' }],
115
+ },
116
+ {
117
+ title: 'History',
118
+ entries: [
119
+ { keys: 'Ctrl+Z', label: 'Undo' },
120
+ { keys: 'Ctrl+Y', label: 'Redo' },
121
+ { keys: 'Ctrl+R', label: 'Reload from save' },
122
+ ],
123
+ },
124
+ {
125
+ title: 'General',
126
+ entries: [
127
+ { keys: '?', label: 'Close help' },
128
+ { keys: 'q', label: 'Quit' },
129
+ ],
130
+ },
131
+ ];
132
+ export { computeCycleAutoRejectTargets } from '../../cycle-auto-reject.js';
133
+ export function buildBreakingRows(breakingChanges) {
134
+ const out = [];
135
+ for (const b of breakingChanges) {
136
+ if (b.changes.length === 0) {
137
+ out.push({ label: `${b.componentName} — breaking`, componentName: b.componentName });
138
+ continue;
139
+ }
140
+ for (const change of b.changes) {
141
+ const label = formatBreakingChange(change, b.current);
142
+ if ('slotId' in change) {
143
+ out.push({
144
+ label,
145
+ componentName: b.componentName,
146
+ focusTarget: { kind: 'slot', name: change.slotId },
147
+ });
148
+ }
149
+ else {
150
+ out.push({
151
+ label,
152
+ componentName: b.componentName,
153
+ focusTarget: { kind: 'prop', name: change.propertyId },
154
+ });
155
+ }
156
+ }
157
+ }
158
+ return out;
159
+ }
160
+ export function deriveBreakingChanges(response) {
161
+ const out = [];
162
+ for (const item of response.components.changed ?? []) {
163
+ if (item.changeClassification?.classification !== 'breaking')
164
+ continue;
165
+ const componentName = item.current?.name;
166
+ if (typeof componentName !== 'string')
167
+ continue;
168
+ out.push({
169
+ componentName,
170
+ changes: item.changeClassification.breakingChanges ?? [],
171
+ impact: item.impact,
172
+ current: item.current,
173
+ });
174
+ }
175
+ return out;
176
+ }
38
177
  export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, livePreview = true, spaceId = '', environmentId = '', cmaToken = '', host = '', tokensPath = '', initialFinalizeError = null, }) {
39
178
  const { stdout } = useStdout();
40
179
  const terminalWidth = stdout?.columns ?? 80;
41
180
  const [components, setComponents] = useState([]);
42
181
  const [loading, setLoading] = useState(true);
43
182
  const [loadError, setLoadError] = useState(null);
44
- const [selectedIdx, setSelectedIdx] = useState(0);
45
- const [sidebarScrollOffset, setSidebarScrollOffset] = useState(0);
183
+ const [nav, setNav] = useState({
184
+ cursorRowIdx: 0,
185
+ sidebarScrollOffset: 0,
186
+ });
187
+ const cursorRowIdx = nav.cursorRowIdx;
188
+ const sidebarScrollOffset = nav.sidebarScrollOffset;
46
189
  const [jsonScrollOffset, setJsonScrollOffset] = useState(0);
47
190
  const [sidebarFocused, setSidebarFocused] = useState(true);
48
191
  const [showFinalize, setShowFinalize] = useState(false);
49
192
  const [showQuit, setShowQuit] = useState(false);
50
- // FieldEditor is the default editor. JSON view is an opt-in read-only toggle.
51
193
  const [showJson, setShowJson] = useState(false);
52
194
  const [draftValue, setDraftValue] = useState('');
53
195
  const [saveError, setSaveError] = useState(null);
54
- // INTEG-4411: inline banner shown when the operator tries to finalize
55
- // with zero accepted components. Cleared on the next 'a' or 'A' press.
56
196
  const [finalizeError, setFinalizeError] = useState(initialFinalizeError);
57
- // Feature 1: per-component review metadata (rationale + source location)
58
- // for the currently-selected component. Reloaded when selection changes.
59
197
  const [reviewMetadata, setReviewMetadata] = useState(null);
60
- // Feature 2: per-component preview annotations refreshed after every
61
- // FieldEditor save via the useLivePreview hook below. Empty when live
62
- // preview is disabled, when creds are missing, or before the first response.
63
198
  const [previewAnnotations, setPreviewAnnotations] = useState(new Map());
64
- // Pilot-2026-06-24: raw removed list for the `d` detail panel. The
65
- // annotation map only carries kind, not the rich summaries we need to list
66
- // names/ids when the operator asks "which ones?".
67
199
  const [removedComponents, setRemovedComponents] = useState([]);
68
- const [showRemovedPanel, setShowRemovedPanel] = useState(false);
69
- // Lifted rationale + source panels (replaces FieldEditor's right pane).
70
- // Mutually exclusive states.
200
+ const [removedBannerCollapsed, setRemovedBannerCollapsed] = useState(false);
201
+ const removedBannerDefaultedRef = useRef(false);
71
202
  const [panelOpen, setPanelOpen] = useState('none');
72
203
  const [panelScrollOffset, setPanelScrollOffset] = useState(0);
73
204
  const [textEntryActive, setTextEntryActive] = useState(false);
74
205
  const [componentRationale, setComponentRationale] = useState(null);
75
- // Tracks the first `g` of a potential `gg` double-tap (jumps to top in
76
- // JSON-view + panel-focused state). Reset on any non-`g` key.
77
206
  const pendingGRef = useRef(false);
207
+ const [slotCycles, setSlotCycles] = useState([]);
208
+ const [cyclePanelScroll, setCyclePanelScroll] = useState(0);
209
+ const [cyclesCursor, setCyclesCursor] = useState(0);
210
+ const cyclePanel = useOverlayPanel({
211
+ toggleKey: 'c',
212
+ onClose: () => {
213
+ setCyclePanelScroll(0);
214
+ setCyclesCursor(0);
215
+ },
216
+ });
217
+ const breakPanel = useOverlayPanel({
218
+ toggleKey: 'x',
219
+ onClose: () => {
220
+ setBreakCursor(0);
221
+ setBreakConfirm(false);
222
+ },
223
+ });
224
+ const [breakCursor, setBreakCursor] = useState(0);
225
+ const [breakConfirm, setBreakConfirm] = useState(false);
226
+ const [expandedGroups, setExpandedGroups] = useState(new Set());
227
+ const seededGroupsRef = useRef(false);
228
+ const [searchOpen, setSearchOpen] = useState(false);
229
+ const [searchQuery, setSearchQuery] = useState('');
230
+ const [autocompleteCandidates, setAutocompleteCandidates] = useState([]);
231
+ const [jumpFilterTarget, setJumpFilterTarget] = useState(null);
232
+ const lineagePanel = useOverlayPanel({ toggleKey: 'l' });
233
+ const [lineageCursor, setLineageCursor] = useState(0);
234
+ const breakingPanel = useOverlayPanel({ toggleKey: 'b', onClose: () => setBreakingDetailOpen(false) });
235
+ const [breakingChanges, setBreakingChanges] = useState([]);
236
+ const [breakingCursor, setBreakingCursor] = useState(0);
237
+ const [breakingDetailOpen, setBreakingDetailOpen] = useState(false);
238
+ const [pendingEditorFocus, setPendingEditorFocus] = useState(null);
239
+ const [columnOneView, setColumnOneView] = useState('grouped');
240
+ const [activeFilters, setActiveFilters] = useState(new Set());
241
+ const [autoRejected, setAutoRejected] = useState([]);
242
+ const [undoSnapshot, setUndoSnapshot] = useState(null);
243
+ const autoRejectFiredRef = useRef(false);
244
+ const [editorDirty, setEditorDirty] = useState(false);
245
+ const [showUnsavedWarning, setShowUnsavedWarning] = useState(false);
246
+ const [pendingFocusAway, setPendingFocusAway] = useState(null);
247
+ const [discardTrigger, setDiscardTrigger] = useState(0);
248
+ const historyRef = useRef(null);
249
+ const historySeededRef = useRef(false);
250
+ const [showReloadDialog, setShowReloadDialog] = useState(false);
251
+ const [showHelp, setShowHelp] = useState(false);
78
252
  const handleLivePreviewResult = (response) => {
79
253
  if (!response)
80
254
  return;
81
255
  setPreviewAnnotations(applyPreviewAnnotations(response, components.map((c) => c.key)));
82
- setRemovedComponents(response.components.removed ?? []);
256
+ const nextRemoved = response.components.removed ?? [];
257
+ setRemovedComponents(nextRemoved);
258
+ if (!removedBannerDefaultedRef.current && nextRemoved.length > 0) {
259
+ removedBannerDefaultedRef.current = true;
260
+ setRemovedBannerCollapsed(nextRemoved.length > 5);
261
+ }
262
+ setBreakingChanges(deriveBreakingChanges(response));
83
263
  };
264
+ const acceptedCountForPreview = components.filter((c) => c.status === 'accepted').length;
84
265
  const livePreviewHook = useLivePreview({
85
266
  enabled: livePreview,
86
267
  sessionId: extractSessionId,
@@ -90,9 +271,10 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
90
271
  cmaToken,
91
272
  host,
92
273
  onResult: handleLivePreviewResult,
274
+ // With nothing accepted, preview the delete-all diff so the review UI shows
275
+ // which existing components a push would remove (instead of an empty preview).
276
+ deleteAllComponents: acceptedCountForPreview === 0,
93
277
  });
94
- // Manual spinner cycling (no extra dep) for the sidebar status-row
95
- // indicator. Runs only while the live-preview hook reports `running`.
96
278
  const SPINNER_FRAMES = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
97
279
  const [spinnerTick, setSpinnerTick] = useState(0);
98
280
  useEffect(() => {
@@ -102,39 +284,56 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
102
284
  return () => clearInterval(id);
103
285
  }, [livePreviewHook.status]);
104
286
  const livePreviewSpinner = SPINNER_FRAMES[spinnerTick % SPINNER_FRAMES.length];
287
+ const loadSessionState = () => {
288
+ const db = openPipelineDb();
289
+ let cdfComponents = [];
290
+ let cycles = [];
291
+ try {
292
+ cdfComponents = loadCDFComponents(db, extractSessionId);
293
+ cycles = loadSlotCycles(db, extractSessionId);
294
+ }
295
+ finally {
296
+ db.close();
297
+ }
298
+ if (cdfComponents.length === 0) {
299
+ return {
300
+ entries: [],
301
+ cycles: [],
302
+ error: 'No generated definitions found for this session. Try re-running generate.',
303
+ };
304
+ }
305
+ const cycleParticipants = new Set();
306
+ for (const c of cycles)
307
+ for (const p of c.path)
308
+ cycleParticipants.add(p);
309
+ const reviewEntries = cdfComponents.map(({ key, entry }) => ({
310
+ key,
311
+ entry,
312
+ status: 'needs-review',
313
+ }));
314
+ return {
315
+ entries: sortComponentsForSidebar(reviewEntries, cycleParticipants),
316
+ cycles,
317
+ error: null,
318
+ };
319
+ };
105
320
  useEffect(() => {
106
- async function load() {
107
- const db = openPipelineDb();
108
- let cdfComponents;
109
- try {
110
- cdfComponents = loadCDFComponents(db, extractSessionId);
111
- }
112
- finally {
113
- db.close();
114
- }
115
- if (cdfComponents.length === 0) {
116
- setLoadError('No generated definitions found for this session. Try re-running generate.');
321
+ try {
322
+ const { entries, cycles, error } = loadSessionState();
323
+ if (error) {
324
+ setLoadError(error);
117
325
  setLoading(false);
118
326
  return;
119
327
  }
120
- const reviewEntries = cdfComponents.map(({ key, entry }) => ({
121
- key,
122
- entry,
123
- status: 'needs-review',
124
- }));
125
- setComponents(sortComponentsForSidebar(reviewEntries));
328
+ setSlotCycles(cycles);
329
+ setComponents(entries);
126
330
  setLoading(false);
127
331
  }
128
- load().catch((e) => {
332
+ catch (e) {
129
333
  setLoadError(String(e));
130
334
  setLoading(false);
131
- });
335
+ }
132
336
  }, []);
133
- // Pilot-2026-06-23 R2: fire the live preview once on entry to final-review
134
- // so diff badges populate before the operator's first save. We gate on the
135
- // livePreview prop to honor --no-live-preview without depending on the
136
- // hook's internal short-circuit. Cred-missing is still handled by the
137
- // hook's own no-op path.
138
337
  useEffect(() => {
139
338
  if (loading)
140
339
  return;
@@ -143,12 +342,279 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
143
342
  if (components.length === 0)
144
343
  return;
145
344
  livePreviewHook.trigger();
146
- // Intentionally only on load completion — subsequent fires happen via
147
- // handleEditSave. Adding livePreviewHook to deps would re-fire on every
148
- // hook re-creation.
149
345
  }, [loading]);
150
- // Feature 1: load review metadata (rationale + source location) for the
151
- // selected component when selection changes.
346
+ const finalizePreview = useFinalizePreview({
347
+ open: showFinalize,
348
+ extractSessionId,
349
+ tokensPath,
350
+ spaceId,
351
+ environmentId,
352
+ cmaToken,
353
+ host,
354
+ acceptedKeys: new Set(components.filter((c) => c.status === 'accepted').map((c) => c.key)),
355
+ });
356
+ const handleFinalizeConfirm = () => {
357
+ const acceptedCount = components.filter((c) => c.status === 'accepted').length;
358
+ const explicitlyRejected = components.filter((c) => c.status === 'rejected').map((c) => c.key);
359
+ const unresolved = components.filter((c) => c.status === 'needs-review').map((c) => c.key);
360
+ const toReject = [...explicitlyRejected, ...unresolved];
361
+ if (toReject.length > 0) {
362
+ const db = openPipelineDb();
363
+ try {
364
+ const stmt = db.prepare(`UPDATE raw_components SET status = 'generate-rejected' WHERE session_id = ? AND name = ?`);
365
+ db.exec('BEGIN');
366
+ try {
367
+ for (const name of toReject) {
368
+ stmt.run(extractSessionId, name);
369
+ }
370
+ db.exec('COMMIT');
371
+ }
372
+ catch (e) {
373
+ db.exec('ROLLBACK');
374
+ throw e;
375
+ }
376
+ }
377
+ finally {
378
+ db.close();
379
+ }
380
+ }
381
+ onFinalize(acceptedCount, explicitlyRejected.length, unresolved.length);
382
+ };
383
+ const recomputeCycles = (currentComponents) => {
384
+ try {
385
+ const view = computeCycleView(currentComponents);
386
+ const rawCycles = view.pushBlocking;
387
+ const next = rawCycles.map((cycle) => ({
388
+ path: cycle.path,
389
+ edges: cycle.edges,
390
+ suggestedBreak: cycle.edges.length > 0 ? suggestCycleBreakEdge(cycle, rawCycles) : null,
391
+ }));
392
+ const prevSerialized = JSON.stringify(slotCycles);
393
+ const nextSerialized = JSON.stringify(next);
394
+ if (prevSerialized === nextSerialized)
395
+ return;
396
+ setSlotCycles(next);
397
+ const db = openPipelineDb();
398
+ try {
399
+ storeSlotCycles(db, extractSessionId, next);
400
+ }
401
+ finally {
402
+ db.close();
403
+ }
404
+ if (next.length === 0)
405
+ setFinalizeError(null);
406
+ }
407
+ catch {
408
+ // Defensive: swallow — never let cycle detection crash the review UI.
409
+ }
410
+ };
411
+ const componentGraph = useMemo(() => buildComponentGraph(components), [components]);
412
+ const sidebarGraph = useMemo(() => buildComponentGraph(components, { stripRejectedEdges: true }), [components]);
413
+ const closures = useMemo(() => computeAllClosures(componentGraph), [componentGraph]);
414
+ useEffect(() => {
415
+ if (seededGroupsRef.current)
416
+ return;
417
+ if (closures.size === 0 && slotCycles.length === 0)
418
+ return;
419
+ seededGroupsRef.current = true;
420
+ const seed = new Set(closures.keys());
421
+ for (const cyc of slotCycles)
422
+ for (const p of cyc.path)
423
+ seed.add(p);
424
+ setExpandedGroups(seed);
425
+ }, [closures, slotCycles]);
426
+ const directIssues = useMemo(() => {
427
+ const m = new Map();
428
+ for (const c of components) {
429
+ if (c.status === 'rejected')
430
+ m.set(c.key, 'error');
431
+ }
432
+ return m;
433
+ }, [components]);
434
+ const cycleView = useMemo(() => computeCycleView(components), [components]);
435
+ useEffect(() => {
436
+ const decision = computeAutoRejectDecision({
437
+ loading,
438
+ autoRejectFired: autoRejectFiredRef.current,
439
+ hasCycle: cycleView.structural.size > 0,
440
+ });
441
+ if (decision === 'skip')
442
+ return;
443
+ autoRejectFiredRef.current = true;
444
+ const targets = computeCycleAutoRejectTargets(slotCycles, componentGraph);
445
+ if (targets.size === 0)
446
+ return;
447
+ const snapshot = new Map();
448
+ const flipped = [];
449
+ for (const c of components) {
450
+ if (!targets.has(c.key))
451
+ continue;
452
+ snapshot.set(c.key, c.status);
453
+ if (c.status !== 'rejected')
454
+ flipped.push(c.key);
455
+ }
456
+ setComponents((prev) => prev.map((c) => (targets.has(c.key) ? { ...c, status: 'rejected' } : c)));
457
+ setAutoRejected(flipped.length > 0 ? flipped : [...targets].sort());
458
+ setUndoSnapshot(flipped.length > 0 ? snapshot : null);
459
+ }, [loading, cycleView, componentGraph, slotCycles]);
460
+ useEffect(() => {
461
+ if (loading)
462
+ return;
463
+ if (historySeededRef.current)
464
+ return;
465
+ historySeededRef.current = true;
466
+ historyRef.current = createHistoryStack({
467
+ components: components.map((c) => ({ key: c.key, entry: c.entry, status: c.status })),
468
+ autoRejected: [],
469
+ undoSnapshot: null,
470
+ });
471
+ }, [loading, components]);
472
+ const pushHistorySnapshot = (entries, autoRej, undoSnap, label) => {
473
+ if (!historyRef.current)
474
+ return;
475
+ historyRef.current.push({
476
+ components: entries.map((c) => ({ key: c.key, entry: c.entry, status: c.status })),
477
+ autoRejected: [...autoRej],
478
+ undoSnapshot: undoSnap === null ? null : new Map(undoSnap),
479
+ }, label);
480
+ };
481
+ const autoRejectPushedRef = useRef(false);
482
+ useEffect(() => {
483
+ if (loading)
484
+ return;
485
+ if (!historySeededRef.current)
486
+ return;
487
+ if (!autoRejectFiredRef.current)
488
+ return;
489
+ if (autoRejectPushedRef.current)
490
+ return;
491
+ autoRejectPushedRef.current = true;
492
+ pushHistorySnapshot(components, autoRejected, undoSnapshot, 'mount-auto-reject');
493
+ }, [autoRejected]);
494
+ const applyHistorySnapshot = (snap) => {
495
+ const restored = snap.components.map((c) => ({
496
+ key: c.key,
497
+ entry: c.entry,
498
+ status: c.status,
499
+ }));
500
+ setComponents(restored);
501
+ setAutoRejected(snap.autoRejected);
502
+ setUndoSnapshot(snap.undoSnapshot);
503
+ recomputeCycles(restored);
504
+ };
505
+ const handleUndo = () => {
506
+ const snap = historyRef.current?.undo();
507
+ if (!snap)
508
+ return;
509
+ applyHistorySnapshot(snap);
510
+ };
511
+ const handleRedo = () => {
512
+ const snap = historyRef.current?.redo();
513
+ if (!snap)
514
+ return;
515
+ applyHistorySnapshot(snap);
516
+ };
517
+ const reloadFromSave = () => {
518
+ try {
519
+ const { entries, cycles, error } = loadSessionState();
520
+ if (error) {
521
+ setLoadError(error);
522
+ return;
523
+ }
524
+ setSlotCycles(cycles);
525
+ setComponents(entries);
526
+ const reloadGraph = buildComponentGraph(entries);
527
+ const reloadClosures = computeAllClosures(reloadGraph);
528
+ const reloadCycleView = computeCycleView(entries);
529
+ const reloadSeed = new Set();
530
+ for (const [name, closure] of reloadClosures.entries()) {
531
+ if (closure.nodes.length > 1)
532
+ reloadSeed.add(name);
533
+ }
534
+ for (const name of reloadCycleView.structural)
535
+ reloadSeed.add(name);
536
+ setExpandedGroups(reloadSeed);
537
+ seededGroupsRef.current = true;
538
+ setAutoRejected([]);
539
+ setUndoSnapshot(null);
540
+ autoRejectFiredRef.current = false;
541
+ autoRejectPushedRef.current = false;
542
+ historyRef.current?.reset({
543
+ components: entries.map((c) => ({ key: c.key, entry: c.entry, status: c.status })),
544
+ autoRejected: [],
545
+ undoSnapshot: null,
546
+ });
547
+ setNav({ cursorRowIdx: 0, sidebarScrollOffset: 0 });
548
+ setSaveError(null);
549
+ setFinalizeError(null);
550
+ }
551
+ catch (e) {
552
+ setLoadError(String(e));
553
+ }
554
+ };
555
+ const groupedItemsMemo = useMemo(() => components.map((c) => ({
556
+ key: c.key,
557
+ entry: c.entry,
558
+ status: (directIssues.get(c.key) ?? 'ok'),
559
+ })), [components, directIssues]);
560
+ const brokenKeys = useMemo(() => new Set(breakingChanges.map((b) => b.componentName)), [breakingChanges]);
561
+ const breakingRows = useMemo(() => buildBreakingRows(breakingChanges), [breakingChanges]);
562
+ const filterVisibleKeys = useMemo(() => {
563
+ if (jumpFilterTarget) {
564
+ return findAllAncestorsInclusive(jumpFilterTarget, sidebarGraph);
565
+ }
566
+ const categoryKeys = computeFilterKeys({
567
+ filters: activeFilters,
568
+ data: { cycles: cycleView.structural, broken: brokenKeys },
569
+ });
570
+ const searchKeys = (() => {
571
+ if (!searchQuery)
572
+ return undefined;
573
+ const matches = groupedItemsMemo.map((it) => it.key).filter((k) => fuzzyMatches(searchQuery, k));
574
+ if (matches.length === 0)
575
+ return undefined;
576
+ return computeDirectNeighborhood(matches, sidebarGraph);
577
+ })();
578
+ return intersectFilterKeys(categoryKeys, searchKeys);
579
+ }, [jumpFilterTarget, activeFilters, cycleView, brokenKeys, searchQuery, groupedItemsMemo, sidebarGraph]);
580
+ const visibleRowsMemo = useMemo(() => buildVisibleRows({
581
+ items: groupedItemsMemo,
582
+ cycleParticipants: cycleView.structural,
583
+ expandedGroups,
584
+ viewMode: columnOneView,
585
+ graph: sidebarGraph,
586
+ filterVisibleKeys,
587
+ }), [groupedItemsMemo, cycleView, expandedGroups, columnOneView, sidebarGraph, filterVisibleKeys]);
588
+ const selectableRowPositions = useMemo(() => {
589
+ const out = [];
590
+ for (let i = 0; i < visibleRowsMemo.length; i++) {
591
+ if (visibleRowsMemo[i].itemIdx >= 0)
592
+ out.push(i);
593
+ }
594
+ return out;
595
+ }, [visibleRowsMemo]);
596
+ const selectedIdx = visibleRowsMemo[cursorRowIdx]?.itemIdx ?? -1;
597
+ const focusedComponentKey = components[selectedIdx]?.key ?? null;
598
+ const { entries: lineageEntries, jumpables: lineageJumpables } = useLineage(focusedComponentKey, componentGraph);
599
+ const { sidebarVisibleCount: visibleCount, panelMaxRows } = computeSidebarBudget({
600
+ rows: stdout?.rows ?? FALLBACK_ROWS,
601
+ panelOpen: lineagePanel.isOpen,
602
+ entryCount: lineageEntries.length,
603
+ });
604
+ useEffect(() => {
605
+ if (selectableRowPositions.length === 0)
606
+ return;
607
+ const cursorInRange = selectableRowPositions.includes(cursorRowIdx);
608
+ const maxScroll = Math.max(0, visibleRowsMemo.length - visibleCount);
609
+ const scrollNeedsClamp = sidebarScrollOffset > maxScroll;
610
+ if (cursorInRange && !scrollNeedsClamp)
611
+ return;
612
+ const nextCursor = cursorInRange ? cursorRowIdx : selectableRowPositions[0];
613
+ setNav(() => ({
614
+ cursorRowIdx: nextCursor,
615
+ sidebarScrollOffset: Math.min(sidebarScrollOffset, maxScroll),
616
+ }));
617
+ }, [selectableRowPositions, cursorRowIdx, sidebarScrollOffset, visibleRowsMemo.length, visibleCount]);
152
618
  useEffect(() => {
153
619
  const current = components[selectedIdx];
154
620
  if (!current) {
@@ -166,9 +632,6 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
166
632
  db.close();
167
633
  }
168
634
  }, [selectedIdx, components, extractSessionId]);
169
- // Load component-level rationale for the selected component (drives the
170
- // `I` ComponentRationalePanel). Decoupled from review metadata so the data
171
- // contracts can evolve independently.
172
635
  useEffect(() => {
173
636
  const current = components[selectedIdx];
174
637
  if (!current) {
@@ -186,48 +649,85 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
186
649
  db.close();
187
650
  }
188
651
  }, [selectedIdx, components, extractSessionId]);
189
- const updateStatus = (idx, status) => {
190
- setComponents((prev) => prev.map((c, i) => (i === idx ? { ...c, status } : c)));
191
- };
192
- const handleFinalizeConfirm = () => {
193
- // Strict opt-in: only EXPLICITLY ACCEPTED components ship. Anything left
194
- // in 'needs-review' OR explicitly 'rejected' is downgraded to
195
- // 'generate-rejected' so loadCDFComponents excludes it from the manifest.
196
- // The operator told us they want accept-to-ship semantics — leaving a
197
- // component unresolved should NOT silently push it (Pilot-2026-06-24 R2).
198
- const acceptedCount = components.filter((c) => c.status === 'accepted').length;
199
- // INTEG-4411 refined: DO NOT block on `acceptedCount === 0` up-front.
200
- // A push with zero accepted but one or more rejections targeting a
201
- // component that exists server-side still produces REMOVALS — a valid
202
- // push, not a no-op. Same for token-only diffs. The load-bearing no-op
203
- // check lives downstream in WizardApp.runPreview, which consults the
204
- // preview response and only blocks when every diff bucket is empty.
205
- // We keep the `finalizeError` state so the wizard can route back here
206
- // with an inline banner when that downstream check fires.
207
- const explicitlyRejected = components.filter((c) => c.status === 'rejected').map((c) => c.key);
208
- const unresolved = components.filter((c) => c.status === 'needs-review').map((c) => c.key);
209
- const toReject = [...explicitlyRejected, ...unresolved];
210
- if (toReject.length > 0) {
211
- const db = openPipelineDb();
212
- try {
213
- const stmt = db.prepare(`UPDATE raw_components SET status = 'generate-rejected' WHERE session_id = ? AND name = ?`);
214
- db.exec('BEGIN');
215
- try {
216
- for (const name of toReject) {
217
- stmt.run(extractSessionId, name);
218
- }
219
- db.exec('COMMIT');
220
- }
221
- catch (e) {
222
- db.exec('ROLLBACK');
223
- throw e;
652
+ const renderStatusByKey = useMemo(() => {
653
+ const merged = new Map();
654
+ for (const closure of closures.values()) {
655
+ const per = computeRenderStatuses(closure, directIssues);
656
+ for (const [name, rs] of per.entries()) {
657
+ const existing = merged.get(name);
658
+ if (!existing || (!existing.isOwn && rs.isOwn)) {
659
+ merged.set(name, rs);
224
660
  }
225
661
  }
226
- finally {
227
- db.close();
662
+ }
663
+ return merged;
664
+ }, [closures, directIssues]);
665
+ const selectionStateByKey = useMemo(() => {
666
+ const map = new Map();
667
+ for (const c of components) {
668
+ if (c.status === 'accepted')
669
+ map.set(c.key, 'accepted');
670
+ else if (c.status === 'rejected')
671
+ map.set(c.key, 'rejected');
672
+ else
673
+ map.set(c.key, 'undecided');
674
+ }
675
+ return map;
676
+ }, [components]);
677
+ const searchMatches = useMemo(() => {
678
+ if (!searchQuery)
679
+ return [];
680
+ const out = [];
681
+ for (const pos of selectableRowPositions) {
682
+ const row = visibleRowsMemo[pos];
683
+ const key = row ? components[row.itemIdx]?.key : undefined;
684
+ if (key && fuzzyMatches(searchQuery, key))
685
+ out.push(pos);
686
+ }
687
+ return out;
688
+ }, [searchQuery, selectableRowPositions, visibleRowsMemo, components]);
689
+ const searchMatchCount = useMemo(() => {
690
+ if (searchMatches.length === 0)
691
+ return 0;
692
+ const seen = new Set();
693
+ for (const pos of searchMatches) {
694
+ const itemIdx = visibleRowsMemo[pos]?.itemIdx;
695
+ if (itemIdx != null && itemIdx >= 0)
696
+ seen.add(itemIdx);
697
+ }
698
+ return seen.size;
699
+ }, [searchMatches, visibleRowsMemo]);
700
+ const dimPredicate = useMemo(() => buildFlatDimPredicate({
701
+ viewMode: columnOneView,
702
+ searchQuery,
703
+ filterVisibleKeys,
704
+ }), [columnOneView, searchQuery, filterVisibleKeys]);
705
+ const jumpCursorToRow = (rowIdx) => {
706
+ if (rowIdx < 0 || rowIdx >= visibleRowsMemo.length)
707
+ return;
708
+ setNav(({ sidebarScrollOffset: prev }) => {
709
+ let nextOff = prev;
710
+ if (rowIdx < prev)
711
+ nextOff = rowIdx;
712
+ else if (rowIdx >= prev + visibleCount)
713
+ nextOff = rowIdx - visibleCount + 1;
714
+ return { cursorRowIdx: rowIdx, sidebarScrollOffset: nextOff };
715
+ });
716
+ setJsonScrollOffset(0);
717
+ setDraftValue('');
718
+ setSaveError(null);
719
+ setPendingEditorFocus(null);
720
+ };
721
+ const jumpCursorToName = (name) => {
722
+ for (let i = 0; i < visibleRowsMemo.length; i++) {
723
+ const row = visibleRowsMemo[i];
724
+ if (row.itemIdx < 0)
725
+ continue;
726
+ if (components[row.itemIdx]?.key === name) {
727
+ jumpCursorToRow(i);
728
+ return;
228
729
  }
229
730
  }
230
- onFinalize(acceptedCount, explicitlyRejected.length, unresolved.length);
231
731
  };
232
732
  const handleEditSave = () => {
233
733
  const current = components[selectedIdx];
@@ -235,7 +735,6 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
235
735
  return;
236
736
  try {
237
737
  const parsed = JSON.parse(draftValue);
238
- // Accept both bare entry and wrapped { [key]: entry } forms
239
738
  const keys = Object.keys(parsed);
240
739
  const entry = keys.length === 1 && typeof parsed[keys[0]] === 'object' && parsed[keys[0]] !== null
241
740
  ? parsed[keys[0]]
@@ -244,7 +743,11 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
244
743
  setSaveError('Invalid CDF entry: must have $type: "component" and $properties object');
245
744
  return;
246
745
  }
247
- setComponents((prev) => prev.map((c, i) => i === selectedIdx ? { ...c, entry, status: c.status === 'needs-review' ? 'accepted' : c.status } : c));
746
+ let updatedComponents = [];
747
+ setComponents((prev) => {
748
+ updatedComponents = prev.map((c, i) => i === selectedIdx ? { ...c, entry, status: c.status === 'needs-review' ? 'accepted' : c.status } : c);
749
+ return updatedComponents;
750
+ });
248
751
  setDraftValue('');
249
752
  setSaveError(null);
250
753
  const db = openPipelineDb();
@@ -254,9 +757,9 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
254
757
  finally {
255
758
  db.close();
256
759
  }
257
- // Feature 2: re-fire the live preview now that pipeline.db reflects
258
- // the new state. The hook owns debounce + cred-missing short-circuit.
760
+ recomputeCycles(updatedComponents);
259
761
  livePreviewHook.trigger();
762
+ pushHistorySnapshot(updatedComponents, autoRejected, undoSnapshot, 'edit-save');
260
763
  }
261
764
  catch (e) {
262
765
  setSaveError(String(e));
@@ -266,34 +769,343 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
266
769
  setDraftValue('');
267
770
  setSaveError(null);
268
771
  };
772
+ const breakEdges = useMemo(() => {
773
+ const cycle = slotCycles[cyclesCursor];
774
+ if (!cycle)
775
+ return [];
776
+ return enumerateCycleBreaks(cycle, components);
777
+ }, [slotCycles, cyclesCursor, components]);
778
+ const handleBreakEdge = (edge) => {
779
+ const idx = components.findIndex((c) => c.key === edge.fromComponent);
780
+ if (idx < 0)
781
+ return;
782
+ const target = components[idx];
783
+ const slot = target.entry.$slots?.[edge.slotName];
784
+ if (!slot || !Array.isArray(slot.$allowedComponents))
785
+ return;
786
+ const nextAllowed = slot.$allowedComponents.filter((v) => v !== edge.toComponent);
787
+ const nextEntry = {
788
+ ...target.entry,
789
+ $slots: {
790
+ ...target.entry.$slots,
791
+ [edge.slotName]: { ...slot, $allowedComponents: nextAllowed },
792
+ },
793
+ };
794
+ const next = components.map((c, i) => (i === idx ? { ...c, entry: nextEntry } : c));
795
+ setComponents(next);
796
+ const db = openPipelineDb();
797
+ try {
798
+ storeCDFComponents(db, extractSessionId, [{ key: target.key, entry: nextEntry }]);
799
+ }
800
+ finally {
801
+ db.close();
802
+ }
803
+ recomputeCycles(next);
804
+ livePreviewHook.trigger();
805
+ pushHistorySnapshot(next, autoRejected, undoSnapshot, 'break-cycle-edge');
806
+ };
807
+ const handleRejectComponent = (key) => {
808
+ const rejectCascade = computeRejectCascade(key, componentGraph);
809
+ const acceptCascade = computeAcceptCascade(key, componentGraph);
810
+ const next = components.map((c) => {
811
+ if (rejectCascade.has(c.key)) {
812
+ return { ...c, status: 'rejected' };
813
+ }
814
+ if (acceptCascade.has(c.key) && c.key !== key) {
815
+ return { ...c, status: 'needs-review' };
816
+ }
817
+ return c;
818
+ });
819
+ setComponents(next);
820
+ recomputeCycles(next);
821
+ pushHistorySnapshot(next, autoRejected, undoSnapshot, 'reject-cascade');
822
+ };
269
823
  const dialogOpen = showFinalize || showQuit;
270
824
  useImmediateInput((input, key) => {
271
- if (loading || loadError)
825
+ if (loading)
826
+ return;
827
+ // On a load error there's nothing to review — still let the operator quit
828
+ // (q / Esc / Enter) instead of trapping them on the error screen.
829
+ if (loadError) {
830
+ if (input === 'q' || key.escape || key.return)
831
+ onQuit();
832
+ return;
833
+ }
834
+ if (showFinalize) {
835
+ // The dialog owns y/n/Enter/Esc; here we own j/k scroll of its deletion list.
836
+ if (input === 'j' || key.downArrow) {
837
+ finalizePreview.scrollBy(1);
838
+ return;
839
+ }
840
+ if (input === 'k' || key.upArrow) {
841
+ finalizePreview.scrollBy(-1);
842
+ return;
843
+ }
272
844
  return;
845
+ }
273
846
  if (dialogOpen)
274
847
  return;
275
- // Pilot-2026-06-24: removed-detail panel. When open, only `d` (toggle)
276
- // and Esc (close) respond — all other input is swallowed so j/k/Enter/
277
- // Ctrl+S can't move state behind the modal. Mirrors the `?` overlay
278
- // pattern from 8f0c62e in FieldEditor.
279
- if (showRemovedPanel) {
280
- if (input === 'd' || key.escape) {
281
- setShowRemovedPanel(false);
848
+ if (showHelp)
849
+ return;
850
+ if (showReloadDialog) {
851
+ if (key.return) {
852
+ reloadFromSave();
853
+ setShowReloadDialog(false);
854
+ return;
855
+ }
856
+ if (key.escape) {
857
+ setShowReloadDialog(false);
858
+ return;
282
859
  }
283
860
  return;
284
861
  }
285
- // `d` opens the panel only when live-preview is enabled and there is at
286
- // least one removed component to display. Sidebar-focused only so it
287
- // doesn't collide with FieldEditor input.
288
- if (input === 'd' && sidebarFocused && livePreview && removedComponents.length > 0) {
289
- setShowRemovedPanel(true);
862
+ if (key.ctrl && input === 'z') {
863
+ handleUndo();
864
+ return;
865
+ }
866
+ if (key.ctrl && input === 'y') {
867
+ handleRedo();
868
+ return;
869
+ }
870
+ if (key.ctrl && input === 'r') {
871
+ setShowReloadDialog(true);
872
+ return;
873
+ }
874
+ if (showUnsavedWarning) {
875
+ if (key.return) {
876
+ handleEditSave();
877
+ setShowUnsavedWarning(false);
878
+ if (pendingFocusAway === 'tab-to-sidebar')
879
+ setSidebarFocused(true);
880
+ setPendingFocusAway(null);
881
+ return;
882
+ }
883
+ if (key.escape) {
884
+ setDiscardTrigger((n) => n + 1);
885
+ setShowUnsavedWarning(false);
886
+ if (pendingFocusAway === 'tab-to-sidebar')
887
+ setSidebarFocused(true);
888
+ setPendingFocusAway(null);
889
+ return;
890
+ }
891
+ setShowUnsavedWarning(false);
892
+ setPendingFocusAway(null);
893
+ return;
894
+ }
895
+ if (searchOpen) {
896
+ if (key.escape) {
897
+ setSearchOpen(false);
898
+ setSearchQuery('');
899
+ setAutocompleteCandidates([]);
900
+ return;
901
+ }
902
+ if (key.return) {
903
+ setAutocompleteCandidates([]);
904
+ if (!searchQuery || searchMatches.length === 0) {
905
+ setSearchOpen(false);
906
+ setSearchQuery('');
907
+ return;
908
+ }
909
+ let jumped = false;
910
+ for (let i = cursorRowIdx + 1; i < visibleRowsMemo.length; i++) {
911
+ const row = visibleRowsMemo[i];
912
+ if (row.itemIdx < 0)
913
+ continue;
914
+ const key2 = components[row.itemIdx]?.key;
915
+ if (key2 && fuzzyMatches(searchQuery, key2)) {
916
+ jumpCursorToRow(i);
917
+ jumped = true;
918
+ break;
919
+ }
920
+ }
921
+ if (!jumped) {
922
+ jumpCursorToRow(searchMatches[0]);
923
+ }
924
+ setSearchOpen(false);
925
+ return;
926
+ }
927
+ if (key.tab) {
928
+ const { completion, candidates } = computeAutocomplete(searchQuery, components.map((c) => c.key));
929
+ setSearchQuery(completion);
930
+ setAutocompleteCandidates(candidates);
931
+ return;
932
+ }
933
+ if (key.backspace) {
934
+ setAutocompleteCandidates([]);
935
+ setSearchQuery((q) => q.slice(0, -1));
936
+ return;
937
+ }
938
+ if (input && input.length === 1 && input >= ' ' && input !== '\r' && input !== '\n') {
939
+ setAutocompleteCandidates([]);
940
+ setSearchQuery((q) => q + input);
941
+ return;
942
+ }
943
+ return;
944
+ }
945
+ if (breakingPanel.isOpen) {
946
+ if (breakingPanel.handleInput(input, key))
947
+ return;
948
+ if (input === 'D') {
949
+ setBreakingDetailOpen((prev) => !prev);
950
+ return;
951
+ }
952
+ if (key.upArrow || input === 'k') {
953
+ setBreakingDetailOpen(false);
954
+ setBreakingCursor((c) => Math.max(0, c - 1));
955
+ return;
956
+ }
957
+ if (key.downArrow || input === 'j') {
958
+ setBreakingDetailOpen(false);
959
+ setBreakingCursor((c) => Math.min(Math.max(0, breakingRows.length - 1), c + 1));
960
+ return;
961
+ }
962
+ if (key.return) {
963
+ const row = breakingRows[breakingCursor];
964
+ if (row) {
965
+ jumpCursorToName(row.componentName);
966
+ if (row.focusTarget) {
967
+ setPendingEditorFocus({ componentName: row.componentName, target: row.focusTarget });
968
+ setSidebarFocused(false);
969
+ }
970
+ }
971
+ breakingPanel.close();
972
+ return;
973
+ }
974
+ return;
975
+ }
976
+ if (lineagePanel.isOpen) {
977
+ if (lineagePanel.handleInput(input, key))
978
+ return;
979
+ if (key.upArrow || input === 'k') {
980
+ setLineageCursor((c) => Math.max(0, c - 1));
981
+ return;
982
+ }
983
+ if (key.downArrow || input === 'j') {
984
+ setLineageCursor((c) => Math.min(Math.max(0, lineageJumpables.length - 1), c + 1));
985
+ return;
986
+ }
987
+ if (key.tab) {
988
+ setLineageCursor((c) => (lineageJumpables.length === 0 ? 0 : (c + 1) % lineageJumpables.length));
989
+ return;
990
+ }
991
+ if (key.return) {
992
+ const target = lineageJumpables[lineageCursor];
993
+ if (target && (target.entry.kind === 'ancestor' || target.entry.kind === 'descendant')) {
994
+ jumpCursorToName(target.entry.jumpTarget);
995
+ }
996
+ lineagePanel.close();
997
+ return;
998
+ }
999
+ return;
1000
+ }
1001
+ if (breakPanel.isOpen) {
1002
+ if (breakConfirm) {
1003
+ if (input === 'y') {
1004
+ const edge = breakEdges[breakCursor];
1005
+ if (edge)
1006
+ handleBreakEdge(edge);
1007
+ setBreakConfirm(false);
1008
+ breakPanel.close();
1009
+ return;
1010
+ }
1011
+ if (input === 'n' || key.escape) {
1012
+ setBreakConfirm(false);
1013
+ return;
1014
+ }
1015
+ return;
1016
+ }
1017
+ if (breakPanel.handleInput(input, key))
1018
+ return;
1019
+ if (key.upArrow || input === 'k') {
1020
+ setBreakCursor((c) => Math.max(0, c - 1));
1021
+ return;
1022
+ }
1023
+ if (key.downArrow || input === 'j') {
1024
+ setBreakCursor((c) => Math.min(Math.max(0, breakEdges.length - 1), c + 1));
1025
+ return;
1026
+ }
1027
+ if (key.return) {
1028
+ if (breakEdges[breakCursor])
1029
+ setBreakConfirm(true);
1030
+ return;
1031
+ }
1032
+ return;
1033
+ }
1034
+ if (cyclePanel.isOpen) {
1035
+ if (input === 'x' && breakEdges.length > 0) {
1036
+ breakPanel.open();
1037
+ setBreakCursor(0);
1038
+ setBreakConfirm(false);
1039
+ return;
1040
+ }
1041
+ if (cyclePanel.handleInput(input, key))
1042
+ return;
1043
+ if (input === 'q') {
1044
+ cyclePanel.close();
1045
+ return;
1046
+ }
1047
+ if (key.upArrow || input === 'k') {
1048
+ setCyclesCursor((c) => {
1049
+ const next = Math.max(0, c - 1);
1050
+ setCyclePanelScroll((scroll) => followCycleScroll(scroll, next, slotCycles, 20));
1051
+ return next;
1052
+ });
1053
+ return;
1054
+ }
1055
+ if (key.downArrow || input === 'j') {
1056
+ setCyclesCursor((c) => {
1057
+ const next = Math.min(Math.max(0, slotCycles.length - 1), c + 1);
1058
+ setCyclePanelScroll((scroll) => followCycleScroll(scroll, next, slotCycles, 20));
1059
+ return next;
1060
+ });
1061
+ return;
1062
+ }
1063
+ if (key.return) {
1064
+ const target = slotCycles[cyclesCursor];
1065
+ if (target && target.path.length > 0)
1066
+ jumpCursorToName(target.path[0]);
1067
+ cyclePanel.close();
1068
+ return;
1069
+ }
1070
+ return;
1071
+ }
1072
+ if (input === 'c' && sidebarFocused && slotCycles.length > 0) {
1073
+ cyclePanel.open();
1074
+ setCyclePanelScroll(0);
1075
+ setCyclesCursor(0);
1076
+ return;
1077
+ }
1078
+ if (input === 'l' && sidebarFocused && focusedComponentKey) {
1079
+ lineagePanel.open();
1080
+ setLineageCursor(0);
1081
+ return;
1082
+ }
1083
+ if (input === 'b' && sidebarFocused && breakingChanges.length > 0) {
1084
+ breakingPanel.open();
1085
+ setBreakingCursor(0);
1086
+ return;
1087
+ }
1088
+ if (input === 'd' && sidebarFocused && removedComponents.length > 0) {
1089
+ setRemovedBannerCollapsed((prev) => !prev);
1090
+ return;
1091
+ }
1092
+ if (sidebarFocused && (input === 'o' || input === 'w')) {
1093
+ const category = input === 'o' ? 'cycles' : 'broken';
1094
+ setActiveFilters((prev) => {
1095
+ const next = new Set(prev);
1096
+ if (next.has(category))
1097
+ next.delete(category);
1098
+ else
1099
+ next.add(category);
1100
+ return next;
1101
+ });
1102
+ return;
1103
+ }
1104
+ if (input === 'i' && sidebarFocused && !key.tab && !key.ctrl && !key.meta && focusedComponentKey) {
1105
+ const t = focusedComponentKey;
1106
+ setJumpFilterTarget((prev) => (prev === t ? null : t));
290
1107
  return;
291
1108
  }
292
- // Lifted rationale + source panels: i/I/s fire from anywhere (sidebar OR
293
- // panel focus). Gated against text-entry surfaces inside FieldEditor
294
- // (description editors, string-default editor, value-list text entry)
295
- // via the `onTextEntryActiveChange` callback, plus the help/finalize/quit
296
- // overlays and the JSON view.
297
1109
  if (panelOpen !== 'none') {
298
1110
  const PANEL_HEIGHT_LOCAL = 12;
299
1111
  const next = computeNextScrollOffset(panelScrollOffset, input, key, 9999, PANEL_HEIGHT_LOCAL);
@@ -306,15 +1118,13 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
306
1118
  setPanelScrollOffset(() => 0);
307
1119
  return;
308
1120
  }
309
- // Guard against Ctrl-letter aliases (Tab is Ctrl+I in ASCII, Ctrl+S would
310
- // collide with save in nested editors). Only react to bare keystrokes.
311
1121
  const togglable = !key.ctrl && !key.tab && !key.meta && !key.return;
312
- if (togglable && input === 'i' && panelOpen === 'prop-rationale') {
1122
+ if (togglable && input === 'p' && panelOpen === 'prop-rationale') {
313
1123
  setPanelOpen('none');
314
1124
  setPanelScrollOffset(() => 0);
315
1125
  return;
316
1126
  }
317
- if (togglable && input === 'I' && panelOpen === 'component-rationale') {
1127
+ if (togglable && input === 'P' && panelOpen === 'component-rationale') {
318
1128
  setPanelOpen('none');
319
1129
  setPanelScrollOffset(() => 0);
320
1130
  return;
@@ -324,13 +1134,12 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
324
1134
  setPanelScrollOffset(() => 0);
325
1135
  return;
326
1136
  }
327
- // Cross-panel toggles while one is open.
328
- if (togglable && input === 'i') {
1137
+ if (togglable && input === 'p') {
329
1138
  setPanelOpen('prop-rationale');
330
1139
  setPanelScrollOffset(() => 0);
331
1140
  return;
332
1141
  }
333
- if (togglable && input === 'I') {
1142
+ if (togglable && input === 'P') {
334
1143
  setPanelOpen('component-rationale');
335
1144
  setPanelScrollOffset(() => 0);
336
1145
  return;
@@ -344,39 +1153,53 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
344
1153
  }
345
1154
  const rationaleKeyOk = !textEntryActive && !showJson && !key.ctrl && !key.tab && !key.meta && !key.return;
346
1155
  if (rationaleKeyOk) {
347
- if (input === 'i') {
1156
+ if (input === 'p') {
348
1157
  setPanelOpen('prop-rationale');
349
1158
  setPanelScrollOffset(() => 0);
350
1159
  return;
351
1160
  }
352
- if (input === 'I') {
1161
+ if (input === 'P') {
353
1162
  setPanelOpen('component-rationale');
354
1163
  setPanelScrollOffset(() => 0);
355
1164
  return;
356
1165
  }
1166
+ if (input === 's') {
1167
+ setPanelOpen('source');
1168
+ setPanelScrollOffset(() => 0);
1169
+ return;
1170
+ }
357
1171
  }
358
- // Tab toggles focus bidirectionally between sidebar and panel. `e` is a
359
- // sidebar-only alias for crossing INTO the panel — gating it to the
360
- // sidebar-focused state prevents collision with FieldEditor's enum-values
361
- // `e` binding (INTEG-4254) when the panel is focused. Crossing back from
362
- // panel to sidebar is Tab-only.
363
1172
  if (key.tab) {
1173
+ if (!sidebarFocused && editorDirty) {
1174
+ setPendingFocusAway('tab-to-sidebar');
1175
+ setShowUnsavedWarning(true);
1176
+ return;
1177
+ }
364
1178
  setSidebarFocused((prev) => !prev);
365
1179
  return;
366
1180
  }
367
- if (input === 'e' && sidebarFocused) {
368
- setSidebarFocused(false);
1181
+ if (input === ' ' && sidebarFocused && !showJson) {
1182
+ const current = components[selectedIdx];
1183
+ if (!current)
1184
+ return;
1185
+ const rootName = resolveGroupRoot(current.key, closures, cycleView.structural);
1186
+ if (!rootName)
1187
+ return;
1188
+ setExpandedGroups((prev) => {
1189
+ const next = new Set(prev);
1190
+ if (next.has(rootName))
1191
+ next.delete(rootName);
1192
+ else
1193
+ next.add(rootName);
1194
+ return next;
1195
+ });
369
1196
  return;
370
1197
  }
371
- // JSON view + panel focused: own j/k/arrows/PageUp/PageDown/Ctrl+u/d/gg/G
372
- // for scrolling. Computed against the live `selectedJson` so the
373
- // viewport math matches what JsonPanel renders.
374
1198
  if (!sidebarFocused && showJson) {
375
1199
  const current = components[selectedIdx];
376
1200
  const currentJson = current ? JSON.stringify({ [current.key]: current.entry }, null, 2) : '';
377
1201
  const totalLines = currentJson.split('\n').length;
378
1202
  const maxOffset = Math.max(0, totalLines - PANEL_HEIGHT);
379
- // `gg` double-tap to jump to top; single `g` arms the pending flag.
380
1203
  if (input === 'g' && !key.ctrl) {
381
1204
  if (pendingGRef.current) {
382
1205
  pendingGRef.current = false;
@@ -389,129 +1212,300 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
389
1212
  const next = computeNextScrollOffset(jsonScrollOffset, input, key, totalLines, PANEL_HEIGHT);
390
1213
  if (next !== null) {
391
1214
  pendingGRef.current = false;
392
- // Functional setState mirrors the cursor-stutter fix (commit 5d11e60).
393
- // Clamp against maxOffset re-computed at apply time in case totalLines
394
- // shifted between events (defensive — helper already clamps).
395
1215
  const clamped = Math.min(maxOffset, Math.max(0, next));
396
1216
  setJsonScrollOffset(() => clamped);
397
1217
  return;
398
1218
  }
399
- // Any other key in this slice resets the gg-pending flag, then falls
400
- // through to the early-return below so the panel-focused state still
401
- // swallows non-scroll input.
402
1219
  pendingGRef.current = false;
403
1220
  }
404
- // When the panel is focused, FieldEditor (or JsonPanel) owns the keys.
405
- // Only Tab (handled above) should escape from the panel-focused state.
406
1221
  if (!sidebarFocused)
407
1222
  return;
408
- // Sidebar-focused keymap.
1223
+ if (input === '/') {
1224
+ setSearchOpen(true);
1225
+ return;
1226
+ }
1227
+ if (key.escape && jumpFilterTarget) {
1228
+ setJumpFilterTarget(null);
1229
+ return;
1230
+ }
1231
+ if (key.escape && searchQuery) {
1232
+ setSearchQuery('');
1233
+ setAutocompleteCandidates([]);
1234
+ return;
1235
+ }
1236
+ if (input === '?') {
1237
+ setShowHelp(true);
1238
+ return;
1239
+ }
409
1240
  if (input === 'q') {
410
1241
  setShowQuit(true);
411
1242
  return;
412
1243
  }
413
- if (input === 'F') {
1244
+ if (input === 'F' || input === 'f') {
1245
+ const acceptedNames = new Set(components.filter((c) => c.status === 'accepted').map((c) => c.key));
1246
+ const acceptedSubgraph = componentGraph.filter((n) => acceptedNames.has(n.name));
1247
+ const acceptedCycles = (() => {
1248
+ try {
1249
+ return findSlotCycles(acceptedSubgraph);
1250
+ }
1251
+ catch {
1252
+ return [];
1253
+ }
1254
+ })();
1255
+ if (acceptedCycles.length > 0) {
1256
+ const participants = new Set();
1257
+ for (const c of acceptedCycles)
1258
+ for (const p of c.path)
1259
+ participants.add(p);
1260
+ setFinalizeError(`Cannot finalize — accepted set still contains a cycle (${[...participants].sort().join(', ')}). Reject a cycle member to break it.`);
1261
+ return;
1262
+ }
414
1263
  setShowFinalize(true);
415
1264
  return;
416
1265
  }
1266
+ if (input === 'L') {
1267
+ const currentKey = cursorRowIdx >= 0 && cursorRowIdx < visibleRowsMemo.length
1268
+ ? (components[visibleRowsMemo[cursorRowIdx]?.itemIdx ?? -1]?.key ?? null)
1269
+ : null;
1270
+ const nextView = columnOneView === 'grouped' ? 'flat' : 'grouped';
1271
+ const nextRows = buildVisibleRows({
1272
+ items: groupedItemsMemo,
1273
+ cycleParticipants: cycleView.structural,
1274
+ expandedGroups,
1275
+ viewMode: nextView,
1276
+ graph: sidebarGraph,
1277
+ });
1278
+ let nextCursor = 0;
1279
+ if (currentKey) {
1280
+ for (let i = 0; i < nextRows.length; i++) {
1281
+ const r = nextRows[i];
1282
+ if (r.itemIdx < 0)
1283
+ continue;
1284
+ if (components[r.itemIdx]?.key === currentKey) {
1285
+ nextCursor = i;
1286
+ break;
1287
+ }
1288
+ }
1289
+ }
1290
+ const nextScroll = nextCursor < sidebarScrollOffset
1291
+ ? nextCursor
1292
+ : nextCursor >= sidebarScrollOffset + visibleCount
1293
+ ? nextCursor - visibleCount + 1
1294
+ : sidebarScrollOffset;
1295
+ setColumnOneView(nextView);
1296
+ setNav({ cursorRowIdx: nextCursor, sidebarScrollOffset: nextScroll });
1297
+ return;
1298
+ }
417
1299
  if (input === 'a') {
418
- updateStatus(selectedIdx, 'accepted');
1300
+ const current = components[selectedIdx];
1301
+ if (!current)
1302
+ return;
1303
+ const cascade = computeAcceptCascade(current.key, componentGraph);
1304
+ const next = components.map((c) => cascade.has(c.key) ? { ...c, status: 'accepted' } : c);
1305
+ setComponents(next);
419
1306
  setFinalizeError(null);
1307
+ recomputeCycles(next);
1308
+ pushHistorySnapshot(next, autoRejected, undoSnapshot, 'accept-cascade');
420
1309
  return;
421
1310
  }
422
1311
  if (input === 'r') {
423
- updateStatus(selectedIdx, 'rejected');
1312
+ const current = components[selectedIdx];
1313
+ if (!current)
1314
+ return;
1315
+ handleRejectComponent(current.key);
424
1316
  return;
425
1317
  }
426
1318
  if (input === 'A') {
427
- setComponents((prev) => prev.map((c) => (c.status === 'needs-review' ? { ...c, status: 'accepted' } : c)));
1319
+ const next = components.map((c) => c.status === 'needs-review' ? { ...c, status: 'accepted' } : c);
1320
+ setComponents(next);
428
1321
  setFinalizeError(null);
1322
+ pushHistorySnapshot(next, autoRejected, undoSnapshot, 'bulk-accept');
1323
+ return;
1324
+ }
1325
+ if (input === 'E') {
1326
+ const roots = new Set();
1327
+ for (const [name, closure] of closures.entries()) {
1328
+ if (closure.nodes.length > 1)
1329
+ roots.add(name);
1330
+ }
1331
+ for (const name of cycleView.structural)
1332
+ roots.add(name);
1333
+ setExpandedGroups(roots);
1334
+ return;
1335
+ }
1336
+ if (input === 'C') {
1337
+ setExpandedGroups(new Set());
429
1338
  return;
430
1339
  }
431
1340
  if (input === 'J') {
432
- // Toggle read-only JSON view.
433
1341
  setShowJson((prev) => !prev);
434
1342
  setJsonScrollOffset(0);
435
1343
  pendingGRef.current = false;
436
1344
  return;
437
1345
  }
1346
+ if (key.return) {
1347
+ const current = components[selectedIdx];
1348
+ if (!current)
1349
+ return;
1350
+ const rs = renderStatusByKey.get(current.key);
1351
+ if (!rs || rs.isOwn)
1352
+ return;
1353
+ for (const closure of closures.values()) {
1354
+ if (!closure.nodes.some((n) => n.name === current.key))
1355
+ continue;
1356
+ const target = pickDrillTarget(current.key, closure, directIssues);
1357
+ if (target && target !== current.key) {
1358
+ jumpCursorToName(target);
1359
+ }
1360
+ break;
1361
+ }
1362
+ return;
1363
+ }
438
1364
  if (key.upArrow || input === 'k') {
439
- // Pilot-2026-06-23 bug: rapid k/j bursts could lose cursor position
440
- // because the previous implementation read `selectedIdx` from the
441
- // handler's closure. Under high keyboard-repeat rate multiple key
442
- // events fire between Ink render flushes, so every invocation saw the
443
- // same stale value and recomputed the same `newIdx`. Using functional
444
- // setState chains the updates correctly: each pending update sees the
445
- // post-update value of the previous one. The viewport offset update is
446
- // nested inside the cursor updater so it always reflects the same
447
- // newIdx that selectedIdx is being set to.
448
- setSelectedIdx((prev) => {
449
- const newIdx = Math.max(0, prev - 1);
450
- setSidebarScrollOffset((off) => Math.min(off, newIdx));
451
- return newIdx;
1365
+ setNav(({ cursorRowIdx: prev, sidebarScrollOffset: off }) => {
1366
+ const positions = selectableRowPositions;
1367
+ if (positions.length === 0)
1368
+ return { cursorRowIdx: prev, sidebarScrollOffset: off };
1369
+ const pos = positions.indexOf(prev);
1370
+ const currentSelectableIdx = pos >= 0
1371
+ ? pos
1372
+ : Math.max(0, positions.reduce((acc, p, i) => (p <= prev ? i : acc), 0));
1373
+ const nextSelectableIdx = Math.max(0, currentSelectableIdx - 1);
1374
+ const newRow = positions[nextSelectableIdx] ?? prev;
1375
+ return { cursorRowIdx: newRow, sidebarScrollOffset: Math.min(off, newRow) };
452
1376
  });
453
1377
  setJsonScrollOffset(0);
454
1378
  setDraftValue('');
455
1379
  setSaveError(null);
1380
+ setPendingEditorFocus(null);
456
1381
  }
457
1382
  else if (key.downArrow || input === 'j') {
458
- setSelectedIdx((prev) => {
459
- const newIdx = Math.min(components.length - 1, prev + 1);
460
- setSidebarScrollOffset((off) => (newIdx >= off + VISIBLE_COUNT ? newIdx - VISIBLE_COUNT + 1 : off));
461
- return newIdx;
1383
+ setNav(({ cursorRowIdx: prev, sidebarScrollOffset: off }) => {
1384
+ const positions = selectableRowPositions;
1385
+ if (positions.length === 0)
1386
+ return { cursorRowIdx: prev, sidebarScrollOffset: off };
1387
+ const pos = positions.indexOf(prev);
1388
+ const currentSelectableIdx = pos >= 0
1389
+ ? pos
1390
+ : Math.max(0, positions.reduce((acc, p, i) => (p <= prev ? i : acc), 0));
1391
+ const nextSelectableIdx = Math.min(positions.length - 1, currentSelectableIdx + 1);
1392
+ const newRow = positions[nextSelectableIdx] ?? prev;
1393
+ const nextOff = newRow >= off + visibleCount ? newRow - visibleCount + 1 : off;
1394
+ return { cursorRowIdx: newRow, sidebarScrollOffset: nextOff };
462
1395
  });
463
1396
  setJsonScrollOffset(0);
464
1397
  setDraftValue('');
465
1398
  setSaveError(null);
1399
+ setPendingEditorFocus(null);
466
1400
  }
467
1401
  });
468
1402
  if (loading) {
469
1403
  return (_jsx(Box, { paddingX: 2, paddingY: 1, children: _jsx(Text, { dimColor: true, children: "Loading generated definitions..." }) }));
470
1404
  }
471
1405
  if (loadError) {
472
- return (_jsx(Box, { paddingX: 2, paddingY: 1, children: _jsx(Text, { color: "red", children: loadError }) }));
1406
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Text, { color: PALETTE.error, children: loadError }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "[q / Enter / Esc] Quit" })] }));
1407
+ }
1408
+ if (showHelp) {
1409
+ return _jsx(HelpOverlay, { sections: HELP_SECTIONS, onClose: () => setShowHelp(false) });
1410
+ }
1411
+ const renderBreakOverlay = (width) => {
1412
+ const highlightedCycle = slotCycles[cyclesCursor];
1413
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.warning, paddingX: 1, width: width, children: [_jsx(Text, { bold: true, color: PALETTE.warning, children: `BREAK CYCLE ${cyclesCursor + 1} — remove a slot edge or reject a member` }), highlightedCycle &&
1414
+ (() => {
1415
+ const segs = formatCyclePathSegments(highlightedCycle);
1416
+ return (_jsxs(Text, { children: [' ', segs.map((seg, si) => seg.kind === 'slot' ? (_jsx(Text, { color: PALETTE.info, children: seg.text }, si)) : seg.kind === 'arrow' ? (_jsx(Text, { dimColor: true, children: seg.text }, si)) : (_jsx(Text, { color: PALETTE.warning, children: seg.text }, si)))] }));
1417
+ })(), _jsx(Text, { dimColor: true, children: highlightedCycle
1418
+ ? 'Deleting an edge removes it from $allowedComponents (undo with Ctrl+Z).'
1419
+ : 'No cycle highlighted.' }), _jsx(Text, { children: " " }), breakEdges.length > 0 && _jsx(Text, { dimColor: true, children: 'remove slot edge:' }), breakEdges.map((edge, idx) => {
1420
+ const isCursor = idx === breakCursor;
1421
+ return (_jsx(Text, { inverse: isCursor, children: `${isCursor ? '▶' : ' '} remove '${edge.toComponent}' from ${edge.fromComponent}.$slots.${edge.slotName}.$allowedComponents` }, `break-${idx}`));
1422
+ }), breakConfirm ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: PALETTE.warning, children: 'Delete this slot edge? [y] confirm [n] cancel' })] })) : (_jsx(Text, { dimColor: true, children: '[↑↓/j/k] move [Enter] delete [x/Esc] close' }))] }));
1423
+ };
1424
+ const breakOverlayFullScreen = breakPanel.isOpen &&
1425
+ shouldBreakOverlayGoFullScreen({
1426
+ rows: stdout?.rows ?? FALLBACK_ROWS,
1427
+ edgeCount: breakEdges.length,
1428
+ });
1429
+ if (breakOverlayFullScreen) {
1430
+ return renderBreakOverlay();
473
1431
  }
474
1432
  const selected = components[selectedIdx] ?? null;
475
1433
  const selectedJson = selected ? JSON.stringify({ [selected.key]: selected.entry }, null, 2) : '';
476
- // A component with zero classified $properties is a real defensibility issue
477
- // it can't be pushed to Contentful (no fields). Surface it in the sidebar via
478
- // the existing warning-color path (yellow) and a "(empty)" suffix so the user
479
- // can see what went wrong. They can manually add props in FieldEditor or
480
- // explicitly reject the component.
481
- const isEmpty = (c) => Object.keys(c.entry.$properties).length === 0;
1434
+ const isEmpty = (c) => Object.keys(c.entry.$properties).length === 0 && Object.keys(c.entry.$slots ?? {}).length === 0;
482
1435
  const emptyCount = components.filter(isEmpty).length;
483
- const sidebarItems = components.map((c) => ({
484
- id: c.key,
485
- name: isEmpty(c) ? `${c.key} (empty)` : c.key,
486
- status: c.status,
487
- previewAnnotation: previewAnnotations.get(c.key),
488
- extractionConfidence: null,
489
- needsReview: false,
490
- validationErrorCount: 0,
491
- validationWarningCount: isEmpty(c) ? 1 : 0,
492
- }));
493
- // Account for the "(empty)" suffix added to zero-prop component names so the
494
- // sidebar doesn't truncate it.
495
- const longestName = components.reduce((m, c) => Math.max(m, c.key.length + (isEmpty(c) ? ' (empty)'.length : 0)), 0);
496
- // +5 = border (1) + status icon (1) + badge column (1) + space (1) + border (1).
497
- // The badge column is reserved even when no annotation is present so the
498
- // sidebar width doesn't jitter as live-preview annotations flip in/out.
499
- const sidebarWidth = Math.min(Math.max(longestName + 5, 14), 30);
1436
+ const cycleParticipantSet = cycleView.structural;
1437
+ const sidebarSuffix = (c) => {
1438
+ if (cycleParticipantSet.has(c.key))
1439
+ return ' (cycle)';
1440
+ if (isEmpty(c))
1441
+ return ' (empty)';
1442
+ return '';
1443
+ };
1444
+ const groupedItems = groupedItemsMemo;
1445
+ const previewAnnotationByKey = previewAnnotations;
1446
+ const longestName = components.reduce((m, c) => {
1447
+ const suffixLen = sidebarSuffix(c).length;
1448
+ const groupOverhead = 12; // "▸ (99 deps) ✗"
1449
+ return Math.max(m, c.key.length + Math.max(suffixLen, groupOverhead));
1450
+ }, 0);
1451
+ const sidebarWidthCap = computeSidebarWidth(terminalWidth);
1452
+ const sidebarWidth = Math.min(Math.max(longestName + 9, 18), sidebarWidthCap);
500
1453
  const panelWidth = Math.max(10, terminalWidth - sidebarWidth - 4);
1454
+ const projectSlotGraph = components.map((c) => ({
1455
+ name: c.key,
1456
+ slots: Object.entries(c.entry.$slots ?? {}).map(([slotName, slotDef]) => ({
1457
+ name: slotName,
1458
+ allowedComponents: Array.isArray(slotDef?.$allowedComponents)
1459
+ ? slotDef.$allowedComponents.filter((v) => typeof v === 'string')
1460
+ : [],
1461
+ })),
1462
+ }));
1463
+ const hasGroupRoots = (() => {
1464
+ for (const c of closures.values())
1465
+ if (c.nodes.length > 1)
1466
+ return true;
1467
+ return false;
1468
+ })();
501
1469
  const accepted = components.filter((c) => c.status === 'accepted').length;
502
1470
  const rejected = components.filter((c) => c.status === 'rejected').length;
503
1471
  const needsReview = components.filter((c) => c.status === 'needs-review').length;
504
1472
  const propCount = selected ? Object.keys(selected.entry.$properties).length : 0;
505
1473
  const slotCount = selected?.entry.$slots ? Object.keys(selected.entry.$slots).length : 0;
506
- return (_jsxs(Box, { flexDirection: "column", children: [showFinalize && (_jsx(FinalizeDialog, { accepted: accepted, rejected: rejected, needsReview: needsReview, onConfirm: handleFinalizeConfirm, onCancel: () => setShowFinalize(false) })), showQuit && _jsx(QuitDialog, { hasUnsavedDrafts: false, onConfirm: onQuit, onCancel: () => setShowQuit(false) }), showRemovedPanel && !dialogOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: `Removed components (${removedComponents.length})` }), _jsx(Text, { dimColor: true, children: "these will be DELETED from the target space" }), _jsx(Text, { children: " " }), removedComponents.map((rc) => (_jsx(Text, { children: `- ${rc.name}${rc.id ? ` (${rc.id})` : ''}` }, rc.id))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "press d or Esc to close" })] })), !dialogOpen &&
1474
+ return (_jsxs(Box, { flexDirection: "column", children: [showFinalize && (_jsx(FinalizeDialog, { accepted: accepted, rejected: rejected, needsReview: needsReview, removed: finalizePreview.removed, previewStatus: finalizePreview.status, removedScrollOffset: finalizePreview.scrollOffset, onConfirm: handleFinalizeConfirm, onCancel: () => setShowFinalize(false) })), showQuit && _jsx(QuitDialog, { hasUnsavedDrafts: false, onConfirm: onQuit, onCancel: () => setShowQuit(false) }), showUnsavedWarning && !dialogOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.warning, paddingX: 1, children: [_jsx(Text, { bold: true, color: PALETTE.warning, children: "Unsaved changes" }), _jsx(Text, { children: "You have unsaved edits in the current field editor." }), _jsx(Text, { children: " " }), _jsx(Text, { children: ' [Enter] Save and continue' }), _jsx(Text, { children: ' [Esc] Discard changes and continue' }), _jsx(Text, { children: ' [Tab] Stay in the panel' })] })), showReloadDialog && !dialogOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.warning, paddingX: 1, children: [_jsx(Text, { bold: true, color: PALETTE.warning, children: "Reload from saved state?" }), _jsx(Text, { children: "Unsaved in-memory changes will be lost." }), _jsx(Text, { children: " " }), _jsx(Text, { children: ' [Enter] Confirm' }), _jsx(Text, { children: ' [Esc] Cancel' })] })), removedComponents.length > 0 && !dialogOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.error, paddingX: 1, children: [_jsx(Text, { bold: true, color: PALETTE.error, children: removedComponentsHeader(removedComponents.length, true) }), !removedBannerCollapsed && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), removedComponents.map((rc) => (_jsx(Text, { children: removedComponentLine(rc) }, rc.id)))] }))] })), breakingChanges.length > 0 && !dialogOpen && (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: PALETTE.warning, children: `[b] ${breakingChanges.length} breaking change${breakingChanges.length === 1 ? '' : 's'}` }) })), breakingPanel.isOpen &&
1475
+ breakingDetailOpen &&
1476
+ !dialogOpen &&
1477
+ (() => {
1478
+ const row = breakingRows[breakingCursor];
1479
+ const comp = row ? breakingChanges.find((b) => b.componentName === row.componentName) : undefined;
1480
+ if (!comp)
1481
+ return null;
1482
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.warning, paddingX: 1, children: [_jsx(Text, { bold: true, color: PALETTE.warning, children: `Breaking changes — ${comp.componentName}` }), comp.impact && (_jsx(Text, { dimColor: true, children: ` affects ${comp.impact.affectedExperiences} experience${comp.impact.affectedExperiences === 1 ? '' : 's'}, ${comp.impact.affectedFragments} fragment${comp.impact.affectedFragments === 1 ? '' : 's'}` })), comp.changes.length === 0 ? (_jsx(Text, { dimColor: true, children: ' (no enumerated changes)' })) : (comp.changes.map((change, ci) => (_jsx(Text, { children: ` • ${formatBreakingChange(change, comp.current)}` }, `bd-detail-${ci}`)))), _jsx(Text, { dimColor: true, children: '[D/Esc] close detail' })] }));
1483
+ })(), cyclePanel.isOpen &&
1484
+ !dialogOpen &&
1485
+ (() => {
1486
+ const PANEL_H = 20;
1487
+ const lines = [];
1488
+ lines.push(_jsx(Text, { bold: true, color: PALETTE.warning, children: `SLOT DEPENDENCY CYCLES (${slotCycles.length})` }, "cyc-title"));
1489
+ lines.push(_jsx(Text, { dimColor: true, children: 'push will fail until these are resolved' }, "cyc-sub"));
1490
+ lines.push(_jsx(Text, { dimColor: true, children: 'To fix: reject a cycle member, or break the cycle by removing a slot edge.' }, "cyc-guide"));
1491
+ lines.push(_jsx(Text, { children: " " }, "cyc-space"));
1492
+ slotCycles.forEach((cycle, idx) => {
1493
+ const nodeCount = new Set(cycle.path).size;
1494
+ const isCursor = idx === cyclesCursor;
1495
+ lines.push(_jsx(Text, { bold: true, inverse: isCursor, children: `${isCursor ? '▶' : ' '} Cycle ${idx + 1} (${nodeCount} component${nodeCount === 1 ? '' : 's'}):` }, `cyc-h-${idx}`));
1496
+ const segs = formatCyclePathSegments(cycle, 16);
1497
+ lines.push(_jsxs(Text, { children: [' ', segs.map((seg, si) => seg.kind === 'slot' ? (_jsx(Text, { color: PALETTE.info, children: seg.text }, si)) : seg.kind === 'arrow' ? (_jsx(Text, { dimColor: true, children: seg.text }, si)) : (_jsx(Text, { children: seg.text }, si)))] }, `cyc-p-${idx}`));
1498
+ if (cycle.suggestedBreak) {
1499
+ const b = cycle.suggestedBreak;
1500
+ lines.push(_jsx(Text, { dimColor: true, children: ` Suggested fix: remove '${b.toComponent}' from ${b.fromComponent}.$slots.${b.slotName}.$allowedComponents` }, `cyc-f-${idx}`));
1501
+ }
1502
+ lines.push(_jsx(Text, { children: " " }, `cyc-s-${idx}`));
1503
+ });
1504
+ const visible = lines.slice(cyclePanelScroll, cyclePanelScroll + PANEL_H);
1505
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.warning, paddingX: 1, children: [visible, _jsx(Text, { dimColor: true, children: '[↑↓/j/k] move [Enter] jump [x] break cycle [c/q/Esc] close' })] }));
1506
+ })(), !dialogOpen &&
507
1507
  livePreview &&
508
1508
  (() => {
509
- // Pilot-2026-06-23 R2: at-a-glance diff summary at the top of the
510
- // step. Mutually exclusive states:
511
- // - hook running (and we don't yet have annotations) → spinner.
512
- // - hook disabled (creds rejected) → static disabled hint.
513
- // - annotations populated → counts.
514
- // - idle, no annotations, not disabled → render nothing.
515
1509
  const counts = { new: 0, changed: 0, removed: 0, breaking: 0 };
516
1510
  for (const v of previewAnnotations.values()) {
517
1511
  counts[v] = (counts[v] ?? 0) + 1;
@@ -525,14 +1519,43 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
525
1519
  }
526
1520
  if (!hasCounts)
527
1521
  return null;
528
- return (_jsxs(Box, { children: [_jsx(Text, { children: 'Preview: ' }), _jsx(Text, { color: "green", children: `${counts.new} new` }), _jsx(Text, { children: ' · ' }), _jsx(Text, { color: "yellow", children: `${counts.changed} changed` }), _jsx(Text, { children: ' · ' }), _jsx(Text, { dimColor: true, children: `${counts.removed} removed` }), removedComponents.length > 0 && _jsx(Text, { dimColor: true, children: ' ([d] removed list)' }), _jsx(Text, { children: ' · ' }), _jsx(Text, { color: "red", bold: true, children: `${counts.breaking} breaking` })] }));
529
- })(), !dialogOpen && emptyCount > 0 && (_jsx(Text, { color: "yellow", children: `⚠ ${emptyCount} component${emptyCount === 1 ? '' : 's'} had no classifiable props — review with care` })), !dialogOpen && finalizeError && _jsx(Text, { color: "red", children: `⚠ ${finalizeError}` }), !dialogOpen && (_jsxs(Box, { children: [_jsx(Sidebar, { components: sidebarItems, selectedId: selected?.key ?? null, focused: sidebarFocused, scrollOffset: sidebarScrollOffset, visibleCount: VISIBLE_COUNT, onSelect: (id) => {
530
- const idx = components.findIndex((c) => c.key === id);
531
- if (idx >= 0) {
532
- setSelectedIdx(idx);
533
- setJsonScrollOffset(0);
1522
+ return (_jsxs(Box, { children: [_jsx(Text, { children: 'Preview: ' }), _jsx(Text, { color: PALETTE.success, children: `${counts.new} new` }), _jsx(Text, { children: ' · ' }), _jsx(Text, { color: PALETTE.warning, children: `${counts.changed} changed` }), _jsx(Text, { children: ' · ' }), _jsx(Text, { dimColor: true, children: `${counts.removed} removed` }), _jsx(Text, { children: ' · ' }), _jsx(Text, { color: PALETTE.error, bold: true, children: `${counts.breaking} breaking` })] }));
1523
+ })(), !dialogOpen &&
1524
+ autoRejected.length > 0 &&
1525
+ (() => {
1526
+ const stillRejected = autoRejected.filter((name) => {
1527
+ const c = components.find((x) => x.key === name);
1528
+ return c?.status === 'rejected';
1529
+ });
1530
+ if (stillRejected.length === 0)
1531
+ return null;
1532
+ const participantSet = cycleView.structural;
1533
+ const members = stillRejected.filter((n) => participantSet.has(n)).sort();
1534
+ const ancestors = stillRejected.filter((n) => !participantSet.has(n)).sort();
1535
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: PALETTE.error, paddingX: 1, children: [_jsx(Text, { color: PALETTE.error, bold: true, children: `Cyclic manifest — auto-rejected ${stillRejected.length} component${stillRejected.length === 1 ? '' : 's'}:` }), members.length > 0 && _jsx(Text, { color: PALETTE.error, children: ` Cycle members: ${members.join(', ')}` }), ancestors.length > 0 && _jsx(Text, { color: PALETTE.error, children: ` Ancestors: ${ancestors.join(', ')}` }), _jsx(Text, { dimColor: true, children: undoSnapshot
1536
+ ? ' [Ctrl+Z] undo · [r]/[a] manually toggle · [F] continue'
1537
+ : ' [r]/[a] manually toggle · [F] continue' })] }));
1538
+ })(), !dialogOpen && emptyCount > 0 && (_jsx(Text, { color: PALETTE.warning, children: `⚠ ${emptyCount} component${emptyCount === 1 ? '' : 's'} had no classifiable props — review with care` })), !dialogOpen && finalizeError && _jsx(Text, { color: PALETTE.error, children: `⚠ ${finalizeError}` }), !dialogOpen && (_jsxs(Box, { children: [breakingPanel.isOpen ? (_jsx(GotoBanner, { title: "Breaking changes", rows: breakingRows.map((r) => ({
1539
+ label: r.label,
1540
+ jumpTarget: r.componentName,
1541
+ })), cursor: breakingCursor, maxRows: panelMaxRows, width: sidebarWidth, footerHint: "[\u2191/\u2193] move \u00B7 [Enter] jump \u00B7 [D] detail \u00B7 [Esc] close" })) : lineagePanel.isOpen && focusedComponentKey ? (_jsx(LineagePanel, { focusedComponentKey: focusedComponentKey, entries: lineageEntries, cursor: lineageCursor, jumpables: lineageJumpables, maxRows: panelMaxRows, width: sidebarWidth })) : (_jsx(GroupedSidebar, { items: groupedItems, cycleParticipants: cycleParticipantSet, selectedIdx: selectedIdx, selectedRowIdx: cursorRowIdx, onSelect: (idx) => {
1542
+ for (let i = 0; i < visibleRowsMemo.length; i++) {
1543
+ if (visibleRowsMemo[i].itemIdx === idx) {
1544
+ jumpCursorToRow(i);
1545
+ return;
1546
+ }
534
1547
  }
535
- }, onScrollChange: setSidebarScrollOffset, width: sidebarWidth }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, flexDirection: "column", children: selected ? (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: selected.key }), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { dimColor: true, children: [propCount, " prop", propCount !== 1 ? 's' : '', slotCount > 0 ? ` · ${slotCount} slot${slotCount !== 1 ? 's' : ''}` : '', ' ', sidebarFocused ? '[e/Tab] focus panel' : '[Tab] focus list'] })] }), panelOpen === 'prop-rationale' ? ((() => {
1548
+ setJsonScrollOffset(0);
1549
+ }, expandedGroups: expandedGroups, onToggleExpanded: (rootName) => {
1550
+ setExpandedGroups((prev) => {
1551
+ const next = new Set(prev);
1552
+ if (next.has(rootName))
1553
+ next.delete(rootName);
1554
+ else
1555
+ next.add(rootName);
1556
+ return next;
1557
+ });
1558
+ }, width: sidebarWidth, focused: sidebarFocused, renderStatusByKey: renderStatusByKey, previewAnnotationByKey: previewAnnotationByKey, selectionStateByKey: selectionStateByKey, scrollOffset: sidebarScrollOffset, visibleCount: visibleCount, dimPredicate: dimPredicate, visibleRows: visibleRowsMemo, viewMode: columnOneView, graph: sidebarGraph })), _jsx(Box, { flexGrow: 1, paddingLeft: 1, flexDirection: "column", children: selected ? (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: selected.key }), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { dimColor: true, children: [propCount, " prop", propCount !== 1 ? 's' : '', slotCount > 0 ? ` · ${slotCount} slot${slotCount !== 1 ? 's' : ''}` : '', ' ', sidebarFocused ? '[Tab] focus panel' : '[Tab] focus list'] })] }), panelOpen === 'prop-rationale' ? ((() => {
536
1559
  const rows = [
537
1560
  ...(componentRationale?.props ?? []).map((p) => ({
538
1561
  name: p.name,
@@ -569,21 +1592,27 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, liveP
569
1592
  : undefined, onTogglePropRationale: () => {
570
1593
  setPanelOpen('prop-rationale');
571
1594
  setPanelScrollOffset(() => 0);
572
- }, onToggleComponentRationale: () => {
1595
+ }, propRationaleKey: "p", componentRationaleKey: "P", onToggleComponentRationale: () => {
573
1596
  setPanelOpen('component-rationale');
574
1597
  setPanelScrollOffset(() => 0);
575
1598
  }, onToggleSourceExternal: () => {
576
1599
  setPanelOpen('source');
577
1600
  setPanelScrollOffset(() => 0);
578
- }, onTextEntryActiveChange: setTextEntryActive }, selected.key)), saveError && _jsx(Text, { color: "red", children: '✗ ' + saveError }), _jsxs(Text, { dimColor: true, children: [sidebarFocused
579
- ? ' [a] accept [r] reject [A] accept all [J] ' +
580
- (showJson ? 'hide JSON' : 'show JSON') +
581
- ' [F] finalize [e/Tab] focus panel' +
582
- (livePreview && removedComponents.length > 0 ? ' [d] removed list' : '') +
583
- ' [q] quit'
1601
+ }, onTextEntryActiveChange: setTextEntryActive, projectSlotGraph: projectSlotGraph, currentComponentName: selected.key, onDirtyChange: setEditorDirty, discardTrigger: discardTrigger, initialFocusTarget: pendingEditorFocus && pendingEditorFocus.componentName === selected.key
1602
+ ? pendingEditorFocus.target
1603
+ : undefined }, pendingEditorFocus && pendingEditorFocus.componentName === selected.key
1604
+ ? `${selected.key}::${pendingEditorFocus.target.kind}:${pendingEditorFocus.target.name}`
1605
+ : selected.key)), saveError && _jsx(Text, { color: PALETTE.error, children: '' + saveError }), _jsxs(Text, { dimColor: true, children: [sidebarFocused
1606
+ ? hasGroupRoots
1607
+ ? ' [Space] expand/collapse group [E/C] expand/collapse all'
1608
+ : ''
584
1609
  : showJson
585
1610
  ? ' [j/k] scroll [Ctrl+u/d] half-page [gg/G] top/bottom [Tab] focus list'
586
- : ' [Tab] focus list (edit fields)', livePreviewHook.status === 'running' && _jsx(Text, { children: ` ${livePreviewSpinner} live preview` }), livePreviewHook.disabled && _jsx(Text, { children: ' · live preview disabled' })] })] })) : (_jsx(Text, { dimColor: true, children: "No component selected" })) })] })), !dialogOpen && (_jsx(StatusBar, { accepted: accepted, rejected: rejected, reviewed: 0, needsReview: needsReview, onApproveAll: () => {
1611
+ : ' [Tab] focus list (edit fields)', livePreviewHook.status === 'running' && _jsx(Text, { children: ` ${livePreviewSpinner} live preview` }), livePreviewHook.disabled && _jsx(Text, { children: ' · live preview disabled' })] })] })) : (_jsx(Text, { dimColor: true, children: "No component selected" })) })] })), breakPanel.isOpen && !breakOverlayFullScreen && !dialogOpen && renderBreakOverlay(), !dialogOpen && slotCycles.length > 0 && !cyclePanel.isOpen && !breakPanel.isOpen && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: PALETTE.warning, children: `⚠ ${slotCycles.length} slot dependency cycle${slotCycles.length === 1 ? '' : 's'} detected — push will fail` }), slotCycles.slice(0, 3).map((cycle, idx) => {
1612
+ const segs = formatCyclePathSegments(cycle);
1613
+ return (_jsxs(Text, { color: PALETTE.warning, children: [' Cycle: ', segs.map((seg, si) => seg.kind === 'slot' ? (_jsx(Text, { color: PALETTE.info, children: seg.text }, si)) : seg.kind === 'arrow' ? (_jsx(Text, { dimColor: true, children: seg.text }, si)) : (_jsx(Text, { color: PALETTE.warning, children: seg.text }, si)))] }, `cyc-banner-${idx}`));
1614
+ }), slotCycles.length > 3 && _jsx(Text, { color: PALETTE.warning, children: ` …${slotCycles.length - 3} more` }), _jsx(Text, { dimColor: true, children: ' press [c] for detail' })] })), !dialogOpen && searchOpen && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [`/${searchQuery}`, _jsx(Text, { color: PALETTE.info, children: '▎' }), searchQuery && _jsx(Text, { dimColor: true, children: ` (${searchMatchCount}/${components.length} matches)` })] }), autocompleteCandidates.length > 1 && (_jsx(Text, { dimColor: true, children: ` possibilities: ${autocompleteCandidates.join(' · ').slice(0, 120)}` }))] })), !dialogOpen && !searchOpen && searchQuery && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: `/${searchQuery} (${searchMatchCount}/${components.length} matches) · [Esc] clear` }) })), !dialogOpen && sidebarFocused && (_jsxs(Box, { columnGap: 2, flexWrap: "wrap", children: [legendEntry('[j/k]', 'move'), legendEntry('[a]', 'accept'), legendEntry('[r]', 'reject'), legendEntry('[A]', 'accept all'), legendEntry('[F]', 'finalize'), legendEntry('[L]', 'flat', columnOneView === 'flat'), legendEntry('[l]', 'lineage', lineagePanel.isOpen), legendEntry('[i]', 'focus lineage', jumpFilterTarget !== null), legendEntry('[w]', 'only breaking', activeFilters.has('broken')), slotCycles.length > 0 && legendEntry('[o]', 'only cycles', activeFilters.has('cycles')), slotCycles.length > 0 && legendEntry('[c]', 'cycle list', cyclePanel.isOpen), legendEntry('[p]', 'prop rationale', panelOpen === 'prop-rationale'), legendEntry('[P]', 'component rationale', panelOpen === 'component-rationale'), legendEntry('[s]', 'source', panelOpen === 'source'), legendEntry('[J]', showJson ? 'hide JSON' : 'show JSON', showJson), breakingChanges.length > 0 && legendEntry('[b]', 'see breaking changes', breakingPanel.isOpen), removedComponents.length > 0 &&
1615
+ legendEntry('[d]', removedBannerCollapsed ? 'show removed' : 'hide removed', !removedBannerCollapsed), legendEntry('[/]', 'search', searchOpen || searchQuery.length > 0), legendEntry('[Tab]', 'focus panel'), legendEntry('[Ctrl+Z]', 'undo'), legendEntry('[Ctrl+Y]', 'redo'), legendEntry('[Ctrl+R]', 'reload'), legendEntry('[?]', 'help'), legendEntry('[q]', 'quit')] })), !dialogOpen && (_jsx(StatusBar, { accepted: accepted, rejected: rejected, reviewed: 0, needsReview: needsReview, onApproveAll: () => {
587
1616
  setComponents((prev) => prev.map((c) => (c.status === 'needs-review' ? { ...c, status: 'accepted' } : c)));
588
1617
  }, onFinalize: () => setShowFinalize(true) }))] }));
589
1618
  }