@contentful/experience-design-system-cli 2.12.0 → 2.12.1-dev-build-bae15c7.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 (144) hide show
  1. package/README.md +214 -357
  2. package/dist/package.json +1 -1
  3. package/dist/src/analyze/extract/astro.js +9 -0
  4. package/dist/src/analyze/extract/react.js +23 -0
  5. package/dist/src/analyze/extract/stencil.js +3 -0
  6. package/dist/src/analyze/extract/vue.js +6 -0
  7. package/dist/src/analyze/extract/web-components.js +10 -0
  8. package/dist/src/analyze/pre-classify.d.ts +1 -1
  9. package/dist/src/analyze/pre-classify.js +3 -3
  10. package/dist/src/analyze/select/persistence.d.ts +13 -0
  11. package/dist/src/analyze/select/persistence.js +38 -0
  12. package/dist/src/analyze/select/preview-annotations.d.ts +26 -0
  13. package/dist/src/analyze/select/preview-annotations.js +62 -0
  14. package/dist/src/analyze/select/tui/App.js +5 -2
  15. package/dist/src/analyze/select/tui/components/ComponentRationalePanel.d.ts +30 -0
  16. package/dist/src/analyze/select/tui/components/ComponentRationalePanel.js +137 -0
  17. package/dist/src/analyze/select/tui/components/FieldEditor.d.ts +58 -1
  18. package/dist/src/analyze/select/tui/components/FieldEditor.js +637 -161
  19. package/dist/src/analyze/select/tui/components/FinalizeDialog.js +1 -1
  20. package/dist/src/analyze/select/tui/components/JsonPanel.js +5 -1
  21. package/dist/src/analyze/select/tui/components/RationalePanel.d.ts +34 -0
  22. package/dist/src/analyze/select/tui/components/RationalePanel.js +92 -0
  23. package/dist/src/analyze/select/tui/components/Sidebar.d.ts +13 -1
  24. package/dist/src/analyze/select/tui/components/Sidebar.js +27 -3
  25. package/dist/src/analyze/select/tui/hooks/scroll-offset.d.ts +29 -0
  26. package/dist/src/analyze/select/tui/hooks/scroll-offset.js +26 -0
  27. package/dist/src/analyze/select-agent/command.d.ts +2 -0
  28. package/dist/src/analyze/select-agent/command.js +260 -45
  29. package/dist/src/analyze/select-agent/show-rationale.d.ts +35 -0
  30. package/dist/src/analyze/select-agent/show-rationale.js +92 -0
  31. package/dist/src/apply/command.js +7 -3
  32. package/dist/src/apply/tui/ServerApplyView.d.ts +3 -1
  33. package/dist/src/apply/tui/ServerApplyView.js +3 -2
  34. package/dist/src/credentials-store.d.ts +5 -0
  35. package/dist/src/credentials-store.js +7 -1
  36. package/dist/src/generate/agent-runner.d.ts +14 -0
  37. package/dist/src/generate/agent-runner.js +16 -0
  38. package/dist/src/generate/command.d.ts +6 -0
  39. package/dist/src/generate/command.js +45 -10
  40. package/dist/src/generate/progress.d.ts +9 -0
  41. package/dist/src/generate/progress.js +11 -0
  42. package/dist/src/generate/prompt-builder.d.ts +8 -0
  43. package/dist/src/generate/prompt-builder.js +22 -11
  44. package/dist/src/import/agent-model-resolve.d.ts +23 -0
  45. package/dist/src/import/agent-model-resolve.js +35 -0
  46. package/dist/src/import/auto-filter-resolve.d.ts +12 -0
  47. package/dist/src/import/auto-filter-resolve.js +16 -0
  48. package/dist/src/import/command.js +248 -8
  49. package/dist/src/import/orchestrator.d.ts +2 -0
  50. package/dist/src/import/orchestrator.js +11 -3
  51. package/dist/src/import/print-prompt.d.ts +35 -0
  52. package/dist/src/import/print-prompt.js +31 -0
  53. package/dist/src/import/tui/CustomPromptBanner.d.ts +11 -0
  54. package/dist/src/import/tui/CustomPromptBanner.js +7 -0
  55. package/dist/src/import/tui/WizardApp.d.ts +112 -8
  56. package/dist/src/import/tui/WizardApp.js +820 -333
  57. package/dist/src/import/tui/auto-filter-error.d.ts +2 -0
  58. package/dist/src/import/tui/auto-filter-error.js +29 -0
  59. package/dist/src/import/tui/final-review-host.d.ts +15 -0
  60. package/dist/src/import/tui/final-review-host.js +20 -0
  61. package/dist/src/import/tui/merge-ai-decisions.d.ts +5 -0
  62. package/dist/src/import/tui/merge-ai-decisions.js +22 -0
  63. package/dist/src/import/tui/push-decision-gate-helpers.d.ts +8 -0
  64. package/dist/src/import/tui/push-decision-gate-helpers.js +8 -0
  65. package/dist/src/import/tui/push-progress.d.ts +23 -0
  66. package/dist/src/import/tui/push-progress.js +14 -0
  67. package/dist/src/import/tui/run-print-files-helpers.d.ts +26 -0
  68. package/dist/src/import/tui/run-print-files-helpers.js +8 -0
  69. package/dist/src/import/tui/run-teaser.d.ts +5 -0
  70. package/dist/src/import/tui/run-teaser.js +9 -0
  71. package/dist/src/import/tui/runLivePreview.d.ts +35 -0
  72. package/dist/src/import/tui/runLivePreview.js +73 -0
  73. package/dist/src/import/tui/runScopeGate.d.ts +22 -0
  74. package/dist/src/import/tui/runScopeGate.js +28 -0
  75. package/dist/src/import/tui/scope-gate-host.d.ts +21 -0
  76. package/dist/src/import/tui/scope-gate-host.js +20 -0
  77. package/dist/src/import/tui/spawn-generate.d.ts +33 -0
  78. package/dist/src/import/tui/spawn-generate.js +53 -0
  79. package/dist/src/import/tui/steps/CredentialsStep.d.ts +25 -1
  80. package/dist/src/import/tui/steps/CredentialsStep.js +31 -3
  81. package/dist/src/import/tui/steps/DoneStep.d.ts +6 -1
  82. package/dist/src/import/tui/steps/DoneStep.js +10 -4
  83. package/dist/src/import/tui/steps/GenerateReviewStep.d.ts +29 -2
  84. package/dist/src/import/tui/steps/GenerateReviewStep.js +412 -48
  85. package/dist/src/import/tui/steps/PushDecisionGateStep.d.ts +19 -0
  86. package/dist/src/import/tui/steps/PushDecisionGateStep.js +76 -0
  87. package/dist/src/import/tui/steps/PushingStep.d.ts +10 -0
  88. package/dist/src/import/tui/steps/PushingStep.js +40 -0
  89. package/dist/src/import/tui/steps/ScopeGateStep.d.ts +23 -0
  90. package/dist/src/import/tui/steps/ScopeGateStep.js +250 -0
  91. package/dist/src/import/tui/steps/TokenInputStep.js +20 -3
  92. package/dist/src/import/tui/useLivePreview.d.ts +35 -0
  93. package/dist/src/import/tui/useLivePreview.js +120 -0
  94. package/dist/src/import/tui/wizard-generate-progress.d.ts +21 -0
  95. package/dist/src/import/tui/wizard-generate-progress.js +25 -0
  96. package/dist/src/import/tui/wizard-save-flow.d.ts +37 -0
  97. package/dist/src/import/tui/wizard-save-flow.js +20 -0
  98. package/dist/src/import/tui/wizard-state-transitions.d.ts +80 -0
  99. package/dist/src/import/tui/wizard-state-transitions.js +79 -0
  100. package/dist/src/lib/contentful-urls.d.ts +34 -0
  101. package/dist/src/lib/contentful-urls.js +41 -0
  102. package/dist/src/program.js +2 -0
  103. package/dist/src/runs/export-helpers.d.ts +30 -0
  104. package/dist/src/runs/export-helpers.js +59 -0
  105. package/dist/src/runs/fingerprint.d.ts +47 -0
  106. package/dist/src/runs/fingerprint.js +75 -0
  107. package/dist/src/runs/ls-command.d.ts +12 -0
  108. package/dist/src/runs/ls-command.js +137 -0
  109. package/dist/src/runs/modify-launcher.d.ts +24 -0
  110. package/dist/src/runs/modify-launcher.js +36 -0
  111. package/dist/src/runs/path-prompt.d.ts +8 -0
  112. package/dist/src/runs/path-prompt.js +72 -0
  113. package/dist/src/runs/push-creds-prompt.d.ts +25 -0
  114. package/dist/src/runs/push-creds-prompt.js +39 -0
  115. package/dist/src/runs/push-helpers.d.ts +29 -0
  116. package/dist/src/runs/push-helpers.js +69 -0
  117. package/dist/src/runs/replay-helpers.d.ts +67 -0
  118. package/dist/src/runs/replay-helpers.js +148 -0
  119. package/dist/src/runs/resolve-run-target.d.ts +12 -0
  120. package/dist/src/runs/resolve-run-target.js +45 -0
  121. package/dist/src/runs/run-picker-mount.d.ts +46 -0
  122. package/dist/src/runs/run-picker-mount.js +72 -0
  123. package/dist/src/runs/run-picker.d.ts +17 -0
  124. package/dist/src/runs/run-picker.js +145 -0
  125. package/dist/src/runs/save-conflict.d.ts +8 -0
  126. package/dist/src/runs/save-conflict.js +56 -0
  127. package/dist/src/runs/save-path-resolver.d.ts +55 -0
  128. package/dist/src/runs/save-path-resolver.js +71 -0
  129. package/dist/src/runs/staleness.d.ts +38 -0
  130. package/dist/src/runs/staleness.js +140 -0
  131. package/dist/src/runs/store.d.ts +78 -0
  132. package/dist/src/runs/store.js +136 -0
  133. package/dist/src/session/cache-keys.d.ts +15 -0
  134. package/dist/src/session/cache-keys.js +38 -0
  135. package/dist/src/session/db.d.ts +89 -3
  136. package/dist/src/session/db.js +447 -104
  137. package/dist/src/setup/auto-filter-prompt.d.ts +13 -0
  138. package/dist/src/setup/auto-filter-prompt.js +24 -0
  139. package/dist/src/setup/command.d.ts +7 -0
  140. package/dist/src/setup/command.js +58 -2
  141. package/dist/src/types.d.ts +6 -0
  142. package/package.json +2 -2
  143. package/skills/generate-components.md +54 -6
  144. package/skills/select-components.md +13 -4
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { Box, Text, useStdout } from 'ink';
4
4
  import { Sidebar } from '../../../analyze/select/tui/components/Sidebar.js';
