全屏模式
Claude Code 如何劫持终端的备用缓冲区、控制鼠标跟踪、在 tmux 中幸存并避免闪烁 - 从 DEC 转义序列到 React hooks。
Claude Code 丰富的交互式 UI 不会在正常终端回滚上呈现。 它占据了 备用屏幕缓冲区 — 与 DEC 私有模式相同
vim, less, 和 htop 使用。 当您退出时,您的 shell 历史记录不会受到影响。 当您进入其中时,鼠标滚轮事件会滚动消息列表,而不是终端的历史记录。
utils/fullscreen.ts → ink/termio/dec.ts →
ink/components/AlternateScreen.tsx →
components/FullscreenLayout.tsx →
components/OffscreenFreeze.tsx
本课程涵盖全屏故事的三个嵌套层:
Detection
是否应该激活全屏? 环境变量、tmux 探针、交互标志。
DEC 序列
进入/退出备用屏幕并启用鼠标跟踪的原始终端转义代码。
反应集成
AlternateScreen 组件、FullscreenLayout 插槽、OffscreenFreeze 优化。
一切都开始于 ink/termio/dec.ts,一个小常量文件,用于对 DEC 私有模式编号进行编码并从中生成转义序列。 理解这些代码是其他一切的先决条件。
// ink/termio/dec.ts — the complete DEC mode table
export const DEC = {
CURSOR_VISIBLE: 25,
ALT_SCREEN: 47, // older; does NOT save/restore cursor
ALT_SCREEN_CLEAR: 1049, // modern: save cursor + switch + clear
MOUSE_NORMAL: 1000, // button press/release + wheel
MOUSE_BUTTON: 1002, // adds drag (button-motion)
MOUSE_ANY: 1003, // adds all-motion (hover)
MOUSE_SGR: 1006, // SGR format: CSI < btn;col;row M/m
FOCUS_EVENTS: 1004,
BRACKETED_PASTE: 2004,
SYNCHRONIZED_UPDATE:2026,
} as const
辅助函数 decset(mode) and decreset(mode) 将每个数字包装到标准中 CSI ? N h (放) / CSI ? N l (重置)格式。 所有具体序列都预先生成为模块级常量,因此在渲染时不会发生字符串格式化:
export const ENTER_ALT_SCREEN = decset(DEC.ALT_SCREEN_CLEAR) // \x1b[?1049h
export const EXIT_ALT_SCREEN = decreset(DEC.ALT_SCREEN_CLEAR) // \x1b[?1049l
// All four mouse modes stacked — DEC 1000 + 1002 + 1003 + 1006
export const ENABLE_MOUSE_TRACKING =
decset(DEC.MOUSE_NORMAL) +
decset(DEC.MOUSE_BUTTON) +
decset(DEC.MOUSE_ANY) +
decset(DEC.MOUSE_SGR)
export const DISABLE_MOUSE_TRACKING =
decreset(DEC.MOUSE_SGR) + // reversed order — disable outer modes first
decreset(DEC.MOUSE_ANY) +
decreset(DEC.MOUSE_BUTTON) +
decreset(DEC.MOUSE_NORMAL)
为什么鼠标模式以相反的顺序禁用?
这是防守分层。 这些模式形成超集层次结构:1003(任意运动)包括 1002(按钮运动)所做的一切,其中包括 1000(正常)所做的一切。 将它们按顺序排列意味着每个都扩展了跟踪范围。 以相反顺序禁用(外部 → 内部,1006 → 1003 → 1002 → 1000)可确保终端看到干净的拆卸,而不是在进程在两次 decreset 调用之间崩溃时使部分模式保持活动状态。 首先禁用 SGR 格式 (1006),因为它是报告格式的修饰符,而不是跟踪模式 - 在跟踪模式之前禁用它意味着拆卸窗口期间的任何虚假事件都会以旧版 X10 格式到达,解析器会安全地忽略该格式。
utils/fullscreen.ts 是一个纯粹的决策模块:它回答“现在应该全屏激活吗?” 通过四个导出谓词。 它们都不直接接触终端——它们只检查环境状态。
tmux -CC 探针:为什么它是同步的
最有趣的检测代码是 probeTmuxControlModeSync()。 它调用
spawnSync('tmux', ['display-message', '-p', '#{client_control_mode}']) — 一个同步子进程,故意阻塞事件循环约 5 毫秒。 代码注释准确地解释了 async 丢失的原因:
// Sync (spawnSync) because the answer gates whether we enter fullscreen —
// an async probe raced against React render and lost: coder-tmux
// (ssh → tmux -CC on a remote box) doesn't propagate TERM_PROGRAM, so
// the env heuristic missed, and by the time the async probe resolved
// we'd already entered alt-screen with mouse tracking enabled.
// Mouse wheel is dead in iTerm2's -CC integration, so users couldn't scroll at all.
这是一个故意的正确性/性能权衡:启动时支付 5 毫秒以避免不可恢复的 UX 错误(iTerm2 tmux -CC 用户的鼠标滚轮死机)。 成本受到两个条件的严格限制——只有在以下情况下才会触发 $TMUX 设置为 AND $TERM_PROGRAM
不存在(SSH-into-tmux 情况)。 直接 iTerm2 和非 tmux 路径通过快速启发式完全跳过子进程。
tmuxControlModeProbed 是模块级的 boolean | undefined。 它以环境启发式结果为种子 before 生成,因此即使生成抛出或返回非零值,缓存也已填充。 如果没有这个,每次后续调用
isTmuxControlMode() (每个渲染帧触发 15 次以上)将重新进入探测函数并可能重新生成子进程。
鼠标旋钮:两个正交控件
即使全屏打开,鼠标行为也有两个独立的终止开关:
| 环境是 | 它禁用什么 | 什么仍然有效 |
|---|---|---|
| CLAUDE_CODE_NO_FLICKER=0 | 完全替代屏幕+全鼠标跟踪 | 正常终端回滚,无虚拟化滚动 |
| CLAUDE_CODE_DISABLE_MOUSE=1 | 鼠标捕获(滚轮 + 单击/拖动) | Alt 屏幕停留; 键盘 PgUp/PgDn/Ctrl+Home/End 仍然有效 |
| CLAUDE_CODE_DISABLE_MOUSE_CLICKS=1 | 仅单击和拖动事件 | Alt-screen + 滚轮滚动仍然有效 |
CLAUDE_CODE_DISABLE_MOUSE 专门为想要替代屏幕(无闪烁)但也需要 tmux/kitty copy-on-select 才能工作的用户而存在。 当应用程序启用捕获时,这些终端多路复用器会拦截鼠标事件; 禁用捕获会恢复本机终端文本选择,同时保留全屏布局。
ink/components/AlternateScreen.tsx 是实际将 DEC 序列写入终端的 React 边界。 它包装整个 REPL 树,并有一个关键约束:转义序列必须到达终端 before 第一个渲染的帧 - 否则第一帧绘制在主屏幕上,然后发生替代屏幕切换,并且当您退出时该帧将被保留为损坏的视图。
为什么使用InsertionEffect而不是useLayoutEffect
// useInsertionEffect (not useLayoutEffect): react-reconciler calls
// resetAfterCommit between the mutation and layout commit phases, and
// Ink's resetAfterCommit triggers onRender. With useLayoutEffect, that
// first onRender fires BEFORE this effect — writing a full frame to the
// main screen with altScreen=false. That frame is preserved when we
// enter alt screen and revealed on exit as a broken view. Insertion
// effects fire during the mutation phase, before resetAfterCommit, so
// ENTER_ALT_SCREEN reaches the terminal before the first frame does.
useInsertionEffect(() => {
const ink = instances.get(process.stdout)
if (!writeRaw) return
writeRaw(
ENTER_ALT_SCREEN
+ '\x1b[2J\x1b[H' // clear screen + home cursor
+ (mouseTracking ? ENABLE_MOUSE_TRACKING : '')
)
ink?.setAltScreenActive(true, mouseTracking)
return () => {
ink?.setAltScreenActive(false)
ink?.clearTextSelection()
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN)
}
}, [writeRaw, mouseTracking])
效果还调用 ink.setAltScreenActive(true, mouseTracking)。 这会通知 Ink 渲染器,以便它在每次绘制时将光标限制在视口内 - 防止光标恢复换行符向上滚动替代屏幕内容。 它还注册了一个信号退出清理处理程序,以便即使 React 卸载从未运行(例如 SIGKILL),替代屏幕也会干净地退出。
高度限制
// Constrain height to terminal rows — no native scrollback in alt-screen
return (
<Box
flexDirection="column"
height={size?.rows ?? 24} // from TerminalSizeContext
width="100%"
flexShrink={0}
>
{children}
</Box>
)
由于备用屏幕没有回滚功能,因此任何高于终端的内容都会消失在底部之后。 AlternateScreen 将其自身高度固定到 size.rows
(from TerminalSizeContext),这强制所有溢出由 Ink 处理
overflow: scroll / flexbox 布局——不是通过终端。
components/FullscreenLayout.tsx 是最高级别的布局组件。 它有两个完全不同的渲染路径,具体取决于 isFullscreenEnvEnabled():
基于槽位的布局
ScrollBox(增大)、粘性底部条(收缩)、绝对模态叠加。 一切都通过 AlternateScreen 受视口限制。
顺序渲染
<>{scrollable}{bottom}{overlay}{modal}</> — 内容只是垂直堆叠到正常的回滚中。
在全屏模式下,布局有五个命名插槽:
type Props = {
scrollable: ReactNode // message list — lives in a ScrollBox with stickyScroll
bottom: ReactNode // pinned bottom strip: prompt input, spinner, permissions
overlay?: ReactNode // rendered inside ScrollBox after messages (PermissionRequest)
bottomFloat?: ReactNode // absolute bottom-right of scroll area (companion speech bubble)
modal?: ReactNode // slash-command dialog: absolute bottom-anchored, fullscreen only
}
模态窗格大小计算
// MODAL_TRANSCRIPT_PEEK = 2 — rows of transcript visible above the modal divider
modal != null && (
<ModalContext value={{
rows: terminalRows - MODAL_TRANSCRIPT_PEEK - 1,
columns: columns - 4,
}}>
<Box
position="absolute"
bottom={0} left={0} right={0}
maxHeight={terminalRows - MODAL_TRANSCRIPT_PEEK}
>
/* ▔▔▔ divider line, then modal content with paddingX=2 */
</Box>
</ModalContext>
)
该模式几乎占据了整个视口高度,始终在分隔线上方留下 2 个文字记录行。 这 ModalContext 携带计算出的内部尺寸(rows - 3, columns - 4)这样模态中的子滚动框就知道它们可以长多高。
“N条新消息”药丸
当用户向上滚动时,滚动区域底部会漂浮一个药丸,显示有多少新消息到达。 可见性状态通过以下方式计算 useSyncExternalStore
订阅 ScrollBox的滚动位置 - 因此药丸出现和消失,而不会触发父 REPL 组件的任何重新渲染。
// pillVisible subscribes directly to ScrollBox — no REPL re-render per scroll frame
const pillVisible = useSyncExternalStore(subscribe, () => {
const s = scrollRef?.current
const dividerY = dividerYRef?.current
if (!s || dividerY == null) return false
return s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY
})
components/OffscreenFreeze.tsx 解决非全屏(主屏)渲染路径中的特定性能问题。 当内容在终端视口上方滚动到回滚缓冲区中时,对该内容的任何更改都会强制 Ink 的渲染器完全重置终端 — 它无法部分更新已经滚出的行。 对于每次更新更新的微调器组件或经过时间计数器,这都会为每个动画帧产生可见的重置。
export function OffscreenFreeze({ children }: Props): React.ReactNode {
'use no memo' // React Compiler opt-out — the freeze IS the memo mechanism
const inVirtualList = useContext(InVirtualListContext)
const [ref, { isVisible }] = useTerminalViewport()
const cached = useRef(children)
if (isVisible || inVirtualList) {
cached.current = children // update cache only while visible
}
// while offscreen: return stale ref — React reconciler bails, zero diff
return <Box ref={ref}>{cached.current}</Box>
}
该机制之所以有效,是因为当返回的元素与之前的渲染具有相同的对象标识时,React 会退出协调。 通过在屏幕外不变地返回缓存的引用,整个子树产生零差异和零终端输出。
inVirtualList 是真的。 ScrollBox 的虚拟列表会剪辑视口内的所有内容 - 无需担心终端回滚。 更重要的是,冻结在虚拟列表中会破坏点击展开功能,因为 useTerminalViewport的可见性计算可能与 ScrollBox 的虚拟滚动位置不一致。
'use no memo' - 明确选择退出 React Compiler 的自动记忆。 如果编译器记忆了组件,它会缓存返回的元素本身,这将破坏冻结机制(冻结是通过故意返回陈旧的缓存引用来工作的,而不是通过记忆组件的输出来实现)。
将所有内容放在一起:从进程启动到首次渲染再返回到 shell。
tmux 和鼠标滚动提示
在 tmux 内部运行时(但不在 tmux -CC 模式下),仅当 tmux 的情况下,鼠标滚轮事件才会转发到应用程序 mouse 选项已启用。 Claude Code 不以编程方式设置 tmux set mouse on — 在先前的实现将 tmux 鼠标状态泄漏到同级窗格(vim、less、shell)之后做出的故意选择。 反而,
maybeGetTmuxMouseHint() 如果 tmux 鼠标选项当前关闭,则在启动时触发一次并返回提示字符串:
"tmux detected · scroll with PgUp/PgDn · or add 'set -g mouse on' to ~/.tmux.conf for wheel scroll"
要点
- 备用屏幕是 DEC 模式 1049 (
\x1b[?1049h),而不是旧模式 47 — 1049 保存和恢复光标位置。 useInsertionEffect被选中useLayoutEffect确保 ENTER_ALT_SCREEN 在 Ink 的第一个渲染帧之前到达终端 - 这是一个微妙的计时要求useLayoutEffect出错了。- tmux -CC 检测探针在设计上是同步的:异步探针与 React 渲染周期竞争,并导致 SSH+tmux 用户出现不可恢复的损坏状态(死鼠标滚轮)。
- 全屏默认设置 on 对于 Anthropic 内部用户(
USER_TYPE=ant) 和 off 对于外部用户——环境变量CLAUDE_CODE_NO_FLICKER=1从外部选择加入。 OffscreenFreeze利用 React 对象身份救援为已滚动到终端回滚的内容生成零差异输出,从而消除了每个刻度的完全重置。- 鼠标跟踪有两个正交的终止开关:
CLAUDE_CODE_DISABLE_MOUSE(终止捕获,保持替代屏幕)和CLAUDE_CODE_NO_FLICKER=0(杀死所有内容,包括替代屏幕)。
检查你的理解情况
useInsertionEffect 而不是 useLayoutEffect?probeTmuxControlModeSync) 是同步的。 如果是异步的会发生什么?OffscreenFreeze's 'use no memo' 指示?CLAUDE_CODE_DISABLE_MOUSE=1。 哪种行为最能描述结果?