anymd 0.0.4 → 0.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anymd",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Convert any document (PDF, DOC, DOCX) to clean Markdown for RAG",
5
5
  "keywords": [
6
6
  "markdown",
package/src/bootstrap.ts CHANGED
@@ -9,8 +9,8 @@ interface BootstrapCallbacks {
9
9
  onStep: (message: string) => void
10
10
  }
11
11
 
12
- const REQUIRED_PACKAGES = ['marker', 'markitdown', 'mlx_vlm', 'pypdfium2']
13
- const PIP_PACKAGES = ['marker-pdf', 'markitdown', 'mlx-vlm', 'pypdfium2']
12
+ const REQUIRED_PACKAGES = ['marker', 'markitdown', 'mammoth', 'mlx_vlm', 'pypdfium2', 'torchvision']
13
+ const PIP_PACKAGES = ['marker-pdf', 'markitdown[docx]', 'mlx-vlm', 'pypdfium2', 'torchvision']
14
14
  const CHANDRA_MODEL_ID = 'mlx-community/chandra-8bit'
15
15
 
16
16
  const checkImportable = async (py: string, pkg: string): Promise<boolean> => {
package/src/tui-data.ts CHANGED
@@ -223,7 +223,7 @@ const runClassify = async (onProgress: (p: ClassifyProgress) => void): Promise<C
223
223
  total: results.length
224
224
  }
225
225
 
226
- mkdirSync(getPaths().dataDir, { recursive: true })
226
+ mkdirSync(getPaths().outputDir, { recursive: true })
227
227
  await writeFile(getPaths().classification, `${JSON.stringify(classification, null, 2)}\n`)
228
228
  return classification
229
229
  }
package/tui.tsx CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  } from '~/tui-data'
27
27
 
28
28
  const DIM = '#888888'
29
+ const SIDEBAR_WIDTH = 38
29
30
 
