/**
* IngestProgress - TUI component for batch PDF/Markdown ingestion
*
* Displays real-time progress for:
* - File discovery
* - Chunking
* - Embedding generation
* - Overall progress
*/
import * as React from "react";
import { render, Box, Text, useApp, useInput } from "ink";
import Spinner from "ink-spinner";
/** Status of a single file being processed */
export interface FileStatus {
path: string;
filename: string;
status: "pending" | "chunking" | "embedding" | "done" | "error";
chunks?: number;
error?: string;
}
/** Overall ingest progress state */
export interface IngestState {
phase: "discovering" | "processing" | "done" | "error";
totalFiles: number;
processedFiles: number;
currentFile?: FileStatus;
recentFiles: FileStatus[];
errors: FileStatus[];
startTime: number;
endTime?: number;
checkpointInProgress?: boolean;
checkpointMessage?: string;
lastCheckpointAt?: number;
}
/** Props for the IngestProgress component */
interface IngestProgressProps {
state: IngestState;
onCancel?: () => void;
}
/**
* Format duration in human-readable form
*/
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}
/**
* Calculate ETA based on current progress
*/
function calculateETA(state: IngestState): string {
if (state.processedFiles === 0) return "calculating...";
const elapsed = Date.now() - state.startTime;
const avgTimePerFile = elapsed / state.processedFiles;
const remaining = state.totalFiles - state.processedFiles;
const etaMs = avgTimePerFile * remaining;
return formatDuration(etaMs);
}
/**
* Progress bar component
*/
function ProgressBar({
percent,
width = 40,
}: {
percent: number;
width?: number;
}) {
const filled = Math.round((percent / 100) * width);
const empty = width - filled;
return (
{"█".repeat(filled)}
{"░".repeat(empty)}
{percent.toFixed(1)}%
);
}
/**
* Status icon based on file status
*/
function StatusIcon({ status }: { status: FileStatus["status"] }) {
switch (status) {
case "pending":
return ○;
case "chunking":
return (
);
case "embedding":
return (
);
case "done":
return ✓;
case "error":
return ✗;
}
}
/**
* Main IngestProgress TUI component
*/
export function IngestProgress({ state, onCancel }: IngestProgressProps) {
const { exit } = useApp();
// Handle keyboard input
useInput((input, key) => {
if (input === "q" || (key.ctrl && input === "c")) {
onCancel?.();
exit();
}
});
const percent =
state.totalFiles > 0 ? (state.processedFiles / state.totalFiles) * 100 : 0;
const elapsed = formatDuration(Date.now() - state.startTime);
const eta = state.phase === "done" ? "-" : calculateETA(state);
return (
{/* Header */}
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ PDF Brain - Batch Ingest ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
{/* Phase indicator */}
Phase:{" "}
{state.phase === "discovering" && (
Discovering files...
)}
{state.phase === "processing" && (
Processing files
)}
{state.phase === "done" && Complete!}
{state.phase === "error" && Error}
{/* Progress bar */}
Progress:
{/* Stats */}
Files: {state.processedFiles}
/
{state.totalFiles}
|
Elapsed: {elapsed}
|
ETA: {eta}
{/* Current file */}
{state.currentFile && (
Current: {" "}
{state.currentFile.filename}
{state.currentFile.status === "chunking" && (
(chunking...)
)}
{state.currentFile.status === "embedding" && (
(embedding...)
)}
{state.currentFile.chunks && (
({state.currentFile.chunks} chunks)
)}
)}
{/* Checkpoint indicator */}
{state.checkpointInProgress && state.checkpointMessage && (
{state.checkpointMessage}
)}
{state.lastCheckpointAt && !state.checkpointInProgress && (
Last checkpoint: {state.lastCheckpointAt} docs
)}
{/* Recent files */}
{state.recentFiles.length > 0 && (
Recent:
{state.recentFiles.slice(-5).map((file) => (
{file.filename}
{file.chunks && ({file.chunks} chunks)}
{file.error && - {file.error}}
))}
)}
{/* Errors summary */}
{state.errors.length > 0 && (
Errors ({state.errors.length}):
{state.errors.slice(-3).map((file) => (
✗ {file.filename}: {file.error}
))}
{state.errors.length > 3 && (
... and {state.errors.length - 3} more
)}
)}
{/* Footer */}
Press 'q' to cancel
);
}
/**
* Render the IngestProgress TUI
* Returns controls for updating state and cleanup
*/
export function renderIngestProgress(initialState: IngestState) {
let currentState = initialState;
let rerender: ((node: React.ReactNode) => void) | null = null;
let cancelled = false;
const handleCancel = () => {
cancelled = true;
};
const {
rerender: _rerender,
unmount,
clear,
} = render();
rerender = _rerender;
return {
/** Update the display state */
update(newState: Partial) {
currentState = { ...currentState, ...newState };
rerender?.(
);
},
/** Check if user cancelled */
isCancelled() {
return cancelled;
},
/** Clean up the TUI */
cleanup() {
clear();
unmount();
},
/** Get current state */
getState() {
return currentState;
},
};
}
/** Create initial ingest state */
export function createInitialState(): IngestState {
return {
phase: "discovering",
totalFiles: 0,
processedFiles: 0,
recentFiles: [],
errors: [],
startTime: Date.now(),
};
}