5
5
  import { JsonPanel } from '../../../analyze/select/tui/components/JsonPanel.js';
@@ -8,10 +8,34 @@ import { StatusBar } from '../../../analyze/select/tui/components/StatusBar.js';
8
8
  import { FinalizeDialog } from '../../../analyze/select/tui/components/FinalizeDialog.js';
9
9
  import { QuitDialog } from '../../../analyze/select/tui/components/QuitDialog.js';
10
10
  import { useImmediateInput } from '../../../analyze/select/tui/hooks/useImmediateInput.js';
11
- import { openPipelineDb, loadCDFComponents, storeCDFComponents } from '../../../session/db.js';
11
+ import { openPipelineDb, loadCDFComponents, storeCDFComponents, loadComponentReviewMetadata, loadComponentRationale, } from '../../../session/db.js';
12
+ import { RationalePanel } from '../../../analyze/select/tui/components/RationalePanel.js';
13
+ import { ComponentRationalePanel } from '../../../analyze/select/tui/components/ComponentRationalePanel.js';
14
+ import { applyPreviewAnnotations } from '../../../analyze/select/preview-annotations.js';
15
+ import { useLivePreview } from '../useLivePreview.js';
16
+ 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) {
28
+ 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;
33
+ return a.key.localeCompare(b.key);
34
+ });
35
+ }
12
36
  const VISIBLE_COUNT = 20;
