@contentful/experience-design-system-cli 2.6.2-dev-build-ea30bad.0 → 2.6.2-dev-build-864e323.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.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.6.2-dev-build-ea30bad.0",
3
+ "version": "2.6.2-dev-build-864e323.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,6 +9,7 @@ import { registerAnalyzeSelectAgentCommand } from './select-agent/command.js';
9
9
  import { openPipelineDb, getOrCreateSession, createStep, updateStep, storeRawComponents } from '../session/db.js';
10
10
  import { preClassifyComponent } from './pre-classify.js';
11
11
  import { isNonAuthorableComponent } from './extract/non-authorable-filter.js';
12
+ import { computeExtractionScore, deriveNeedsReview } from './extract/scoring.js';
12
13
  const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.ts', '.tsx', '.vue']);
13
14
  const IGNORED_DIRECTORY_NAMES = new Set([
14
15
  '.git',
@@ -130,7 +131,13 @@ export function registerAnalyzeCommand(program) {
130
131
  filterWarnings.push(`Skipped non-authorable component: ${component.name} (${verdict.reason})`);
131
132
  continue;
132
133
  }
133
- filteredComponents.push(component);
134
+ const { confidence, reasons } = computeExtractionScore(component);
135
+ filteredComponents.push({
136
+ ...component,
137
+ extractionConfidence: confidence,
138
+ reviewReasons: reasons,
139
+ needsReview: deriveNeedsReview(confidence),
140
+ });
134
141
  }
135
142
  storeRawComponents(db, sessionId, filteredComponents);
136
143
  updateStep(db, stepId, 'complete', { sessionId });
@@ -146,6 +153,8 @@ export function registerAnalyzeCommand(program) {
146
153
  propCount: c.props.length,
147
154
  slotCount: c.slots.length,
148
155
  warnings: allWarnings.filter((w) => w.startsWith(c.name + ':')),
156
+ extractionConfidence: c.extractionConfidence ?? 100,
157
+ needsReview: c.needsReview ?? false,
149
158
  })),
150
159
  totalWarnings: allWarnings.length,
151
160
  };
@@ -0,0 +1,7 @@
1
+ import type { RawComponentDefinition } from '../../types.js';
2
+ export type ExtractionScore = {
3
+ confidence: number;
4
+ reasons: string[];
5
+ };
6
+ export declare function computeExtractionScore(component: RawComponentDefinition): ExtractionScore;
7
+ export declare function deriveNeedsReview(confidence: number): boolean;
@@ -0,0 +1,87 @@
1
+ // Prop type strings that indicate the extractor couldn't resolve a concrete type
2
+ const OPAQUE_TYPES = new Set(['any', 'unknown', 'object', 'Record<string, unknown>', 'Record<string, any>']);
3
+ // Prop names that are non-obvious and benefit from a description
4
+ const OBVIOUS_PROP_NAMES = new Set([
5
+ 'className',
6
+ 'style',
7
+ 'id',
8
+ 'children',
9
+ 'disabled',
10
+ 'hidden',
11
+ 'onClick',
12
+ 'onChange',
13
+ 'onSubmit',
14
+ 'onBlur',
15
+ 'onFocus',
16
+ 'href',
17
+ 'src',
18
+ 'alt',
19
+ 'type',
20
+ 'value',
21
+ 'checked',
22
+ 'placeholder',
23
+ 'label',
24
+ 'title',
25
+ 'name',
26
+ 'required',
27
+ 'readOnly',
28
+ 'autoFocus',
29
+ 'tabIndex',
30
+ 'role',
31
+ 'aria-label',
32
+ 'aria-describedby',
33
+ ]);
34
+ // A union is "wide" if it mixes 3+ distinct base primitive types (ignoring nullability modifiers).
35
+ // e.g. "string | number | boolean" → wide. "string | null | undefined" → NOT wide (just nullable string).
36
+ function isWidePrimitiveUnion(type) {
37
+ const parts = type.split('|').map((p) => p.trim());
38
+ if (parts.length < 3)
39
+ return false;
40
+ const basePrimitives = new Set(['string', 'number', 'boolean']);
41
+ const nullability = new Set(['null', 'undefined']);
42
+ const baseCount = parts.filter((p) => basePrimitives.has(p)).length;
43
+ const nullabilityCount = parts.filter((p) => nullability.has(p)).length;
44
+ // Wide only when there are 3+ base primitives, or 2+ base primitives combined with nullability modifiers
45
+ return baseCount >= 3 || (baseCount >= 2 && nullabilityCount > 0 && baseCount + nullabilityCount >= 3);
46
+ }
47
+ export function computeExtractionScore(component) {
48
+ let confidence = 100;
49
+ const reasons = [];
50
+ // No props and no slots — extractor likely missed something or component is a wrapper
51
+ if (component.props.length === 0 && component.slots.length === 0) {
52
+ confidence -= 15;
53
+ reasons.push('no-props-or-slots');
54
+ }
55
+ for (const prop of component.props) {
56
+ // Opaque types — extractor couldn't resolve the real type
57
+ if (OPAQUE_TYPES.has(prop.type.trim())) {
58
+ confidence -= 20;
59
+ reasons.push(`opaque-type:${prop.name}`);
60
+ break; // only penalise once per component
61
+ }
62
+ // Wide primitive union — hard for the AI agent to classify meaningfully
63
+ if (isWidePrimitiveUnion(prop.type)) {
64
+ confidence -= 10;
65
+ reasons.push(`wide-union:${prop.name}`);
66
+ break; // only penalise once
67
+ }
68
+ // Non-obvious prop name with no description
69
+ if (!prop.description && !OBVIOUS_PROP_NAMES.has(prop.name)) {
70
+ confidence -= 10;
71
+ reasons.push('props-missing-description');
72
+ break; // only penalise once
73
+ }
74
+ }
75
+ // High prop count — possible DOM inflation near-miss or overly broad extraction
76
+ if (component.props.length > 50) {
77
+ confidence -= 20;
78
+ reasons.push(`high-prop-count:${component.props.length}`);
79
+ }
80
+ return {
81
+ confidence: Math.max(0, Math.min(100, confidence)),
82
+ reasons: [...new Set(reasons)], // deduplicate
83
+ };
84
+ }
85
+ export function deriveNeedsReview(confidence) {
86
+ return confidence < 70;
87
+ }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { Box, Text, useStdout } from 'ink';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { createReviewSessionDetail } from '../types.js';
@@ -40,6 +40,11 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
40
40
  const [finalizedResult, setFinalizedResult] = useState(null);
