Screen System: Designing Fullscreen Interaction Modes

A deep dive into Claude Code's fullscreen system — Doctor diagnostics, Resume session recovery, the REPL screen, and the switching mechanism between conversation and fullscreen modes

Setting the Stage

When you type claude to start a new session, the terminal is taken over by a fullscreen interactive interface — messages scroll in the upper area, the input box is fixed at the bottom, and the mouse wheel lets you browse history. When you type /doctor, the entire interface is replaced by a diagnostic panel. When you run claude --resume, a session picker appears, letting you browse and restore past conversations. Three completely different interaction modes, all running in the same terminal window.

How are these different "screens" organized? How do they switch between each other? What fundamentally distinguishes fullscreen mode from normal terminal output? How is the terminal's alternate screen buffer utilized? This article dives into Claude Code's src/screens/ directory to analyze the architecture of this screen system.

Core questions include:

  1. Screen directory architecture: How do the three screen files (REPL, Doctor, ResumeConversation) divide responsibilities? What patterns do they share?
  2. Fullscreen mechanism: How does the AlternateScreen component use the terminal's DEC Private Mode 1049 to implement screen switching?
  3. Lifecycle management: How is screen entry, rendering, interaction, exit, and state restoration orchestrated?
  4. Cross-screen switching: How does Doctor return to the REPL after diagnostics complete? How does Resume seamlessly transition to the REPL after a session is selected?

screens/ Directory Architecture

Three Core Screen Files

Claude Code's screen system lives in the src/screens/ directory, containing three core files:

Text
1src/screens/
2├── REPL.tsx # Main interaction screen (~5000 lines)
3├── Doctor.tsx # Diagnostic screen (~500 lines)
4└── ResumeConversation.tsx # Session recovery screen (~400 lines)

The size difference between these three files reflects their scope of responsibility. REPL is the application's main battleground — message flow, tool execution, permission requests, scroll management, and shortcut handling all live here. Doctor is a one-shot diagnostic panel. ResumeConversation is a transitional screen that hands control to the REPL after selection is complete.

...

Shared Patterns Across Screens

Despite the vast difference in complexity between the three screens, they share several key architectural patterns:

Props-driven lifecycle callbacks. Each screen receives a callback function to signal "done":

Doctor.tsx
TypeScript
1type Props = {
2 onDone: (result?: string, options?: {
3 display?: CommandResultDisplay;
4 }) => void;
5};

The Doctor screen calls onDone when the user presses Enter; ResumeConversation renders an embedded REPL component after loading completes. This pattern makes screen composition declarative — the parent doesn't need to know about the screen's internal state machine.

React state management + async initialization. Each screen initiates async operations on mount (Doctor fetches diagnostic info, Resume loads history logs), driving state transitions from loading to ready via useState + useEffect.

KeybindingSetup wrapping. All screens run within a KeybindingSetup context, ensuring the shortcut system (Ctrl+C to exit, Enter to confirm, etc.) is available in every screen.

REPL Screen: The Core Interaction Hub

Component Scale and Responsibilities

REPL is Claude Code's most central screen and the largest single component file. Its import list spans over 280 lines, covering message management, tool execution, permission control, MCP connections, keybindings, scroll management, voice integration, and nearly every other subsystem.