13
37
  const PANEL_HEIGHT = 22;
14
- export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, }) {
38
+ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, livePreview = true, spaceId = '', environmentId = '', cmaToken = '', host = '', tokensPath = '', }) {
15
39
  const { stdout } = useStdout();
16
40
  const terminalWidth = stdout?.columns ?? 80;
17
41
  const [components, setComponents] = useState([]);
@@ -23,9 +47,58 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, }) {
23
47
  const [sidebarFocused, setSidebarFocused] = useState(true);
24
48
  const [showFinalize, setShowFinalize] = useState(false);
25
49
  const [showQuit, setShowQuit] = useState(false);
26
- const [editMode, setEditMode] = useState(false);
50
+ // FieldEditor is the default editor. JSON view is an opt-in read-only toggle.
51
+ const [showJson, setShowJson] = useState(false);
27
52
  const [draftValue, setDraftValue] = useState('');
28
53
  const [saveError, setSaveError] = useState(null);
54
+ // Feature 1: per-component review metadata (rationale + source location)
55
+ // for the currently-selected component. Reloaded when selection changes.
56
+ const [reviewMetadata, setReviewMetadata] = useState(null);
57
+ // Feature 2: per-component preview annotations refreshed after every
58
+ // FieldEditor save via the useLivePreview hook below. Empty when live
59
+ // preview is disabled, when creds are missing, or before the first response.
60
+ const [previewAnnotations, setPreviewAnnotations] = useState(new Map());
61
+ // Pilot-2026-06-24: raw removed list for the `d` detail panel. The
62
+ // annotation map only carries kind, not the rich summaries we need to list
63
+ // names/ids when the operator asks "which ones?".
64
+ const [removedComponents, setRemovedComponents] = useState([]);
65
+ const [showRemovedPanel, setShowRemovedPanel] = useState(false);
66
+ // Lifted rationale + source panels (replaces FieldEditor's right pane).
67
+ // Mutually exclusive states.
68
+ const [panelOpen, setPanelOpen] = useState('none');
69
+ const [panelScrollOffset, setPanelScrollOffset] = useState(0);
70
+ const [textEntryActive, setTextEntryActive] = useState(false);
71
+ const [componentRationale, setComponentRationale] = useState(null);
72
+ // Tracks the first `g` of a potential `gg` double-tap (jumps to top in
73
+ // JSON-view + panel-focused state). Reset on any non-`g` key.
74
+ const pendingGRef = useRef(false);
75
+ const handleLivePreviewResult = (response) => {
76
+ if (!response)
77
+ return;
78
+ setPreviewAnnotations(applyPreviewAnnotations(response, components.map((c) => c.key)));
79
+ setRemovedComponents(response.components.removed ?? []);
80
+ };
81
+ const livePreviewHook = useLivePreview({
82
+ enabled: livePreview,
83
+ sessionId: extractSessionId,
84
+ tokensPath,
85
+ spaceId,
86
+ environmentId,
87
+ cmaToken,
88
+ host,
89
+ onResult: handleLivePreviewResult,
90
+ });
91
+ // Manual spinner cycling (no extra dep) for the sidebar status-row
92
+ // indicator. Runs only while the live-preview hook reports `running`.
93
+ const SPINNER_FRAMES = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
94
+ const [spinnerTick, setSpinnerTick] = useState(0);
95
+ useEffect(() => {
96
+ if (livePreviewHook.status !== 'running')
97
+ return;
98
+ const id = setInterval(() => setSpinnerTick((t) => t + 1), 80);
99
+ return () => clearInterval(id);
100
+ }, [livePreviewHook.status]);
101
+ const livePreviewSpinner = SPINNER_FRAMES[spinnerTick % SPINNER_FRAMES.length];
29
102
  useEffect(() => {
30
103
  async function load() {
31
104
  const db = openPipelineDb();
@@ -41,7 +114,12 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, }) {
41
114
  setLoading(false);
42
115
  return;
43
116
  }
44
- setComponents(cdfComponents.map(({ key, entry }) => ({ key, entry, status: 'needs-review' })));
117
+ const reviewEntries = cdfComponents.map(({ key, entry }) => ({
118
+ key,
119
+ entry,
120
+ status: 'needs-review',
121
+ }));
122
+ setComponents(sortComponentsForSidebar(reviewEntries));
45
123
  setLoading(false);
46
124
  }
47
125
  load().catch((e) => {
@@ -49,18 +127,81 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, }) {
49
127
  setLoading(false);
50
128
  });
