@contentful/experience-design-system-cli 2.6.2-dev-build-864e323.0 → 2.7.1-dev-build-02830eb.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 (45) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/analyze/command.js +1 -1
  3. package/dist/src/analyze/extract/scoring.d.ts +3 -2
  4. package/dist/src/analyze/extract/scoring.js +39 -22
  5. package/dist/src/analyze/select/tui/App.js +54 -474
  6. package/dist/src/analyze/select/tui/components/ComponentDetail.d.ts +3 -5
  7. package/dist/src/analyze/select/tui/components/ComponentDetail.js +9 -10
  8. package/dist/src/analyze/select/tui/components/FinalizeDialog.d.ts +1 -1
  9. package/dist/src/analyze/select/tui/components/FinalizeDialog.js +2 -10
  10. package/dist/src/analyze/select/tui/components/HelpOverlay.d.ts +1 -1
  11. package/dist/src/analyze/select/tui/components/HelpOverlay.js +2 -7
  12. package/dist/src/analyze/select/tui/components/JsonEditor.d.ts +7 -5
  13. package/dist/src/analyze/select/tui/components/JsonEditor.js +15 -146
  14. package/dist/src/analyze/select/tui/components/QuitDialog.d.ts +1 -1
  15. package/dist/src/analyze/select/tui/components/QuitDialog.js +2 -10
  16. package/dist/src/analyze/select/tui/components/Sidebar.d.ts +0 -2
  17. package/dist/src/analyze/select/tui/components/StatusBar.d.ts +0 -2
  18. package/dist/src/analyze/select/tui/hooks/useImmediateInput.d.ts +5 -0
  19. package/dist/src/analyze/select/tui/hooks/useImmediateInput.js +7 -4
  20. package/dist/src/analyze/select/tui/hooks/useKeymap.d.ts +0 -23
  21. package/dist/src/analyze/select/tui/hooks/useKeymap.js +1 -67
  22. package/dist/src/analyze/select/tui/hooks/useRawMode.d.ts +6 -0
  23. package/dist/src/analyze/select/tui/hooks/useRawMode.js +16 -0
  24. package/dist/src/analyze/select/tui/hooks/useSideEffects.d.ts +17 -0
  25. package/dist/src/analyze/select/tui/hooks/useSideEffects.js +226 -0
  26. package/dist/src/analyze/select/tui/hooks/useUndo.js +15 -21
  27. package/dist/src/analyze/select/tui/inputToAction.d.ts +13 -0
  28. package/dist/src/analyze/select/tui/inputToAction.js +68 -0
  29. package/dist/src/analyze/select/tui/state.d.ts +136 -0
  30. package/dist/src/analyze/select/tui/state.js +385 -0
  31. package/dist/src/analyze/select/tui/utils.d.ts +5 -0
  32. package/dist/src/analyze/select/tui/utils.js +11 -0
  33. package/dist/src/analyze/select/types.d.ts +1 -1
  34. package/dist/src/analyze/select/types.js +1 -1
  35. package/dist/src/analyze/tui/AnalyzeView.d.ts +1 -1
  36. package/dist/src/analyze/tui/AnalyzeView.js +5 -3
  37. package/dist/src/generate/agent-runner.js +7 -2
  38. package/dist/src/import/tui/WizardApp.js +2 -0
  39. package/dist/src/import/tui/steps/GenerateReviewStep.js +5 -12
  40. package/dist/src/print/validate/tui/ValidateView.js +2 -0
  41. package/dist/src/session/db.js +4 -4
  42. package/dist/src/types.d.ts +3 -1
  43. package/dist/src/types.js +4 -1
  44. package/package.json +2 -2
  45. package/skills/select-components.md +8 -3
@@ -1,8 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useEffect, useReducer, useRef } from 'react';
3
3
  import { Box, Text, useStdout } from 'ink';
4
- import { readFile } from 'node:fs/promises';
5
- import { createReviewSessionDetail } from '../types.js';
6
4
  import { TopBar } from './components/TopBar.js';
7
5
  import { Sidebar } from './components/Sidebar.js';
8
6
  import { ComponentDetail } from './components/ComponentDetail.js';
@@ -11,367 +9,69 @@ import { HelpOverlay } from './components/HelpOverlay.js';
11
9
  import { FinalizeDialog } from './components/FinalizeDialog.js';
12
10
  import { QuitDialog } from './components/QuitDialog.js';