41
41
  const [sidebarScrollOffset, setSidebarScrollOffset] = useState(0);
42
42
  const [jsonScrollOffset, setJsonScrollOffset] = useState(0);
43
+ // Keeps the visual sort order in sync for use inside keymap handlers (which close over a stale render)
44
+ const sortedIdsRef = useRef([]);
45
+ // Source code kept separate from session state so lazy loading never mutates
46
+ // session.components — that would invalidate useMemo(sessionSummary) and flash the sidebar
47
+ const [sourceCodeById, setSourceCodeById] = useState({});
43
48
  const [previewAnnotations, setPreviewAnnotations] = useState(() => {
44
49
  const raw = process.env['EDS_PREVIEW_ANNOTATIONS'];
45
50
  if (!raw)
@@ -138,13 +143,22 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
138
143
  clearTimeout(previewDebounceRef.current);
139
144
  };
140
145
  }, []);
141
- // Sync loaded session into local state
146
+ // Sync loaded session into local state; selectedId is set to null here
147
+ // and will be corrected to the first sorted item on first render via the effect below
142
148
  useEffect(() => {
143
149
  if (loadedSession && !session) {
144
150
  setSession(loadedSession);
145
- setSelectedId(loadedSession.components[0]?.id ?? null);
151
+ setSelectedId(null);
146
152
  }
147
153
  }, [loadedSession]);
154
+ // Once the sort order is known (after first render), select the first sorted item.
155
+ // Runs only when session transitions from null → loaded.
156
+ const sessionLoaded = session !== null;
157
+ useEffect(() => {
158
+ if (sessionLoaded && selectedId === null && sortedIdsRef.current.length > 0) {
159
+ setSelectedId(sortedIdsRef.current[0]);
160
+ }
161
+ }, [sessionLoaded]);
148
162
  useEffect(() => {
149
163
  if (session && !previewResponse) {
150
164
  const raw = process.env['EDS_PREVIEW_COUNTS'];
@@ -172,26 +186,22 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
172
186
  }
173
187
  }
174
188
  }, [session]);
175
- // Lazy source code loading
189
+ // Lazy source code loading — writes to sourceCodeById, NOT session.components,
190
+ // so it doesn't invalidate useMemo(sessionSummary) and cause a sidebar flash
176
191
  useEffect(() => {
177
192
  if (!session || !selectedId)
178
193
  return;
194
+ if (sourceCodeById[selectedId] !== undefined)
195
+ return; // already loaded
179
196
  const selectedComponent = session.components.find((c) => c.id === selectedId);
180
- if (!selectedComponent || selectedComponent.sourceCode !== null)
197
+ if (!selectedComponent)
181
198
  return;
182
199
  readFile(selectedComponent.resolvedSourcePath, 'utf8')
183
200
  .then((code) => {
184
- setSession((prev) => {
185
- if (!prev)
186
- return prev;
187
- return {
188
- ...prev,
189
- components: prev.components.map((c) => (c.id === selectedComponent.id ? { ...c, sourceCode: code } : c)),
190
- };
191
- });
201
+ setSourceCodeById((prev) => ({ ...prev, [selectedId]: code }));
192
202
  })
193
203
  .catch(() => {
194
- // Leave as null; SourcePanel shows [No source available]
204
+ setSourceCodeById((prev) => ({ ...prev, [selectedId]: '' }));
195
205
  });
196
206
  }, [selectedId]);