REPL.tsx
TypeScript
1export function REPL({
2 commands: initialCommands,
3 debug,
4 initialTools,
5 initialMessages,
6 pendingHookMessages,
7 initialFileHistorySnapshots,
8 initialContentReplacements,
9 initialAgentName,
10 initialAgentColor,
11 mcpClients: initialMcpClients,
12 dynamicMcpConfig: initialDynamicMcpConfig,
13 autoConnectIdeFlag,
14 strictMcpConfig = false,
15 systemPrompt: customSystemPrompt,
16 appendSystemPrompt,
17 onBeforeQuery,
18 onTurnComplete,
19 disabled = false,
20 mainThreadAgentDefinition: initialMainThreadAgentDefinition,
21 disableSlashCommands = false,
22 taskListId,
23 remoteSessionConfig,
24 directConnectConfig,
25 sshSession,
26 thinkingConfig
27}: Props): React.ReactNode {

The props REPL receives define the entire session's initial state — initial messages (for resume), tool list, MCP clients, system prompt, etc. These props are injected by main.tsx at the routing layer.

Fullscreen Layout: FullscreenLayout

REPL's core layout is managed by the FullscreenLayout component. This component divides the terminal viewport into two areas: a scrollable message area and an input area fixed at the bottom.

FullscreenLayout.tsx
TypeScript
1/**
2 * Layout wrapper for the REPL. In fullscreen mode, puts scrollable
3 * content in a sticky-scroll box and pins bottom content via flexbox.
4 * Outside fullscreen mode, renders content sequentially so the existing
5 * main-screen scrollback rendering works unchanged.
6 *
7 * Fullscreen mode defaults on for ants (CLAUDE_CODE_NO_FLICKER=0 to opt out)
8 * and off for external users (CLAUDE_CODE_NO_FLICKER=1 to opt in).
9 */

FullscreenLayout has two rendering paths. In fullscreen mode, it uses ScrollBox (with stickyScroll) as the message container, with the bottom area fixed via flexbox flexShrink={0}:

FullscreenLayout.tsx
TypeScript
1if (isFullscreenEnvEnabled()) {
2 return (
3 <PromptOverlayProvider>
4 {/* Scrollable message area */}
5 <Box flexGrow={1} flexDirection="column" overflow="hidden">
6 <StickyPromptHeader />
7 <ScrollBox ref={scrollRef} flexGrow={1} stickyScroll={true}>
8 <ScrollChromeContext value={chromeCtx}>
9 {scrollable}
10 </ScrollChromeContext>
11 {overlay}
12 </ScrollBox>
13 <NewMessagesPill count={newMessageCount} onClick={onPillClick} />
14 {bottomFloat}
15 </Box>
16 {/* Fixed bottom area (input box, spinner, permission requests) */}
17 <Box flexDirection="column" flexShrink={0} maxHeight="50%">
18 <SuggestionsOverlay />
19 <DialogOverlay />
20 <Box flexDirection="column" overflowY="hidden">
21 {bottom}
22 </Box>
23 </Box>
24 {/* Modal layer (slash command dialogs) */}
25 {modal && <ModalContext ...> ... </ModalContext>}
26 </PromptOverlayProvider>
27 );
28}
29// Non-fullscreen mode: simple sequential rendering
30return <>{scrollable}{bottom}{overlay}{modal}</>;

In non-fullscreen mode, all content renders sequentially, relying on the terminal's native scroll buffer. This is an important degradation path — used in tmux -CC mode or when the user explicitly disables fullscreen.

AlternateScreen: Terminal Double Buffering

The underlying foundation of fullscreen mode is the terminal's alternate screen buffer (DEC Private Mode 1049). The AlternateScreen component encapsulates this mechanism:

AlternateScreen.tsx
TypeScript
1export function AlternateScreen({
2 children,
3 mouseTracking = true,
4}: Props): React.ReactNode {
5 const size = useContext(TerminalSizeContext)
6 const writeRaw = useContext(TerminalWriteContext)
7
8 useInsertionEffect(() => {
9 const ink = instances.get(process.stdout)
10 if (!writeRaw) return
11
12 writeRaw(
13 ENTER_ALT_SCREEN + // ESC[?1049h
14 '\x1b[2J\x1b[H' + // Clear screen + cursor home
15 (mouseTracking ? ENABLE_MOUSE_TRACKING : ''),
16 )
17 ink?.setAltScreenActive(true, mouseTracking)
18
19 return () => {
20 ink?.setAltScreenActive(false)
21 ink?.clearTextSelection()
22 writeRaw(
23 (mouseTracking ? DISABLE_MOUSE_TRACKING : '') +
24 EXIT_ALT_SCREEN // ESC[?1049l
25 )
26 }
27 }, [writeRaw, mouseTracking])
28
29 return (
30 <Box flexDirection="column"
31 height={size?.rows ?? 24}
32 width="100%" flexShrink={0}>
33 {children}
34 </Box>
35 )
36}

This code is worth analyzing line by line:

  1. useInsertionEffect rather than useLayoutEffect: This is a critical detail. The React reconciler calls resetAfterCommit between mutation and layout commit, and Ink's resetAfterCommit triggers onRender. If useLayoutEffect were used, the first onRender would fire before the effect — writing a full frame to the main screen. Insertion effects execute during the mutation phase, ensuring the ENTER_ALT_SCREEN sequence reaches the terminal before the first frame.

  2. height={size?.rows ?? 24}: Fixes the component height to the terminal row count. This is the core constraint for fullscreen mode — without this limit, ScrollBox's flexGrow has no upper bound, the viewport equals content height, and scrollTop is always 0.

  3. Cleanup function ordering: First setAltScreenActive(false) notifies the Ink renderer, then clearTextSelection clears text selection state, and finally writes EXIT_ALT_SCREEN to restore the main screen.

REPL wraps AlternateScreen at the root level:

REPL.tsx
TypeScript
1if (isFullscreenEnvEnabled()) {
2 return <AlternateScreen mouseTracking={isMouseTrackingEnabled()}>
3 {mainReturn}
4 </AlternateScreen>;
5}
6return mainReturn;

Doctor Screen: /doctor Environment Diagnostics

Launch Path

The Doctor screen has two entry points:

  1. CLI subcommand: claude doctor launches directly, routed to doctorHandler through main.tsx's Commander routing
  2. REPL slash command: Typing /doctor in conversation, where REPL renders the Doctor component in the current context

The CLI entry point's startup code demonstrates the standalone screen launch pattern:

cli/handlers/util.tsx
TypeScript
1export async function doctorHandler(root: Root): Promise<void> {
2 logEvent('tengu_doctor_command', {});
3 await new Promise<void>(resolve => {
4 root.render(
5 <AppStateProvider>
6 <KeybindingSetup>
7 <MCPConnectionManager
8 dynamicMcpConfig={undefined}
9 isStrictMcpConfig={false}
10 >
11 <DoctorWithPlugins onDone={() => {
12 void resolve();
13 }} />
14 </MCPConnectionManager>
15 </KeybindingSetup>
16 </AppStateProvider>
17 );
18 });
19 root.unmount();
20 process.exit(0);
21}

Note the pattern here: create a Promise, pass resolve to the component's onDone callback. When the user presses Enter to close the diagnostic panel, the Promise resolves, then the component is unmounted and the process exits. Doctor as a CLI subcommand doesn't need AlternateScreen — it's a simple information display panel that relies on the terminal's native scrolling.

Diagnostic Data Collection

The Doctor component collects multi-dimensional diagnostic data in parallel on mount:

Doctor.tsx
TypeScript
1const [diagnostic, setDiagnostic] = useState(null);
2const [agentInfo, setAgentInfo] = useState(null);
3const [contextWarnings, setContextWarnings] = useState(null);
4const [versionLockInfo, setVersionLockInfo] = useState(null);
5const validationErrors = useSettingsErrors();
6
7useEffect(() => {
8 // 1. Basic diagnostics: version, install path, package manager, ripgrep status
9 getDoctorDiagnostic().then(setDiagnostic);
10
11 (async () => {
12 // 2. Agent info: loaded agents, failed parse files
13 const userAgentsDir = join(getClaudeConfigHomeDir(), "agents");
14 const projectAgentsDir = join(getOriginalCwd(), ".claude", "agents");
15 const { activeAgents, allAgents, failedFiles } = agentDefinitions;
16 // ...
17
18 // 3. Context warnings: CLAUDE.md size, MCP tool count
19 const warnings = await checkContextWarnings(tools, ...);
20 setContextWarnings(warnings);
21
22 // 4. Version lock info: PID lock file cleanup
23 if (isPidBasedLockingEnabled()) {
24 const staleLocksCleaned = cleanupStaleLocks(locksDir);
25 const locks = getAllLockInfo(locksDir);
26 setVersionLockInfo({ enabled: true, locks, locksDir, staleLocksCleaned });
27 }
28 })();
29}, [toolPermissionContext, tools, agentDefinitions]);

The collected diagnostic information is rendered across multiple panels:

PanelContent
DiagnosticsVersion number, installation type, path, package manager, ripgrep status
UpdatesAuto-update status, update permissions, stable/latest version numbers
SandboxSandbox isolation status
MCPMCP server configuration parse warnings
KeybindingsKeybinding configuration warnings
Environment VariablesEnvironment variable validation errors
Version LocksPID lock file status, stale lock cleanup
Agent Parse ErrorsAgent definition file parse failures
Plugin ErrorsPlugin errors
Context Usage WarningsCLAUDE.md size, agent context consumption

All panels are wrapped with a Pane component, displaying Checking installation status... placeholder text before diagnostic data has loaded.

Exit Mechanism

Doctor uses the simplest exit pattern — a PressEnterToContinue component with keybinding listeners:

Doctor.tsx
TypeScript
1const handleDismiss = () => {
2 onDone("Claude Code diagnostics dismissed", { display: "system" });
3};
4
5useKeybindings({
6 "confirm:yes": handleDismiss,
7 "confirm:no": handleDismiss
8}, { context: "Confirmation" });
9
10// Render bottom
11<Box><PressEnterToContinue /></Box>

Both confirm:yes and confirm:no map to dismiss — whether the user presses Enter or Escape, the panel closes. In CLI mode this triggers process.exit(0); in REPL's /doctor mode this returns control to the conversation flow.

Resume Screen: Session Recovery UI

Progressive Log Loading

ResumeConversation is a transitional screen — its sole purpose is to let the user select a past session, then hand control to the REPL. But the seemingly simple feature of "selecting a past session" involves an elegant progressive loading design.

ResumeConversation.tsx
TypeScript
1React.useEffect(() => {
2 loadSameRepoMessageLogsProgressive(worktreePaths).then(result => {
3 sessionLogResultRef.current = result;
4 logCountRef.current = result.logs.length;
5 setLogs(result.logs);
6 setLoading(false);
7 }).catch(error => {
8 logError(error);
9 setLoading(false);
10 });
11}, [worktreePaths]);

loadSameRepoMessageLogsProgressive only loads session logs from the same repository (including worktrees). Users can switch to "all projects" mode to load cross-repository logs:

ResumeConversation.tsx
TypeScript
1const loadLogs = React.useCallback((allProjects: boolean) => {
2 setLoading(true);
3 const promise = allProjects
4 ? loadAllProjectsMessageLogsProgressive()
5 : loadSameRepoMessageLogsProgressive(worktreePaths);
6 promise.then(result => {
7 sessionLogResultRef.current = result;
8 logCountRef.current = result.logs.length;
9 setLogs(result.logs);
10 }).finally(() => setLoading(false));
11}, [worktreePaths]);

Furthermore, when the user scrolls to the bottom of the list, loadMoreLogs loads additional history on demand:

ResumeConversation.tsx
TypeScript
1const loadMoreLogs = React.useCallback((count: number) => {
2 const ref = sessionLogResultRef.current;
3 if (!ref || ref.nextIndex >= ref.allStatLogs.length) return;
4 void enrichLogs(ref.allStatLogs, ref.nextIndex, count).then(result => {
5 ref.nextIndex = result.nextIndex;
6 if (result.logs.length > 0) {
7 const offset = logCountRef.current;
8 result.logs.forEach((log, i) => { log.value = offset + i; });
9 setLogs(prev => prev.concat(result.logs));
10 logCountRef.current += result.logs.length;
11 } else if (ref.nextIndex < ref.allStatLogs.length) {
12 loadMoreLogs(count); // Recursively load until valid logs are found
13 }
14 });
15}, []);

Session Selection and Recovery Flow

When the user selects a session from LogSelector, it triggers the onSelect callback — the most complex part of the entire recovery flow:

sequenceDiagram
  participant U as User
  participant RS as ResumeConversation
  participant LS as LogSelector
  participant CR as conversationRecovery
  participant SS as sessionStorage
  participant REPL as REPL Screen

  U->>LS: Select session
  LS->>RS: onSelect(log)
  RS->>RS: setResuming(true)
  RS->>RS: checkCrossProjectResume()
  alt Cross-project session
    RS->>U: Display cross-project command prompt
  else Same-project session
    RS->>CR: loadConversationForResume(log)
    CR-->>RS: { messages, fileHistorySnapshots, ... }
    RS->>SS: switchSession(sessionId)
    RS->>SS: restoreCostStateForSession()
    RS->>SS: restoreSessionMetadata()
    RS->>RS: setResumeData({ messages, ... })
    RS->>REPL: Render REPL component
  end

Key steps in the recovery flow (L178-292):

  1. Cross-project detection: checkCrossProjectResume checks whether the selected session originates from a different directory. For cross-project sessions, it displays a prompt command (already copied to clipboard) for the user to resume in the correct directory.

  2. Message loading: loadConversationForResume deserializes message history from JSONL files.

  3. Session switching: switchSession updates the global session ID; restoreCostStateForSession restores the API call cost statistics for that session.

  4. Agent restoration: restoreAgentFromSession restores agent definitions and color schemes based on session metadata.

  5. State transition: setResumeData triggers a React re-render, and the conditional branch switches from LogSelector to the REPL component.

The conditional rendering logic for state transitions:

ResumeConversation.tsx
TypeScript
1if (crossProjectCommand) {
2 return <CrossProjectMessage command={crossProjectCommand} />;
3}
4if (resumeData) {
5 return <REPL
6 debug={debug}
7 commands={commands}
8 initialMessages={resumeData.messages}
9 initialFileHistorySnapshots={resumeData.fileHistorySnapshots}
10 // ... all recovery data injected via props to REPL
11 />;
12}
13if (loading) {
14 return <Box><Spinner /><Text> Loading conversations...</Text></Box>;
15}
16if (resuming) {
17 return <Box><Spinner /><Text> Resuming conversation...</Text></Box>;
18}
19return <LogSelector logs={filteredLogs} ... />;

This is a state-machine-driven rendering pattern: loading -> LogSelector -> resuming -> REPL (or CrossProjectMessage). Each state corresponds to a completely different UI.

Screen Lifecycle: Entry / Render / Interact / Exit / State Restore

Lifecycle Model

The three screens each have different lifecycle models, but they can be abstracted into unified phases:

...

Doctor has the simplest lifecycle:

PhaseBehavior
Initmount, register keybindings
LoadinggetDoctorDiagnostic() + parallel data fetching
ReadyRender diagnostic panel
InteractionOnly listens for Enter/Escape
ExitingCalls onDone, triggers unmount

ResumeConversation has a transitional lifecycle:

PhaseBehavior
Initmount
LoadingloadSameRepoMessageLogsProgressive()
ReadyRender LogSelector
InteractionList navigation, search, cross-project switching
ExitingNot a true exit — switches to rendering REPL

REPL has a long-lived lifecycle:

PhaseBehavior
Initmount, enter alt screen, initialize 200+ state variables
LoadingParallel loading of hooks, MCP connections, tool pool
ReadyFullscreenLayout rendering
InteractionInfinite loop: user input -> API query -> tool execution -> message rendering
ExitingExecute session end hooks -> exit alt screen -> unmount

AlternateScreen Entry and Exit

Fullscreen mode entry and exit are accomplished through terminal escape sequences. On entry:

Text
1ESC[?1049h -> Save main screen cursor position, switch to alternate screen buffer
2ESC[2J -> Clear alternate screen
3ESC[H -> Move cursor to (0,0)
4ESC[?1000h -> Enable mouse click tracking
5ESC[?1002h -> Enable mouse button event tracking
6ESC[?1006h -> Enable SGR extended mouse protocol

On exit:

Text
1ESC[?1006l -> Disable SGR mouse
2ESC[?1002l -> Disable button event tracking
3ESC[?1000l -> Disable mouse tracking
4ESC[?1049l -> Switch back to main screen buffer, restore cursor position

The terminal maintains two independent screen buffers. The main screen's content is saved when entering the alt screen and automatically restored when exiting. This means that after Claude Code exits, the previous terminal content (command history, other programs' output) remains intact.

Switching Mechanism with the Main Conversation Flow

main.tsx Routing Layer

Screen switching occurs at two levels: routing at startup and mode switching at runtime.

Startup routing is controlled by main.tsx. It uses Commander.js to parse command-line arguments and determines which screen to render based on subcommands and flags:

TypeScript
1// main.tsx routing logic (simplified)
2
3// claude doctor -> standalone diagnostic screen
4program.command('doctor').action(async () => {
5 const root = await createRoot(getBaseRenderOptions(false));
6 await doctorHandler(root); // render Doctor -> onDone -> unmount -> exit
7});
8
9// claude --resume -> session recovery screen or direct REPL entry
10if (hasResumeFlag) {
11 if (specificSessionId) {
12 // Directly resume specified session -> launchRepl
13 await launchRepl(root, appProps, {
14 initialMessages: resumedMessages,
15 // ...
16 }, renderAndRun);
17 } else {
18 // Show picker -> launchResumeChooser
19 await launchResumeChooser(root, appProps, worktreePaths, {
20 initialSearchQuery: searchTerm,
21 forkSession: options.forkSession,
22 // ...
23 });
24 }
25} else {
26 // Default path -> launchRepl (new session)
27 await launchRepl(root, appProps, sessionConfig, renderAndRun);
28}

launchRepl and launchResumeChooser encapsulate React tree construction and rendering:

replLauncher.tsx
TypeScript
1export async function launchRepl(
2 root: Root,
3 appProps: AppWrapperProps,
4 replProps: REPLProps,
5 renderAndRun: (root: Root, element: React.ReactNode) => Promise<void>,
6): Promise<void> {
7 const { App } = await import('./components/App.js');
8 const { REPL } = await import('./screens/REPL.js');
9 await renderAndRun(root, <App {...appProps}><REPL {...replProps} /></App>);
10}

Note the use of import() — both REPL and App are dynamically imported. This allows the Doctor subcommand to run without loading the REPL code, saving startup time.

Runtime Screen Switching

During REPL execution, the /doctor slash command doesn't replace the entire React tree — it renders the Doctor component within the REPL's existing context. Doctor's onDone callback injects the result as a system message into the conversation flow:

TypeScript
1// Doctor within REPL's onDone
2onDone: (result?: string, options?) => {
3 onDone("Claude Code diagnostics dismissed", { display: "system" });
4}

In this pattern, Doctor doesn't need its own AppStateProvider or KeybindingSetup — it reuses the REPL's existing context. This is also why Doctor's CLI entry point needs to manually wrap these Providers, while REPL's built-in /doctor does not.

Transcript mode (Ctrl+O) is another form of runtime switching. REPL switches rendered content within the same AlternateScreen:

REPL.tsx
TypeScript
1if (transcriptScrollRef) {
2 return <AlternateScreen mouseTracking={isMouseTrackingEnabled()}>
3 {transcriptReturn}
4 </AlternateScreen>;
5}
6return transcriptReturn;

This ensures the alt buffer remains unchanged during mode switching — the React reconciler sees the same type of AlternateScreen component and only updates the children.

Fullscreen Mode Environment Compatibility

tmux -CC Detection

Fullscreen mode is not available in all terminal environments. The trickiest compatibility issue comes from tmux's -CC mode (iTerm2 integration mode). In this mode, tmux's alt screen + mouse tracking can corrupt terminal state.

src/utils/fullscreen.ts implements multi-layer detection:

fullscreen.ts
TypeScript
1function isTmuxControlModeEnvHeuristic(): boolean {
2 if (!process.env.TMUX) return false;
3 if (process.env.TERM_PROGRAM !== 'iTerm.app') return false;
4 const term = process.env.TERM ?? '';
5 return !term.startsWith('screen') && !term.startsWith('tmux');
6}
7
8function probeTmuxControlModeSync(): void {
9 tmuxControlModeProbed = isTmuxControlModeEnvHeuristic();
10 if (tmuxControlModeProbed) return;
11 if (!process.env.TMUX) return;
12 if (process.env.TERM_PROGRAM) return; // Non-iTerm -> no probe needed
13 // SSH scenario: TERM_PROGRAM doesn't propagate, need to query tmux directly
14 let result;
15 try {
16 result = spawnSync('tmux',
17 ['display-message', '-p', '#{client_control_mode}'],
18 { encoding: 'utf8', timeout: 2000 });
19 } catch { return; }
20 if (result.status !== 0) return;
21 tmuxControlModeProbed = result.stdout.trim() === '1';
22}

Detection has two layers:

  1. Environment variable heuristic (zero process overhead): If TMUX is set and TERM_PROGRAM is iTerm.app, and TERM doesn't start with screen/tmux, then it's identified as -CC mode.
  2. Synchronous tmux probe (~5ms): Only triggered in SSH scenarios (where TERM_PROGRAM doesn't propagate), querying tmux's client_control_mode variable via spawnSync.

Using spawnSync (synchronous) rather than the async version is deliberate — this result determines whether to enter fullscreen. Async probing once caused a race condition: React had already rendered the alt screen before the probe completed, killing the user's mouse wheel.

Environment Variable Control

Fullscreen mode is controlled via the CLAUDE_CODE_NO_FLICKER environment variable:

fullscreen.ts
TypeScript
1export function isFullscreenEnvEnabled(): boolean {
2 if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_NO_FLICKER)) return false;
3 if (isEnvTruthy(process.env.CLAUDE_CODE_NO_FLICKER)) return true;
4 if (isTmuxControlMode()) {
5 // Auto-disable, log the reason
6 return false;
7 }
8 return process.env.USER_TYPE === 'ant'; // Enabled by default for internal users
9}

Additional fine-grained controls include:

  • CLAUDE_CODE_DISABLE_MOUSE: Keeps alt screen but disables mouse tracking (solves copy conflicts in tmux)
  • CLAUDE_CODE_DISABLE_MOUSE_CLICKS: Disables mouse clicks but keeps scroll wheel (prevents accidental clicks)

Portable Patterns

Claude Code's screen system provides several reusable design patterns for terminal applications:

Pattern 1: Props Callback-Driven Screen Lifecycle

Each screen receives an onDone callback, and the parent waits for completion via Promise. This pattern makes screens reusable in different contexts (standalone CLI command, REPL embedded, test environment).

TypeScript
1// General pattern
2await new Promise<void>(resolve => {
3 root.render(
4 <Providers>
5 <ScreenComponent onDone={() => resolve()} />
6 </Providers>
7 );
8});
9root.unmount();

Pattern 2: AlternateScreen Double Buffering

Leveraging the terminal alt screen buffer for fullscreen applications, with automatic content restoration on exit. The key is using useInsertionEffect to ensure escape sequences reach the terminal before the first frame renders.

Pattern 3: Progressive State Machine Rendering

ResumeConversation's loading -> selector -> resuming -> REPL state machine demonstrates how to implement multi-phase UI transitions within a single component, with each phase rendering a completely different component tree.

Pattern 4: Environment Compatibility Degradation

Fullscreen mode's multi-layer detection (environment variable heuristic -> synchronous process probe -> environment variable override) is a reusable terminal capability detection strategy. In environments that can't use fullscreen, the system degrades to sequential rendering mode with no loss of functionality.

Pattern 5: Screen Reuse and Context Inheritance

Doctor reuses REPL's Provider context (AppState, KeybindingSetup, MCPConnectionManager) when running inside the REPL, but brings its own Providers in CLI mode. This design allows the same component to work in both scenarios without modifying the component itself.

Summary

Claude Code's screen system is a carefully designed multi-layer architecture:

  • Bottom layer: The AlternateScreen component uses terminal DEC 1049 private mode for double buffering, with useInsertionEffect guaranteeing correct timing
  • Layout layer: FullscreenLayout divides the terminal viewport into a scrollable area and a fixed bottom area, using flexbox for adaptive layout
  • Screen layer: Three screen files each serve their purpose — REPL handles the main interaction loop, Doctor provides diagnostic information, and ResumeConversation manages session recovery transitions
  • Routing layer: main.tsx uses Commander routing to determine which screen to launch, with replLauncher.tsx and dialogLaunchers.tsx encapsulating React tree construction
  • Compatibility layer: tmux -CC detection, environment variable control, and non-fullscreen degradation ensure the system works across various terminal environments

The biggest design insight of this system is: the terminal's alternate screen buffer is equivalent to a browser's "page navigation". Entering alt screen is like opening a new page (the old page is saved), and exiting is like pressing the back button (the old page is restored). Claude Code builds a React-driven screen management system on this foundation, giving terminal applications a multi-view experience similar to SPAs.