diff --git a/src/screens/ResumeConversation.tsx b/src/screens/ResumeConversation.tsx index f5c8bb178a..4b92ab2fd2 100644 --- a/src/screens/ResumeConversation.tsx +++ b/src/screens/ResumeConversation.tsx @@ -10,6 +10,8 @@ import { Spinner } from '../components/Spinner.js'; import { restoreCostStateForSession } from '../cost-tracker.js'; import { setClipboard } from '../ink/termio/osc.js'; import { Box, Text } from '../ink.js'; +import { AlternateScreen } from '../ink/components/AlternateScreen.js'; +import useApp from '../ink/hooks/use-app.js'; import { useKeybinding } from '../keybindings/useKeybinding.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../services/analytics/index.js'; import type { MCPServerConnection, ScopedMcpServerConfig } from '../services/mcp/types.js'; @@ -27,6 +29,7 @@ import { loadConversationForResume } from '../utils/conversationRecovery.js'; import { checkCrossProjectResume } from '../utils/crossProjectResume.js'; import { errorMessage } from '../utils/errors.js'; import type { FileHistorySnapshot } from '../utils/fileHistory.js'; +import { isMouseTrackingEnabled, isTmuxControlMode } from '../utils/fullscreen.js'; import { logError } from '../utils/log.js'; import { createSystemMessage } from '../utils/messages.js'; import { computeStandaloneAgentContext, createForkSessionInfoMessage, restoreAgentFromSession, restoreWorktreeForResume } from '../utils/sessionRestore.js'; @@ -87,6 +90,7 @@ export function ResumeConversation({ } = useTerminalSize(); const agentDefinitions = useAppState(s => s.agentDefinitions); const setAppState = useSetAppState(); + const { exit } = useApp(); const [logs, setLogs] = React.useState([]); const [loading, setLoading] = React.useState(true); const [resuming, setResuming] = React.useState(false); @@ -157,9 +161,34 @@ export function ResumeConversation({ setShowAllProjects(newValue); loadLogs(newValue); }, [showAllProjects, loadLogs]); + /** + * Exit the picker with the given code after Ink has unmounted. + * Unmount restores terminal modes and exits the alternate screen back to + * the main buffer via synchronous writes; any remaining async output is + * flushed first via the drain callback because process.exit() drops + * unflushed writes. The failsafe covers a stdout that never drains. + */ + function exitPicker(exitCode: number): void { + try { + process.stdout.write('', () => { + // eslint-disable-next-line custom-rules/no-process-exit -- flush-then-exit, see above + process.exit(exitCode); + }); + } catch { + // eslint-disable-next-line custom-rules/no-process-exit -- flush-then-exit, see above + process.exit(exitCode); + } + // eslint-disable-next-line custom-rules/no-process-exit -- failsafe, see above + setTimeout(() => process.exit(exitCode), 500); + } function onCancel() { - // eslint-disable-next-line custom-rules/no-process-exit - process.exit(1); + // Unmount first so terminal modes are restored and the alternate screen + // is exited back to the main buffer; exitPicker flushes remaining output + // before exiting. Exiting here preempts renderAndRun's shutdown epilogue, + // so no resume hint or SessionEnd hooks run for a cancel — same observable + // behavior as before, minus the residue. Exit code 1 (cancel) is preserved. + exit(); + exitPicker(1); } async function onSelect(log_0: LogOption) { setResumeError(null); @@ -310,10 +339,19 @@ export function ResumeConversation({ if (filteredLogs.length === 0) { return ; } - return + const picker = {resumeErrorBanner} loadLogs(showAllProjects) : undefined} onLoadMore={loadMoreLogs} initialSearchQuery={initialSearchQuery} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} /> ; + // Run the picker on the alternate screen everywhere except tmux -CC, where + // alt-screen is known to corrupt terminal state. Cancelling then restores + // the main buffer instead of leaving a stale frame: Ink deliberately + // persists the last inline frame on exit, so inline rendering can never + // exit cleanly here. Mirrors the REPL's usage. + if (!isTmuxControlMode()) { + return {picker}; + } + return picker; } function NoConversationsMessage() { const $ = _c(2);