@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
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { useImmediateInput } from '../../../analyze/select/tui/hooks/useImmediateInput.js';
5
+ const OPTIONS = [
6
+ { value: 'both', label: 'Save AND push', shortcut: 'b' },
7
+ { value: 'push-only', label: 'Push only', shortcut: 'p' },
8
+ { value: 'save-only', label: 'Save only', shortcut: 's' },
9
+ ];
10
+ const SAVE_ONLY_INDEX = OPTIONS.findIndex((o) => o.value === 'save-only');
11
+ export function PushDecisionGateStep({ summary, context, onChoice, onQuit, pushDisabled = false, }) {
12
+ // When push is disabled, the cursor defaults to "Save only" (the only
13
+ // selectable row). When push is enabled, "Save AND push" is the default —
14
+ // matches the existing scope-gate UX.
15
+ const [cursor, setCursor] = useState(pushDisabled ? SAVE_ONLY_INDEX : 0);
16
+ function isSelectable(index) {
17
+ if (!pushDisabled)
18
+ return true;
19
+ return OPTIONS[index].value === 'save-only';
20
+ }
21
+ useImmediateInput((input, key) => {
22
+ if (key.return) {
23
+ if (!isSelectable(cursor))
24
+ return;
25
+ onChoice(OPTIONS[cursor].value);
26
+ return;
27
+ }
28
+ if (input === 'b') {
29
+ if (pushDisabled)
30
+ return;
31
+ onChoice('both');
32
+ return;
33
+ }
34
+ if (input === 'p') {
35
+ if (pushDisabled)
36
+ return;
37
+ onChoice('push-only');
38
+ return;
39
+ }
40
+ if (input === 's') {
41
+ onChoice('save-only');
42
+ return;
43
+ }
44
+ if (input === 'j' || key.downArrow) {
45
+ setCursor((c) => {
46
+ // Walk forward to the next selectable row. If none, stay put.
47
+ for (let i = c + 1; i < OPTIONS.length; i++) {
48
+ if (isSelectable(i))
49
+ return i;
50
+ }
51
+ return c;
52
+ });
53
+ return;
54
+ }
55
+ if (input === 'k' || key.upArrow) {
56
+ setCursor((c) => {
57
+ for (let i = c - 1; i >= 0; i--) {
58
+ if (isSelectable(i))
59
+ return i;
60
+ }
61
+ return c;
62
+ });
63
+ return;
64
+ }
65
+ if (input === 'q' || key.escape) {
66
+ onQuit();
67
+ return;
68
+ }
69
+ });
70
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsx(Text, { color: "green", children: "\u2713 Generation complete" }), _jsx(Text, { dimColor: true, children: summary }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: context }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: OPTIONS.map((opt, i) => {
71
+ const selected = i === cursor;
72
+ const disabled = pushDisabled && opt.value !== 'save-only';
73
+ const suffix = disabled ? ' (unavailable — credentials skipped)' : '';
74
+ return (_jsxs(Text, { color: selected ? 'cyan' : undefined, dimColor: disabled, children: [selected ? '›' : ' ', " [", opt.shortcut, "] ", opt.label, suffix] }, opt.value));
75
+ }) }), _jsxs(Box, { gap: 3, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "[Enter] Confirm" }), _jsx(Text, { dimColor: true, children: "[j/k] Move" }), _jsx(Text, { dimColor: true, children: "[q] Quit" })] })] }));
76
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import type { PushExpected, PushProgress } from '../push-progress.js';
3
+ type PushingStepProps = {
4
+ stepNumber: number;
5
+ totalSteps: number;
6
+ expected: PushExpected | null;
7
+ progress: PushProgress;
8
+ };
9
+ export declare function PushingStep({ stepNumber, totalSteps, expected, progress }: PushingStepProps): React.ReactElement;
10
+ export {};
@@ -0,0 +1,40 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5
+ const ACTION_LABELS = {
6
+ create: 'Creating',
7
+ update: 'Updating',
8
+ remove: 'Deleting',
9
+ };
10
+ function ActionRow({ label, expectedCount, progress, }) {
11
+ // Per spec: do NOT fake proportional tallies. Left side stays "?/N" until
12
+ // terminal status surfaces real per-action counts.
13
+ const left = progress && progress.kind === 'progress' ? '?' : '?';
14
+ return (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { children: [" ", label] }), _jsxs(Text, { dimColor: true, children: [left, "/", expectedCount] })] }));
15
+ }
16
+ function EntitySection({ title, counts, progress, }) {
17
+ const visibleActions = ['create', 'update', 'remove'].filter((k) => counts[k] > 0);
18
+ if (visibleActions.length === 0)
19
+ return null;
20
+ return (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(Text, { bold: true, children: title }), visibleActions.map((k) => (_jsx(ActionRow, { label: ACTION_LABELS[k], expectedCount: counts[k], progress: progress }, k)))] }));
21
+ }
22
+ export function PushingStep({ stepNumber, totalSteps, expected, progress }) {
23
+ const [frame, setFrame] = useState(0);
24
+ const [elapsed, setElapsed] = useState(0);
25
+ useEffect(() => {
26
+ const spinner = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
27
+ const timer = setInterval(() => setElapsed((s) => s + 1), 1000);
28
+ return () => {
29
+ clearInterval(spinner);
30
+ clearInterval(timer);
31
+ };
32
+ }, []);
33
+ const mins = Math.floor(elapsed / 60);
34
+ const secs = elapsed % 60;
35
+ const elapsedStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
36
+ const operationId = progress && progress.kind === 'queued' ? progress.operationId : null;
37
+ const showGlobal = progress && progress.kind === 'progress';
38
+ const showCurrent = progress && progress.kind === 'progress' && progress.current ? progress.current : null;
39
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(Text, { dimColor: true, children: '─'.repeat(40) }), _jsxs(Box, { gap: 1, children: [_jsxs(Text, { bold: true, children: ["Step ", stepNumber, " of ", totalSteps] }), _jsx(Text, { bold: true, children: "\u2014" }), _jsx(Text, { bold: true, children: "Push to Contentful" })] }), _jsx(Text, { dimColor: true, children: '─'.repeat(40) })] }), _jsx(Text, { children: "Writing component types and design tokens to your Contentful space..." }), operationId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Operation:" }), _jsx(Text, { children: operationId })] })), expected && (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(EntitySection, { title: "Component types", counts: expected.componentTypes, progress: progress }), _jsx(EntitySection, { title: "Design tokens", counts: expected.designTokens, progress: progress })] })), showGlobal && progress && progress.kind === 'progress' && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: SPINNER_FRAMES[frame] }), _jsxs(Text, { dimColor: true, children: [progress.processed, "/", progress.total, " entities"] })] })), showCurrent && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Now processing:" }), _jsx(Text, { children: showCurrent })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Elapsed: ", elapsedStr] }) })] }));
40
+ }
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ export type ScopeComponent = {
3
+ name: string;
4
+ componentId: string;
5
+ aiDecision?: 'accepted' | 'rejected' | 'failed' | null;
6
+ aiReason?: string | null;
7
+ };
8
+ export type ScopeGateStepProps = {
9
+ components: ScopeComponent[];
10
+ onConfirm: (decisions: {
11
+ accepted: string[];
12
+ rejected: string[];
13
+ }) => void;
14
+ onQuit: () => void;
15
+ aiFilterStatus?: 'idle' | 'running' | 'complete' | 'cancelled' | 'failed';
16
+ aiFilterProgress?: {
17
+ done: number;
18
+ total: number;
19
+ } | null;
20
+ aiFilterError?: string | null;
21
+ onCancelAutoFilter?: () => void;
22
+ };
23
+ export declare function ScopeGateStep({ components, onConfirm, onQuit, aiFilterStatus, aiFilterProgress, aiFilterError, onCancelAutoFilter, }: ScopeGateStepProps): React.ReactElement;
@@ -0,0 +1,250 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import React, { useMemo, useState } from 'react';
4
+ import { useImmediateInput } from '../../../analyze/select/tui/hooks/useImmediateInput.js';
5
+ const VISIBLE_COUNT = 10;
6
+ const REASON_DISPLAY_MAX = 60;
7
+ // Pilot-2026-06-25 R2: AI-flagged rows render a subtle cyan `*` glyph before
8
+ // the name (in place of the Round-1 `[AI]` text badge). The marker persists
9
+ // regardless of whether the operator later toggles the row INCLUDED — manual
10
+ // decision wins; the marker is informational only.
11
+ const AI_MARKER = '*';
12
+ // Indentation prefix for the wrapped reason on the focused AI row. Aligns
13
+ // the reason under the row label so the eye reads it as a continuation.
14
+ const REASON_WRAP_INDENT = ' ';
15
+ function truncateReason(reason) {
16
+ if (reason === null || reason === undefined || reason === '')
17
+ return '<no reason given>';
18
+ if (reason.length <= REASON_DISPLAY_MAX)
19
+ return reason;
20
+ return reason.slice(0, REASON_DISPLAY_MAX - 1).trimEnd() + '…';
21
+ }
22
+ function isAiFlagged(row) {
23
+ // INTEG-4318: `failed` means the LLM omitted a decision for this component
24
+ // (e.g. batch under-emit). Surface these in the AI-recommended-exclusions
25
+ // section so the operator sees them and can override — silent inclusion was
26
+ // the bug.
27
+ return row.aiDecision === 'rejected' || row.aiDecision === 'failed';
28
+ }
29
+ export function ScopeGateStep({ components, onConfirm, onQuit, aiFilterStatus = 'idle', aiFilterProgress = null, aiFilterError = null, onCancelAutoFilter, }) {
30
+ // Pilot-2026-06-25: scope-gate UX overhaul — single unified list.
31
+ //
32
+ // Inverted mental model: we now track which rows are INCLUDED rather than
33
+ // which are EXCLUDED. The underlying delta-on-prop pattern survives so
34
+ // operator decisions stay sticky across streaming AI updates:
35
+ // - userExcluded: rows the operator explicitly toggled OFF
36
+ // - userUnExcluded: rows the operator explicitly toggled ON
37
+ // Effective INCLUDED for a row:
38
+ // if userExcluded.has(name): false
39
+ // else if userUnExcluded.has(name): true
40
+ // else: row.aiDecision !== 'rejected' // default: follow AI, default true if AI silent
41
+ const [userExcluded, setUserExcluded] = useState(new Set());
42
+ const [userUnExcluded, setUserUnExcluded] = useState(new Set());
43
+ const [cursor, setCursor] = useState(0);
44
+ const [scrollOffset, setScrollOffset] = useState(0);
45
+ // Pilot-2026-06-23: `s` opens a side panel showing the full reject_reason
46
+ // (untruncated) for an AI-flagged row under the cursor. With the focused-row
47
+ // wrap landed, the panel is mostly redundant — kept for screen-reader /
48
+ // overflow cases per spec.
49
+ const [reasonPanelOpen, setReasonPanelOpen] = useState(false);
50
+ // Pilot-2026-06-25 R2: two-section render. AI-flagged rows go to a top
51
+ // section ("AI recommended exclusions"); the rest fall into "Components".
52
+ // Within each section we preserve prop (extraction) order. Cursor walks
53
+ // the unified flat list [...aiList, ...componentsList].
54
+ const aiList = components.filter(isAiFlagged);
55
+ const componentsList = components.filter((c) => !isAiFlagged(c));
56
+ const flatList = [...aiList, ...componentsList];
57
+ const isIncluded = (row) => {
58
+ // Pilot-2026-06-25 invariant: operator decisions are ALWAYS sticky over
59
+ // streaming AI updates. If the operator explicitly toggled this row
60
+ // (userExcluded or userUnExcluded), that wins regardless of any later
61
+ // aiDecision the auto-filter writes via prop updates. The [AI] badge
62
+ // still appears on the row — it's informational, not authoritative.
63
+ if (userExcluded.has(row.name))
64
+ return false;
65
+ if (userUnExcluded.has(row.name))
66
+ return true;
67
+ // INTEG-4318: exclude on 'rejected' AND 'failed'. Missing/null aiDecision
68
+ // (auto-filter not run, or component never seen) still defaults to
69
+ // included so --no-auto-filter and skip-credentials flows are unchanged.
70
+ return row.aiDecision !== 'rejected' && row.aiDecision !== 'failed';
71
+ };
72
+ const partition = () => {
73
+ const accepted = [];
74
+ const rejected = [];
75
+ for (const c of flatList) {
76
+ if (isIncluded(c))
77
+ accepted.push(c.name);
78
+ else
79
+ rejected.push(c.name);
80
+ }
81
+ return { accepted, rejected };
82
+ };
83
+ const toggleFocused = () => {
84
+ const target = flatList[cursor];
85
+ if (!target)
86
+ return;
87
+ const currentlyIn = isIncluded(target);
88
+ if (currentlyIn) {
89
+ // Flip to EXCLUDED.
90
+ setUserExcluded((prev) => {
91
+ if (prev.has(target.name))
92
+ return prev;
93
+ const next = new Set(prev);
94
+ next.add(target.name);
95
+ return next;
96
+ });
97
+ setUserUnExcluded((prev) => {
98
+ if (!prev.has(target.name))
99
+ return prev;
100
+ const next = new Set(prev);
101
+ next.delete(target.name);
102
+ return next;
103
+ });
104
+ }
105
+ else {
106
+ // Flip to INCLUDED.
107
+ setUserUnExcluded((prev) => {
108
+ if (prev.has(target.name))
109
+ return prev;
110
+ const next = new Set(prev);
111
+ next.add(target.name);
112
+ return next;
113
+ });
114
+ setUserExcluded((prev) => {
115
+ if (!prev.has(target.name))
116
+ return prev;
117
+ const next = new Set(prev);
118
+ next.delete(target.name);
119
+ return next;
120
+ });
121
+ }
122
+ };
123
+ useImmediateInput((input, key) => {
124
+ if (input === 'q' || key.escape) {
125
+ // Feature 3: while auto-filter is running, q cancels the LLM run instead
126
+ // of quitting the wizard. After completion (or if no auto-filter ran), q
127
+ // falls back to the existing wizard-quit behavior.
128
+ if (aiFilterStatus === 'running' && onCancelAutoFilter) {
129
+ onCancelAutoFilter();
130
+ return;
131
+ }
132
+ // Esc also closes the reason panel without quitting.
133
+ if (key.escape && reasonPanelOpen) {
134
+ setReasonPanelOpen(false);
135
+ return;
136
+ }
137
+ onQuit();
138
+ return;
139
+ }
140
+ if (input === 'f' || input === 'F') {
141
+ onConfirm(partition());
142
+ return;
143
+ }
144
+ if (input === 's') {
145
+ setReasonPanelOpen((prev) => !prev);
146
+ return;
147
+ }
148
+ // `a`, space, and `r` are all aliases for "toggle focused row INCLUDED ↔ EXCLUDED".
149
+ // `r` kept for muscle-memory; it no longer means "reject".
150
+ if (input === 'a' || input === ' ' || input === 'r') {
151
+ toggleFocused();
152
+ return;
153
+ }
154
+ if (input === 'A') {
155
+ // Pilot-2026-06-25 R2: toggle-all operates ONLY on the Components
156
+ // section. The AI section's defaults stay put — operators rarely want
157
+ // a mass-include of AI-flagged rows via one keystroke. Toggle behavior:
158
+ // if any Components row is excluded, include them all; else exclude all.
159
+ const compNames = componentsList.map((c) => c.name);
160
+ const anyCompExcluded = componentsList.some((c) => !isIncluded(c));
161
+ if (anyCompExcluded) {
162
+ setUserUnExcluded((prev) => {
163
+ const next = new Set(prev);
164
+ for (const n of compNames)
165
+ next.add(n);
166
+ return next;
167
+ });
168
+ setUserExcluded((prev) => {
169
+ const next = new Set(prev);
170
+ for (const n of compNames)
171
+ next.delete(n);
172
+ return next;
173
+ });
174
+ }
175
+ else {
176
+ setUserExcluded((prev) => {
177
+ const next = new Set(prev);
178
+ for (const n of compNames)
179
+ next.add(n);
180
+ return next;
181
+ });
182
+ setUserUnExcluded((prev) => {
183
+ const next = new Set(prev);
184
+ for (const n of compNames)
185
+ next.delete(n);
186
+ return next;
187
+ });
188
+ }
189
+ return;
190
+ }
191
+ if (key.upArrow || input === 'k') {
192
+ const len = flatList.length;
193
+ if (len === 0)
194
+ return;
195
+ setCursor((c) => {
196
+ const next = c <= 0 ? 0 : c - 1;
197
+ setScrollOffset((prev) => Math.min(prev, next));
198
+ return next;
199
+ });
200
+ return;
201
+ }
202
+ if (key.downArrow || input === 'j') {
203
+ const len = flatList.length;
204
+ if (len === 0)
205
+ return;
206
+ setCursor((c) => {
207
+ const next = c >= len - 1 ? len - 1 : c + 1;
208
+ setScrollOffset((prev) => (next >= prev + VISIBLE_COUNT ? next - VISIBLE_COUNT + 1 : prev));
209
+ return next;
210
+ });
211
+ return;
212
+ }
213
+ });
214
+ const total = flatList.length;
215
+ const includedCount = useMemo(() => flatList.filter((c) => isIncluded(c)).length, [components, userExcluded, userUnExcluded]);
216
+ const hasAnyAi = flatList.some(isAiFlagged);
217
+ const visibleEnd = Math.min(scrollOffset + VISIBLE_COUNT, total);
218
+ const visible = flatList.slice(scrollOffset, visibleEnd);
219
+ const above = scrollOffset;
220
+ const below = Math.max(0, total - visibleEnd);
221
+ // ── Banner / status helpers ───────────────────────────────────────────────
222
+ const showRunningHeader = aiFilterStatus === 'running' && aiFilterProgress !== null && aiFilterProgress.total > 0;
223
+ const showCancelledBanner = aiFilterStatus === 'cancelled';
224
+ const showFailedBanner = aiFilterStatus === 'failed';
225
+ const allRejected = aiFilterStatus === 'complete' && total > 0 && flatList.every((c) => !isIncluded(c));
226
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsx(Text, { color: "green", children: "\u2713 Extraction complete" }), _jsxs(Text, { dimColor: true, children: ["Found ", total, " component", total === 1 ? '' : 's', ". Pick which ones to import. Generation runs only on the included set."] }), showRunningHeader && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { color: "cyan", children: ["[AI filtering (", aiFilterProgress.done, "/", aiFilterProgress.total, ")\u2026] ", _jsx(Text, { dimColor: true, children: "[q] cancels" })] }) })), showCancelledBanner && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["AI auto-filter cancelled", aiFilterProgress ? ` at ${aiFilterProgress.done}/${aiFilterProgress.total}` : '', ". Review remaining manually."] }) })), showFailedBanner && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["AI auto-filter failed: ", aiFilterError ?? 'unknown error', ". Continuing without AI suggestions."] }) })), reasonPanelOpen && flatList[cursor] !== undefined && isAiFlagged(flatList[cursor]) && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: `AI rejection reason: ${flatList[cursor].name}` }), _jsx(Text, { children: flatList[cursor].aiReason ?? '<no reason given>' }), _jsx(Text, { dimColor: true, children: "[s] close \u00B7 [Esc] close" })] })), allRejected ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: "AI excluded all components \u2014 press [a] to override or [q] to quit" }) })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [above > 0 && _jsxs(Text, { dimColor: true, children: ["\u2191 ", above, " above"] }), visible.map((c, vi) => {
227
+ const i = vi + scrollOffset;
228
+ const isCursor = i === cursor;
229
+ const included = isIncluded(c);
230
+ const aiFlagged = isAiFlagged(c);
231
+ const prefix = isCursor ? '›' : ' ';
232
+ // R2: color-glyphs replace word labels. Green [✓] for included,
233
+ // red [✗] for excluded. The component name follows the same
234
+ // color UNLESS the row is the cursor row, in which case the
235
+ // name flips to cyan (state glyph keeps its red/green).
236
+ const stateGlyph = included ? '[✓]' : '[✗]';
237
+ const stateColor = included ? 'green' : 'red';
238
+ // R2: cyan `*` glyph replaces the verbose `[AI]` badge.
239
+ const aiMarkerNode = aiFlagged ? _jsx(Text, { color: "cyan", children: `${AI_MARKER} ` }) : null;
240
+ const inlineReason = !isCursor && aiFlagged ? ` ${truncateReason(c.aiReason)}` : '';
241
+ const showAiHeader = aiList.length > 0 && i === 0;
242
+ const showComponentsHeader = componentsList.length > 0 && i === aiList.length;
243
+ const header = showAiHeader ? (_jsx(Text, { bold: true, children: `AI recommended exclusions (${aiList.length})` }, `hdr-ai-${i}`)) : showComponentsHeader ? (_jsx(Text, { bold: true, children: `Components (${componentsList.length})` }, `hdr-comp-${i}`)) : null;
244
+ if (isCursor) {
245
+ const wrapReason = aiFlagged && c.aiReason !== null && c.aiReason !== undefined && c.aiReason.length > 0;
246
+ return (_jsxs(React.Fragment, { children: [header, _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: `${prefix} ` }), aiMarkerNode, _jsx(Text, { color: stateColor, children: stateGlyph }), _jsx(Text, { color: "cyan", children: ` ${c.name}` })] }), wrapReason && _jsx(Text, { dimColor: true, children: `${REASON_WRAP_INDENT}${c.aiReason}` })] }, c.componentId));
247
+ }
248
+ return (_jsxs(React.Fragment, { children: [header, _jsxs(Text, { children: [_jsx(Text, { children: `${prefix} ` }), aiMarkerNode, _jsx(Text, { color: stateColor, children: stateGlyph }), _jsx(Text, { color: stateColor, children: ` ${c.name}` }), inlineReason !== '' && _jsx(Text, { dimColor: true, children: inlineReason })] })] }, c.componentId));
249
+ }), below > 0 && _jsxs(Text, { dimColor: true, children: ["\u2193 ", below, " below"] })] })), _jsxs(Box, { gap: 3, marginTop: 1, children: [includedCount > 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: includedCount }), _jsxs(Text, { dimColor: true, children: ["/", total, " included"] })] })) : (_jsx(Text, { color: "yellow", children: "none included" })), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "[j/k]" }), " ", _jsx(Text, { dimColor: true, children: "move" })] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "[a/space]" }), " ", _jsx(Text, { dimColor: true, children: "toggle" })] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "[A]" }), " ", _jsx(Text, { dimColor: true, children: "toggle all" })] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "[f]" }), " ", _jsx(Text, { dimColor: true, children: "continue" })] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "[q]" }), " ", _jsx(Text, { dimColor: true, children: "quit" })] }), hasAnyAi && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "[s]" }), " ", _jsx(Text, { dimColor: true, children: "AI reason" })] })), hasAnyAi && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "*" }), " ", _jsx(Text, { dimColor: true, children: "originally excluded by AI" })] }))] })] }));
250
+ }
@@ -9,6 +9,7 @@ export function TokenInputStep({ onConfirm, onSkip, onQuit }) {
9
9
  const [cursorVisible, setCursorVisible] = useState(true);
10
10
  const [error, setError] = useState(null);
11
11
  const [resolvedPath, setResolvedPath] = useState(null);
12
+ const [typingMode, setTypingMode] = useState(false);
12
13
  useEffect(() => {
13
14
  const interval = setInterval(() => setCursorVisible((v) => !v), 500);
14
15
  return () => clearInterval(interval);
@@ -45,11 +46,26 @@ export function TokenInputStep({ onConfirm, onSkip, onQuit }) {
45
46
  onConfirm(normalized);
46
47
  return;
47
48
  }
48
- if (input === 's') {
49
+ if (key.tab) {
50
+ setTypingMode(false);
51
+ return;
52
+ }
53
+ if (key.escape) {
54
+ if (typingMode) {
55
+ setTypingMode(false);
56
+ setInputValue('');
57
+ setError(null);
58
+ setResolvedPath(null);
59
+ return;
60
+ }
61
+ onQuit();
62
+ return;
63
+ }
64
+ if (!typingMode && input === 's') {
49
65
  onSkip();
50
66
  return;
51
67
  }
52
- if (key.escape || input === 'q') {
68
+ if (!typingMode && input === 'q') {
53
69
  onQuit();
54
70
  return;
55
71
  }
@@ -60,11 +76,12 @@ export function TokenInputStep({ onConfirm, onSkip, onQuit }) {
60
76
  return;
61
77
  }
62
78
  if (input && !key.ctrl && !key.meta) {
79
+ setTypingMode(true);
63
80
  setInputValue((v) => v + input);
64
81
  setError(null);
65
82
  setResolvedPath(null);
66
83
  }
67
84
  });
68
85
  const displayValue = inputValue + (cursorVisible ? '█' : ' ');
69
- return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Design tokens" }), _jsx(Text, { dimColor: true, children: "Point me to your raw token file (e.g. ~/design-tokens/tokens.json). You can use ~, relative, or absolute paths. Claude will map it to DTCG format." }), _jsxs(Box, { flexDirection: "column", marginTop: 1, gap: 0, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "?" }), _jsx(Text, { children: "Token path (file or directory):" }), _jsx(Text, { children: displayValue })] }), resolvedPath && resolvedPath !== inputValue.trim() && !error && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2192 ", resolvedPath] }) })), error && (_jsxs(Box, { marginTop: 0, flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["\u2717 ", error] }), resolvedPath && _jsxs(Text, { dimColor: true, children: [" Resolved to: ", resolvedPath] })] }))] }), _jsxs(Box, { gap: 3, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "[Enter] Confirm path" }), _jsx(Text, { dimColor: true, children: "[s] Skip tokens" }), _jsx(Text, { dimColor: true, children: "[q] Quit" })] })] }));
86
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Design tokens" }), _jsx(Text, { dimColor: true, children: "Point me to your raw token file (e.g. ~/design-tokens/tokens.json). You can use ~, relative, or absolute paths. Claude will map it to DTCG format." }), _jsxs(Box, { flexDirection: "column", marginTop: 1, gap: 0, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "?" }), _jsx(Text, { children: "Token path (file or directory):" }), _jsx(Text, { children: displayValue })] }), resolvedPath && resolvedPath !== inputValue.trim() && !error && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2192 ", resolvedPath] }) })), error && (_jsxs(Box, { marginTop: 0, flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["\u2717 ", error] }), resolvedPath && _jsxs(Text, { dimColor: true, children: [" Resolved to: ", resolvedPath] })] }))] }), _jsxs(Box, { gap: 3, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "[Enter] Submit / Skip if empty" }), _jsx(Text, { dimColor: true, children: "[Tab] Exit typing" }), _jsx(Text, { dimColor: true, children: "[Esc] Clear & exit" }), _jsx(Text, { dimColor: true, children: "[s] Skip" }), _jsx(Text, { dimColor: true, children: "[q] Quit" })] })] }));
70
87
  }
@@ -0,0 +1,35 @@
1
+ import type { ServerPreviewResponse } from '@contentful/experience-design-system-types';
2
+ export type UseLivePreviewOptions = {
3
+ enabled: boolean;
4
+ sessionId: string;
5
+ tokensPath: string;
6
+ spaceId: string;
7
+ environmentId: string;
8
+ cmaToken: string;
9
+ host: string;
10
+ onResult: (response: ServerPreviewResponse | null) => void;
11
+ debounceMs?: number;
12
+ };
13
+ export type LivePreviewStatus = 'idle' | 'running';
14
+ export type UseLivePreviewReturn = {
15
+ trigger: () => void;
16
+ status: LivePreviewStatus;
17
+ disabled: boolean;
18
+ };
19
+ /**
20
+ * Hook that owns the post-save live-preview side effect for the wizard's
21
+ * final-review step. Centralizes:
22
+ *
23
+ * - 500ms debounce so rapid Ctrl+S spam collapses to a single API call.
24
+ * - Generation tag on every fire so a stale in-flight call's response is
25
+ * discarded if a newer fire has already kicked off (in lieu of an
26
+ * AbortController for v1).
27
+ * - Disable-for-session on 401/403: subsequent triggers no-op until the
28
+ * wizard exits. One-line stderr warning. No modal, no re-prompt — the
29
+ * operator is mid-edit and we must not interrupt the flow.
30
+ * - Status state for the sidebar spinner (`'idle' | 'running'`).
31
+ *
32
+ * Cred-missing / disabled / not-enabled cases short-circuit BEFORE the timer
33
+ * is set so we don't accumulate dead timers.
34
+ */
35
+ export declare function useLivePreview(opts: UseLivePreviewOptions): UseLivePreviewReturn;
@@ -0,0 +1,120 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { ApiError } from '../../apply/api-client.js';
3
+ import { runLivePreview } from './runLivePreview.js';
4
+ const DEFAULT_DEBOUNCE_MS = 500;
5
+ /**
6
+ * Hook that owns the post-save live-preview side effect for the wizard's
7
+ * final-review step. Centralizes:
8
+ *
9
+ * - 500ms debounce so rapid Ctrl+S spam collapses to a single API call.
10
+ * - Generation tag on every fire so a stale in-flight call's response is
11
+ * discarded if a newer fire has already kicked off (in lieu of an
12
+ * AbortController for v1).
13
+ * - Disable-for-session on 401/403: subsequent triggers no-op until the
14
+ * wizard exits. One-line stderr warning. No modal, no re-prompt — the
15
+ * operator is mid-edit and we must not interrupt the flow.
16
+ * - Status state for the sidebar spinner (`'idle' | 'running'`).
17
+ *
18
+ * Cred-missing / disabled / not-enabled cases short-circuit BEFORE the timer
19
+ * is set so we don't accumulate dead timers.
20
+ */
21
+ export function useLivePreview(opts) {
22
+ const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
23
+ const [status, setStatus] = useState('idle');
24
+ const [disabled, setDisabled] = useState(false);
25
+ const timerRef = useRef(null);
26
+ const generationRef = useRef(0);
27
+ const latestRef = useRef(0);
28
+ const inflightRef = useRef(0);
29
+ // Mirror props in a ref so the trigger callback can stay stable.
30
+ const optsRef = useRef(opts);
31
+ useEffect(() => {
32
+ optsRef.current = opts;
33
+ });
34
+ // Mirror disabled into a ref so the timer callback sees the latest value
35
+ // without redefining `trigger` (which would defeat its memoization).
36
+ const disabledRef = useRef(disabled);
37
+ useEffect(() => {
38
+ disabledRef.current = disabled;
39
+ }, [disabled]);
40
+ // Clear any pending timer on unmount.
41
+ useEffect(() => {
42
+ return () => {
43
+ if (timerRef.current) {
44
+ clearTimeout(timerRef.current);
45
+ timerRef.current = null;
46
+ }
47
+ };
48
+ }, []);
49
+ const trigger = useCallback(() => {
50
+ const current = optsRef.current;
51
+ if (!current.enabled)
52
+ return;
53
+ if (disabledRef.current)
54
+ return;
55
+ if (!current.spaceId || !current.environmentId || !current.cmaToken)
56
+ return;
57
+ if (timerRef.current) {
58
+ clearTimeout(timerRef.current);
59
+ timerRef.current = null;
60
+ }
61
+ timerRef.current = setTimeout(() => {
62
+ timerRef.current = null;
63
+ void fire();
64
+ }, debounceMs);
65
+ }, [debounceMs]);
66
+ const fire = async () => {
67
+ const current = optsRef.current;
68
+ if (disabledRef.current)
69
+ return;
70
+ generationRef.current += 1;
71
+ const generation = generationRef.current;
72
+ latestRef.current = generation;
73
+ inflightRef.current += 1;
74
+ setStatus('running');
75
+ try {
76
+ const result = await runLivePreview({
77
+ sessionId: current.sessionId,
78
+ tokensPath: current.tokensPath,
79
+ spaceId: current.spaceId,
80
+ environmentId: current.environmentId,
81
+ cmaToken: current.cmaToken,
82
+ host: current.host,
83
+ generation,
84
+ });
85
+ // Discard stale responses (generation tag).
86
+ if (result.generation !== latestRef.current)
87
+ return;
88
+ current.onResult(result.response);
89
+ }
90
+ catch (err) {
91
+ if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
92
+ setDisabled(true);
93
+ disabledRef.current = true;
94
+ try {
95
+ process.stderr.write(`live-preview: ${err.status}, disabling for this session\n`);
96
+ }
97
+ catch {
98
+ // best-effort
99
+ }
100
+ }
101
+ else {
102
+ const msg = err instanceof Error ? err.message : String(err);
103
+ try {
104
+ process.stderr.write(`live-preview: ${msg}\n`);
105
+ }
106
+ catch {
107
+ // best-effort
108
+ }
109
+ }
110
+ }
111
+ finally {
112
+ inflightRef.current -= 1;
113
+ if (inflightRef.current <= 0) {
114
+ inflightRef.current = 0;
115
+ setStatus('idle');
116
+ }
117
+ }
118
+ };
119
+ return { trigger, status, disabled };
120
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Pure parser for the generate-step stderr stream.
3
+ *
4
+ * Two line shapes can carry progress info:
5
+ *
6
+ * 1. `progress=generate:<done>/<total>:<name>` — emitted at terminal completion
7
+ * of each component in `runAllComponents`. `done` is the count of completed
8
+ * components and is monotonically non-decreasing.
9
+ * 2. `[<index+1>/<total>] <name>` — legacy line emitted at the START of each
10
+ * component (and again on retry). With concurrency > 1, the index reflects
11
+ * the worker's starting position, not completions, so it MUST NOT drive
12
+ * `done` — only `current` (the name shown to the user).
13
+ *
14
+ * Given a chunk and the previous state, returns the updated state.
15
+ */
16
+ export type GenerateProgressState = {
17
+ done: number;
18
+ total: number;
19
+ current: string;
20
+ } | null;
21
+ export declare function parseGenerateStderrChunk(chunk: string, prev: GenerateProgressState): GenerateProgressState;