@contentful/experience-design-system-cli 2.12.0 → 2.12.1-dev-build-589b615.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 (142) 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 +259 -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 +814 -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/push-decision-gate-helpers.d.ts +8 -0
  62. package/dist/src/import/tui/push-decision-gate-helpers.js +8 -0
  63. package/dist/src/import/tui/push-progress.d.ts +23 -0
  64. package/dist/src/import/tui/push-progress.js +14 -0
  65. package/dist/src/import/tui/run-print-files-helpers.d.ts +26 -0
  66. package/dist/src/import/tui/run-print-files-helpers.js +8 -0
  67. package/dist/src/import/tui/run-teaser.d.ts +5 -0
  68. package/dist/src/import/tui/run-teaser.js +9 -0
  69. package/dist/src/import/tui/runLivePreview.d.ts +35 -0
  70. package/dist/src/import/tui/runLivePreview.js +73 -0
  71. package/dist/src/import/tui/runScopeGate.d.ts +22 -0
  72. package/dist/src/import/tui/runScopeGate.js +28 -0
  73. package/dist/src/import/tui/scope-gate-host.d.ts +21 -0
  74. package/dist/src/import/tui/scope-gate-host.js +20 -0
  75. package/dist/src/import/tui/spawn-generate.d.ts +33 -0
  76. package/dist/src/import/tui/spawn-generate.js +53 -0
  77. package/dist/src/import/tui/steps/CredentialsStep.d.ts +25 -1
  78. package/dist/src/import/tui/steps/CredentialsStep.js +31 -3
  79. package/dist/src/import/tui/steps/DoneStep.d.ts +6 -1
  80. package/dist/src/import/tui/steps/DoneStep.js +10 -4
  81. package/dist/src/import/tui/steps/GenerateReviewStep.d.ts +29 -2
  82. package/dist/src/import/tui/steps/GenerateReviewStep.js +412 -48
  83. package/dist/src/import/tui/steps/PushDecisionGateStep.d.ts +19 -0
  84. package/dist/src/import/tui/steps/PushDecisionGateStep.js +76 -0
  85. package/dist/src/import/tui/steps/PushingStep.d.ts +10 -0
  86. package/dist/src/import/tui/steps/PushingStep.js +40 -0
  87. package/dist/src/import/tui/steps/ScopeGateStep.d.ts +23 -0
  88. package/dist/src/import/tui/steps/ScopeGateStep.js +243 -0
  89. package/dist/src/import/tui/steps/TokenInputStep.js +20 -3
  90. package/dist/src/import/tui/useLivePreview.d.ts +35 -0
  91. package/dist/src/import/tui/useLivePreview.js +120 -0
  92. package/dist/src/import/tui/wizard-generate-progress.d.ts +21 -0
  93. package/dist/src/import/tui/wizard-generate-progress.js +25 -0
  94. package/dist/src/import/tui/wizard-save-flow.d.ts +37 -0
  95. package/dist/src/import/tui/wizard-save-flow.js +20 -0
  96. package/dist/src/import/tui/wizard-state-transitions.d.ts +80 -0
  97. package/dist/src/import/tui/wizard-state-transitions.js +79 -0
  98. package/dist/src/lib/contentful-urls.d.ts +34 -0
  99. package/dist/src/lib/contentful-urls.js +41 -0
  100. package/dist/src/program.js +2 -0
  101. package/dist/src/runs/export-helpers.d.ts +30 -0
  102. package/dist/src/runs/export-helpers.js +59 -0
  103. package/dist/src/runs/fingerprint.d.ts +47 -0
  104. package/dist/src/runs/fingerprint.js +75 -0
  105. package/dist/src/runs/ls-command.d.ts +12 -0
  106. package/dist/src/runs/ls-command.js +137 -0
  107. package/dist/src/runs/modify-launcher.d.ts +24 -0
  108. package/dist/src/runs/modify-launcher.js +36 -0
  109. package/dist/src/runs/path-prompt.d.ts +8 -0
  110. package/dist/src/runs/path-prompt.js +72 -0
  111. package/dist/src/runs/push-creds-prompt.d.ts +25 -0
  112. package/dist/src/runs/push-creds-prompt.js +39 -0
  113. package/dist/src/runs/push-helpers.d.ts +29 -0
  114. package/dist/src/runs/push-helpers.js +69 -0
  115. package/dist/src/runs/replay-helpers.d.ts +67 -0
  116. package/dist/src/runs/replay-helpers.js +148 -0
  117. package/dist/src/runs/resolve-run-target.d.ts +12 -0
  118. package/dist/src/runs/resolve-run-target.js +45 -0
  119. package/dist/src/runs/run-picker-mount.d.ts +46 -0
  120. package/dist/src/runs/run-picker-mount.js +72 -0
  121. package/dist/src/runs/run-picker.d.ts +17 -0
  122. package/dist/src/runs/run-picker.js +145 -0
  123. package/dist/src/runs/save-conflict.d.ts +8 -0
  124. package/dist/src/runs/save-conflict.js +56 -0
  125. package/dist/src/runs/save-path-resolver.d.ts +55 -0
  126. package/dist/src/runs/save-path-resolver.js +71 -0
  127. package/dist/src/runs/staleness.d.ts +38 -0
  128. package/dist/src/runs/staleness.js +140 -0
  129. package/dist/src/runs/store.d.ts +78 -0
  130. package/dist/src/runs/store.js +136 -0
  131. package/dist/src/session/cache-keys.d.ts +15 -0
  132. package/dist/src/session/cache-keys.js +38 -0
  133. package/dist/src/session/db.d.ts +89 -3
  134. package/dist/src/session/db.js +447 -104
  135. package/dist/src/setup/auto-filter-prompt.d.ts +13 -0
  136. package/dist/src/setup/auto-filter-prompt.js +24 -0
  137. package/dist/src/setup/command.d.ts +7 -0
  138. package/dist/src/setup/command.js +58 -2
  139. package/dist/src/types.d.ts +6 -0
  140. package/package.json +2 -2
  141. package/skills/generate-components.md +54 -6
  142. 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' | 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,243 @@
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
+ return row.aiDecision === 'rejected';
24
+ }
25
+ export function ScopeGateStep({ components, onConfirm, onQuit, aiFilterStatus = 'idle', aiFilterProgress = null, aiFilterError = null, onCancelAutoFilter, }) {
26
+ // Pilot-2026-06-25: scope-gate UX overhaul — single unified list.
27
+ //
28
+ // Inverted mental model: we now track which rows are INCLUDED rather than
29
+ // which are EXCLUDED. The underlying delta-on-prop pattern survives so
30
+ // operator decisions stay sticky across streaming AI updates:
31
+ // - userExcluded: rows the operator explicitly toggled OFF
32
+ // - userUnExcluded: rows the operator explicitly toggled ON
33
+ // Effective INCLUDED for a row:
34
+ // if userExcluded.has(name): false
35
+ // else if userUnExcluded.has(name): true
36
+ // else: row.aiDecision !== 'rejected' // default: follow AI, default true if AI silent
37
+ const [userExcluded, setUserExcluded] = useState(new Set());
38
+ const [userUnExcluded, setUserUnExcluded] = useState(new Set());
39
+ const [cursor, setCursor] = useState(0);
40
+ const [scrollOffset, setScrollOffset] = useState(0);
41
+ // Pilot-2026-06-23: `s` opens a side panel showing the full reject_reason
42
+ // (untruncated) for an AI-flagged row under the cursor. With the focused-row
43
+ // wrap landed, the panel is mostly redundant — kept for screen-reader /
44
+ // overflow cases per spec.
45
+ const [reasonPanelOpen, setReasonPanelOpen] = useState(false);
46
+ // Pilot-2026-06-25 R2: two-section render. AI-flagged rows go to a top
47
+ // section ("AI recommended exclusions"); the rest fall into "Components".
48
+ // Within each section we preserve prop (extraction) order. Cursor walks
49
+ // the unified flat list [...aiList, ...componentsList].
50
+ const aiList = components.filter(isAiFlagged);
51
+ const componentsList = components.filter((c) => !isAiFlagged(c));
52
+ const flatList = [...aiList, ...componentsList];
53
+ const isIncluded = (row) => {
54
+ // Pilot-2026-06-25 invariant: operator decisions are ALWAYS sticky over
55
+ // streaming AI updates. If the operator explicitly toggled this row
56
+ // (userExcluded or userUnExcluded), that wins regardless of any later
57
+ // aiDecision the auto-filter writes via prop updates. The [AI] badge
58
+ // still appears on the row — it's informational, not authoritative.
59
+ if (userExcluded.has(row.name))
60
+ return false;
61
+ if (userUnExcluded.has(row.name))
62
+ return true;
63
+ return row.aiDecision !== 'rejected';
64
+ };
65
+ const partition = () => {
66
+ const accepted = [];
67
+ const rejected = [];
68
+ for (const c of flatList) {
69
+ if (isIncluded(c))
70
+ accepted.push(c.name);
71
+ else
72
+ rejected.push(c.name);
73
+ }
74
+ return { accepted, rejected };
75
+ };
76
+ const toggleFocused = () => {
77
+ const target = flatList[cursor];
78
+ if (!target)
79
+ return;
80
+ const currentlyIn = isIncluded(target);
81
+ if (currentlyIn) {
82
+ // Flip to EXCLUDED.
83
+ setUserExcluded((prev) => {
84
+ if (prev.has(target.name))
85
+ return prev;
86
+ const next = new Set(prev);
87
+ next.add(target.name);
88
+ return next;
89
+ });
90
+ setUserUnExcluded((prev) => {
91
+ if (!prev.has(target.name))
92
+ return prev;
93
+ const next = new Set(prev);
94
+ next.delete(target.name);
95
+ return next;
96
+ });
97
+ }
98
+ else {
99
+ // Flip to INCLUDED.
100
+ setUserUnExcluded((prev) => {
101
+ if (prev.has(target.name))
102
+ return prev;
103
+ const next = new Set(prev);
104
+ next.add(target.name);
105
+ return next;
106
+ });
107
+ setUserExcluded((prev) => {
108
+ if (!prev.has(target.name))
109
+ return prev;
110
+ const next = new Set(prev);
111
+ next.delete(target.name);
112
+ return next;
113
+ });
114
+ }
115
+ };
116
+ useImmediateInput((input, key) => {
117
+ if (input === 'q' || key.escape) {
118
+ // Feature 3: while auto-filter is running, q cancels the LLM run instead
119
+ // of quitting the wizard. After completion (or if no auto-filter ran), q
120
+ // falls back to the existing wizard-quit behavior.
121
+ if (aiFilterStatus === 'running' && onCancelAutoFilter) {
122
+ onCancelAutoFilter();
123
+ return;
124
+ }
125
+ // Esc also closes the reason panel without quitting.
126
+ if (key.escape && reasonPanelOpen) {
127
+ setReasonPanelOpen(false);
128
+ return;
129
+ }
130
+ onQuit();
131
+ return;
132
+ }
133
+ if (input === 'f' || input === 'F') {
134
+ onConfirm(partition());
135
+ return;
136
+ }
137
+ if (input === 's') {
138
+ setReasonPanelOpen((prev) => !prev);
139
+ return;
140
+ }
141
+ // `a`, space, and `r` are all aliases for "toggle focused row INCLUDED ↔ EXCLUDED".
142
+ // `r` kept for muscle-memory; it no longer means "reject".
143
+ if (input === 'a' || input === ' ' || input === 'r') {
144
+ toggleFocused();
145
+ return;
146
+ }
147
+ if (input === 'A') {
148
+ // Pilot-2026-06-25 R2: toggle-all operates ONLY on the Components
149
+ // section. The AI section's defaults stay put — operators rarely want
150
+ // a mass-include of AI-flagged rows via one keystroke. Toggle behavior:
151
+ // if any Components row is excluded, include them all; else exclude all.
152
+ const compNames = componentsList.map((c) => c.name);
153
+ const anyCompExcluded = componentsList.some((c) => !isIncluded(c));
154
+ if (anyCompExcluded) {
155
+ setUserUnExcluded((prev) => {
156
+ const next = new Set(prev);
157
+ for (const n of compNames)
158
+ next.add(n);
159
+ return next;
160
+ });
161
+ setUserExcluded((prev) => {
162
+ const next = new Set(prev);
163
+ for (const n of compNames)
164
+ next.delete(n);
165
+ return next;
166
+ });
167
+ }
168
+ else {
169
+ setUserExcluded((prev) => {
170
+ const next = new Set(prev);
171
+ for (const n of compNames)
172
+ next.add(n);
173
+ return next;
174
+ });
175
+ setUserUnExcluded((prev) => {
176
+ const next = new Set(prev);
177
+ for (const n of compNames)
178
+ next.delete(n);
179
+ return next;
180
+ });
181
+ }
182
+ return;
183
+ }
184
+ if (key.upArrow || input === 'k') {
185
+ const len = flatList.length;
186
+ if (len === 0)
187
+ return;
188
+ setCursor((c) => {
189
+ const next = c <= 0 ? 0 : c - 1;
190
+ setScrollOffset((prev) => Math.min(prev, next));
191
+ return next;
192
+ });
193
+ return;
194
+ }
195
+ if (key.downArrow || input === 'j') {
196
+ const len = flatList.length;
197
+ if (len === 0)
198
+ return;
199
+ setCursor((c) => {
200
+ const next = c >= len - 1 ? len - 1 : c + 1;
201
+ setScrollOffset((prev) => (next >= prev + VISIBLE_COUNT ? next - VISIBLE_COUNT + 1 : prev));
202
+ return next;
203
+ });
204
+ return;
205
+ }
206
+ });
207
+ const total = flatList.length;
208
+ const includedCount = useMemo(() => flatList.filter((c) => isIncluded(c)).length, [components, userExcluded, userUnExcluded]);
209
+ const hasAnyAi = flatList.some(isAiFlagged);
210
+ const visibleEnd = Math.min(scrollOffset + VISIBLE_COUNT, total);
211
+ const visible = flatList.slice(scrollOffset, visibleEnd);
212
+ const above = scrollOffset;
213
+ const below = Math.max(0, total - visibleEnd);
214
+ // ── Banner / status helpers ───────────────────────────────────────────────
215
+ const showRunningHeader = aiFilterStatus === 'running' && aiFilterProgress !== null && aiFilterProgress.total > 0;
216
+ const showCancelledBanner = aiFilterStatus === 'cancelled';
217
+ const showFailedBanner = aiFilterStatus === 'failed';
218
+ const allRejected = aiFilterStatus === 'complete' && total > 0 && flatList.every((c) => !isIncluded(c));
219
+ 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]?.aiDecision === 'rejected' && (_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) => {
220
+ const i = vi + scrollOffset;
221
+ const isCursor = i === cursor;
222
+ const included = isIncluded(c);
223
+ const aiFlagged = isAiFlagged(c);
224
+ const prefix = isCursor ? '›' : ' ';
225
+ // R2: color-glyphs replace word labels. Green [✓] for included,
226
+ // red [✗] for excluded. The component name follows the same
227
+ // color UNLESS the row is the cursor row, in which case the
228
+ // name flips to cyan (state glyph keeps its red/green).
229
+ const stateGlyph = included ? '[✓]' : '[✗]';
230
+ const stateColor = included ? 'green' : 'red';
231
+ // R2: cyan `*` glyph replaces the verbose `[AI]` badge.
232
+ const aiMarkerNode = aiFlagged ? _jsx(Text, { color: "cyan", children: `${AI_MARKER} ` }) : null;
233
+ const inlineReason = !isCursor && aiFlagged ? ` ${truncateReason(c.aiReason)}` : '';
234
+ const showAiHeader = aiList.length > 0 && i === 0;
235
+ const showComponentsHeader = componentsList.length > 0 && i === aiList.length;
236
+ 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;
237
+ if (isCursor) {
238
+ const wrapReason = aiFlagged && c.aiReason !== null && c.aiReason !== undefined && c.aiReason.length > 0;
239
+ 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));
240
+ }
241
+ 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));
242
+ }), 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" })] }))] })] }));
243
+ }
@@ -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;
@@ -0,0 +1,25 @@
1
+ const PROGRESS_LINE = /^progress=generate:(\d+)\/(\d+):(.+)$/;
2
+ const LEGACY_LINE = /\[(\d+)\/(\d+)\]\s+(.+)/;
3
+ export function parseGenerateStderrChunk(chunk, prev) {
4
+ let state = prev;
5
+ for (const line of chunk.split('\n')) {
6
+ const progressMatch = PROGRESS_LINE.exec(line);
7
+ if (progressMatch) {
8
+ state = {
9
+ done: Number(progressMatch[1]),
10
+ total: Number(progressMatch[2]),
11
+ current: progressMatch[3].trim(),
12
+ };
13
+ continue;
14
+ }
15
+ const legacyMatch = LEGACY_LINE.exec(line);
16
+ if (legacyMatch) {
17
+ state = {
18
+ done: state?.done ?? 0,
19
+ total: Number(legacyMatch[2]),
20
+ current: legacyMatch[3].trim(),
21
+ };
22
+ }
23
+ }
24
+ return state;
25
+ }