13
11
  import { PreviewSummaryBar } from './components/PreviewSummaryBar.js';
14
- import { useKeymap } from './hooks/useKeymap.js';
15
12
  import { useImmediateInput } from './hooks/useImmediateInput.js';
13
+ import { useRawMode } from './hooks/useRawMode.js';
16
14
  import { useSession } from './hooks/useSession.js';
17
- import { openPipelineDb, storeRawComponents, loadCDFComponents } from '../../../session/db.js';
18
- import { ImportApiClient } from '../../../apply/api-client.js';
19
- import { readTokensFromPath } from '../../../apply/manifest.js';
20
- import { buildManifest } from '@contentful/experience-design-system-types';
15
+ import { useSideEffects } from './hooks/useSideEffects.js';
16
+ import { reducer, initialState } from './state.js';
17
+ import { inputToAction } from './inputToAction.js';
18
+ import { createReviewSessionDetail } from '../types.js';
21
19
  export function App({ sessionId, artifactsRoot, reviewRoot }) {
22
20
  const { stdout } = useStdout();
23
21
  const terminalWidth = stdout?.columns ?? 80;
22
+ const visibleCount = Math.max(1, (stdout?.rows ?? 24) - 5);
23
+ useRawMode();
24
24
  const { session: loadedSession, paths, loading, error: sessionError, saveState, appendEvent, } = useSession({
25
25
  sessionId,
26
26
  artifactsRoot,
27
27
  reviewRoot,
28
28
  });
29
- const [session, setSession] = useState(null);
30
- const [selectedId, setSelectedId] = useState(null);
31
- const [draftsByComponentId, setDraftsByComponentId] = useState({});
32
- const [sidebarFocused, setSidebarFocused] = useState(true);
33
- const [editMode, setEditMode] = useState(false);
34
- const [sourceVisible, setSourceVisible] = useState(false);
35
- const [showHelp, setShowHelp] = useState(false);
36
- const [showFinalizeDialog, setShowFinalizeDialog] = useState(false);
37
- const [showQuitDialog, setShowQuitDialog] = useState(false);
38
- const [isSaving] = useState(false);
39
- const [saveError, setSaveError] = useState(null);
40
- const [finalizedResult, setFinalizedResult] = useState(null);
41
- const [sidebarScrollOffset, setSidebarScrollOffset] = useState(0);
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({});
48
- const [previewAnnotations, setPreviewAnnotations] = useState(() => {
49
- const raw = process.env['EDS_PREVIEW_ANNOTATIONS'];
50
- if (!raw)
51
- return {};
52
- try {
53
- return JSON.parse(raw);
54
- }
55
- catch {
56
- return {};
57
- }
29
+ const [state, dispatch] = useReducer(reducer, initialState);
30
+ // Refs so the single stdin listener always reads the latest committed values
31
+ const stateRef = useRef(state);
32
+ const visibleCountRef = useRef(visibleCount);
33
+ const terminalWidthRef = useRef(terminalWidth);
34
+ stateRef.current = state;
35
+ visibleCountRef.current = visibleCount;
36
+ terminalWidthRef.current = terminalWidth;
37
+ // ── Single stdin listener ─────────────────────────────────────────────────
38
+ useImmediateInput((input, key) => {
39
+ const action = inputToAction(input, key, stateRef.current, visibleCountRef.current, terminalWidthRef.current);
40
+ if (action)
41
+ dispatch(action);
58
42
  });
59
- const [previewLoading, setPreviewLoading] = useState(false);
60
- const [previewError, setPreviewError] = useState(null);
61
- const [previewResponse, setPreviewResponse] = useState(null);
62
- const refreshPreview = useCallback(async () => {
63
- const cmaToken = process.env['EDS_CMA_TOKEN'];
64
- const spaceId = process.env['EDS_SPACE_ID'];
65
- const environmentId = process.env['EDS_ENVIRONMENT_ID'];
66
- const tokensPath = process.env['EDS_TOKENS_PATH'];
67
- if (!cmaToken || !spaceId || !environmentId)
68
- return;
69
- setPreviewLoading(true);
70
- try {
71
- const db = openPipelineDb();
72
- let components = [];
73
- try {
74
- components = loadCDFComponents(db, sessionId);
75
- }
76
- finally {
77
- db.close();
78
- }
79
- let tokens = [];
80
- if (tokensPath)
81
- tokens = await readTokensFromPath('tokens', tokensPath);
82
- const manifest = buildManifest(components, tokens);
83
- if (!manifest.componentsManifest)
84
- manifest.componentsManifest = {};
85
- const client = new ImportApiClient({ cmaToken, spaceId, environmentId });
86
- const preview = await client.previewImport(manifest);
87
- const annotations = {};
88
- for (const item of preview.components.new) {
89
- const name = item.name ?? '';
90
- if (name)
91
- annotations[name] = 'new';
92
- }
93
- for (const item of preview.components.removed) {
94
- annotations[item.name] = 'removed';
95
- }
96
- for (const item of preview.components.changed) {
97
- if (item.changeClassification?.classification === 'breaking') {
98
- annotations[item.current.name] = 'breaking';
99
- }
100
- else {
101
- annotations[item.current.name] = 'changed';
102
- }
103
- }
104
- setPreviewAnnotations(annotations);
105
- setPreviewResponse(preview);
106
- setPreviewError(null);
107
- }
108
- catch (err) {
109
- const msg = err instanceof Error ? err.message : String(err);
110
- setPreviewError(msg.includes('token') ? 'Preview request failed' : msg);
111
- }
112
- finally {
113
- setPreviewLoading(false);
114
- }
115
- }, [sessionId]);
116
- const syncSessionToDb = useCallback((currentSession) => {
117
- const db = openPipelineDb();
118
- try {
119
- db.prepare(`UPDATE raw_components SET status = 'extracted' WHERE session_id = ?`).run(sessionId);
120
- const acceptedNames = currentSession.components
121
- .filter((c) => c.status === 'accepted' || c.status === 'reviewed')
122
- .map((c) => c.name);
123
- if (acceptedNames.length > 0) {
124
- db.prepare(`UPDATE raw_components SET status = 'generated' WHERE session_id = ? AND name IN (${acceptedNames.map(() => '?').join(',')})`).run(sessionId, ...acceptedNames);
125
- }
126
- }
127
- finally {
128
- db.close();
129
- }
130
- }, [sessionId]);
131
- const previewDebounceRef = useRef(null);
132
- const debouncedRefreshPreview = useCallback((currentSession) => {
133
- if (previewDebounceRef.current)
134
- clearTimeout(previewDebounceRef.current);
135
- previewDebounceRef.current = setTimeout(() => {
136
- syncSessionToDb(currentSession);
137
- void refreshPreview();
138
- }, 500);
139
- }, [syncSessionToDb, refreshPreview]);
43
+ // ── Load session ──────────────────────────────────────────────────────────
140
44
  useEffect(() => {
141
- return () => {
142
- if (previewDebounceRef.current)
143
- clearTimeout(previewDebounceRef.current);
144
- };
145
- }, []);
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
148
- useEffect(() => {
149
- if (loadedSession && !session) {
150
- setSession(loadedSession);
151
- setSelectedId(null);
45
+ if (loadedSession && !state.session) {
46
+ dispatch({ type: 'SESSION_LOADED', session: loadedSession, paths });
152
47
  }
153
48
  }, [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]);
162
- useEffect(() => {
163
- if (session && !previewResponse) {
164
- const raw = process.env['EDS_PREVIEW_COUNTS'];
165
- if (raw) {
166
- try {
167
- const counts = JSON.parse(raw);
168
- setPreviewResponse({
169
- components: {
170
- new: Array(counts.compNew).fill({}),
171
- changed: Array(counts.compChanged).fill({}),
172
- removed: Array(counts.compRemoved).fill({}),
173
- unchanged: Array(counts.compUnchanged).fill({}),
174
- },
175
- tokens: {
176
- new: Array(counts.tokNew).fill({}),
177
- changed: Array(counts.tokChanged).fill({}),
178
- removed: Array(counts.tokRemoved).fill({}),
179
- unchanged: Array(counts.tokUnchanged).fill({}),
180
- },
181
- });
182
- }
183
- catch (err) {
184
- process.stderr.write(`[eds] failed to parse EDS_PREVIEW_COUNTS: ${err instanceof Error ? err.message : String(err)}\n`);
185
- }
186
- }
187
- }
188
- }, [session]);
189
- // Lazy source code loading — writes to sourceCodeById, NOT session.components,
190
- // so it doesn't invalidate useMemo(sessionSummary) and cause a sidebar flash
191
- useEffect(() => {
192
- if (!session || !selectedId)
193
- return;
194
- if (sourceCodeById[selectedId] !== undefined)
195
- return; // already loaded
196
- const selectedComponent = session.components.find((c) => c.id === selectedId);
197
- if (!selectedComponent)
198
- return;
199
- readFile(selectedComponent.resolvedSourcePath, 'utf8')
200
- .then((code) => {
201
- setSourceCodeById((prev) => ({ ...prev, [selectedId]: code }));
202
- })
203
- .catch(() => {
204
- setSourceCodeById((prev) => ({ ...prev, [selectedId]: '' }));
205
- });
206
- }, [selectedId]);
207
- // SIGINT handler
208
- useEffect(() => {
209
- const handler = () => {
210
- if (Object.keys(draftsByComponentId).length > 0) {
211
- setShowQuitDialog(true);
212
- }
213
- else {
214
- process.exit(1);
215
- }
216
- };
217
- process.on('SIGINT', handler);
218
- return () => {
219
- process.off('SIGINT', handler);
220
- };
221
- }, [draftsByComponentId]);
222
- const updateStatus = async (componentId, newStatus) => {
223
- if (!session || !paths)
224
- return;
225
- const updatedSession = {
226
- ...session,
227
- components: session.components.map((c) => (c.id === componentId ? { ...c, status: newStatus } : c)),
228
- };
229
- setSession(updatedSession);
230
- await saveState(updatedSession);
231
- const component = session.components.find((c) => c.id === componentId);
232
- await appendEvent({
233
- type: 'status_changed',
234
- payload: { componentId, from: component?.status, to: newStatus },
235
- });
236
- debouncedRefreshPreview(updatedSession);
237
- };
238
- const dialogOpen = showHelp || showFinalizeDialog || showQuitDialog;
239
- useKeymap({
240
- sidebarFocused,
241
- editMode,
242
- dialogOpen,
243
- disabled: isSaving,
244
- }, {
245
- onSidebarUp: () => {
246
- if (!session)
247
- return;
248
- const ids = sortedIdsRef.current;
249
- const idx = ids.indexOf(selectedId ?? '');
250
- if (idx > 0) {
251
- setSelectedId(ids[idx - 1]);
252
- setJsonScrollOffset(0);
253
- setSidebarScrollOffset((prev) => Math.min(prev, idx - 1));
254
- }
255
- },
256
- onSidebarDown: () => {
257
- if (!session)
258
- return;
259
- const ids = sortedIdsRef.current;
260
- const idx = ids.indexOf(selectedId ?? '');
261
- if (idx < ids.length - 1) {
262
- setSelectedId(ids[idx + 1]);
263
- setJsonScrollOffset(0);
264
- setSidebarScrollOffset((prev) => {
265
- const newIdx = idx + 1;
266
- return newIdx >= prev + visibleCount ? newIdx - visibleCount + 1 : prev;
267
- });
268
- }
269
- },
270
- onSidebarSelect: () => { },
271
- onAccept: () => {
272
- if (selectedId)
273
- void updateStatus(selectedId, 'accepted');
274
- },
275
- onReject: () => {
276
- if (selectedId)
277
- void updateStatus(selectedId, 'rejected');
278
- },
279
- onEnterEditMode: () => {
280
- if (!session || !selectedId)
281
- return;
282
- const component = session.components.find((c) => c.id === selectedId);
283
- if (!component)
284
- return;
285
- setEditMode(true);
286
- setDraftsByComponentId((prev) => ({
287
- ...prev,
288
- [selectedId]: (() => {
289
- const { extractionConfidence: _c, reviewReasons: _r, needsReview: _n, ...rest } = component.editedProposal;
290
- return prev[selectedId] ?? JSON.stringify(rest, null, 2);
291
- })(),
292
- }));
293
- },
294
- onToggleSource: () => {
295
- if (terminalWidth < 120) {
296
- setSaveError('Terminal too narrow for source panel (need 120+ cols)');
297
- setTimeout(() => setSaveError(null), 3000);
298
- return;
299
- }
300
- setSourceVisible((prev) => !prev);
301
- },
302
- onScrollUp: () => {
303
- setJsonScrollOffset((prev) => Math.max(0, prev - 1));
304
- },
305
- onScrollDown: () => {
306
- setJsonScrollOffset((prev) => prev + 1);
307
- },
308
- onToggleFocus: () => setSidebarFocused((prev) => !prev),
309
- onApproveAll: async () => {
310
- if (!session || !paths)
311
- return;
312
- const updatedComponents = session.components.map((c) => c.status === 'needs-review' ? { ...c, status: 'accepted' } : c);
313
- const affected = updatedComponents.filter((c) => c.status === 'accepted').length -
314
- session.components.filter((c) => c.status === 'accepted').length;
315
- const updatedSession = { ...session, components: updatedComponents };
316
- setSession(updatedSession);
317
- await saveState(updatedSession);
318
- await appendEvent({ type: 'approve_all', payload: { affected } });
319
- syncSessionToDb(updatedSession);
320
- void refreshPreview();
321
- },
322
- onFinalize: () => setShowFinalizeDialog(true),
323
- onQuit: () => {
324
- if (Object.keys(draftsByComponentId).length > 0) {
325
- setShowQuitDialog(true);
326
- }
327
- else {
328
- process.exit(1);
329
- }
330
- },
331
- onToggleHelp: () => setShowHelp((prev) => !prev),
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;
355
- if (loading) {
49
+ // ── All side effects in one hook ──────────────────────────────────────────
50
+ useSideEffects(state, dispatch, { sessionId, saveState, appendEvent });
51
+ // ── Render ────────────────────────────────────────────────────────────────
52
+ if (loading)
356
53
  return _jsx(Text, { children: "Loading session..." });
357
- }
358
- if (sessionError) {
54
+ if (sessionError)
359
55
  return (_jsxs(Text, { color: "red", children: [sessionError, '\nPress q to exit.'] }));
360
- }
361
- if (!session || !paths) {
56
+ if (!state.session)
362
57
  return _jsx(Text, { color: "red", children: "Session unavailable." });
363
- }
364
- if (finalizedResult) {
365
- return _jsx(FinalizedScreen, { result: finalizedResult });
366
- }
58
+ const { mode, session, selectedId, sidebarScrollOffset, jsonScrollOffset, editor } = state;
59
+ if (mode.type === 'finalized')
60
+ return _jsx(FinalizedScreen, { result: mode });
367
61
  const selectedRecord = session.components.find((c) => c.id === selectedId) ?? null;
368
62
  const sessionDetail = selectedRecord ? createReviewSessionDetail({ ...session, components: [selectedRecord] }) : null;
369
63
  const selectedDetail = sessionDetail?.components[0] ?? null;
370
- const acceptedCount = session.components.filter((c) => c.status === 'accepted').length;
371
- const rejectedCount = session.components.filter((c) => c.status === 'rejected').length;
372
- const reviewedCount = session.components.filter((c) => c.status === 'reviewed').length;
373
- const needsReviewCount = session.components.filter((c) => c.status === 'needs-review').length;
374
- const hints = editMode
64
+ const counts = {
65
+ accepted: session.components.filter((c) => c.status === 'accepted').length,
66
+ rejected: session.components.filter((c) => c.status === 'rejected').length,
67
+ reviewed: session.components.filter((c) => c.status === 'reviewed').length,
68
+ needsReview: session.components.filter((c) => c.status === 'needs-review').length,
69
+ };
70
+ const dialogOpen = mode.type === 'dialog';
71
+ const isEditing = mode.type === 'editing';
72
+ const sourceVisible = mode.type === 'browsing' && mode.sourceVisible;
73
+ const sidebarFocused = mode.type === 'browsing' && mode.sidebarFocused;
74
+ const hints = isEditing
375
75
  ? [
376
76
  { key: 'Ctrl+S', label: 'save' },
377
77
  { key: 'Esc', label: 'discard' },
@@ -383,141 +83,21 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
383
83
  { key: 'q', label: 'quit' },
384
84
  ];
385
85
  const collapsed = terminalWidth < 80;
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);
390
- const longestName = session.components.reduce((max, c) => Math.max(max, c.name.length), 0);
391
- // icon + space + name + 2 border chars; min 14, max 22
86
+ const longestName = session.components.reduce((m, c) => Math.max(m, c.name.length), 0);
392
87
  const sidebarWidth = collapsed ? 3 : Math.min(Math.max(longestName + 4, 14), 22);
393
- const handleDraftSave = async () => {
394
- if (!selectedId || !session || !paths)
395
- return;
396
- const draft = draftsByComponentId[selectedId];
397
- if (!draft)
398
- return;
399
- try {
400
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
401
- const parsed = JSON.parse(draft);
402
- const currentStatus = session.components.find((c) => c.id === selectedId)?.status;
403
- const newStatus = currentStatus === 'needs-review' ? 'reviewed' : currentStatus;
404
- const updatedSession = {
405
- ...session,
406
- components: session.components.map((c) => c.id === selectedId
407
- ? {
408
- ...c,
409
- editedProposal: parsed,
410
- status: newStatus,
411
- }
412
- : c),
413
- };
414
- const { [selectedId]: _removed, ...remainingDrafts } = draftsByComponentId;
415
- setSession(updatedSession);
416
- setDraftsByComponentId(remainingDrafts);
417
- setEditMode(false);
418
- await saveState(updatedSession);
419
- await appendEvent({
420
- type: 'draft_saved',
421
- payload: { componentId: selectedId },
422
- });
423
- // Sync all edited proposals to pipeline DB (keep 'generated' for preview)
424
- const allEdited = updatedSession.components.map((c) => c.editedProposal);
425
- const syncDb = openPipelineDb();
426
- try {
427
- storeRawComponents(syncDb, sessionId, allEdited, {
428
- status: 'generated',
429
- preserveCDF: true,
430
- });
431
- }
432
- finally {
433
- syncDb.close();
434
- }
435
- void refreshPreview();
436
- }
437
- catch {
438
- // JSON parse error — JsonEditor already shows the error inline
439
- }
440
- };
441
- const handleDraftDiscard = () => {
442
- if (!selectedId)
443
- return;
444
- const { [selectedId]: _removed, ...remainingDrafts } = draftsByComponentId;
445
- setDraftsByComponentId(remainingDrafts);
446
- setEditMode(false);
447
- };
448
- const handleFinalize = async () => {
449
- if (!session || !paths)
450
- return;
451
- try {
452
- await appendEvent({
453
- type: 'finalized',
454
- payload: {
455
- accepted: acceptedCount,
456
- rejected: rejectedCount,
457
- excluded: needsReviewCount,
458
- },
459
- });
460
- // Write all components back to DB, marking accepted as 'generated'
461
- // so loadCDFComponents (push/preview) only picks up accepted ones,
462
- // but loadRawComponents (editor re-entry) still finds all of them
463
- const acceptedNames = new Set(session.components.filter((c) => c.status === 'accepted').map((c) => c.name));
464
- const db = openPipelineDb();
465
- try {
466
- storeRawComponents(db, sessionId, session.components.map((c) => c.editedProposal), { status: 'extracted', preserveCDF: true });
467
- if (acceptedNames.size > 0) {
468
- db.prepare(`UPDATE raw_components SET status = 'generated' WHERE session_id = ? AND name IN (${[...acceptedNames].map(() => '?').join(',')})`).run(sessionId, ...acceptedNames);
469
- }
470
- }
471
- finally {
472
- db.close();
473
- }
474
- const output = JSON.stringify({
475
- status: 'finalized',
476
- sessionDir: paths.sessionDir,
477
- accepted: acceptedCount,
478
- rejected: rejectedCount,
479
- excluded: needsReviewCount,
480
- }, null, 2) + '\n';
481
- process.stdout.write(output);
482
- setFinalizedResult({
483
- accepted: acceptedCount,
484
- rejected: rejectedCount,
485
- excluded: needsReviewCount,
486
- });
487
- }
488
- catch (err) {
489
- setSaveError(String(err));
490
- }
491
- };
492
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBar, { subcommand: "analyze select", hints: hints }), showHelp && _jsx(HelpOverlay, { mode: "review", onClose: () => setShowHelp(false) }), showFinalizeDialog && (_jsx(FinalizeDialog, { accepted: acceptedCount, rejected: rejectedCount, needsReview: needsReviewCount, onConfirm: () => {
493
- void handleFinalize();
494
- }, onCancel: () => setShowFinalizeDialog(false) })), showQuitDialog && (_jsx(QuitDialog, { hasUnsavedDrafts: Object.keys(draftsByComponentId).length > 0, onConfirm: async () => {
495
- if (paths) {
496
- await appendEvent({
497
- type: 'session_quit',
498
- payload: { reason: 'user_quit' },
499
- });
500
- }
88
+ const { sessionSummary } = state; // stable reference — only changes when data changes, never on scroll/select
89
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBar, { subcommand: "analyze select", hints: hints }), mode.type === 'dialog' && mode.which === 'help' && (_jsx(HelpOverlay, { mode: "review", onClose: () => dispatch({ type: 'CLOSE_DIALOG' }) })), mode.type === 'dialog' && mode.which === 'finalize' && (_jsx(FinalizeDialog, { accepted: counts.accepted, rejected: counts.rejected, needsReview: counts.needsReview, onConfirm: () => dispatch({ type: 'FINALIZE_CONFIRM' }), onCancel: () => dispatch({ type: 'CLOSE_DIALOG' }) })), mode.type === 'dialog' && mode.which === 'quit' && (_jsx(QuitDialog, { hasUnsavedDrafts: Object.keys(state.draftsByComponentId).length > 0, onConfirm: async () => {
90
+ if (paths)
91
+ await appendEvent({ type: 'session_quit', payload: { reason: 'user_quit' } });
501
92
  process.exit(1);
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) => {
503
- setSelectedId(id);
504
- setJsonScrollOffset(0);
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) => {
506
- if (!selectedId)
507
- return;
508
- setDraftsByComponentId((prev) => ({
509
- ...prev,
510
- [selectedId]: value,
511
- }));
512
- }, onSaveDraft: () => {
513
- void handleDraftSave();
514
- }, onDiscardDraft: handleDraftDiscard, onScrollChange: setJsonScrollOffset })) : (_jsx(Text, { dimColor: true, children: "No component selected" })) })] })), _jsx(PreviewSummaryBar, { preview: previewResponse, loading: previewLoading }), previewError && _jsx(Text, { color: "yellow", children: '⚠ Preview: ' + previewError }), saveError && _jsx(Text, { color: "red", children: '⚠ ' + saveError }), !dialogOpen && (_jsx(StatusBar, { accepted: acceptedCount, rejected: rejectedCount, reviewed: reviewedCount, needsReview: needsReviewCount, onApproveAll: () => { }, onFinalize: () => setShowFinalizeDialog(true) }))] }));
93
+ }, onCancel: () => dispatch({ type: 'CLOSE_DIALOG' }) })), !dialogOpen && (_jsxs(Box, { flexGrow: 1, children: [_jsx(Sidebar, { components: sessionSummary, selectedId: selectedId, focused: sidebarFocused, scrollOffset: sidebarScrollOffset, visibleCount: visibleCount, collapsed: collapsed, width: sidebarWidth }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: selectedDetail ? (_jsx(ComponentDetail, { component: selectedDetail, sourceCode: selectedId ? (state.sourceCodeById[selectedId] ?? null) : null, draftValue: selectedId ? (state.draftsByComponentId[selectedId] ?? '') : '', editMode: isEditing, editorState: isEditing ? editor : null, sourceVisible: sourceVisible, jsonScrollOffset: jsonScrollOffset, sourceScrollX: 0, sourceScrollY: 0, terminalWidth: terminalWidth, previewAnnotation: selectedRecord
94
+ ? state.previewAnnotations[selectedRecord.name]
95
+ : undefined })) : (_jsx(Text, { dimColor: true, children: "No component selected" })) })] })), _jsx(PreviewSummaryBar, { preview: state.previewResponse, loading: state.previewLoading }), state.previewError && _jsx(Text, { color: "yellow", children: '⚠ Preview: ' + state.previewError }), state.saveError && _jsx(Text, { color: "red", children: '⚠ ' + state.saveError }), !dialogOpen && (_jsx(StatusBar, { accepted: counts.accepted, rejected: counts.rejected, reviewed: counts.reviewed, needsReview: counts.needsReview }))] }));
515
96
  }