197
207
  // SIGINT handler
@@ -235,9 +245,10 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
235
245
  onSidebarUp: () => {
236
246
  if (!session)
237
247
  return;
238
- const idx = session.components.findIndex((c) => c.id === selectedId);
248
+ const ids = sortedIdsRef.current;
249
+ const idx = ids.indexOf(selectedId ?? '');
239
250
  if (idx > 0) {
240
- setSelectedId(session.components[idx - 1].id);
251
+ setSelectedId(ids[idx - 1]);
241
252
  setJsonScrollOffset(0);
242
253
  setSidebarScrollOffset((prev) => Math.min(prev, idx - 1));
243
254
  }
@@ -245,9 +256,10 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
245
256
  onSidebarDown: () => {
246
257
  if (!session)
247
258
  return;
248
- const idx = session.components.findIndex((c) => c.id === selectedId);
249
- if (idx < session.components.length - 1) {
250
- setSelectedId(session.components[idx + 1].id);
259
+ const ids = sortedIdsRef.current;
260
+ const idx = ids.indexOf(selectedId ?? '');
261
+ if (idx < ids.length - 1) {
262
+ setSelectedId(ids[idx + 1]);
251
263
  setJsonScrollOffset(0);
252
264
  setSidebarScrollOffset((prev) => {
253
265
  const newIdx = idx + 1;
@@ -273,7 +285,10 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
273
285
  setEditMode(true);
274
286
  setDraftsByComponentId((prev) => ({
275
287
  ...prev,
276
- [selectedId]: prev[selectedId] ?? JSON.stringify(component.editedProposal, null, 2),
288
+ [selectedId]: (() => {
289
+ const { extractionConfidence: _c, reviewReasons: _r, needsReview: _n, ...rest } = component.editedProposal;
290
+ return prev[selectedId] ?? JSON.stringify(rest, null, 2);
291
+ })(),
277
292
  }));
278
293
  },
279
294
  onToggleSource: () => {
@@ -315,6 +330,28 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
315
330
  },
316
331
  onToggleHelp: () => setShowHelp((prev) => !prev),
317
332
  });
333
+ // Sorted order: flagged+unresolved first, then ascending confidence.
334
+ // Memoised above early returns (React rules of hooks) so the array reference
335
+ // is stable between renders — prevents Sidebar repainting on scroll/select.
336
+ const sessionSummary = useMemo(() => (session?.components ?? [])
337
+ .map((c) => ({
338
+ id: c.id,
339
+ name: c.name,
340
+ status: c.status,
341
+ previewAnnotation: previewAnnotations[c.name],
342
+ extractionConfidence: c.originalProposal.extractionConfidence ?? 100,
343
+ needsReview: c.originalProposal.needsReview ?? false,
344
+ }))
345
+ .sort((a, b) => {
346
+ const aFlagged = a.needsReview && a.status === 'needs-review' ? 0 : 1;
347
+ const bFlagged = b.needsReview && b.status === 'needs-review' ? 0 : 1;
348
+ if (aFlagged !== bFlagged)
349
+ return aFlagged - bFlagged;
350
+ return a.extractionConfidence - b.extractionConfidence;
351
+ }), [session?.components, previewAnnotations]);
352
+ // Stable ID order — kept in a ref for use inside keymap handlers
353
+ const sortedIds = useMemo(() => sessionSummary.map((c) => c.id), [sessionSummary]);
354
+ sortedIdsRef.current = sortedIds;
318
355
  if (loading) {
319
356
  return _jsx(Text, { children: "Loading session..." });
320
357
  }
@@ -327,12 +364,6 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
327
364
  if (finalizedResult) {
328
365
  return _jsx(FinalizedScreen, { result: finalizedResult });
329
366
  }
330
- const sessionSummary = session.components.map((c) => ({
331
- id: c.id,
332
- name: c.name,
333
- status: c.status,
334
- previewAnnotation: previewAnnotations[c.name],
335
- }));
336
367
  const selectedRecord = session.components.find((c) => c.id === selectedId) ?? null;
337
368
  const sessionDetail = selectedRecord ? createReviewSessionDetail({ ...session, components: [selectedRecord] }) : null;
338
369
  const selectedDetail = sessionDetail?.components[0] ?? null;
@@ -352,7 +383,10 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
352
383
  { key: 'q', label: 'quit' },
353
384
  ];
354
385
  const collapsed = terminalWidth < 80;
355
- const visibleCount = 20;
386
+ // TopBar(1) + statusbar(1) + footer(1) + border padding(2) = ~5 chrome rows
387
+ const CHROME_ROWS = 5;
388
+ const terminalRows = stdout?.rows ?? 24;
389
+ const visibleCount = Math.max(1, terminalRows - CHROME_ROWS);
356
390
  const longestName = session.components.reduce((max, c) => Math.max(max, c.name.length), 0);
357
391
  // icon + space + name + 2 border chars; min 14, max 22
358
392
  const sidebarWidth = collapsed ? 3 : Math.min(Math.max(longestName + 4, 14), 22);
@@ -468,7 +502,7 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
468
502
  }, onCancel: () => setShowQuitDialog(false) })), !showHelp && !showFinalizeDialog && !showQuitDialog && (_jsxs(Box, { flexGrow: 1, children: [_jsx(Sidebar, { components: sessionSummary, selectedId: selectedId, focused: sidebarFocused, scrollOffset: sidebarScrollOffset, visibleCount: visibleCount, onSelect: (id) => {
469
503
  setSelectedId(id);
470
504
  setJsonScrollOffset(0);
471
- }, onScrollChange: setSidebarScrollOffset, collapsed: collapsed, width: sidebarWidth }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: selectedDetail ? (_jsx(ComponentDetail, { component: selectedDetail, sourceCode: selectedRecord?.sourceCode ?? null, draftValue: selectedId ? (draftsByComponentId[selectedId] ?? '') : '', editMode: editMode, sourceVisible: sourceVisible, jsonScrollOffset: jsonScrollOffset, sourceScrollX: 0, sourceScrollY: 0, terminalWidth: terminalWidth, previewAnnotation: selectedRecord ? previewAnnotations[selectedRecord.name] : undefined, onDraftChange: (value) => {
505
+ }, onScrollChange: setSidebarScrollOffset, collapsed: collapsed, width: sidebarWidth }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: selectedDetail ? (_jsx(ComponentDetail, { component: selectedDetail, sourceCode: selectedId ? (sourceCodeById[selectedId] ?? null) : null, draftValue: selectedId ? (draftsByComponentId[selectedId] ?? '') : '', editMode: editMode, sourceVisible: sourceVisible, jsonScrollOffset: jsonScrollOffset, sourceScrollX: 0, sourceScrollY: 0, terminalWidth: terminalWidth, previewAnnotation: selectedRecord ? previewAnnotations[selectedRecord.name] : undefined, onDraftChange: (value) => {
472
506
  if (!selectedId)
473
507
  return;
474
508
  setDraftsByComponentId((prev) => ({
@@ -34,10 +34,16 @@ export function ComponentDetail({ component, sourceCode, draftValue, editMode, s
34
34
  editWidth = availableWidth - originalWidth - 3;
35
35
  sourceWidth = 0;
36
36
  }
37
- const originalJson = JSON.stringify(component.originalProposal, null, 2);
38
- const editedJson = JSON.stringify(component.editedProposal, null, 2);
37
+ // Strip internal scoring fields — they're metadata, not part of the component definition
38
+ const stripScoring = ({ extractionConfidence: _c, reviewReasons: _r, needsReview: _n, ...rest }) => rest;
39
+ const originalJson = JSON.stringify(stripScoring(component.originalProposal), null, 2);
40
+ const editedJson = JSON.stringify(stripScoring(component.editedProposal), null, 2);
41
+ const conf = component.originalProposal.extractionConfidence ?? 100;
42
+ const nr = component.originalProposal.needsReview ?? false;
43
+ const confColor = nr ? 'red' : conf >= 80 ? 'white' : conf >= 50 ? 'yellow' : 'red';
44
+ const confLabel = (nr ? '⚑ ' : '') + 'confidence: ' + String(conf);
39
45
  return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: component.name }), (() => {
40
46
  const ann = annotationLabel(previewAnnotation);
41
47
  return ann ? _jsx(Text, { color: ann.color, children: ann.text }) : null;
42
- })(), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { dimColor: true, children: [sourceVisible ? '[s] hide src' : '[s] src', editMode ? '' : ' [e] edit'] })] }), _jsxs(Box, { children: [_jsx(JsonPanel, { label: "ORIGINAL (read-only)", value: originalJson, scrollOffset: jsonScrollOffset, width: originalWidth, height: panelHeight, active: false }), _jsx(Text, { children: " " }), editMode ? (_jsx(FieldEditor, { value: draftValue || editedJson, width: editWidth, height: panelHeight, onChange: onDraftChange, onSave: onSaveDraft, onDiscard: onDiscardDraft })) : (_jsx(JsonPanel, { label: "EDIT (draft)", value: draftValue || editedJson, scrollOffset: jsonScrollOffset, width: editWidth, height: panelHeight, active: true })), sourceVisible && sourceWidth > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(SourcePanel, { sourceCode: sourceCode, filePath: component.originalProposal.source, width: sourceWidth, height: panelHeight, scrollX: sourceScrollX, scrollY: sourceScrollY })] }))] }), !editMode && _jsx(Text, { dimColor: true, children: ' [a] accept [r] reject [e] edit [A] accept all [↑↓] scroll' })] }));
48
+ })(), _jsx(Text, { color: confColor, children: ' — ' + confLabel }), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { dimColor: true, children: [sourceVisible ? '[s] hide src' : '[s] src', editMode ? '' : ' [e] edit'] })] }), _jsxs(Box, { children: [_jsx(JsonPanel, { label: "ORIGINAL (read-only)", value: originalJson, scrollOffset: jsonScrollOffset, width: originalWidth, height: panelHeight, active: false }), _jsx(Text, { children: " " }), editMode ? (_jsx(FieldEditor, { value: draftValue || editedJson, width: editWidth, height: panelHeight, onChange: onDraftChange, onSave: onSaveDraft, onDiscard: onDiscardDraft })) : (_jsx(JsonPanel, { label: "EDIT (draft)", value: draftValue || editedJson, scrollOffset: jsonScrollOffset, width: editWidth, height: panelHeight, active: true })), sourceVisible && sourceWidth > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(SourcePanel, { sourceCode: sourceCode, filePath: component.originalProposal.source, width: sourceWidth, height: panelHeight, scrollX: sourceScrollX, scrollY: sourceScrollY })] }))] }), !editMode && _jsx(Text, { dimColor: true, children: ' [a] accept [r] reject [e] edit [A] accept all [↑↓] scroll' })] }));
43
49
  }
