/**
* RewindViewer — conversation timeline for rewind selection
*
* Displays checkpoints as a navigable list. Users select a point to
* rewind to with arrow keys and Enter. Esc cancels.
*
* UX modeled after Gemini CLI's rewind viewer:
* - Shows each turn with a summary of what happened
* - Lists file changes for each turn
* - Highlight selected checkpoint
* - Confirmation step with rewind options
*/
import { useState, useMemo } from "react";
import { Box, Text, useInput } from "ink";
import { colors, symbols } from "../shared/Theme.js";
import type { RewindCheckpoint } from "../services/rewind.js";
// ============================================================================
// TYPES
// ============================================================================
export enum RewindOutcome {
RewindAndRevert = "rewind_and_revert",
RewindOnly = "rewind_only",
RevertOnly = "revert_only",
Cancel = "cancel",
}
interface RewindViewerProps {
checkpoints: RewindCheckpoint[];
onRewind: (checkpointIndex: number, outcome: RewindOutcome) => void;
onCancel: () => void;
}
// ============================================================================
// HELPERS
// ============================================================================
function truncateSummary(text: string, maxLen: number): string {
if (!text) return "(no text)";
const cleaned = text.replace(/\n/g, " ").trim();
if (cleaned.length <= maxLen) return cleaned;
return cleaned.slice(0, maxLen - 1) + "\u2026";
}
function formatFileOp(op: string, isNew: boolean): string {
if (isNew) return "created";
switch (op) {
case "write": return "written";
case "edit": return "edited";
case "multi_edit": return "edited";
default: return "modified";
}
}
function formatTimeAgo(timestamp: number): string {
const diff = Date.now() - timestamp;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
function basename(filePath: string): string {
const parts = filePath.split("/");
return parts[parts.length - 1] || filePath;
}
// ============================================================================
// CONFIRMATION COMPONENT
// ============================================================================
function RewindConfirmation({
checkpoint,
fileChangeCount,
onConfirm,
onCancel,
}: {
checkpoint: RewindCheckpoint;
fileChangeCount: number;
onConfirm: (outcome: RewindOutcome) => void;
onCancel: () => void;
}) {
const hasFileChanges = fileChangeCount > 0;
const options = useMemo(() => {
const opts: Array<{ label: string; value: RewindOutcome }> = [];
if (hasFileChanges) {
opts.push({
label: "Rewind conversation and revert file changes",
value: RewindOutcome.RewindAndRevert,
});
}
opts.push({
label: "Rewind conversation only",
value: RewindOutcome.RewindOnly,
});
if (hasFileChanges) {
opts.push({
label: "Revert file changes only",
value: RewindOutcome.RevertOnly,
});
}
opts.push({
label: "Cancel (Esc)",
value: RewindOutcome.Cancel,
});
return opts;
}, [hasFileChanges]);
const [selectedIndex, setSelectedIndex] = useState(0);
useInput((input, key) => {
if (key.escape) {
onCancel();
return;
}
if (key.upArrow) {
setSelectedIndex(prev => Math.max(0, prev - 1));
return;
}
if (key.downArrow) {
setSelectedIndex(prev => Math.min(options.length - 1, prev + 1));
return;
}
if (key.return) {
onConfirm(options[selectedIndex].value);
return;
}
});
const termWidth = process.stdout.columns || 80;
const boxWidth = Math.min(termWidth - 2, 60);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={colors.border}
paddingX={1}
paddingY={1}
width={boxWidth}
>
<Box marginBottom={1}>
<Text bold color={colors.brand}>Confirm Rewind</Text>
</Box>
<Box marginBottom={1} flexDirection="column">
<Text dimColor>
Rewind to turn {checkpoint.turnIndex + 1}: {truncateSummary(checkpoint.summary, 40)}
</Text>
{hasFileChanges && (
<Text dimColor>
{fileChangeCount} file change{fileChangeCount !== 1 ? "s" : ""} will be affected
</Text>
)}
{!hasFileChanges && (
<Text dimColor>No file changes to revert.</Text>
)}
</Box>
<Box marginBottom={1}>
<Text>Select an action:</Text>
</Box>
<Box flexDirection="column">
{options.map((opt, i) => (
<Box key={opt.value}>
<Text color={i === selectedIndex ? colors.brand : colors.quaternary}>
{i === selectedIndex ? symbols.arrowRight : " "}{" "}
</Text>
<Text
color={i === selectedIndex ? colors.text : colors.secondary}
bold={i === selectedIndex}
>
{opt.label}
</Text>
</Box>
))}
</Box>
</Box>
);
}
// ============================================================================
// MAIN VIEWER COMPONENT
// ============================================================================
export function RewindViewer({ checkpoints, onRewind, onCancel }: RewindViewerProps) {
// Start selection at the last checkpoint (current position)
const [selectedIndex, setSelectedIndex] = useState(checkpoints.length - 1);
const [confirmIndex, setConfirmIndex] = useState<number | null>(null);
// Calculate visible window (scrolling for long lists)
const termHeight = process.stdout.rows || 24;
const maxVisible = Math.max(3, Math.min(checkpoints.length, termHeight - 8));
const scrollOffset = useMemo(() => {
if (checkpoints.length <= maxVisible) return 0;
// Keep selected item roughly centered
const half = Math.floor(maxVisible / 2);
const start = Math.max(0, Math.min(selectedIndex - half, checkpoints.length - maxVisible));
return start;
}, [selectedIndex, maxVisible, checkpoints.length]);
const visibleCheckpoints = checkpoints.slice(scrollOffset, scrollOffset + maxVisible);
useInput((input, key) => {
if (confirmIndex !== null) return; // Confirmation handles its own input
if (key.escape) {
onCancel();
return;
}
if (key.upArrow) {
setSelectedIndex(prev => Math.max(0, prev - 1));
return;
}
if (key.downArrow) {
setSelectedIndex(prev => Math.min(checkpoints.length - 1, prev + 1));
return;
}
if (key.return) {
// Selecting the last checkpoint (current) = cancel
if (selectedIndex === checkpoints.length - 1) {
onCancel();
return;
}
setConfirmIndex(selectedIndex);
return;
}
});
const termWidth = process.stdout.columns || 80;
const boxWidth = Math.min(termWidth - 2, 70);
// Show confirmation dialog
if (confirmIndex !== null) {
const checkpoint = checkpoints[confirmIndex];
const fileChangeCount = checkpoints
.slice(confirmIndex + 1)
.reduce((sum, cp) => sum + cp.fileChanges.length, 0);
return (
<RewindConfirmation
checkpoint={checkpoint}
fileChangeCount={fileChangeCount}
onConfirm={(outcome) => {
if (outcome === RewindOutcome.Cancel) {
setConfirmIndex(null);
} else {
onRewind(confirmIndex, outcome);
}
}}
onCancel={() => setConfirmIndex(null)}
/>
);
}
if (checkpoints.length === 0) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={colors.border}
paddingX={1}
paddingY={1}
width={boxWidth}
>
<Text bold color={colors.brand}>Rewind</Text>
<Text>{" "}</Text>
<Text dimColor>Nothing to rewind to.</Text>
<Text>{" "}</Text>
<Text dimColor>Press Esc to close.</Text>
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={colors.border}
paddingX={1}
paddingY={1}
width={boxWidth}
>
{/* Header */}
<Box marginBottom={1}>
<Text bold color={colors.brand}>{symbols.arrowRight} Rewind</Text>
</Box>
{/* Scroll indicator (top) */}
{scrollOffset > 0 && (
<Text dimColor> {symbols.dot}{symbols.dot}{symbols.dot} {scrollOffset} more above</Text>
)}
{/* Checkpoint list */}
<Box flexDirection="column">
{visibleCheckpoints.map((cp, visIdx) => {
const realIdx = scrollOffset + visIdx;
const isSelected = realIdx === selectedIndex;
const isCurrent = realIdx === checkpoints.length - 1;
return (
<Box key={realIdx} flexDirection="column" marginBottom={1}>
{/* Turn header */}
<Box>
<Text color={isSelected ? colors.brand : colors.quaternary}>
{isSelected ? symbols.arrowRight : " "}{" "}
</Text>
<Text color={isSelected ? colors.text : (isCurrent ? colors.secondary : colors.text)}>
{isCurrent ? symbols.dot : symbols.bullet}
</Text>
<Text
color={isSelected ? colors.brand : colors.secondary}
bold={isSelected}
>
{" "}Turn {cp.turnIndex + 1}
</Text>
<Text dimColor> {symbols.divider} </Text>
<Text
color={isSelected ? colors.text : colors.secondary}
bold={isSelected}
>
{truncateSummary(cp.summary, 45)}
</Text>
{isCurrent && (
<Text dimColor> (current)</Text>
)}
</Box>
{/* File changes */}
{cp.fileChanges.length > 0 && (
<Box flexDirection="column" marginLeft={4}>
{cp.fileChanges.map((fc, fi) => (
<Text key={fi} dimColor>
{basename(fc.filePath)} ({formatFileOp(fc.operation, fc.isNewFile)})
</Text>
))}
</Box>
)}
{/* Timestamp */}
{isSelected && (
<Box marginLeft={4}>
<Text dimColor>{formatTimeAgo(cp.timestamp)}</Text>
</Box>
)}
</Box>
);
})}
</Box>
{/* Scroll indicator (bottom) */}
{scrollOffset + maxVisible < checkpoints.length && (
<Text dimColor> {symbols.dot}{symbols.dot}{symbols.dot} {checkpoints.length - scrollOffset - maxVisible} more below</Text>
)}
{/* Footer */}
<Box marginTop={1}>
<Text dimColor>
{symbols.arrowRight} Use arrow keys to select, Enter to rewind, Esc to cancel
</Text>
</Box>
</Box>
);
}