Skip to content
源码分析手册

Fullscreen Rendering:REPL 从终端滚屏到虚拟视口的蜕变

核心概念

fullscreen rendering(全屏渲染)在 Claude Code 中并不是简单地将终端窗口最大化,而是一次底层的交互范式切换。它的本质是将 REPL 的运行模式从传统的“不断向终端原生 scrollback 追加文本”切换为“接管 alternate screen buffer(交替屏幕缓冲区),并维护一个应用内受控视口”。

这种切换意味着 Claude Code 拿回了对屏幕、滚动和文本选择的完全控制权。在全屏模式下:

  • 输入框固定在底部,不再随消息增长而上移。
  • 长会话不再导致终端内存无限堆积(因为只渲染可见部分)。
  • 鼠标事件、搜索和文本选择逻辑由 Claude Code 应用层接管,而非终端原生处理。

压成一句话:全屏渲染不是窗口状态,而是 REPL 从终端原生滚屏模式到“Alt-screen + 虚拟化视口”模式的跃迁。

源码级拆解

全屏模式的启动并不只是一个简单的环境变量开关。在 claude-code-opensource/src/utils/fullscreen.ts 中,isFullscreenEnvEnabled() 负责精细的启用裁决。它识别 CLAUDE_CODE_NO_FLICKER 环境变量,同时明确将 tmux -CC(iTerm2 操控模式)判定为自动禁用的场景,因为 Alt-screen 和鼠标跟踪在控制模式下会导致滚轮失效、状态错乱等具体问题。

一旦启用全屏,核心渲染逻辑的变化主要发生在以下三个层面:

1. 终端模式切换与缓冲区接管

claude-code-opensource/src/screens/REPL.tsx 中,整个 REPL 根节点会被包裹在 AlternateScreen 组件内。这会在 claude-code-opensource/src/ink/ink.tsx 层级触发终端的交替屏幕缓冲区切换(altScreenActive)。此时,Ink 引擎的输出目标从终端主屏幕转向了另一块受控的 Buffer,Claude Code 可以在其上自由控制光标位置、处理局部刷新和实现精细的 Diff 绘制。

2. 内存优化的基石:虚拟滚动

全屏模式能让长会话保持“内存稳定”,其真正功臣是虚拟滚动机制。在非全屏模式下,即使 Ink 只绘制可见部分,React Fiber 树和 Yoga 布局节点仍会随着消息增加而膨胀。 claude-code-opensource/src/hooks/useVirtualScroll.tsclaude-code-opensource/src/components/VirtualMessageList.tsx 合作实现了一套虚拟化协议:

  • 只挂载视口(Viewport)及附近少量缓冲区(Overscan)的消息。
  • 其余未显示消息在 DOM 结构中被“上下 Spacer”替代,仅维持高度。
  • 通过动态测量真实消息高度,不断修正滚动位置。

3. 应用内交互系统的重构

由于会话正文不再位于终端原生滚屏区,传统的终端搜索(Cmd+F)和鼠标选择会失效。为此,Claude Code 重新实现了一套完整的交互层:

  • 搜索与跳转claude-code-opensource/src/ink/render-to-screen.ts 会将单条消息侧向渲染后扫描精确的匹配位置,支持 VirtualMessageList 在长会话中进行秒级跳转和高亮。
  • 文本选择claude-code-opensource/src/ink/selection.ts 维护了一套屏幕坐标系下的选择模型,支持双击选词、三击选行以及拖动超视口时的自动滚动。
  • 跨平台复制:在 claude-code-opensource/src/ink/termio/osc.ts 中,复制操作被分流为本地剪贴板工具、tmux 缓冲区和 OSC 52 协议三条路径,以确保在 SSH 或容器内也能尝试写入剪贴板。

踩坑指南

  1. Alt-screen 限制:全屏模式改变的是绘制协议而非窗口尺寸。它只在交互式会话中生效,--print 等非交互命令不会触发此逻辑。
  2. 终端原生功能失效:一旦开启,终端自带的 Cmd+F 和原生拖选将无法直接搜索或选中会话正文,必须使用应用内提供的 Ctrl+O 或搜索功能。
  3. 虚拟化依赖:内存优化的前提是虚拟滚动。如果没有 VirtualMessageList,单纯切到全屏模式无法解决 React 层的性能堆积。
  4. 硬边界:tmux -CC:代码中已明确将其视为禁用场景,强行开启会导致不可预知的终端 UI 破坏。
  5. 逃生口设计:Claude Code 提供了 /dump[)将内容写回终端滚屏区,以及 /visualv)将全文送入外部编辑器,作为全屏模式接管控制权的补偿。

延伸阅读

  • 搜索高亮实现:如果你想研究如何精准定位并高亮虚拟列表中的文本,请看 render-to-screen.ts
  • 输入拦截机制:想了解滚动快捷键如何不与输入框冲突,请移步 claude-code-opensource/src/components/ScrollKeybindingHandler.tsx
  • 终端底层适配:研究如何处理不同终端的 ANSI 兼容性,看 ink/ink.tsx

源码锚点

  • claude-code-opensource/src/utils/fullscreen.ts: 启用条件与环境探测。
📄 src/utils/fullscreen.ts — 启用条件与环境探测。L7-11 of 203
typescript
let loggedTmuxCcDisable = false
let checkedTmuxMouseHint = false