51
129
  }, []);
130
+ // Pilot-2026-06-23 R2: fire the live preview once on entry to final-review
131
+ // so diff badges populate before the operator's first save. We gate on the
132
+ // livePreview prop to honor --no-live-preview without depending on the
133
+ // hook's internal short-circuit. Cred-missing is still handled by the
134
+ // hook's own no-op path.
135
+ useEffect(() => {
136
+ if (loading)
137
+ return;
138
+ if (!livePreview)
139
+ return;
140
+ if (components.length === 0)
141
+ return;
142
+ livePreviewHook.trigger();
143
+ // Intentionally only on load completion — subsequent fires happen via
144
+ // handleEditSave. Adding livePreviewHook to deps would re-fire on every
145
+ // hook re-creation.
146
+ }, [loading]);
147
+ // Feature 1: load review metadata (rationale + source location) for the
148
+ // selected component when selection changes.
149
+ useEffect(() => {
150
+ const current = components[selectedIdx];
151
+ if (!current) {
152
+ setReviewMetadata(null);
153
+ return;
154
+ }
155
+ const db = openPipelineDb();
156
+ try {
157
+ setReviewMetadata(loadComponentReviewMetadata(db, extractSessionId, current.key));
158
+ }
159
+ catch {
160
+ setReviewMetadata(null);
161
+ }
162
+ finally {
163
+ db.close();
164
+ }
165
+ }, [selectedIdx, components, extractSessionId]);
166
+ // Load component-level rationale for the selected component (drives the
167
+ // `I` ComponentRationalePanel). Decoupled from review metadata so the data
168
+ // contracts can evolve independently.
169
+ useEffect(() => {
170
+ const current = components[selectedIdx];
171
+ if (!current) {
172
+ setComponentRationale(null);
173
+ return;
174
+ }
175
+ const db = openPipelineDb();
176
+ try {
177
+ setComponentRationale(loadComponentRationale(db, extractSessionId, current.key));
178
+ }
179
+ catch {
180
+ setComponentRationale(null);
181
+ }
182
+ finally {
183
+ db.close();
184
+ }
185
+ }, [selectedIdx, components, extractSessionId]);
52
186
  const updateStatus = (idx, status) => {
53
187
  setComponents((prev) => prev.map((c, i) => (i === idx ? { ...c, status } : c)));
54
188
  };
55
189
  const handleFinalizeConfirm = () => {
56
- const rejected = components.filter((c) => c.status === 'rejected').map((c) => c.key);
57
- if (rejected.length > 0) {
190
+ // Strict opt-in: only EXPLICITLY ACCEPTED components ship. Anything left
191
+ // in 'needs-review' OR explicitly 'rejected' is downgraded to
192
+ // 'generate-rejected' so loadCDFComponents excludes it from the manifest.
193
+ // The operator told us they want accept-to-ship semantics — leaving a
194
+ // component unresolved should NOT silently push it (Pilot-2026-06-24 R2).
195
+ const explicitlyRejected = components.filter((c) => c.status === 'rejected').map((c) => c.key);
196
+ const unresolved = components.filter((c) => c.status === 'needs-review').map((c) => c.key);
197
+ const toReject = [...explicitlyRejected, ...unresolved];
198
+ if (toReject.length > 0) {
58
199
  const db = openPipelineDb();
59
200
  try {
60
201
  const stmt = db.prepare(`UPDATE raw_components SET status = 'generate-rejected' WHERE session_id = ? AND name = ?`);
61
202
  db.exec('BEGIN');
62
203
  try {
63
- for (const name of rejected) {
204
+ for (const name of toReject) {
64
205
  stmt.run(extractSessionId, name);
65
206
  }
66
207
  db.exec('COMMIT');
@@ -74,8 +215,8 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, }) {
74
215
  db.close();
75
216
  }
76
217
  }
77
- const accepted = components.filter((c) => c.status !== 'rejected').length;
78
- onFinalize(accepted, rejected.length);
218
+ const acceptedCount = components.filter((c) => c.status === 'accepted').length;
219
+ onFinalize(acceptedCount, explicitlyRejected.length, unresolved.length);
79
220
  };
80
221
  const handleEditSave = () => {
81
222
  const current = components[selectedIdx];
@@ -93,7 +234,6 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, }) {
93
234
  return;
94
235
  }
95
236
  setComponents((prev) => prev.map((c, i) => i === selectedIdx ? { ...c, entry, status: c.status === 'needs-review' ? 'accepted' : c.status } : c));
96
- setEditMode(false);
97
237
  setDraftValue('');
98
238
  setSaveError(null);
99
239
  const db = openPipelineDb();
@@ -103,13 +243,15 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, }) {
103
243
  finally {
104
244
  db.close();
105
245
  }
246
+ // Feature 2: re-fire the live preview now that pipeline.db reflects
247
+ // the new state. The hook owns debounce + cred-missing short-circuit.
248
+ livePreviewHook.trigger();
106
249
  }