516
97
  function FinalizedScreen({ result, }) {
517
98
  useImmediateInput((_input, key) => {
518
- if (key.return || _input === 'q' || key.escape) {
99
+ if (key.return || _input === 'q' || key.escape)
519
100
  process.exit(0);
520
- }
521
101
  });
522
- return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, gap: 1, children: [_jsx(Text, { bold: true, color: "green", children: "\u2713 Finalized" }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "green", children: "\u2713" }), _jsxs(Text, { children: [result.accepted, " accepted"] })] }), result.rejected > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "red", children: "\u2717" }), _jsxs(Text, { children: [result.rejected, " rejected"] })] })), result.excluded > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "\u00B7" }), _jsxs(Text, { dimColor: true, children: [result.excluded, " excluded (unresolved)"] })] }))] }), _jsx(Text, { dimColor: true, children: "Decisions saved. Ready for the next step." }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[Enter / q] Exit" }) })] }));
102
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, gap: 1, children: [_jsx(Text, { bold: true, color: "green", children: "\u2713 Finalized" }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "green", children: "\u2713" }), _jsxs(Text, { children: [result.accepted, " accepted"] })] }), result.rejected > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "red", children: "\u2717" }), _jsxs(Text, { children: [result.rejected, " rejected"] })] })), result.excluded > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "\u00B7" }), _jsxs(Text, { dimColor: true, children: [result.excluded, " excluded"] })] }))] }), _jsx(Text, { dimColor: true, children: "Decisions saved. Ready for the next step." }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[Enter / q] Exit" }) })] }));
523
103
  }
