Skip to content

fix(resume): clean terminal exit from session picker - #24

Merged
MotionDevLab merged 2 commits into
mainfrom
fix/resume-picker-esc-exit
Oct 13, 2026
Merged

MotionDevLab merged 2 commits into
mainfrom
fix/resume-picker-esc-exit

Conversation

@MotionDevLab

Copy link
Copy Markdown
Owner

Summary

  • what changed: the --resume session picker list now renders inside Ink's <AlternateScreen> (same component the REPL uses), and onCancel unmounts via useApp().exit() before exiting 1 in a stdout-drain callback (500 ms failsafe). Two commits, single file (src/screens/ResumeConversation.tsx, +41/−3).
  • why it changed: pressing Esc in the picker exited the process but left the whole picker frame painted behind the shell prompt, looking like a frozen CLI. Root cause: Ink deliberately persists the last inline frame on exit (getRenderOpsForDone in src/ink/log-update.ts returns an empty diff when the cursor is visible), so no flush-based cleanup can ever erase it — only leaving the alternate buffer restores the main screen. An earlier flush-only attempt was verified to fail on a real terminal, which is what motivated this approach.

Impact

  • user-facing impact: Esc (and double-Ctrl+C) from the resume picker now returns to a clean shell prompt. Exit code 1 (cancel) is preserved, and no resume hint or SessionEnd hooks run on cancel — same observable behavior as before, minus the residue. Under tmux -CC (where alt-screen corrupts terminal state) the picker stays inline, exactly as before — no regression there.
  • developer/maintainer impact: none outside the picker cancel path. CrossProjectMessage / NoConversationsMessage exits intentionally untouched (their post-exit text must persist).

Testing

  • I ran the required local preflight (see results/notes below; full check could not complete on this machine).
  • exact commands and results:
    • bun install --frozen-lockfile — ok
    • bunx eslint src/screens/ResumeConversation.tsx — clean
    • bun run build — green (dist/cli.mjs, SDK bundle, externals valid)
    • bun run typecheck:type-tests — pass (10 files)
    • node bin/openclaude --version — 0.31.0 (OpenClaude); compile-cache-disabled launcher check — pass
    • bun run test:provider-recommendation — 165 pass / 0 fail
    • live test (built CLI, PTY): --resume + Esc emits ?1049h on mount and ?1049l + full terminal-mode cleanup + EXIT:1, fast, no hint noise
    • local LLM gate (laya, english ckpt): ship plurality, regression risk low (1.2), evidence sufficient (0.86) — advisory only
  • focused tests: user-confirmed in Windows Terminal (screenshot in conversation): Esc restores main buffer, clean prompt, $LASTEXITCODE 1
  • documented skipped checks, platform limitations, or verified pre-existing failures:
    • bun run check → test:full does not complete on this machine: process-spawning tests stall (5 s timeouts) — launcher compile-cache/heap suites and bughunter git-context tests. Reproduced the launcher files (21 pass / 9 fail) identically on a pristine origin/main worktree (C:\Users\reini\openclaude-base, detached 02ac9901, same node_modules tree) — not PR-owned (PR touches no launcher/git/bughunter surface).
    • bun run typecheck — 12 errors, reproduced byte-identically on pristine origin/main (react-reconciler type skew in untouched src/ink/{ink,reconciler,render-to-screen}.tsx; zero errors in the changed file).
    • bun run test:provider — 1748 pass / 3 fail; all 3 are network/timing environmental (stream-watchdog temp-file race, API timeout clamp, Copilot error-type mismatch), none on the PR surface.
    • bun run security:pr-scan -- --base FETCH_HEAD --head HEAD — 1 medium finding in scripts/prepare.mjs; git log origin/main..HEAD -- scripts/prepare.mjs is empty (PR never touches it — upstream rebrand churn between our base and upstream tip). Not PR-owned.

Notes

  • provider/model path tested: local OpenCode Zen Router lane; UI change verified visually (Windows Terminal) plus PTY byte trace (1049h/1049l).
  • screenshots attached (if UI changed): before/after screenshots are in the linked conversation; after-shot shows clean prompt + exit code 1.
  • follow-up work or known limitations: none — tmux -CC keeps the old inline behavior by design.

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
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.
@MotionDevLab
MotionDevLab merged commit 0b3b2d4 into main Oct 13, 2026
5 checks passed
@MotionDevLab
MotionDevLab deleted the fix/resume-picker-esc-exit branch October 13, 2026 07:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant