From 7417743e19d127efa29156f3b11120a6d495776a Mon Sep 17 00:00:00 2001 From: Reinis Date: Tue, 13 Oct 2026 00:32:30 +0300 Subject: [PATCH 1/2] fix(resume): flush picker frame erase before exit on Esc cancel Esc from the --resume picker left a stale frame: Ink unmount erases inline rows via an async stdout write, which process.exit drops. onCancel now unmounts via useApp exit, then exits 1 in a stdout-drain callback (500ms failsafe). Exit code and no-hint/no-hooks cancel behavior preserved. CrossProjectMessage untouched (its post-exit message must persist). Verified: tsc clean for file (12 pre-existing ink reconciler errors), eslint clean, build ok, live PTY Esc test erases frame with EXIT 1, laya gate: ship/low-risk. Plan: thoughts/shared/plans/2026-10-12-resume-picker-esc-exit-cleanup.md --- src/screens/ResumeConversation.tsx | 31 ++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/screens/ResumeConversation.tsx b/src/screens/ResumeConversation.tsx index f5c8bb178a..8407276179 100644 --- a/src/screens/ResumeConversation.tsx +++ b/src/screens/ResumeConversation.tsx @@ -10,6 +10,7 @@ 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 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'; @@ -87,6 +88,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 +159,34 @@ export function ResumeConversation({ setShowAllProjects(newValue); loadLogs(newValue); }, [showAllProjects, loadLogs]); + /** + * Exit the picker with the given code after Ink has unmounted. + * Ink's unmount erases the inline picker frame via an async stdout write; + * process.exit() drops unflushed writes, which left the stale frame behind + * (Esc looked dead although the process had ended). Flushing first lets the + * erase land. 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 the picker frame is erased and terminal modes are + // restored; exitPicker flushes the erase 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); From 1fa4bc873818023bd59ce7a2b050b564311a1334 Mon Sep 17 00:00:00 2001 From: Reinis Date: Tue, 13 Oct 2026 00:51:42 +0300 Subject: [PATCH 2/2] fix(resume): run resume picker on alternate screen Ink deliberately persists the last inline frame on exit (getRenderOpsForDone returns empty unless cursor hidden), so no flush trick can clean an inline picker cancel. Render the LogSelector branch inside AlternateScreen instead: cancelling exits back to the main buffer via the existing writeSync alt-exit path. Gated off under tmux -CC where alt-screen corrupts state; mouseTracking follows isMouseTrackingEnabled. Keeps exitPicker flush-then-exit(1) so code and no-hint/no-hooks cancel behavior are unchanged. Verified live: alt enter on mount, 1049l plus full mode cleanup and EXIT 1 on Esc. --- src/screens/ResumeConversation.tsx | 31 ++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/screens/ResumeConversation.tsx b/src/screens/ResumeConversation.tsx index 8407276179..4b92ab2fd2 100644 --- a/src/screens/ResumeConversation.tsx +++ b/src/screens/ResumeConversation.tsx @@ -10,6 +10,7 @@ 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'; @@ -28,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'; @@ -161,10 +163,10 @@ export function ResumeConversation({ }, [showAllProjects, loadLogs]); /** * Exit the picker with the given code after Ink has unmounted. - * Ink's unmount erases the inline picker frame via an async stdout write; - * process.exit() drops unflushed writes, which left the stale frame behind - * (Esc looked dead although the process had ended). Flushing first lets the - * erase land. The failsafe covers a stdout that never drains. + * 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 { @@ -180,11 +182,11 @@ export function ResumeConversation({ setTimeout(() => process.exit(exitCode), 500); } function onCancel() { - // Unmount first so the picker frame is erased and terminal modes are - // restored; exitPicker flushes the erase 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. + // 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); } @@ -337,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);