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.ts 和 claude-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 或容器内也能尝试写入剪贴板。
踩坑指南
- Alt-screen 限制:全屏模式改变的是绘制协议而非窗口尺寸。它只在交互式会话中生效,
--print等非交互命令不会触发此逻辑。 - 终端原生功能失效:一旦开启,终端自带的
Cmd+F和原生拖选将无法直接搜索或选中会话正文,必须使用应用内提供的Ctrl+O或搜索功能。 - 虚拟化依赖:内存优化的前提是虚拟滚动。如果没有
VirtualMessageList,单纯切到全屏模式无法解决 React 层的性能堆积。 - 硬边界:tmux -CC:代码中已明确将其视为禁用场景,强行开启会导致不可预知的终端 UI 破坏。
- 逃生口设计:Claude Code 提供了
/dump([)将内容写回终端滚屏区,以及/visual(v)将全文送入外部编辑器,作为全屏模式接管控制权的补偿。
延伸阅读
- 搜索高亮实现:如果你想研究如何精准定位并高亮虚拟列表中的文本,请看
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 — 启用条件与环境探测。
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` 包裹点。
// <AlternateScreen>'s <Box height={rows}> constraint — without it,claude-code-opensource/src/hooks/useVirtualScroll.ts: 虚拟滚动核心状态管理。
📄 src/hooks/useVirtualScroll.ts — 虚拟滚动核心状态管理。
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 实现。
* 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 — 屏幕坐标系下的文本选择逻辑。
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)。
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