@@ -1,20 +1,18 @@
1
1
  import React from 'react';
2
2
  import type { PreviewAnnotation, ReviewComponentDetail } from '../../types.js';
3
+ import type { EditorState } from '../state.js';
3
4
  type ComponentDetailProps = {
4
5
  component: ReviewComponentDetail;
5
6
  sourceCode: string | null;
6
7
  draftValue: string;
7
8
  editMode: boolean;
9
+ editorState: EditorState | null;
8
10
  sourceVisible: boolean;
9
11
  jsonScrollOffset: number;
10
12
  sourceScrollX: number;
11
13
  sourceScrollY: number;
12
14
  terminalWidth: number;
13
15
  previewAnnotation?: PreviewAnnotation;
14
- onDraftChange: (value: string) => void;
15
- onSaveDraft: () => void;
16
- onDiscardDraft: () => void;
17
- onScrollChange: (offset: number) => void;
18
16
  };
19
- export declare function ComponentDetail({ component, sourceCode, draftValue, editMode, sourceVisible, jsonScrollOffset, sourceScrollX, sourceScrollY, terminalWidth, previewAnnotation, onDraftChange, onSaveDraft, onDiscardDraft, }: ComponentDetailProps): React.ReactElement;
17
+ export declare function ComponentDetail({ component, sourceCode, draftValue, editMode, editorState, sourceVisible, jsonScrollOffset, sourceScrollX, sourceScrollY, terminalWidth, previewAnnotation, }: ComponentDetailProps): React.ReactElement;
20
18
  export {};
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { stripScoringFields } from '../../../../types.js';
3
4
  import { JsonPanel } from './JsonPanel.js';