107
250
  catch (e) {
108
251
  setSaveError(String(e));
109
252
  }
110
253
  };
111
254
  const handleEditDiscard = () => {
112
- setEditMode(false);
113
255
  setDraftValue('');
114
256
  setSaveError(null);
115
257
  };
@@ -119,20 +261,148 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, }) {
119
261
  return;
120
262
  if (dialogOpen)
121
263
  return;
122
- if (editMode)
264
+ // Pilot-2026-06-24: removed-detail panel. When open, only `d` (toggle)
265
+ // and Esc (close) respond — all other input is swallowed so j/k/Enter/
266
+ // Ctrl+S can't move state behind the modal. Mirrors the `?` overlay
267
+ // pattern from 8f0c62e in FieldEditor.
268
+ if (showRemovedPanel) {
269
+ if (input === 'd' || key.escape) {
270
+ setShowRemovedPanel(false);
271
+ }
123
272
  return;
124
- if (input === 'q') {
125
- setShowQuit(true);
273
+ }
274
+ // `d` opens the panel only when live-preview is enabled and there is at
275
+ // least one removed component to display. Sidebar-focused only so it
276
+ // doesn't collide with FieldEditor input.
277
+ if (input === 'd' && sidebarFocused && livePreview && removedComponents.length > 0) {
278
+ setShowRemovedPanel(true);
126
279
  return;
127
280
  }
128
- if (input === 'F') {
129
- setShowFinalize(true);
281
+ // Lifted rationale + source panels: i/I/s fire from anywhere (sidebar OR
282
+ // panel focus). Gated against text-entry surfaces inside FieldEditor
283
+ // (description editors, string-default editor, value-list text entry)
284
+ // via the `onTextEntryActiveChange` callback, plus the help/finalize/quit
285
+ // overlays and the JSON view.
286
+ if (panelOpen !== 'none') {
287
+ const PANEL_HEIGHT_LOCAL = 12;
288
+ const next = computeNextScrollOffset(panelScrollOffset, input, key, 9999, PANEL_HEIGHT_LOCAL);
289
+ if (next !== null) {
290
+ setPanelScrollOffset(() => next);
291
+ return;
292
+ }
293
+ if (key.escape) {
294
+ setPanelOpen('none');
295
+ setPanelScrollOffset(() => 0);
296
+ return;
297
+ }
298
+ // Guard against Ctrl-letter aliases (Tab is Ctrl+I in ASCII, Ctrl+S would
299
+ // collide with save in nested editors). Only react to bare keystrokes.
300
+ const togglable = !key.ctrl && !key.tab && !key.meta && !key.return;
301
+ if (togglable && input === 'i' && panelOpen === 'prop-rationale') {
302
+ setPanelOpen('none');
303
+ setPanelScrollOffset(() => 0);
304
+ return;
305
+ }
306
+ if (togglable && input === 'I' && panelOpen === 'component-rationale') {
307
+ setPanelOpen('none');
308
+ setPanelScrollOffset(() => 0);
309
+ return;
310
+ }
311
+ if (togglable && input === 's' && panelOpen === 'source') {
312
+ setPanelOpen('none');
313
+ setPanelScrollOffset(() => 0);
314
+ return;
315
+ }
316
+ // Cross-panel toggles while one is open.
317
+ if (togglable && input === 'i') {
318
+ setPanelOpen('prop-rationale');
319
+ setPanelScrollOffset(() => 0);
320
+ return;
321
+ }
322
+ if (togglable && input === 'I') {
323
+ setPanelOpen('component-rationale');
324
+ setPanelScrollOffset(() => 0);
325
+ return;
326
+ }
327
+ if (togglable && input === 's') {
328
+ setPanelOpen('source');
329
+ setPanelScrollOffset(() => 0);
330
+ return;
331
+ }
130
332
  return;
131
333
  }
334
+ const rationaleKeyOk = !textEntryActive && !showJson && !key.ctrl && !key.tab && !key.meta && !key.return;
335
+ if (rationaleKeyOk) {
336
+ if (input === 'i') {
337
+ setPanelOpen('prop-rationale');
338
+ setPanelScrollOffset(() => 0);
339
+ return;
340
+ }
341
+ if (input === 'I') {
342
+ setPanelOpen('component-rationale');
343
+ setPanelScrollOffset(() => 0);
344
+ return;
345
+ }
346
+ }
347
+ // Tab toggles focus bidirectionally between sidebar and panel. `e` is a
348
+ // sidebar-only alias for crossing INTO the panel — gating it to the
349
+ // sidebar-focused state prevents collision with FieldEditor's enum-values
350
+ // `e` binding (INTEG-4254) when the panel is focused. Crossing back from
351
+ // panel to sidebar is Tab-only.
132
352
  if (key.tab) {
133
353
  setSidebarFocused((prev) => !prev);
134
354
  return;
135
355
  }
356
+ if (input === 'e' && sidebarFocused) {
357
+ setSidebarFocused(false);
358
+ return;
359
+ }
360
+ // JSON view + panel focused: own j/k/arrows/PageUp/PageDown/Ctrl+u/d/gg/G
361
+ // for scrolling. Computed against the live `selectedJson` so the
362
+ // viewport math matches what JsonPanel renders.
363
+ if (!sidebarFocused && showJson) {
364
+ const current = components[selectedIdx];
365
+ const currentJson = current ? JSON.stringify({ [current.key]: current.entry }, null, 2) : '';
366
+ const totalLines = currentJson.split('\n').length;
367
+ const maxOffset = Math.max(0, totalLines - PANEL_HEIGHT);
368
+ // `gg` double-tap to jump to top; single `g` arms the pending flag.
369
+ if (input === 'g' && !key.ctrl) {
370
+ if (pendingGRef.current) {
371
+ pendingGRef.current = false;
372
+ setJsonScrollOffset(() => 0);
373
+ return;
374
+ }
375
+ pendingGRef.current = true;
376
+ return;
377
+ }
378
+ const next = computeNextScrollOffset(jsonScrollOffset, input, key, totalLines, PANEL_HEIGHT);
379
+ if (next !== null) {
380
+ pendingGRef.current = false;
381
+ // Functional setState mirrors the cursor-stutter fix (commit 5d11e60).
382
+ // Clamp against maxOffset re-computed at apply time in case totalLines
383
+ // shifted between events (defensive — helper already clamps).
384
+ const clamped = Math.min(maxOffset, Math.max(0, next));
385
+ setJsonScrollOffset(() => clamped);
386
+ return;
387
+ }
388
+ // Any other key in this slice resets the gg-pending flag, then falls
389
+ // through to the early-return below so the panel-focused state still
390
+ // swallows non-scroll input.
391
+ pendingGRef.current = false;
392
+ }
393
+ // When the panel is focused, FieldEditor (or JsonPanel) owns the keys.
394
+ // Only Tab (handled above) should escape from the panel-focused state.
395
+ if (!sidebarFocused)
396
+ return;
397
+ // Sidebar-focused keymap.
398
+ if (input === 'q') {
399
+ setShowQuit(true);
400
+ return;
401
+ }
402
+ if (input === 'F') {
403
+ setShowFinalize(true);
404
+ return;
405
+ }
136
406
  if (input === 'a') {
137
407
  updateStatus(selectedIdx, 'accepted');
138
408
  return;
@@ -145,32 +415,41 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, }) {
145
415
  setComponents((prev) => prev.map((c) => (c.status === 'needs-review' ? { ...c, status: 'accepted' } : c)));
146
416
  return;
147
417
  }
148
- if (input === 'e' && selected) {
149
- setDraftValue(JSON.stringify({ [selected.key]: selected.entry }, null, 2));
150
- setEditMode(true);
418
+ if (input === 'J') {
419
+ // Toggle read-only JSON view.
420
+ setShowJson((prev) => !prev);
421
+ setJsonScrollOffset(0);
422
+ pendingGRef.current = false;
151
423
  return;
152
424
  }
153
- if (sidebarFocused) {
154
- if (key.upArrow || input === 'k') {
155
- const newIdx = Math.max(0, selectedIdx - 1);
156
- setSelectedIdx(newIdx);
157
- setJsonScrollOffset(0);
158
- setSidebarScrollOffset((prev) => Math.min(prev, newIdx));
159
- }
160
- else if (key.downArrow || input === 'j') {
161
- const newIdx = Math.min(components.length - 1, selectedIdx + 1);
162
- setSelectedIdx(newIdx);
163
- setJsonScrollOffset(0);
164
- setSidebarScrollOffset((prev) => (newIdx >= prev + VISIBLE_COUNT ? newIdx - VISIBLE_COUNT + 1 : prev));
165
- }
425
+ if (key.upArrow || input === 'k') {
426
+ // Pilot-2026-06-23 bug: rapid k/j bursts could lose cursor position
427
+ // because the previous implementation read `selectedIdx` from the
428
+ // handler's closure. Under high keyboard-repeat rate multiple key
429
+ // events fire between Ink render flushes, so every invocation saw the
430
+ // same stale value and recomputed the same `newIdx`. Using functional
431
+ // setState chains the updates correctly: each pending update sees the
432
+ // post-update value of the previous one. The viewport offset update is
433
+ // nested inside the cursor updater so it always reflects the same
434
+ // newIdx that selectedIdx is being set to.
435
+ setSelectedIdx((prev) => {
436
+ const newIdx = Math.max(0, prev - 1);
437
+ setSidebarScrollOffset((off) => Math.min(off, newIdx));
438
+ return newIdx;
439
+ });
440
+ setJsonScrollOffset(0);
441
+ setDraftValue('');
442
+ setSaveError(null);
166
443
  }
167
- else {
168
- if (key.upArrow || input === 'k') {
169
- setJsonScrollOffset((prev) => Math.max(0, prev - 1));
170
- }
171
- else if (key.downArrow || input === 'j') {
172
- setJsonScrollOffset((prev) => prev + 1);
173
- }
444
+ else if (key.downArrow || input === 'j') {
445
+ setSelectedIdx((prev) => {
446
+ const newIdx = Math.min(components.length - 1, prev + 1);
447
+ setSidebarScrollOffset((off) => (newIdx >= off + VISIBLE_COUNT ? newIdx - VISIBLE_COUNT + 1 : off));
448
+ return newIdx;
449
+ });
450
+ setJsonScrollOffset(0);
451
+ setDraftValue('');
452
+ setSaveError(null);
174
453
  }
175
454
  });
176
455
  if (loading) {
@@ -181,32 +460,117 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, }) {
181
460
  }
182
461
  const selected = components[selectedIdx] ?? null;
183
462
  const selectedJson = selected ? JSON.stringify({ [selected.key]: selected.entry }, null, 2) : '';
463
+ // A component with zero classified $properties is a real defensibility issue —
464
+ // it can't be pushed to Contentful (no fields). Surface it in the sidebar via
465
+ // the existing warning-color path (yellow) and a "(empty)" suffix so the user
466
+ // can see what went wrong. They can manually add props in FieldEditor or
467
+ // explicitly reject the component.
468
+ const isEmpty = (c) => Object.keys(c.entry.$properties).length === 0;
469
+ const emptyCount = components.filter(isEmpty).length;
184
470
  const sidebarItems = components.map((c) => ({
185
471
  id: c.key,
186
- name: c.key,
472
+ name: isEmpty(c) ? `${c.key} (empty)` : c.key,
187
473
  status: c.status,
474
+ previewAnnotation: previewAnnotations.get(c.key),
188
475
  extractionConfidence: null,
189
476
  needsReview: false,
190
477
  validationErrorCount: 0,
191
- validationWarningCount: 0,
478
+ validationWarningCount: isEmpty(c) ? 1 : 0,
192
479
  }));
193
- const longestName = components.reduce((m, c) => Math.max(m, c.key.length), 0);
194
- const sidebarWidth = Math.min(Math.max(longestName + 4, 14), 22);
480
+ // Account for the "(empty)" suffix added to zero-prop component names so the
481
+ // sidebar doesn't truncate it.
482
+ const longestName = components.reduce((m, c) => Math.max(m, c.key.length + (isEmpty(c) ? ' (empty)'.length : 0)), 0);
483
+ // +5 = border (1) + status icon (1) + badge column (1) + space (1) + border (1).
484
+ // The badge column is reserved even when no annotation is present so the
485
+ // sidebar width doesn't jitter as live-preview annotations flip in/out.
486
+ const sidebarWidth = Math.min(Math.max(longestName + 5, 14), 30);
195
487
  const panelWidth = Math.max(10, terminalWidth - sidebarWidth - 4);
196
488
  const accepted = components.filter((c) => c.status === 'accepted').length;
197
489
  const rejected = components.filter((c) => c.status === 'rejected').length;
198
490
  const needsReview = components.filter((c) => c.status === 'needs-review').length;
199
491
  const propCount = selected ? Object.keys(selected.entry.$properties).length : 0;
200
492
  const slotCount = selected?.entry.$slots ? Object.keys(selected.entry.$slots).length : 0;
201
- 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) }), !dialogOpen && (_jsxs(Box, { children: [_jsx(Sidebar, { components: sidebarItems, selectedId: selected?.key ?? null, focused: sidebarFocused, scrollOffset: sidebarScrollOffset, visibleCount: VISIBLE_COUNT, onSelect: (id) => {
493
+ 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 &&
494
+ livePreview &&
495
+ (() => {
496
+ // Pilot-2026-06-23 R2: at-a-glance diff summary at the top of the
497
+ // step. Mutually exclusive states:
498
+ // - hook running (and we don't yet have annotations) → spinner.
499
+ // - hook disabled (creds rejected) → static disabled hint.
500
+ // - annotations populated → counts.
501
+ // - idle, no annotations, not disabled → render nothing.
502
+ const counts = { new: 0, changed: 0, removed: 0, breaking: 0 };
503
+ for (const v of previewAnnotations.values()) {
504
+ counts[v] = (counts[v] ?? 0) + 1;
505
+ }
506
+ const hasCounts = counts.new + counts.changed + counts.removed + counts.breaking > 0;
507
+ if (livePreviewHook.disabled) {
508
+ return _jsx(Text, { dimColor: true, children: 'Preview: disabled (creds rejected)' });
509
+ }
510
+ if (livePreviewHook.status === 'running' && !hasCounts) {
511
+ return _jsx(Text, { dimColor: true, children: `Preview: ${livePreviewSpinner} running...` });
512
+ }
513
+ if (!hasCounts)
514
+ return null;
515
+ 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` })] }));
516
+ })(), !dialogOpen && emptyCount > 0 && (_jsx(Text, { color: "yellow", children: `⚠ ${emptyCount} component${emptyCount === 1 ? '' : 's'} had no classifiable props — review with care` })), !dialogOpen && (_jsxs(Box, { children: [_jsx(Sidebar, { components: sidebarItems, selectedId: selected?.key ?? null, focused: sidebarFocused, scrollOffset: sidebarScrollOffset, visibleCount: VISIBLE_COUNT, onSelect: (id) => {
202
517
  const idx = components.findIndex((c) => c.key === id);
203
518
  if (idx >= 0) {
204
519
  setSelectedIdx(idx);
205
520
  setJsonScrollOffset(0);
206
521
  }
207
- }, 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 ? '[Tab] focus panel' : '[Tab] focus list'] })] }), editMode ? (_jsx(FieldEditor, { value: draftValue || selectedJson, width: panelWidth, height: PANEL_HEIGHT, onChange: setDraftValue, onSave: handleEditSave, onDiscard: handleEditDiscard })) : (_jsx(JsonPanel, { label: "GENERATED DEFINITION", value: selectedJson, scrollOffset: jsonScrollOffset, width: panelWidth, height: PANEL_HEIGHT, active: !sidebarFocused })), saveError && _jsx(Text, { color: "red", children: '✗ ' + saveError }), _jsx(Text, { dimColor: true, children: editMode
208
- ? ' [Ctrl+S] save [Esc] discard'
209
- : ' [a] accept [r] reject [e] edit [A] accept all [F] finalize [Tab] toggle focus [q] quit' })] })) : (_jsx(Text, { dimColor: true, children: "No component selected" })) })] })), !dialogOpen && (_jsx(StatusBar, { accepted: accepted, rejected: rejected, reviewed: 0, needsReview: needsReview, onApproveAll: () => {
522
+ }, 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' ? ((() => {
523
+ const rows = [
524
+ ...(componentRationale?.props ?? []).map((p) => ({
525
+ name: p.name,
526
+ kind: 'prop',
527
+ rationale: p.rationale ?? '',
528
+ })),
529
+ ...(componentRationale?.slots ?? []).map((s) => ({
530
+ name: s.name,
531
+ kind: 'slot',
532
+ rationale: s.rationale ?? '',
533
+ })),
534
+ ];
535
+ return (_jsx(RationalePanel, { componentName: componentRationale?.name ?? selected.key, rows: rows, scrollOffset: panelScrollOffset, width: panelWidth, height: PANEL_HEIGHT, active: true }));
536
+ })()) : panelOpen === 'component-rationale' ? (_jsx(ComponentRationalePanel, { data: componentRationale ?? {
537
+ name: selected.key,
538
+ description: null,
539
+ descriptionRationale: null,
540
+ propsRationale: null,
541
+ slotsRationale: null,
542
+ props: [],
543
+ slots: [],
544
+ }, scrollOffset: panelScrollOffset, width: panelWidth, height: PANEL_HEIGHT, active: true })) : panelOpen === 'source' ? ((() => {
545
+ const path = reviewMetadata?.sourcePath ?? null;
546
+ const src = reviewMetadata?.componentSource ?? null;
547
+ const headerPath = path ?? '<unknown source path>';
548
+ const lines = src ? src.split('\n').slice(panelScrollOffset, panelScrollOffset + PANEL_HEIGHT) : [];
549
+ return (_jsxs(Box, { flexDirection: "column", width: panelWidth, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: `source: ${headerPath}` }), src ? (lines.map((ln, i) => (_jsx(Text, { dimColor: true, children: ln }, `source-line-${i}`)))) : (_jsx(Text, { dimColor: true, children: '(no source captured)' })), _jsx(Text, { dimColor: true, children: '[s/Esc] close' })] }));
550
+ })()) : showJson ? (_jsx(JsonPanel, { label: "GENERATED DEFINITION (read-only)", value: selectedJson, scrollOffset: jsonScrollOffset, width: panelWidth, height: PANEL_HEIGHT, active: !sidebarFocused })) : (_jsx(FieldEditor, { value: draftValue || selectedJson, width: panelWidth, height: PANEL_HEIGHT, active: !sidebarFocused, onChange: setDraftValue, onSave: handleEditSave, onDiscard: handleEditDiscard, onExit: () => setSidebarFocused(true), metadata: reviewMetadata
551
+ ? {
552
+ sourcePath: reviewMetadata.sourcePath,
553
+ componentSource: reviewMetadata.componentSource,
554
+ props: reviewMetadata.props,
555
+ }
556
+ : undefined, onTogglePropRationale: () => {
557
+ setPanelOpen('prop-rationale');
558
+ setPanelScrollOffset(() => 0);
559
+ }, onToggleComponentRationale: () => {
560
+ setPanelOpen('component-rationale');
561
+ setPanelScrollOffset(() => 0);
562
+ }, onToggleSourceExternal: () => {
563
+ setPanelOpen('source');
564
+ setPanelScrollOffset(() => 0);
565
+ }, onTextEntryActiveChange: setTextEntryActive }, selected.key)), saveError && _jsx(Text, { color: "red", children: '✗ ' + saveError }), _jsxs(Text, { dimColor: true, children: [sidebarFocused
566
+ ? ' [a] accept [r] reject [A] accept all [J] ' +
567
+ (showJson ? 'hide JSON' : 'show JSON') +
568
+ ' [F] finalize [e/Tab] focus panel' +
569
+ (livePreview && removedComponents.length > 0 ? ' [d] removed list' : '') +
570
+ ' [q] quit'
571
+ : showJson
572
+ ? ' [j/k] scroll [Ctrl+u/d] half-page [gg/G] top/bottom [Tab] focus list'
573
+ : ' [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: () => {
210
574
  setComponents((prev) => prev.map((c) => (c.status === 'needs-review' ? { ...c, status: 'accepted' } : c)));
211
575
  }, onFinalize: () => setShowFinalize(true) }))] }));
212
576
  }
@@ -0,0 +1,19 @@
1
+ import React from 'react';
2
+ export type PushDecisionChoice = 'both' | 'push-only' | 'save-only';
3
+ type PushDecisionGateStepProps = {
4
+ summary: string;
5
+ context: string;
6
+ fileList: string;
7
+ onChoice: (choice: PushDecisionChoice) => void;
8
+ onQuit: () => void;
9
+ /**
10
+ * Skip-credentials spec — Task 3. When the operator advanced past the
11
+ * credentials screen via the skip path, push options are not actually
12
+ * usable (we never validated a token). Render all three rows for visual
13
+ * continuity but disable "Save AND push" and "Push only", with the
14
+ * `(unavailable — credentials skipped)` suffix.
15
+ */
16
+ pushDisabled?: boolean;
17
+ };
18
+ export declare function PushDecisionGateStep({ summary, context, onChoice, onQuit, pushDisabled, }: PushDecisionGateStepProps): React.ReactElement;
19
+ export {};