@@ -38,7 +38,7 @@ export function Sidebar({ components, selectedId, focused, scrollOffset, visible
38
38
  const isSelected = component.id === selectedId;
39
39
  const icon = statusIcon(component.status);
40
40
  const color = statusColor(component.status);
41
- const maxNameLen = Math.max(1, width - 5);
41
+ const maxNameLen = Math.max(1, width - 4);
42
42
  const name = truncateName(component.name, maxNameLen);
43
43
  if (collapsed) {
44
44
  return (_jsx(Box, { children: _jsx(Text, { color: color, inverse: isSelected && focused, underline: isSelected && !focused, children: icon }) }, component.id));
@@ -22,6 +22,8 @@ export type ReviewComponentSummary = {
22
22
  name: string;
23
23
  status: ReviewComponentStatus;
24
24
  previewAnnotation?: PreviewAnnotation;
25
+ extractionConfidence: number;
26
+ needsReview: boolean;
25
27
  };
26
28
  export type ReviewSessionSnapshot = {
27
29
  components: ReviewComponentRecord[];
@@ -4,6 +4,8 @@ export function createReviewSessionSummary(session) {
4
4
  id: component.id,
5
5
  name: component.name,
6
6
  status: component.status,
7
+ extractionConfidence: component.originalProposal.extractionConfidence ?? 100,
8
+ needsReview: component.originalProposal.needsReview ?? false,
7
9
  })),
8
10
  };
9
11
  }
