claude-code/commands/resume/resume.tsx

275 lines
36 KiB
TypeScript
Raw Permalink Normal View History

import { c as _c } from "react/compiler-runtime";
import chalk from 'chalk';
import type { UUID } from 'crypto';
import figures from 'figures';
import * as React from 'react';
import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js';
import type { CommandResultDisplay, ResumeEntrypoint } from '../../commands.js';
import { LogSelector } from '../../components/LogSelector.js';
import { MessageResponse } from '../../components/MessageResponse.js';
import { Spinner } from '../../components/Spinner.js';
import { useIsInsideModal } from '../../context/modalContext.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { setClipboard } from '../../ink/termio/osc.js';
import { Box, Text } from '../../ink.js';
import type { LocalJSXCommandCall } from '../../types/command.js';
import type { LogOption } from '../../types/logs.js';
import { agenticSessionSearch } from '../../utils/agenticSessionSearch.js';
import { checkCrossProjectResume } from '../../utils/crossProjectResume.js';
import { getWorktreePaths } from '../../utils/getWorktreePaths.js';
import { logError } from '../../utils/log.js';
import { getLastSessionLog, getSessionIdFromLog, isCustomTitleEnabled, isLiteLog, loadAllProjectsMessageLogs, loadFullLog, loadSameRepoMessageLogs, searchSessionsByCustomTitle } from '../../utils/sessionStorage.js';
import { validateUuid } from '../../utils/uuid.js';
type ResumeResult = {
resultType: 'sessionNotFound';
arg: string;
} | {
resultType: 'multipleMatches';
arg: string;
count: number;
};
function resumeHelpMessage(result: ResumeResult): string {
switch (result.resultType) {
case 'sessionNotFound':
return `Session ${chalk.bold(result.arg)} was not found.`;
case 'multipleMatches':
return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Please use /resume to pick a specific session.`;
}
}
function ResumeError(t0) {
const $ = _c(10);
const {
message,
args,
onDone
} = t0;
let t1;
let t2;
if ($[0] !== onDone) {
t1 = () => {
const timer = setTimeout(onDone, 0);
return () => clearTimeout(timer);
};
t2 = [onDone];
$[0] = onDone;
$[1] = t1;
$[2] = t2;
} else {
t1 = $[1];
t2 = $[2];
}
React.useEffect(t1, t2);
let t3;
if ($[3] !== args) {
t3 = <Text dimColor={true}>{figures.pointer} /resume {args}</Text>;
$[3] = args;
$[4] = t3;
} else {
t3 = $[4];
}
let t4;
if ($[5] !== message) {
t4 = <MessageResponse><Text>{message}</Text></MessageResponse>;
$[5] = message;
$[6] = t4;
} else {
t4 = $[6];
}
let t5;
if ($[7] !== t3 || $[8] !== t4) {
t5 = <Box flexDirection="column">{t3}{t4}</Box>;
$[7] = t3;
$[8] = t4;
$[9] = t5;
} else {
t5 = $[9];
}
return t5;
}
function ResumeCommand({
onDone,
onResume
}: {
onDone: (result?: string, options?: {
display?: CommandResultDisplay;
}) => void;
onResume: (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => Promise<void>;
}): React.ReactNode {
const [logs, setLogs] = React.useState<LogOption[]>([]);
const [worktreePaths, setWorktreePaths] = React.useState<string[]>([]);
const [loading, setLoading] = React.useState(true);
const [resuming, setResuming] = React.useState(false);
const [showAllProjects, setShowAllProjects] = React.useState(false);
const {
rows
} = useTerminalSize();
const insideModal = useIsInsideModal();
const loadLogs = React.useCallback(async (allProjects: boolean, paths: string[]) => {
setLoading(true);
try {
const allLogs = allProjects ? await loadAllProjectsMessageLogs() : await loadSameRepoMessageLogs(paths);
const resumable = filterResumableSessions(allLogs, getSessionId());
if (resumable.length === 0) {
onDone('No conversations found to resume');
return;
}
setLogs(resumable);
} catch (_err) {
onDone('Failed to load conversations');
} finally {
setLoading(false);
}
}, [onDone]);
React.useEffect(() => {
async function init() {
const paths_0 = await getWorktreePaths(getOriginalCwd());
setWorktreePaths(paths_0);
void loadLogs(false, paths_0);
}
void init();
}, [loadLogs]);
const handleToggleAllProjects = React.useCallback(() => {
const newValue = !showAllProjects;
setShowAllProjects(newValue);
void loadLogs(newValue, worktreePaths);
}, [showAllProjects, loadLogs, worktreePaths]);
async function handleSelect(log: LogOption) {
const sessionId = validateUuid(getSessionIdFromLog(log));
if (!sessionId) {
onDone('Failed to resume conversation');
return;
}
// Load full messages for lite logs
const fullLog = isLiteLog(log) ? await loadFullLog(log) : log;
// Check if this conversation is from a different directory
const crossProjectCheck = checkCrossProjectResume(fullLog, showAllProjects, worktreePaths);
if (crossProjectCheck.isCrossProject) {
if (crossProjectCheck.isSameRepoWorktree) {
// Same repo worktree - can resume directly
setResuming(true);
void onResume(sessionId, fullLog, 'slash_command_picker');
return;
}
// Different project - show command instead of resuming
const raw = await setClipboard(crossProjectCheck.command);
if (raw) process.stdout.write(raw);
// Format the output message
const message = ['', 'This conversation is from a different directory.', '', 'To resume, run:', ` ${crossProjectCheck.command}`, '', '(Command copied to clipboard)', ''].join('\n');
onDone(message, {
display: 'user'
});
return;
}
// Same directory - proceed with resume
setResuming(true);
void onResume(sessionId, fullLog, 'slash_command_picker');
}
function handleCancel() {
onDone('Resume cancelled', {
display: 'system'
});
}
if (loading) {
return <Box>
<Spinner />
<Text> Loading conversations</Text>
</Box>;
}
if (resuming) {
return <Box>
<Spinner />
<Text> Resuming conversation</Text>
</Box>;
}
return <LogSelector logs={logs} maxHeight={insideModal ? Math.floor(rows / 2) : rows - 2} onCancel={handleCancel} onSelect={handleSelect} onLogsChanged={() => loadLogs(showAllProjects, worktreePaths)} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} />;
}
export function filterResumableSessions(logs: LogOption[], currentSessionId: string): LogOption[] {
return logs.filter(l => !l.isSidechain && getSessionIdFromLog(l) !== currentSessionId);
}
export const call: LocalJSXCommandCall = async (onDone, context, args) => {
const onResume = async (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => {
try {
await context.resume?.(sessionId, log, entrypoint);
onDone(undefined, {
display: 'skip'
});
} catch (error) {
logError(error as Error);
onDone(`Failed to resume: ${(error as Error).message}`);
}
};
const arg = args?.trim();
// No argument provided - show picker
if (!arg) {
return <ResumeCommand key={Date.now()} onDone={onDone} onResume={onResume} />;
}
// Load logs to search (includes same-repo worktrees)
const worktreePaths = await getWorktreePaths(getOriginalCwd());
const logs = await loadSameRepoMessageLogs(worktreePaths);
if (logs.length === 0) {
const message = 'No conversations found to resume.';
return <ResumeError message={message} args={arg} onDone={() => onDone(message)} />;
}
// First, check if arg is a valid UUID
const maybeSessionId = validateUuid(arg);
if (maybeSessionId) {
const matchingLogs = logs.filter(l => getSessionIdFromLog(l) === maybeSessionId).sort((a, b) => b.modified.getTime() - a.modified.getTime());
if (matchingLogs.length > 0) {
const log = matchingLogs[0]!;
const fullLog = isLiteLog(log) ? await loadFullLog(log) : log;
void onResume(maybeSessionId, fullLog, 'slash_command_session_id');
return null;
}
// Enriched logs didn't find it — try direct file lookup. This handles
// sessions filtered out by enrichLogs (e.g., first message >16KB makes
// firstPrompt extraction fail, causing the session to be dropped).
const directLog = await getLastSessionLog(maybeSessionId);
if (directLog) {
void onResume(maybeSessionId, directLog, 'slash_command_session_id');
return null;
}
}
// Next, try exact custom title match (only if feature is enabled)
if (isCustomTitleEnabled()) {
const titleMatches = await searchSessionsByCustomTitle(arg, {
exact: true
});
if (titleMatches.length === 1) {
const log = titleMatches[0]!;
const sessionId = getSessionIdFromLog(log);
if (sessionId) {
const fullLog = isLiteLog(log) ? await loadFullLog(log) : log;
void onResume(sessionId, fullLog, 'slash_command_title');
return null;
}
}
// Multiple matches - show error
if (titleMatches.length > 1) {
const message = resumeHelpMessage({
resultType: 'multipleMatches',
arg,
count: titleMatches.length
});
return <ResumeError message={message} args={arg} onDone={() => onDone(message)} />;
}
}
// No match found - show error
const message = resumeHelpMessage({
resultType: 'sessionNotFound',
arg
});
return <ResumeError message={message} args={arg} onDone={() => onDone(message)} />;
};
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJjaGFsayIsIlVVSUQiLCJmaWd1cmVzIiwiUmVhY3QiLCJnZXRPcmlnaW5hbEN3ZCIsImdldFNlc3Npb25JZCIsIkNvbW1hbmRSZXN1bHREaXNwbGF5IiwiUmVzdW1lRW50cnlwb2ludCIsIkxvZ1NlbGVjdG9yIiwiTWVzc2FnZVJlc3BvbnNlIiwiU3Bpbm5lciIsInVzZUlzSW5zaWRlTW9kYWwiLCJ1c2VUZXJtaW5hbFNpemUiLCJzZXRDbGlwYm9hcmQiLCJCb3giLCJUZXh0IiwiTG9jYWxKU1hDb21tYW5kQ2FsbCIsIkxvZ09wdGlvbiIsImFnZW50aWNTZXNzaW9uU2VhcmNoIiwiY2hlY2tDcm9zc1Byb2plY3RSZXN1bWUiLCJnZXRXb3JrdHJlZVBhdGhzIiwibG9nRXJyb3IiLCJnZXRMYXN0U2Vzc2lvbkxvZyIsImdldFNlc3Npb25JZEZyb21Mb2ciLCJpc0N1c3RvbVRpdGxlRW5hYmxlZCIsImlzTGl0ZUxvZyIsImxvYWRBbGxQcm9qZWN0c01lc3NhZ2VMb2dzIiwibG9hZEZ1bGxMb2ciLCJsb2FkU2FtZVJlcG9NZXNzYWdlTG9ncyIsInNlYXJjaFNlc3Npb25zQnlDdXN0b21UaXRsZSIsInZhbGlkYXRlVXVpZCIsIlJlc3VtZVJlc3VsdCIsInJlc3VsdFR5cGUiLCJhcmciLCJjb3VudCIsInJlc3VtZUhlbHBNZXNzYWdlIiwicmVzdWx0IiwiYm9sZCIsIlJlc3VtZUVycm9yIiwidDAiLCIkIiwiX2MiLCJtZXNzYWdlIiwiYXJncyIsIm9uRG9uZSIsInQxIiwidDIiLCJ0aW1lciIsInNldFRpbWVvdXQiLCJjbGVhclRpbWVvdXQiLCJ1c2VFZmZlY3QiLCJ0MyIsInBvaW50ZXIiLCJ0NCIsInQ1IiwiUmVzdW1lQ29tbWFuZCIsIm9uUmVzdW1lIiwib3B0aW9ucyIsImRpc3BsYXkiLCJzZXNzaW9uSWQiLCJsb2ciLCJlbnRyeXBvaW50IiwiUHJvbWlzZSIsIlJlYWN0Tm9kZSIsImxvZ3MiLCJzZXRMb2dzIiwidXNlU3RhdGUiLCJ3b3JrdHJlZVBhdGhzIiwic2V0V29ya3RyZWVQYXRocyIsImxvYWRpbmciLCJzZXRMb2FkaW5nIiwicmVzdW1pbmciLCJzZXRSZXN1bWluZyIsInNob3dBbGxQcm9qZWN0cyIsInNldFNob3dBbGxQcm9qZWN0cyIsInJvd3MiLCJpbnNpZGVNb2RhbCIsImxvYWRMb2dzIiwidXNlQ2FsbGJhY2siLCJhbGxQcm9qZWN0cyIsInBhdGhzIiwiYWxsTG9ncyIsInJlc3VtYWJsZSIsImZpbHRlclJlc3VtYWJsZVNlc3Npb25zIiwibGVuZ3RoIiwiX2VyciIsImluaXQiLCJoYW5kbGVUb2dnbGVBbGxQcm9qZWN0cyIsIm5ld1ZhbHVlIiwiaGFuZGxlU2VsZWN0IiwiZnVsbExvZyIsImNyb3NzUHJvamVjdENoZWNrIiwiaXNDcm9zc1Byb2plY3QiLCJpc1NhbWVSZXBvV29ya3RyZWUiLCJyYXciLCJjb21tYW5kIiwicHJvY2VzcyIsInN0ZG91dCIsIndyaXRlIiwiam9pbiIsImhhbmRsZUNhbmNlbCIsIk1hdGgiLCJmbG9vciIsImN1cnJlbnRTZXNzaW9uSWQiLCJmaWx0ZXIiLCJsIiwiaXNTaWRlY2hhaW4iLCJjYWxsIiwiY29udGV4dCIsInJlc3VtZSIsInVuZGVmaW5lZCIsImVycm9yIiwiRXJyb3IiLCJ0cmltIiwiRGF0ZSIsIm5vdyIsIm1heWJlU2Vzc2lvbklkIiwibWF0Y2hpbmdMb2dzIiwic29ydCIsImEiLCJiIiwibW9kaWZpZWQiLCJnZXRUaW1lIiwiZGlyZWN0TG9nIiwidGl0bGVNYXRjaGVzIiwiZXhhY3QiXSwic291cmNlcyI6WyJyZXN1bWUudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBjaGFsayBmcm9tICdjaGFsaydcbmltcG9ydCB0eXBlIHsgVVVJRCB9IGZyb20gJ2NyeXB0bydcbmltcG9ydCBmaWd1cmVzIGZyb20gJ2ZpZ3VyZXMnXG5pbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IGdldE9yaWdpbmFsQ3dkLCBnZXRTZXNzaW9uSWQgfSBmcm9tICcuLi8uLi9ib290c3RyYXAvc3RhdGUuanMnXG5pbXBvcnQgdHlwZSB7IENvbW1hbmRSZXN1bHREaXNwbGF5LCBSZXN1bWVFbnRyeXBvaW50IH0gZnJvbSAnLi4vLi4vY29tbWFuZHMuanMnXG5pbXBvcnQgeyBMb2dTZWxlY3RvciB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvTG9nU2VsZWN0b3IuanMnXG5pbXBvcnQgeyBNZXNzYWdlUmVzcG9uc2UgfSBmcm9tICcuLi8uLi9jb21wb25lbnRzL01lc3NhZ2VSZXNwb25zZS5qcydcbmltcG9ydCB7IFNwaW5uZXIgfSBmcm9tICcuLi8uLi9jb21wb25lbnRzL1NwaW5uZXIuanMnXG5pbXBvcnQgeyB1c2VJc0luc2lkZU1vZGFsIH0gZnJvbSAnLi4vLi4vY29udGV4dC9tb2RhbENvbnRleHQuanMnXG5pbXBvcnQgeyB1c2VUZXJtaW5hbFNpemUgfSBmcm9tICcuLi8uLi9ob29rcy91c2VUZXJtaW5hbFNpemUuanMnXG5pbXBvcnQgeyBzZXRDbGlwYm9hcmQgfSBmcm9tICcuLi8uLi9pbmsvdGVybWlvL29zYy5qcydcbmltcG9ydCB7IEJveCwgVGV4dCB9IGZyb20gJy4uLy4uL2luay5qcydcbmltcG9ydCB0eXBlIHsgTG9jYWxKU1hDb21tYW5kQ2FsbCB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5pbXBvcnQgdHlwZSB7IExvZ09wdGlvbiB9IGZyb20gJy4uLy4uL3R5cGVzL2xvZ3MuanMnXG5pbXBvcnQgeyBhZ2VudGljU2Vzc2lvblNlYXJjaCB9IGZyb20gJy4uLy4uL3V0aWxzL2FnZW50aWNTZXNzaW9uU2VhcmNoLmpzJ1xuaW1wb3J0IHsgY2hlY2tDcm9zc1Byb2plY3RSZXN1bWUgfSBmcm9tICcuLi8uLi91dGlscy9jcm9zc1Byb2plY3RSZXN1bWUuanMnXG5pbXBvcnQgeyBnZXRXb3JrdHJlZVBhdGhzIH0gZnJvbSAnLi4vLi4vdXRpbHMvZ2V0V29ya3RyZWVQYXRocy5qcydcbmltcG9ydCB7IGxvZ0Vycm9yIH0gZnJvbSAnLi4vLi4vdXRpbHMvbG9nLmpzJ1xuaW1wb3J0IHtcbiAgZ2V0TGFzdFNlc3Npb25Mb2csXG4gIGdldFNlc3Npb25JZEZyb21Mb2csXG4gIGlzQ3VzdG9tVGl0bGVFbmFibGVkLFxuICBpc0xpdGVMb2csXG4gIGxvYWRBbGxQcm9qZWN0c01lc3NhZ2VMb2dzLFxuICBsb2FkRnVsbExvZyxcbiAgbG9hZFNhbWVSZXBvTWVzc2FnZUxvZ3MsXG4gIHNlYXJjaFNlc3Npb25zQnlDdXN0b21UaXRsZSxcbn0gZnJvbSAnLi4vLi4vdXRpbHMvc2Vzc2lvblN0b3JhZ2UuanMnXG5pbXBvcnQgeyB2YWxpZGF0ZVV1aWQgfSBmcm9tICcuLi8uLi91dGlscy91dWlkLmpzJ1xuXG50eXBlIFJlc3VtZVJlc3VsdCA9XG4gIHwgeyB