4
- import { FieldEditor } from './FieldEditor.js';
5
+ import { JsonEditor } from './JsonEditor.js';
5
6
  import { SourcePanel } from './SourcePanel.js';
6
7
  function annotationLabel(annotation) {
7
8
  switch (annotation) {
@@ -17,7 +18,7 @@ function annotationLabel(annotation) {
17
18
  return null;
18
19
  }
19
20
  }
20
- export function ComponentDetail({ component, sourceCode, draftValue, editMode, sourceVisible, jsonScrollOffset, sourceScrollX, sourceScrollY, terminalWidth, previewAnnotation, onDraftChange, onSaveDraft, onDiscardDraft, }) {
21
+ export function ComponentDetail({ component, sourceCode, draftValue, editMode, editorState, sourceVisible, jsonScrollOffset, sourceScrollX, sourceScrollY, terminalWidth, previewAnnotation, }) {
21
22
  const sidebarWidth = terminalWidth < 80 ? 5 : 20;
22
23
  const availableWidth = terminalWidth - sidebarWidth - 2;
23
24
  const panelHeight = 20;
@@ -34,16 +35,14 @@ export function ComponentDetail({ component, sourceCode, draftValue, editMode, s
34
35
  editWidth = availableWidth - originalWidth - 3;
35
36
  sourceWidth = 0;
36
37
  }
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;
38
+ const originalJson = JSON.stringify(stripScoringFields(component.originalProposal), null, 2);
39
+ const editedJson = JSON.stringify(stripScoringFields(component.editedProposal), null, 2);
40
+ const conf = component.originalProposal.extractionConfidence ?? null;
42
41
  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);
42
+ const confColor = conf === null ? 'gray' : nr ? 'red' : conf >= 4 ? 'white' : conf >= 3 ? 'yellow' : 'red';
43
+ const confLabel = conf === null ? 'confidence: —' : (nr ? '⚑ ' : '') + 'confidence: ' + String(conf) + '/5';
45
44
  return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: component.name }), (() => {
46
45
  const ann = annotationLabel(previewAnnotation);
47
46
  return ann ? _jsx(Text, { color: ann.color, children: ann.text }) : null;
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' })] }));
47
+ })(), _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 && editorState ? (_jsx(JsonEditor, { editorState: editorState, width: editWidth, height: panelHeight })) : (_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' })] }));
49
48
  }