@@ -9,6 +9,8 @@ export type AnalyzeViewResult = {
9
9
  propCount: number;
10
10
  slotCount: number;
11
11
  warnings: string[];
12
+ extractionConfidence: number;
13
+ needsReview: boolean;
12
14
  }>;
13
15
  totalWarnings: number;
14
16
  };
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
- import { Box, Text } from 'ink';
3
+ import { Box, Text, useStdout } from 'ink';
4
4
  import { TopBar } from '../select/tui/components/TopBar.js';
5
5
  import { useImmediateInput } from '../select/tui/hooks/useImmediateInput.js';
6
6
  function truncateName(name, maxLen = 30) {
@@ -10,6 +10,12 @@ function truncateName(name, maxLen = 30) {
10
10
  }
11
11
  export function AnalyzeView({ result, onExit }) {
12
12
  const [scrollOffset, setScrollOffset] = useState(0);
13
+ const { stdout } = useStdout();
14
+ // Header lines: TopBar(1) + summary(3) + blank(1) + dividers+header(3) + blank(1) + footer(1) + footer-bar(1) = ~12
15
+ const HEADER_ROWS = 12;
16
+ const terminalRows = stdout?.rows ?? 24;
17
+ const visibleCount = Math.max(1, terminalRows - HEADER_ROWS);
18
+ const maxOffset = Math.max(0, result.components.length - visibleCount);
13
19
  useImmediateInput((input, key) => {
14
20
  if (input === 'q' || key.return) {
15
21
  onExit();
@@ -19,19 +25,36 @@ export function AnalyzeView({ result, onExit }) {
19
25
  setScrollOffset((o) => Math.max(0, o - 1));
20
26
  }
21
27
  else if (key.downArrow || input === 'j') {
22
- setScrollOffset((o) => o + 1);
28
+ setScrollOffset((o) => Math.min(maxOffset, o + 1));
23
29
  }
24
30
  else if (input === 'g') {
25
31
  setScrollOffset(0);
26
32
  }
27
33
  else if (input === 'G') {
28
- setScrollOffset(Math.max(0, result.components.length + 10));
34
+ setScrollOffset(maxOffset);
29
35
  }
30
36
  });
37
+ const visible = result.components.slice(scrollOffset, scrollOffset + visibleCount);
38
+ const showScrollUp = scrollOffset > 0;
39
+ const showScrollDown = scrollOffset + visibleCount < result.components.length;
31
40
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBar, { subcommand: "analyze", hints: [
32
41
  { key: '?', label: 'help' },
33
42
  { key: 'q', label: 'quit' },
34
- ] }), _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Text, { children: 'Scanned ' + result.fileCount + ' source files in ' + result.sourceDirectory }), _jsx(Text, { children: 'Extracted ' + result.components.length + ' components' }), _jsx(Text, { dimColor: true, children: 'Session: ' + result.sessionId }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, children: "Components" }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components.slice(scrollOffset).map((component) => (_jsxs(Box, { children: [component.warnings.length > 0 && _jsx(Text, { color: "yellow", children: "\u26A0 " }), component.warnings.length === 0 && _jsx(Text, { children: " " }), _jsx(Text, { children: truncateName(component.name).padEnd(20) }), _jsx(Text, { dimColor: true, children: component.framework.padEnd(10) }), _jsx(Text, { children: (component.propCount + ' props').padEnd(10) }), _jsx(Text, { children: component.slotCount + ' ' + (component.slotCount === 1 ? 'slot' : 'slots') }), component.warnings.length > 0 && (_jsx(Text, { color: "yellow", children: ' ' + component.warnings.length + ' warning' + (component.warnings.length === 1 ? '' : 's') }))] }, component.name))), result.totalWarnings > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "yellow", children: 'Warnings (' + result.totalWarnings + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components
43
+ ] }), _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Text, { children: 'Scanned ' + result.fileCount + ' source files in ' + result.sourceDirectory }), _jsx(Text, { children: 'Extracted ' + result.components.length + ' components' }), _jsx(Text, { dimColor: true, children: 'Session: ' + result.sessionId }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, children: 'Components' +
44
+ (showScrollUp || showScrollDown
45
+ ? ' (' +
46
+ (scrollOffset + 1) +
47
+ '–' +
48
+ Math.min(scrollOffset + visibleCount, result.components.length) +
49
+ ' of ' +
50
+ result.components.length +
51
+ ')'
52
+ : '') }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), showScrollUp && _jsx(Text, { dimColor: true, children: " \u25B2 scroll up" }), visible.map((component) => {
53
+ const conf = component.extractionConfidence ?? 100;
54
+ const confColor = component.needsReview ? 'red' : conf >= 80 ? 'white' : conf >= 50 ? 'yellow' : 'red';
55
+ const confLabel = (component.needsReview ? '⚑ ' : '') + String(conf);
56
+ return (_jsxs(Box, { children: [component.warnings.length > 0 && _jsx(Text, { color: "yellow", children: "\u26A0 " }), component.warnings.length === 0 && _jsx(Text, { children: " " }), _jsx(Text, { children: truncateName(component.name).padEnd(20) }), _jsx(Text, { dimColor: true, children: component.framework.padEnd(10) }), _jsx(Text, { children: (component.propCount + ' props').padEnd(10) }), _jsx(Text, { children: (component.slotCount + ' ' + (component.slotCount === 1 ? 'slot' : 'slots')).padEnd(8) }), _jsx(Text, { color: confColor, children: confLabel }), component.warnings.length > 0 && (_jsx(Text, { color: "yellow", children: ' ⚠ ' + component.warnings.length + ' warning' + (component.warnings.length === 1 ? '' : 's') }))] }, component.name));
57
+ }), showScrollDown && _jsx(Text, { dimColor: true, children: " \u25BC scroll down" }), result.totalWarnings > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "yellow", children: 'Warnings (' + result.totalWarnings + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components
35
58
  .filter((c) => c.warnings.length > 0)