30
31
  const setTerminalTitle = (title: string): void => {
31
32
  process.stdout.write(`\u001B]0;${title}\u0007`)
@@ -195,10 +196,10 @@ const SpinnerDots = ({ fg }: { fg?: string }) => {
195
196
  return <text fg={fg ?? 'yellow'}>{SPINNER_FRAMES[frame]} </text>
196
197
  }
197
198
 
198
- const PROGRESS_FULL = '\u2588'
199
- const PROGRESS_EMPTY = '\u2591'
199
+ const PROGRESS_FULL = ''
200
+ const PROGRESS_EMPTY = ''
200
201
 
201
- const ProgressBarSimple = ({ value, width = 20 }: { value: number; width?: number }) => {
202
+ const ProgressBarSimple = ({ value, width = 12 }: { value: number; width?: number }) => {
202
203
  const filled = Math.round((value / 100) * width)
203
204
  const empty = width - filled
204
205
  return (
@@ -227,20 +228,19 @@ const ElapsedTimer = ({ startedAt }: { startedAt: number }) => {
227
228
  }
228
229
 
229
230
  const RecentFiles = ({ files }: { files: { duration: number; name: string; pages: number; per_page: number }[] }) => {
230
- const display = files.slice(-5)
231
+ const display = files.slice(-3)
231
232
  return (
232
233
  <box flexDirection='column'>
233
234
  <text fg={DIM}>
234
- <b>─── recent ───</b>
235
+ <b>── recent ──</b>
235
236
  </text>
236
237
  {display.map((f, i) => (
237
238
  // eslint-disable-next-line react/no-array-index-key
238
239
  <text key={i}>
239
- <span fg='green'>✓ </span>
240
- <span fg='cyan'>{f.name.slice(0, 30).padEnd(30)}</span>
241
- <span> {f.pages.toString().padStart(3)}p </span>
242
- <span fg='yellow'>{formatDuration(f.duration).padStart(7)}</span>
243
- <span fg={DIM}> ({formatDuration(f.per_page)}/p)</span>
240
+ <span fg='green'>✓</span>
241
+ <span fg='cyan'> {f.name.slice(0, 18)}</span>
242
+ <span> {f.pages.toString().padStart(2)}p</span>
243
+ <span fg='yellow'> {formatDuration(f.duration)}</span>
244
244
  </text>
245
245
  ))}
246
246
  </box>
@@ -265,41 +265,41 @@ const OcrLiveInfo = ({ progress }: { progress: OcrProgress }) => {
265
265
  }, [progress.current_file, progress.current_file_started])
266
266
 
267
267
  return (
268
- <box flexDirection='column' paddingLeft={6}>
268
+ <box flexDirection='column' paddingLeft={2}>
269
269
  {progress.current_file === '-' ? null : (
270
270
  <text>
271
- <span fg={DIM}>Current: </span>
272
- <b fg='cyan'>{progress.current_file}</b>
273
- {progress.current_pages_total ? <span fg={DIM}> ({progress.current_pages_total}p)</span> : null}
274
- <span fg={DIM}> page </span>
275
- <span>{progress.current_page}</span>
276
- <span fg={DIM}>{' '}elapsed: </span>
277
- <span fg='yellow'>{formatDuration(elapsed)}</span>
271
+ <span fg={DIM}>Now: </span>
272
+ <b fg='cyan'>{progress.current_file.slice(0, 20)}</b>
278
273
  </text>
279
274
  )}
280
- <box gap={2}>
281
- <text>
282
- <span fg={DIM}>ETA: </span>
283
- <span fg='yellow'>{progress.eta}</span>
284
- </text>
285
- <text>
286
- <span fg={DIM}>avg: </span>
287
- <span>{progress.avg_per_file}/file</span>
288
- </text>
275
+ {progress.current_file === '-' ? null : (
289
276
  <text>
290
- <span fg={DIM}>errors: </span>
291
- <span fg={progress.errors > 0 ? 'red' : 'green'}>{progress.errors}</span>
277
+ <span fg={DIM}>p</span>
278
+ <span>{progress.current_page}</span>
279
+ {progress.current_pages_total ? <span fg={DIM}>/{progress.current_pages_total}</span> : null}
280
+ <span fg={DIM}> </span>
281
+ <span fg='yellow'>{formatDuration(elapsed)}</span>
292
282
  </text>
293
- </box>
283
+ )}
284
+ <text>
285
+ <span fg={DIM}>ETA </span>
286
+ <span fg='yellow'>{progress.eta}</span>
287
+ <span fg={DIM}> avg </span>
288
+ <span>{progress.avg_per_file}</span>
289
+ </text>
290
+ <text>
291
+ <span fg={DIM}>err </span>
292
+ <span fg={progress.errors > 0 ? 'red' : 'green'}>{progress.errors}</span>
293
+ </text>
294
294
  {progress.recent_files?.length ? <RecentFiles files={progress.recent_files} /> : null}
295
295
  </box>
296
296
  )
297
297
  }
298
298
 
299
299
  const getStepIcon = (isDone: boolean, isFailed: boolean): string => {
300
- if (isFailed) return '\u2717'
301
- if (isDone) return '\u2713'
302
- return ' '
300
+ if (isFailed) return ''
301
+ if (isDone) return ''
302
+ return '·'
303
303
  }
304
304
 
305
305
  const getStepColor = (isRunning: boolean, isDone: boolean, isFailed: boolean): string => {
@@ -328,100 +328,7 @@ const StepIcon = ({
328
328
  )
329
329
  }
330
330
 
331
- const StepHeader = ({
332
- color,
333
- completedDuration,
334
- done,
335
- failedCount,
336
- isActive,
337
- isDone,
338
- isRunning,
339
- pct,
340
- step,
341
- stepStartedAt,
342
- total
343
- }: {
344
- color: string
345
- completedDuration?: number
346
- done: number
347
- failedCount?: number
348
- isActive: boolean
349
- isDone: boolean
350
- isRunning: boolean
351
- pct: number
352
- step: StepConfig
353
- stepStartedAt: number
354
- total: number
355
- }) => {
356
- const fgColor = isActive ? (color === 'gray' ? undefined : color) : DIM
357
- return (
358
- <box gap={1}>
359
- <box width={3}>
360
- <StepIcon color={color} isDone={isDone} isFailed={false} isRunning={isRunning} />
361
- </box>
362
- <text fg={color}>
363
- <b>[{step.stepNum}]</b>
364
- </text>
365
- <text fg={fgColor}>{isActive ? <b>{step.name}</b> : step.name}</text>
366
- {isRunning ? <ElapsedTimer startedAt={stepStartedAt} /> : null}
367
- {isDone && !isRunning && completedDuration !== undefined ? (
368
- <text fg={DIM}>{formatDuration(completedDuration)}</text>
369
- ) : null}
370
- <box width={20}>
371
- <ProgressBarSimple value={pct} />
372
- </box>
373
- <text>
374
- <span fg='green'>{done}</span>
375
- <span fg={DIM}>
376
- /{total > 0 ? total : '?'} {step.unit}
377
- </span>
378
- {failedCount && failedCount > 0 ? <span fg='red'> ({failedCount} failed)</span> : null}
379
- </text>
380
- </box>
381
- )
382
- }
383
-
384
- const StepDetails = ({
385
- isRunning,
386
- ocrProgress,
387
- requires,
388
- runningStatus,
389
- showOcr,
390
- texts
391
- }: {
392
- isRunning: boolean
393
- ocrProgress?: null | OcrProgress
394
- requires?: string
395
- runningStatus: string
396
- showOcr: boolean
397
- texts: string[]
398
- }) => (
399
- <>
400
- {texts.length > 0 ? (
401
- <box paddingLeft={6}>
402
- {texts.map((d, i) => (
403
- // eslint-disable-next-line react/no-array-index-key
404
- <text fg={DIM} key={i}>
405
- {d}
406
- </text>
407
- ))}
408
- </box>
409
- ) : null}
410
- {isRunning && runningStatus !== '' ? (
411
- <box paddingLeft={6}>
412
- <text fg='cyan'>{runningStatus}</text>
413
- </box>
414
- ) : null}
415
- {requires ? (
416
- <box paddingLeft={6}>
417
- <text fg='yellow'>{`\u26A0 Requires: ${requires}`}</text>
418
- </box>
419
- ) : null}
420
- {showOcr && ocrProgress ? <OcrLiveInfo progress={ocrProgress} /> : null}
421
- </>
422
- )
423
-
424
- const StepCard = ({
331
+ const SidebarStep = ({
425
332
  completedDuration,
426
333
  failedCount,
427
334
  isFailed,
@@ -441,6 +348,7 @@ const StepCard = ({
441
348
  step: StepConfig
442
349
  stepData: StepData | undefined
443
350
  stepStartedAt: number
351
+ // eslint-disable-next-line complexity, max-statements
444
352
  }) => {
445
353
  const done = stepData?.done ?? 0
446
354
  const total = stepData?.total ?? 0
@@ -452,67 +360,52 @@ const StepCard = ({
452
360
  const showOcr = isRunning && step.command === 'ocr'
453
361
 
454
362
  return (
455
- <box flexDirection='column' paddingLeft={1}>
456
- <StepHeader
457
- color={color}
458
- completedDuration={completedDuration}
459
- done={done}
460
- failedCount={failedCount}
461
- isActive={isActive}
462
- isDone={isDone}
463
- isRunning={isRunning}
464
- pct={pct}
465
- step={step}
466
- stepStartedAt={stepStartedAt}
467
- total={total}
468
- />
469
- <StepDetails
470
- isRunning={isRunning}
471
- ocrProgress={ocrProgress}
472
- requires={showRequires ? stepData?.requires : undefined}
473
- runningStatus={runningStatus}
474
- showOcr={showOcr}
475
- texts={stepData?.details ?? []}
476
- />
363
+ <box flexDirection='column'>
364
+ <box>
365
+ <box width={2}>
366
+ <StepIcon color={color} isDone={isDone} isFailed={false} isRunning={isRunning} />
367
+ </box>
368
+ <text fg={isActive ? color : DIM}>{step.name}</text>
369
+ {isRunning ? <ElapsedTimer startedAt={stepStartedAt} /> : null}
370
+ {isDone && !isRunning && completedDuration !== undefined ? (
371
+ <text fg={DIM}> {formatDuration(completedDuration)}</text>
372
+ ) : null}
373
+ </box>
374
+ <box paddingLeft={2}>
375
+ <ProgressBarSimple value={pct} />
376
+ <text>
377
+ <span fg='green'> {done}</span>
378
+ <span fg={DIM}>/{total > 0 ? total : '?'}</span>
379
+ {failedCount && failedCount > 0 ? <span fg='red'> {failedCount}✗</span> : null}
380
+ </text>
381
+ </box>
382
+ {stepData?.details?.length ? (
383
+ <box flexDirection='column' paddingLeft={2}>
384
+ {stepData.details.map((d, i) => (
385
+ // eslint-disable-next-line react/no-array-index-key
386
+ <text fg={DIM} key={i}>
387
+ {d}
388
+ </text>
389
+ ))}
390
+ </box>
391
+ ) : null}
392
+ {isRunning && runningStatus !== '' ? (
393
+ <box paddingLeft={2}>
394
+ <text fg='cyan'>{runningStatus}</text>
395
+ </box>
396
+ ) : null}
397
+ {showRequires ? (
398
+ <box paddingLeft={2}>
399
+ <text fg='yellow'>⚠ {stepData?.requires}</text>
400
+ </box>
401
+ ) : null}
402
+ {showOcr && ocrProgress ? <OcrLiveInfo progress={ocrProgress} /> : null}
477
403
  </box>
478
404
  )
479
405
  }
480
406
 
481
407
  const ERROR_PATTERN = /\b(?:ERROR|Error:|Failed:|failed|FAILED|\u2716|exception|traceback)/iu
482
408
 
483
- const OutputBox = ({ lines, status }: { lines: string[]; status: string }) => {
484
- if (lines.length === 0 && status === '') return null
485
- return (
486
- <box
487
- border
488
- borderColor='gray'
489
- borderStyle='rounded'
490
- flexDirection='column'
491
- marginTop={1}
492
- paddingLeft={1}
493
- paddingRight={1}>
494
- <text fg={DIM}>
495
- <b>Output</b>
496
- </text>
497
- <box flexDirection='column'>
498
- {lines.length > 0 ? (
499
- lines.map((line, i) => {
500
- const isError = ERROR_PATTERN.test(line)
501
- return (
502
- // eslint-disable-next-line react/no-array-index-key
503
- <text fg={isError ? 'red' : DIM} key={i}>
504
- {line}
505
- </text>
506
- )
507
- })
508
- ) : (
509
- <text fg='cyan'>{status}</text>
510
- )}
511
- </box>
512
- </box>
513
- )
514
- }
515
-
516
409
  const PreflightBanner = ({ errors, warnings }: { errors: string[]; warnings: string[] }) => {
517
410
  if (errors.length === 0 && warnings.length === 0) return null
518
411
  return (
@@ -555,98 +448,20 @@ const PreflightBanner = ({ errors, warnings }: { errors: string[]; warnings: str
555
448
  )
556
449
  }
557
450
 
558
- const LogOverlay = ({ lines }: { lines: string[] }) => {
559
- const { height } = useTerminalDimensions()
560
- const logHeight = Math.max(5, height - 18)
561
-
562
- return (
563
- <box
564
- border
565
- borderColor='magenta'
566
- borderStyle='rounded'
567
- flexDirection='column'
568
- marginTop={1}
569
- paddingLeft={1}
570
- paddingRight={1}>
571
- <box justifyContent='space-between'>
572
- <text fg='magenta'>
573
- <b>Log</b>
574
- </text>
575
- <text fg={DIM}>L/ESC close · ↑↓ scroll</text>
576
- </box>
577
- <scrollbox focused height={logHeight} marginTop={1} stickyScroll>
578
- {lines.length > 0 ? (
579
- lines.map((line, i) => (
580
- // eslint-disable-next-line react/no-array-index-key
581
- <text fg={DIM} key={i}>
582
- {line}
583
- </text>
584
- ))
585
- ) : (
586
- <text fg={DIM}>No log entries yet</text>
587
- )}
588
- </scrollbox>
589
- </box>
590
- )
591
- }
592
-
593
- const TitleBar = ({ allDone, failed }: { allDone: boolean; failed: boolean }) => (
594
- <box justifyContent='space-between' paddingLeft={1} paddingRight={1}>
595
- <text fg='cyan'>
596
- <b>Document Pipeline</b>
597
- </text>
598
- <box gap={2}>
599
- {failed ? (
600
- <>
601
- <text>
602
- <b fg='yellow'>[R]</b>
603
- <span fg={DIM}> Retry</span>
604
- </text>
605
- <text>
606
- <b fg='yellow'>[S]</b>
607
- <span fg={DIM}> Skip</span>
608
- </text>
609
- </>
610
- ) : null}
611
- {allDone ? (
612
- <text fg='green'>
613
- <b>✓ Pipeline complete</b>
614
- </text>
615
- ) : null}
616
- <text>
617
- <b fg='magenta'>[L]</b>
618
- <span fg={DIM}> Log</span>
619
- </text>
620
- <text>
621
- <b fg='red'>[Q]</b>
622
- <span fg={DIM}> Quit</span>
623
- </text>
624
- </box>
625
- </box>
626
- )
627
-
628
- const formatChars = (chars: number): string => {
629
- if (chars >= 1_000_000) return `${(chars / 1_000_000).toFixed(1)}M`
630
- if (chars >= 1000) return `${(chars / 1000).toFixed(1)}K`
631
- return String(chars)
632
- }
633
-
634
451
  // eslint-disable-next-line max-statements
635
- const PipelineSummary = ({
452
+ const TitleBarTop = ({
636
453
  allDone,
637
454
  backgroundOcr,
638
- datasetResult,
455
+ failed,
639
456
  pipelineStartedAt,
640
457
  runningCommand,
641
- stepDurations,
642
458
  stepsData
643
459
  }: {
644
460
  allDone: boolean
645
461
  backgroundOcr: boolean
646
- datasetResult: DatasetResult | null
462
+ failed: boolean
647
463
  pipelineStartedAt: number
648
464
  runningCommand: CommandKey | null
649
- stepDurations: Partial<Record<CommandKey, number>>
650
465
  stepsData: AllStepsData | null
651
466
  }) => {
652
467
  const [elapsed, setElapsed] = useState(0)
@@ -661,83 +476,148 @@ const PipelineSummary = ({
661
476
  return () => clearInterval(timer)
662
477
  }, [pipelineStartedAt])
663
478
 
664
- if (!stepsData) return null
665
- const elapsedStr = formatDuration(elapsed)
479
+ let statusText = ''
480
+ if (allDone) statusText = '✓ Complete'
481
+ else if (failed) statusText = '✗ Failed'
482
+ else if (runningCommand && stepsData) {
483
+ let currentIdx = 0
484
+ let currentName = ''
485
+ for (const s of STEPS)
486
+ if (s.command === runningCommand) {
487
+ currentIdx = s.stepNum
488
+ currentName = s.name
489
+ break
490
+ }
491
+ if (currentIdx > 0) {
492
+ const parallelLabel = backgroundOcr && runningCommand === 'pipeline' ? '2+3/5 Convert+OCR' : null
493
+ statusText = parallelLabel ?? `${currentIdx}/5 ${currentName}`
494
+ }
495
+ }
496
+
497
+ const statusColor = allDone ? 'green' : failed ? 'red' : 'cyan'
666
498
 
667
- if (allDone)
668
- return (
669
- <box flexDirection='column' paddingLeft={1} paddingRight={1}>
670
- <box justifyContent='space-between'>
671
- <text fg='green'>
672
- <b>✓ All 5 steps complete</b>
499
+ return (
500
+ <box justifyContent='space-between' paddingLeft={1} paddingRight={1}>
501
+ <text fg='#e0a040'>
502
+ <b>anymd</b>
503
+ </text>
504
+ <box gap={2}>
505
+ {statusText === '' ? null : (
506
+ <text fg={statusColor}>
507
+ <b>{statusText}</b>
673
508
  </text>
674
- <text fg={DIM}>total: {elapsedStr}</text>
675
- </box>
676
- {datasetResult ? (
677
- <box gap={3} paddingLeft={2}>
509
+ )}
510
+ {pipelineStartedAt > 0 ? <text fg={DIM}>{formatDuration(elapsed)}</text> : null}
511
+ </box>
512
+ </box>
513
+ )
514
+ }
515
+
516
+ const formatChars = (chars: number): string => {
517
+ if (chars >= 1_000_000) return `${(chars / 1_000_000).toFixed(1)}M`
518
+ if (chars >= 1000) return `${(chars / 1000).toFixed(1)}K`
519
+ return String(chars)
520
+ }
521
+
522
+ const SidebarSummary = ({
523
+ allDone,
524
+ datasetResult,
525
+ stepDurations
526
+ }: {
527
+ allDone: boolean
528
+ datasetResult: DatasetResult | null
529
+ stepDurations: Partial<Record<CommandKey, number>>
530
+ }) => {
531
+ if (!allDone) return null
532
+ return (
533
+ <box flexDirection='column' paddingLeft={1} paddingTop={1}>
534
+ <text fg='green'>
535
+ <b>✓ All 5 steps complete</b>
536
+ </text>
537
+ {datasetResult ? (
538
+ <box flexDirection='column' paddingLeft={1}>
539
+ <text>
540
+ <span fg={DIM}>entries </span>
541
+ <b fg='green'>{datasetResult.entries.toLocaleString()}</b>
542
+ </text>
543
+ <text>
544
+ <span fg={DIM}>chars </span>
545
+ <b>{formatChars(datasetResult.totalChars)}</b>
546
+ </text>
547
+ {datasetResult.skipped > 0 ? (
678
548
  <text>
679
- <span fg={DIM}>entries: </span>
680
- <b fg='green'>{datasetResult.entries.toLocaleString()}</b>
549
+ <span fg={DIM}>skipped </span>
550
+ <span fg='yellow'>{datasetResult.skipped}</span>
681
551
  </text>
552
+ ) : null}
553
+ {datasetResult.duplicates > 0 ? (
682
554
  <text>
683
- <span fg={DIM}>chars: </span>
684
- <b>{formatChars(datasetResult.totalChars)}</b>
555
+ <span fg={DIM}>deduped </span>
556
+ <span fg='yellow'>{datasetResult.duplicates}</span>
685
557
  </text>
686
- {datasetResult.skipped > 0 ? (
687
- <text>
688
- <span fg={DIM}>skipped: </span>
689
- <span fg='yellow'>{datasetResult.skipped}</span>
690
- </text>
691
- ) : null}
692
- {datasetResult.duplicates > 0 ? (
693
- <text>
694
- <span fg={DIM}>deduped: </span>
695
- <span fg='yellow'>{datasetResult.duplicates}</span>
696
- </text>
697
- ) : null}
698
- </box>
699
- ) : null}
700
- <box gap={2} paddingLeft={2}>
701
- {STEPS.map(step => {
702
- const dur = stepDurations[step.command]
703
- if (dur === undefined) return null
704
- return (
705
- <text key={step.command}>
706
- <span fg={DIM}>{step.name}: </span>
707
- <span fg='cyan'>{formatDuration(dur)}</span>
708
- </text>
709
- )
710
- })}
558
+ ) : null}
711
559
  </box>
560
+ ) : null}
561
+ <box flexDirection='column' paddingLeft={1}>
562
+ {STEPS.map(step => {
563
+ const dur = stepDurations[step.command]
564
+ if (dur === undefined) return null
565
+ return (
566
+ <text key={step.command}>
567
+ <span fg={DIM}>{step.name.slice(0, 14).padEnd(14)} </span>
568
+ <span fg='cyan'>{formatDuration(dur)}</span>
569
+ </text>
570
+ )
571
+ })}
712
572
  </box>
713
- )
714
-
715
- let currentIdx = 0
716
- let currentName = ''
717
- for (const s of STEPS)
718
- if (s.command === runningCommand) {
719
- currentIdx = s.stepNum
720
- currentName = s.name
721
- break
722
- }
723
-
724
- if (currentIdx === 0) return null
573
+ </box>
574
+ )
575
+ }
725
576
 
726
- const parallelLabel = backgroundOcr && runningCommand === 'pipeline' ? 'Steps 2+3/5 \u2014 Convert + OCR' : null
577
+ const HelpDialog = ({ height, width }: { height: number; width: number }) => {
578
+ const boxW = 40
579
+ const boxH = 12
580
+ const left = Math.max(0, Math.floor((width - boxW) / 2))
581
+ const top = Math.max(0, Math.floor((height - boxH) / 2))
727
582
 
728
583
  return (
729
- <box justifyContent='space-between' paddingLeft={1} paddingRight={1}>
584
+ <box
585
+ border
586
+ borderColor='#e0a040'
587
+ borderStyle='rounded'
588
+ flexDirection='column'
589
+ height={boxH}
590
+ marginLeft={left}
591
+ marginTop={top}
592
+ paddingLeft={2}
593
+ paddingRight={2}
594
+ width={boxW}>
595
+ <text fg='#e0a040'>
596
+ <b>Keybinds</b>
597
+ </text>
598
+ <text> </text>
730
599
  <text>
731
- {parallelLabel ? (
732
- <b fg='cyan'>{parallelLabel}</b>
733
- ) : (
734
- <>
735
- <b fg='cyan'>Step {currentIdx}/5</b>
736
- <span fg={DIM}> {currentName}</span>
737
- </>
738
- )}
600
+ <b fg='cyan'>Q</b>
601
+ <span fg={DIM}>{' '}Quit</span>
602
+ </text>
603
+ <text>
604
+ <b fg='cyan'>L</b>
605
+ <span fg={DIM}>{' '}Toggle log / output view</span>
606
+ </text>
607
+ <text>
608
+ <b fg='cyan'>R</b>
609
+ <span fg={DIM}>{' '}Retry failed step</span>
610
+ </text>
611
+ <text>
612
+ <b fg='cyan'>S</b>
613
+ <span fg={DIM}>{' '}Skip failed step</span>
614
+ </text>
615
+ <text>
616
+ <b fg='cyan'>?</b>
617
+ <span fg={DIM}>{' '}Toggle this help</span>
739
618
  </text>
740
- <text fg={DIM}>elapsed: {elapsedStr}</text>
619
+ <text> </text>
620
+ <text fg={DIM}>Press ? or Esc to close</text>
741
621
  </box>
742
622
  )
743
623
  }
@@ -757,13 +637,6 @@ const computeTerminalTitle = (s: AppState): string => {
757
637
  return `Doc Pipeline \u2014 ${label}`
758
638
  }
759
639
 
760
- const RunningFooter = () => (
761
- <box justifyContent='space-between' marginTop={1} paddingLeft={1} paddingRight={1}>
762
- <text fg={DIM}>Ctrl+C safe — progress saved, re-run to resume</text>
763
- <text fg={DIM}>↻ auto-refresh 2s</text>
764
- </box>
765
- )
766
-
767
640
  // eslint-disable-next-line max-statements
768
641
  const readStream = async (stream: ReadableStream<Uint8Array>, onLine: (line: string) => void): Promise<void> => {
769
642
  const reader = stream.getReader()
@@ -792,6 +665,8 @@ const App = () => {
792
665
  const ocrProcRef = useRef<null | ReturnType<typeof Bun.spawn>>(null)
793
666
  const busyRef = useRef(false)
794
667
  const errorLogClearedRef = useRef(false)
668
+ const [showHelp, setShowHelp] = useState(false)
669
+ const { height, width } = useTerminalDimensions()
795
670
 
796
671
  useEffect(() => {
797
672
  setTerminalTitle(computeTerminalTitle(state))
@@ -1093,9 +968,13 @@ const App = () => {
1093
968
 
1094
969
  // eslint-disable-next-line complexity, max-statements
1095
970
  useKeyboard(key => {
1096
- if (state.showLog) {
1097
- if (key.name === 'l' || key.name === 'escape') dispatch({ type: 'TOGGLE_LOG' })
971
+ if (showHelp) {
972
+ if (key.name === '?' || key.name === 'escape') setShowHelp(false)
973
+ return
974
+ }
1098
975
 
976
+ if (key.name === '?') {
977
+ setShowHelp(true)
1099
978
  return
1100
979
  }
1101
980
 
@@ -1129,55 +1008,76 @@ const App = () => {
1129
1008
  }
1130
1009
  })
1131
1010
 
1011
+ const logHeight = Math.max(5, height - 3)
1012
+ const displayLines = state.showLog ? state.logLines : state.runningLines
1013
+
1132
1014
  return (
1133
- <box flexDirection='column'>
1134
- <TitleBar allDone={state.allDone} failed={state.failed} />
1135
- <PipelineSummary
1015
+ <box flexDirection='column' height={height}>
1016
+ <TitleBarTop
1136
1017
  allDone={state.allDone}
1137
1018
  backgroundOcr={state.backgroundOcr}
1138
- datasetResult={state.datasetResult}
1019
+ failed={state.failed}
1139
1020
  pipelineStartedAt={state.pipelineStartedAt}
1140
1021
  runningCommand={state.runningCommand}
1141
- stepDurations={state.stepDurations}
1142
1022
  stepsData={state.stepsData}
1143
1023
  />
1144
-
1145
- {state.stepsData ? (
1146
- <box flexDirection='column'>
1147
- {STEPS.map(step => {
1148
- const isFg = state.runningCommand === step.command
1149
- const isBgOcr = step.command === 'ocr' && state.backgroundOcr
1150
- const isActive = isFg || isBgOcr
1151
- const sd = state.stepsData ? state.stepsData[step.command] : undefined
1152
- const failures = sd?.failed ?? state.stepFailures[step.command]
1153
- return (
1154
- <StepCard
1155
- completedDuration={state.stepDurations[step.command]}
1156
- failedCount={failures}
1157
- isFailed={state.failed ? isFg : false}
1158
- isRunning={!state.failed && isActive}
1159
- key={step.command}
1160
- ocrProgress={step.command === 'ocr' ? state.stepsData?.ocr.progress : undefined}
1161
- runningStatus={isFg ? state.runningStatus : ''}
1162
- step={step}
1163
- stepData={sd}
1164
- stepStartedAt={isActive ? state.stepStartedAt : 0}
1165
- />
1166
- )
1167
- })}
1024
+ <box flexGrow={1}>
1025
+ <box flexDirection='column' paddingLeft={1} width={SIDEBAR_WIDTH}>
1026
+ {state.stepsData ? (
1027
+ <box flexDirection='column'>
1028
+ {STEPS.map(step => {
1029
+ const isFg = state.runningCommand === step.command
1030
+ const isBgOcr = step.command === 'ocr' && state.backgroundOcr
1031
+ const isActive = isFg || isBgOcr
1032
+ const sd = state.stepsData ? state.stepsData[step.command] : undefined
1033
+ const failures = sd?.failed ?? state.stepFailures[step.command]
1034
+ return (
1035
+ <SidebarStep
1036
+ completedDuration={state.stepDurations[step.command]}
1037
+ failedCount={failures}
1038
+ isFailed={state.failed ? isFg : false}
1039
+ isRunning={!state.failed && isActive}
1040
+ key={step.command}
1041
+ ocrProgress={step.command === 'ocr' ? state.stepsData?.ocr.progress : undefined}
1042
+ runningStatus={isFg ? state.runningStatus : ''}
1043
+ step={step}
1044
+ stepData={sd}
1045
+ stepStartedAt={isActive ? state.stepStartedAt : 0}
1046
+ />
1047
+ )
1048
+ })}
1049
+ </box>
1050
+ ) : (
1051
+ <text fg={DIM}>Loading...</text>
1052
+ )}
1053
+ <SidebarSummary
1054
+ allDone={state.allDone}
1055
+ datasetResult={state.datasetResult}
1056
+ stepDurations={state.stepDurations}
1057
+ />
1168
1058
  </box>
1169
- ) : (
1170
- <text fg={DIM}>Loading...</text>
1171
- )}
1172
-
1173
- <PreflightBanner errors={state.preflightErrors} warnings={state.preflightWarnings} />
1174
-
1175
- {state.runningCommand || state.runningLines.length > 0 ? (
1176
- <OutputBox lines={state.runningLines} status={state.runningStatus} />
1177
- ) : null}
1178
- {state.runningCommand && !state.failed ? <RunningFooter /> : null}
1179
-
1180
- {state.showLog ? <LogOverlay lines={state.logLines} /> : null}
1059
+ <box flexDirection='column' flexGrow={1} paddingLeft={2}>
1060
+ {state.preflightErrors.length > 0 || state.preflightWarnings.length > 0 ? (
1061
+ <PreflightBanner errors={state.preflightErrors} warnings={state.preflightWarnings} />
1062
+ ) : null}
1063
+ <scrollbox focused height={logHeight} paddingLeft={1} stickyScroll>
1064
+ {displayLines.length > 0 ? (
1065
+ displayLines.map((line, i) => {
1066
+ const isError = ERROR_PATTERN.test(line)
1067
+ return (
1068
+ // eslint-disable-next-line react/no-array-index-key
1069
+ <text fg={isError ? 'red' : DIM} key={i}>
1070
+ {line}
1071
+ </text>
1072
+ )
1073
+ })
1074
+ ) : (
1075
+ <text fg={DIM}>Waiting for output...</text>
1076
+ )}
1077
+ </scrollbox>
1078
+ </box>
1079
+ </box>
1080
+ {showHelp ? <HelpDialog height={height} width={width} /> : null}
1181
1081
  </box>
1182
1082
  )
1183
1083
  }