/**
 * Cached result from `tmux display-message -p '#{client_control_mode}'`.
  • claude-code-opensource/src/screens/REPL.tsx: 全屏模式根入口及 AlternateScreen 包裹点。
📄 src/screens/REPL.tsx — 全屏模式根入口及 `AlternateScreen` 包裹点。L4477-4477 of 5006
tsx
    // <AlternateScreen>'s <Box height={rows}> constraint — without it,
  • claude-code-opensource/src/hooks/useVirtualScroll.ts: 虚拟滚动核心状态管理。
📄 src/hooks/useVirtualScroll.ts — 虚拟滚动核心状态管理。L19-48 of 722
typescript
const DEFAULT_ESTIMATE = 3
/**
 * Extra rows rendered above and below the viewport. Generous because real
 * heights can be 10x the estimate for long tool results.
 */
const OVERSCAN_ROWS = 80
/** Items rendered before the ScrollBox has laid out (viewportHeight=0). */
const COLD_START_COUNT = 30
/**
 * scrollTop quantization for the useSyncExternalStore snapshot. Without
 * this, every wheel tick (3-5 per notch) triggers a full React commit +
 * Yoga calculateLayout() + Ink diff cycle — the CPU spike. Visual scroll
 * stays smooth regardless: ScrollBox.forceRender fires on every scrollBy
 * and Ink reads the REAL scrollTop from the DOM node, independent of what
 * React thinks. React only needs to re-render when the mounted range must
 * shift; half of OVERSCAN_ROWS is the tightest safe bin (guarantees ≥40
 * rows of overscan remain before the new range is needed).
 */
const SCROLL_QUANTUM = OVERSCAN_ROWS >> 1
/**
 * Worst-case height assumed for unmeasured items when computing coverage.
 * A MessageRow can be as small as 1 row (single-line tool call). Using 1
 * here guarantees the mounted span physically reaches the viewport bottom
 * regardless of how small items actually are — at the cost of over-mounting
 * when items are larger (which is fine, overscan absorbs it).
 */
const PESSIMISTIC_HEIGHT = 1
/** Cap on mounted items to bound fiber allocation even in degenerate cases. */
const MAX_MOUNTED_ITEMS = 300
/**
  • claude-code-opensource/src/components/VirtualMessageList.tsx: 虚拟化列表的 React 实现。
📄 src/components/VirtualMessageList.tsx — 虚拟化列表的 React 实现。L42-68 of 1082
tsx
 *  2 rows via overflow:hidden — this just bounds the React prop size. */
const STICKY_TEXT_CAP = 500;

/** Imperative handle for transcript navigation. Methods compute matches
 *  HERE (renderableMessages indices are only valid inside this component —
 *  Messages.tsx filters and reorders, REPL can't compute externally). */
export type JumpHandle = {
  jumpToIndex: (i: number) => void;
  setSearchQuery: (q: string) => void;
  nextMatch: () => void;
  prevMatch: () => void;
  /** Capture current scrollTop as the incsearch anchor. Typing jumps
   *  around as preview; 0-matches snaps back here. Enter/n/N never
   *  restore (they don't call setSearchQuery with empty). Next / call
   *  overwrites. */
  setAnchor: () => void;
  /** Warm the search-text cache by extracting every message's text.
   *  Returns elapsed ms, or 0 if already warm (subsequent / in same
   *  transcript session). Yields before work so the caller can paint
   *  "indexing…" first. Caller shows "indexed in Xms" on resolve. */
  warmSearchIndex: () => Promise<number>;
  /** Manual scroll (j/k/PgUp/wheel) exited the search context. Clear
   *  positions (yellow goes away, inverse highlights stay). Next n/N
   *  re-establishes via step()→jump(). Wired from ScrollKeybindingHandler's
   *  onScroll — only fires for keyboard/wheel, not programmatic scrollTo. */
  disarmSearch: () => void;
};
  • claude-code-opensource/src/ink/selection.ts: 屏幕坐标系下的文本选择逻辑。
📄 src/ink/selection.ts — 屏幕坐标系下的文本选择逻辑。L17-17 of 918
typescript
type Point = { col: number; row: number }
  • claude-code-opensource/src/ink/termio/osc.ts: 剪贴板分流策略(OSC 52/Tmux/Local)。
📄 src/ink/termio/osc.ts — 剪贴板分流策略(OSC 52/Tmux/Local)。L229-249 of 494
typescript
export const OSC = {
  SET_TITLE_AND_ICON: 0,
  SET_ICON: 1,
  SET_TITLE: 2,
  SET_COLOR: 4,
  SET_CWD: 7,
  HYPERLINK: 8,
  ITERM2: 9, // iTerm2 proprietary sequences
  SET_FG_COLOR: 10,
  SET_BG_COLOR: 11,
  SET_CURSOR_COLOR: 12,
  CLIPBOARD: 52,
  KITTY: 99, // Kitty notification protocol
  RESET_COLOR: 104,
  RESET_FG_COLOR: 110,
  RESET_BG_COLOR: 111,
  RESET_CURSOR_COLOR: 112,
  SEMANTIC_PROMPT: 133,
  GHOSTTY: 777, // Ghostty notification protocol
  TAB_STATUS: 21337, // Tab status extension
} as const

基于 Claude Code v2.1.88 开源快照的深度分析