36
59
  .flatMap((c) => c.warnings.map((w) => ({ component: c.name, warning: w })))
37
60
  .map((w, i) => (_jsx(Text, { color: "yellow", children: ' ⚠ ' + w.component + ': ' + w.warning }, i)))] })), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: 'Run: analyze select --session ' + result.sessionId }), _jsx(Text, { children: " " })] }), _jsx(Box, { borderStyle: "single", paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter or q to exit" }) })] }));
@@ -37,11 +37,13 @@ export interface SelectComponentCall {
37
37
  tool: 'select_component';
38
38
  name: string;
39
39
  reason?: string;
40
+ confidence?: number;
40
41
  }
41
42
  export interface RejectComponentCall {
42
43
  tool: 'reject_component';
43
44
  name: string;
44
45
  reason?: string;
46
+ confidence?: number;
45
47
  }
46
48
  export type SelectToolCall = SelectComponentCall | RejectComponentCall;
47
49
  export interface ParsedSelectToolCalls {
@@ -30,6 +30,9 @@ export function parseSelectToolCallLines(stdout) {
30
30
  };
31
31
  if (typeof rec.reason === 'string')
32
32
  call.reason = rec.reason;
33
+ if (typeof rec.confidence === 'number' && rec.confidence >= 0 && rec.confidence <= 100) {
34
+ call.confidence = rec.confidence;
35
+ }
33
36
  calls.push(call);
34
37
  }
35
38
  return { calls, warnings };
@@ -185,6 +185,8 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, }) {
185
185
  id: c.key,
186
186
  name: c.key,
187
187
  status: c.status,
188
+ extractionConfidence: 100,
189
+ needsReview: false,
188
190
  }));
189
191
  const longestName = components.reduce((m, c) => Math.max(m, c.key.length), 0);
190
192
  const sidebarWidth = Math.min(Math.max(longestName + 4, 14), 22);
@@ -34,15 +34,18 @@ CREATE TABLE IF NOT EXISTS steps (
34
34
  );
35
35
 
36
36
  CREATE TABLE IF NOT EXISTS raw_components (
37
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
38
- component_id TEXT NOT NULL,
39
- name TEXT NOT NULL,
40
- source TEXT NOT NULL,
41
- framework TEXT NOT NULL,
42
- extracted_at TEXT NOT NULL,
43
- status TEXT NOT NULL DEFAULT 'extracted',
44
- cdf_schema TEXT,
45
- description TEXT,
37
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
38
+ component_id TEXT NOT NULL,
39
+ name TEXT NOT NULL,
40
+ source TEXT NOT NULL,
41
+ framework TEXT NOT NULL,
42
+ extracted_at TEXT NOT NULL,
43
+ status TEXT NOT NULL DEFAULT 'extracted',
44
+ cdf_schema TEXT,
45
+ description TEXT,
46
+ extraction_confidence INTEGER NOT NULL DEFAULT 100,
47
+ review_reasons TEXT NOT NULL DEFAULT '[]',
48
+ needs_review INTEGER NOT NULL DEFAULT 0,
46
49
  PRIMARY KEY (session_id, component_id)
47
50
  );
48
51
 
@@ -164,6 +167,18 @@ function applyDbMigrations(db) {
164
167
  if (!cols.some((c) => c.name === 'required')) {
165
168
  db.exec('ALTER TABLE raw_slots ADD COLUMN required INTEGER NOT NULL DEFAULT 1 CHECK (required IN (0, 1))');
166
169
  }
170
+ // Add extraction confidence scoring columns if they don't exist yet (added in v0.6.0).
171
+ const rawCompCols = db.prepare('PRAGMA table_info(raw_components)').all();
172
+ const rawCompColNames = new Set(rawCompCols.map((c) => c.name));
173
+ if (!rawCompColNames.has('extraction_confidence')) {
174
+ db.exec('ALTER TABLE raw_components ADD COLUMN extraction_confidence INTEGER NOT NULL DEFAULT 100');
175
+ }
176
+ if (!rawCompColNames.has('review_reasons')) {
177
+ db.exec("ALTER TABLE raw_components ADD COLUMN review_reasons TEXT NOT NULL DEFAULT '[]'");
178
+ }
179
+ if (!rawCompColNames.has('needs_review')) {
180
+ db.exec('ALTER TABLE raw_components ADD COLUMN needs_review INTEGER NOT NULL DEFAULT 0');
181
+ }
167
182
  // Add generation_cache table if it doesn't exist yet.
168
183
  const tables = db
169
184
  .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='generation_cache'`)
@@ -352,8 +367,8 @@ export function updateStep(db, stepId, status, outputs, error) {
352
367
  }
353
368
  export function storeRawComponents(db, sessionId, components, options) {
354
369
  const now = new Date().toISOString();
355
- const insertComp = db.prepare(`INSERT INTO raw_components (session_id, component_id, name, source, framework, extracted_at)
356
- VALUES (?, ?, ?, ?, ?, ?)`);
370
+ const insertComp = db.prepare(`INSERT INTO raw_components (session_id, component_id, name, source, framework, extracted_at, extraction_confidence, review_reasons, needs_review)
371
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
357
372
  const insertProp = db.prepare(`INSERT INTO raw_props
358
373
  (session_id, component_id, name, type, required, category, default_value, description, token_reference, position)
359
374
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
@@ -387,7 +402,7 @@ export function storeRawComponents(db, sessionId, components, options) {
387
402
  db.prepare('DELETE FROM raw_components WHERE session_id = ?').run(sessionId);
388
403
  for (const comp of components) {
389
404
  const componentId = deriveComponentId(comp.name, comp.source);
390
- insertComp.run(sessionId, componentId, comp.name, comp.source, comp.framework, now);
405
+ insertComp.run(sessionId, componentId, comp.name, comp.source, comp.framework, now, comp.extractionConfidence ?? 100, JSON.stringify(comp.reviewReasons ?? []), comp.needsReview ? 1 : 0);
391
406
  for (let i = 0; i < comp.props.length; i++) {
392
407
  const prop = comp.props[i];
393
408
  insertProp.run(sessionId, componentId, prop.name, prop.type, prop.required ? 1 : 0, prop.category ?? null, prop.defaultValue ?? null, prop.description ?? null, prop.tokenReference ?? null, i);
@@ -467,7 +482,7 @@ export function storeRawComponents(db, sessionId, components, options) {
467
482
  }
468
483
  export function loadRawComponents(db, sessionId, allowedNames) {
469
484
  const all = db
470
- .prepare('SELECT component_id, name, source, framework FROM raw_components WHERE session_id = ? ORDER BY rowid')
485
+ .prepare('SELECT component_id, name, source, framework, extraction_confidence, review_reasons, needs_review FROM raw_components WHERE session_id = ? ORDER BY rowid')
471
486
  .all(sessionId);
472
487
  const components = allowedNames ? all.filter((c) => allowedNames.has(c.name)) : all;
473
488
  if (components.length === 0)
@@ -498,6 +513,16 @@ export function loadRawComponents(db, sessionId, allowedNames) {
498
513
  name: c.name,
499
514
  source: c.source,
500
515
  framework: c.framework,
516
+ extractionConfidence: c.extraction_confidence ?? 100,
517
+ reviewReasons: (() => {
518
+ try {
519
+ return JSON.parse(c.review_reasons ?? '[]');
520
+ }
521
+ catch {
522
+ return [];
523
+ }
524
+ })(),
525
+ needsReview: Boolean(c.needs_review),
501
526
  props: (propsByComponent.get(c.component_id) ?? []).map((p) => {
502
527
  const av = allowedValuesByProp.get(`${c.component_id}::${p.name}`);
503
528
  const prop = {
@@ -34,6 +34,9 @@ export interface RawComponentDefinition {
34
34
  * non-authorable context-provider components.
35
35
  */
36
36
  usesCreateContext?: boolean;
37
+ extractionConfidence?: number;
38
+ reviewReasons?: string[];
39
+ needsReview?: boolean;
37
40
  }
38
41
  export interface ComponentExtractionResult {
39
42
  components: RawComponentDefinition[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.6.2-dev-build-ea30bad.0",
3
+ "version": "2.6.2-dev-build-864e323.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "react-dom": "^18.3.1",
37
37
  "ts-morph": "^27.0.2",
38
38
  "typescript": "^5.9.3",
39
- "@contentful/experience-design-system-types": "2.6.2-dev-build-ea30bad.0"
39
+ "@contentful/experience-design-system-types": "2.6.2-dev-build-864e323.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@tsconfig/node24": "^24.0.3",
@@ -70,15 +70,16 @@ Emit one JSON object on a single line. Lines not starting with `{` are ignored b
70
70
  **Two tool calls — emit exactly one:**
71
71
 
72
72
  ```
73
- {"tool":"select_component","name":"<ComponentName>","reason":"<brief reason>"}
73
+ {"tool":"select_component","name":"<ComponentName>","reason":"<brief reason>","confidence":<0-100>}
74
74
 
75
- {"tool":"reject_component","name":"<ComponentName>","reason":"<brief reason>"}
75
+ {"tool":"reject_component","name":"<ComponentName>","reason":"<brief reason>","confidence":<0-100>}
76
76
  ```
77
77
 
78
78
  **Rules:**
79
79
  - Emit exactly one JSON object, on one line. No multi-line JSON. No markdown fences.
80
80
  - The `name` must match the component name in the input.
81
81
  - `reason` is a brief phrase documenting your decision.
82
+ - `confidence` is your certainty (0–100) that the decision is correct. Use 90–100 for obvious cases (clear UI atom or obvious infrastructure), 60–80 for borderline cases (few props, ambiguous purpose), and below 60 when you're genuinely unsure.
82
83
  - Emit prose lines (not starting with `{`) to log your reasoning before the final tool call.
83
84
 
84
85
  ---