Claude Code は React と Ink をベースにしたターミナル UI を持つ。しかし「React + Ink を使っている」という一文では実態の半分も説明できない。ソースコードを読み解くと、標準的な Ink アプリケーションとは大きく異なる、高度にカスタマイズされたレンダリングエンジンが見えてくる。
この記事では、src/ink/ に実装されたカスタムエンジンから React 18 の先進 API 活用、OSC シーケンスによるターミナル制御まで、Claude Code の TUI を支える設計を順に分析する。
1. カスタム Ink エンジン——標準ライブラリの限界を越える
src/ink/ ディレクトリには、公開されている Ink ライブラリとは別の独自実装が含まれる。標準 Ink との最大の違いは「レンダリングコスト」への向き合い方だ。
1.1 インクリメンタル・レンダリング(Blit)
src/ink/render-node-to-output.ts には、フレームをまたいで変化した領域だけを再描画する仕組みが実装されている。
// src/ink/render-node-to-output.ts:27-41
// Per-frame scratch: set when any node's yoga position/size differs from
// its cached value, or a child was removed. Read by ink.tsx to decide
// whether the full-damage sledgehammer is needed this frame.
// Steady-state frames (spinner tick, clock tick, text append into a
// fixed-height box) don't shift layout → narrow damage bounds →
// O(changed cells) diff instead of O(rows×cols).
let layoutShifted = false
export function resetLayoutShifted(): void {
layoutShifted = false
}
export function didLayoutShift(): boolean {
return layoutShifted
}
layoutShifted フラグは「前フレームからレイアウト(位置・サイズ)が変化したか」を追跡する。スピナーの更新やストリーミングテキストの追記のような 定常フレームではフラグが立たない。これにより、再描画のコストは O(rows×cols) ではなく O(変化したセル数) に抑えられる。数千行の履歴を持つセッションでも、小さな変化で画面全体を書き換えない。
変化のなかったノードは「ブリット」——ビットブロック転送の概念を応用し、前フレームの出力をそのまま使い回す。Yoga レイアウトエンジンの計算結果もキャッシュされ、layoutShifted フラグとの組み合わせで不要な再計算を排除している。
1.2 ハードウェア・スクロール(DECSTBM)
スクロール操作には、ターミナルエミュレータのハードウェア機能を直接活用する。
// src/ink/render-node-to-output.ts:44-49
// DECSTBM scroll optimization hint. When a ScrollBox's scrollTop changes
// between frames (and nothing else moved), log-update.ts can emit a
// hardware scroll (DECSTBM + SU/SD) instead of rewriting the whole
// viewport. top/bottom are 0-indexed inclusive screen rows; delta > 0 =
// content moved up (scrollTop increased, CSI n S).
export type ScrollHint = { top: number; bottom: number; delta: number }
ScrollHint には、スクロール対象の上下端(top/bottom)とスクロール量(delta)が格納される。ScrollBox の scrollTop だけが変化しレイアウトシフトがない場合、レンダラーは画面全体を書き換える代わりに DECSTBM(Set Top and Bottom Margins)と SU/SD(Scroll Up/Down)の ANSI エスケープシーケンスをターミナルに送出する。ターミナルエミュレータ側がハードウェアで処理するため、アプリケーション側の CPU 負荷はほぼゼロに抑えられる。
1.3 カスタム・リコンサイラ
React の仮想 DOM をターミナル DOM に反映する調停処理も、独自実装だ。
// src/ink/reconciler.ts:4
import createReconciler from 'react-reconciler'
react-reconciler を直接使用し、src/ink/dom.ts が定義するターミナル DOM モデルに最適化されたホスト設定を与えている。React のコンポーネントツリーの変更が、必要最小限のターミナル操作として出力される。
2. React の並行性 API をターミナルで活用する
2.1 useSyncExternalStore——Concurrent Rendering と安全に同期する
グローバルな状態は React の外側(後述のカスタムストア)で管理される。UI との同期に使われるのが useSyncExternalStore だ。
// src/state/AppState.tsx:3
import React, { useContext, useEffect, useEffectEvent, useState, useSyncExternalStore } from 'react';
useSyncExternalStore は Concurrent Rendering において、外部ストアの状態と UI の「ティアリング」(部分的な整合性の崩れ)を防ぐために設計されたフックだ。Claude Code ではこれを AppState、VirtualMessageList、カスタムストア購読など複数の場所で使用し、React の外部で起きる変更(ツール実行完了、モデルのストリーミング出力など)を安全かつ効率的に UI へ伝える。
2.2 useEffectEvent——エフェクトの再実行を抑えてチラつきを防ぐ
// src/state/AppState.tsx:3
import React, { useContext, useEffect, useEffectEvent, useState, useSyncExternalStore } from 'react';
useEffectEvent は React 19.2(2025年10月)で安定版として正式リリースされたフックだ。依存配列に含めると useEffect が再実行されてしまうコールバックを、「常に最新の値を参照しながら再実行を起こさない」形でカプセル化する。ターミナルではエフェクトの不要な再実行がチラつき(フリッカー)として現れやすいため、Claude Code はこれを意図的に採用している。
2.3 useDeferredValue——ストリーミング出力の重い再描画を遅延する
// src/screens/REPL.tsx:25
import { useEffect, useMemo, useRef, useState, useCallback, useDeferredValue, ... } from 'react';
LLM のストリーミング出力は高頻度でテキストが追加される。useDeferredValue によって重いリスト再描画を遅延させ、ユーザーのキー入力への反応を優先している。Markdown パースのような計算コストの高い処理も、ユーザー操作をブロックしない。
2.4 React Compiler と 'use no memo'
src/screens/REPL.tsx、src/components/VirtualMessageList.tsx などを含む約 395 ファイルに、React Compiler(React Forget)による自動メモ化が適用されている。
// React Compiler が生成するキャッシュ(各ファイル先頭)
import { c as _c } from "react/compiler-runtime";
開発者が手動で useMemo/useCallback を書かなくても、ビルド時に自動で依存関係が最適化される。ただし、StreamingMarkdown コンポーネントは意図的に除外されている。
// src/components/Markdown.tsx:186-194
export function StreamingMarkdown({
children
}: StreamingProps): React.ReactNode {
// React Compiler: this component reads and writes stablePrefixRef.current
// during render by design. The boundary only advances (monotonic), so
// the ref mutation is idempotent under StrictMode double-render — but the
// compiler can't prove that, and memoizing around the ref reads would
// break the algorithm (stale boundary). Opt out.
'use no memo';
StreamingMarkdown はレンダー中に ref.current を読み書きするアルゴリズムを持つ。コンパイラがこれをメモ化すると境界が古いまま固定され、アルゴリズムが壊れる。'use no memo' ディレクティブで局所的にオプトアウトし、React Compiler の全体適用と例外的な手動制御を共存させている。
3. OSC シーケンスによるターミナル制御
TUI の「演出」は ANSI エスケープシーケンスだけではない。Claude Code は OSC(Operating System Command)シーケンスを積極的に活用し、ターミナルエミュレータ本来の機能と統合する。
// src/ink/termio/osc.ts:229-249
export const OSC = {
SET_TITLE_AND_ICON: 0,
SET_ICON: 1,
SET_TITLE: 2,
SET_COLOR: 4,
SET_CWD: 7,
HYPERLINK: 8, // クリック可能なリンク
ITERM2: 9,
SET_FG_COLOR: 10,
SET_BG_COLOR: 11, // ターミナル背景色の問い合わせ(auto-theme)
SET_CURSOR_COLOR: 12,
CLIPBOARD: 52,
KITTY: 99,
RESET_COLOR: 104,
RESET_FG_COLOR: 110,
RESET_BG_COLOR: 111,
RESET_CURSOR_COLOR: 112,
SEMANTIC_PROMPT: 133, // シェルプロンプト境界マーク
GHOSTTY: 777,
TAB_STATUS: 21337, // タブへの状態インジケータ
} as const
| OSC 番号 | 用途 | 効果 |
|---|---|---|
| 11 | SET_BG_COLOR |
ターミナルの背景色を問い合わせ、テーマを自動切換 |
| 8 | HYPERLINK |
ターミナル上でクリック可能なリンクを描画 |
| 133 | SEMANTIC_PROMPT |
シェルのプロンプト境界をマーク(シェル統合) |
| 21337 | TAB_STATUS |
ターミナルタブに状態インジケータを表示 |
タブ状態インジケータは src/ink/hooks/use-tab-status.ts に実装されており、idle / busy / waiting の 3 状態に対応する RGB カラーとテキストが定義されている。
// src/ink/hooks/use-tab-status.ts:21-40
const TAB_STATUS_PRESETS: Record<
TabStatusKind,
{ indicator: Color; status: string; statusColor: Color }
> = {
idle: { indicator: rgb(0, 215, 95), status: 'Idle', statusColor: rgb(136, 136, 136) },
busy: { indicator: rgb(255, 149, 0), status: 'Working…',statusColor: rgb(255, 149, 0) },
waiting: { indicator: rgb(95, 135, 255),status: 'Waiting', statusColor: rgb(95, 135, 255) },
}
OSC シーケンスに未対応のターミナルでは、これらのシーケンスは無視される。ターミナル能力検出と安全なフォールバックにより、機能差異に依存しない設計になっている。
4. コンポーネント設計
4.1 仮想化リスト(VirtualMessageList.tsx)
ブラウザの仮想スクロール(Virtual Scrolling)と同等の仕組みをターミナルで実装している。src/components/VirtualMessageList.tsx は useVirtualScroll フックと連携し、現在の表示領域外のメッセージをレンダリング対象から除外する。数千件の会話履歴があっても、実際に描画されるのはビューポート内のメッセージのみだ。
4.2 デザインシステム(ThemedBox / ThemedText)
src/components/design-system/ThemedBox.tsx は Ink の Box コンポーネントをラップし、primary、secondary、error などのテーマキーによるスタイリングを提供する。
// src/components/design-system/ThemedBox.tsx:42-50
function resolveColor(color: keyof Theme | Color | undefined, theme: Theme): Color | undefined {
if (!color) return undefined;
if (color.startsWith('rgb(') || color.startsWith('#') || ...) {
return color as Color; // 生カラー値はそのまま通す
}
return theme[color as keyof Theme] as Color; // テーマキーを解決
}
コンポーネントは生の ANSI カラーコードを直接扱わない。テーマキーを解決する層を挟むことで、全体のカラーテーマを一箇所で管理できる。
4.3 ストリーミング UI の実装
LLM 特有の動的な出力に対応する専用コンポーネントが用意されている。
| コンポーネント | 役割 |
|---|---|
StreamingMarkdown |
ストリーミングされる Markdown テキストをリアルタイムでパース・描画 |
AssistantThinkingMessage |
モデルの思考プロセスを折り畳み可能な UI として表示 |
ToolUseLoader |
ツール実行中の stdout をリアルタイム表示しつつ UI 応答性を維持 |
5. カスタムストアと状態管理
5.1 外部ライブラリに依存しないストア実装
Zustand などの外部ライブラリは使わず、src/state/store.ts にシンプルなストアを自前実装している。
// src/state/store.ts:4-34
export type Store<T> = {
getState: () => T
setState: (updater: (prev: T) => T) => void
subscribe: (listener: Listener) => () => void
}
export function createStore<T>(initialState: T, onChange?: OnChange<T>): Store<T> {
let state = initialState
const listeners = new Set<Listener>()
return {
getState: () => state,
setState: (updater) => {
const prev = state
const next = updater(prev)
if (Object.is(next, prev)) return // 参照が変わらなければ通知しない
state = next
onChange?.({ newState: next, oldState: prev })
for (const listener of listeners) listener()
},
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
getState・setState・subscribe の 3 メソッドで構成される。useSyncExternalStore の要求するインターフェースと一致しており、React との統合が直接行える。Object.is による等価チェックで、値が変わっていなければリスナーへの通知を省略する。
5.2 状態の分類と配置ルール
分類軸は「グローバルに共有・永続化が必要か、LLM の推論に影響するか」だ。
| 状態の種類 | 場所 | 内容 |
|---|---|---|
| AppState | src/state/AppState.tsx |
会話履歴、MCP 状態、プロジェクト構成など LLM の推論に影響するドメインデータ |
Local State (useState) |
各コンポーネント | ホバー・フォーカス・ダイアログ開閉などの UI 状態から、OAuth フロー進捗・Git 操作状態・ウィザード進捗など画面ローカルの業務ロジックまで広く担う |
useState の用途はホバーやフォーカスに限らない。たとえば WorktreeExitDialog はワークツリー終了フローの状態遷移(loading → asking → keeping/removing → done)や Git の変更ファイルリストを useState で管理し、ConsoleOAuthFlow は OAuth の認証ステップ(idle → waiting_for_login → creating_api_key → success/error)を useState で追う。これらは AppState に置かれない——セッションをまたいで永続化する必要がなく、LLM が直接知る必要もない状態だからだ。
AppState の変更は onChangeAppState ハンドラーを通じて設定ファイルへの保存や IDE への通知などの二次的な副作用につながる。コンポーネントは副作用を直接実行せず、ストアの更新に専念する設計だ。
6. ディレクトリ設計と責務分離
大規模な CLI アプリケーションとして、責務の分離が徹底されている。
| ディレクトリ | 役割 |
|---|---|
src/query.ts |
エージェント思考ループ・ツール実行判断・コンテキスト管理(React から分離されたコアエンジン) |
src/screens/ |
画面全体のオーケストレーション。query.ts を呼び出して UI に反映するコントローラー層 |
src/components/ |
再利用可能な UI 部品。messages/・tasks/・design-system/ で機能単位に整理 |
src/hooks/ |
104 個のカスタムフック。notifs/(通知管理)・toolPermission/(権限管理)などでサブディレクトリ整理 |
src/tools/ / src/tasks/
|
実行ロジック(.ts)と表示コンポーネント(.tsx)を近接配置。機能の自己完結性を高める |
src/services/ |
LSP・MCP・Compact・認証など特定ドメインに特化した複雑なロジック |
src/utils/ |
100 以上の小モジュール。Git・ファイル操作・トークン計算などの純粋関数群 |
特徴的なのは src/tools/ と src/tasks/ の設計だ。たとえば BashTool/ ディレクトリには BashTool.ts(実行ロジック)と BashTool.tsx(表示コンポーネント)が並んで置かれる。機能を追加・変更する際に、ロジックと UI を同じ場所で完結させられる。
まとめ
Claude Code の TUI は、React + Ink という選択を起点にしながら、その上に複数の独自レイヤーを積み上げた設計だ。
-
レンダリングエンジン:
layoutShiftedフラグと DECSTBM ハードウェアスクロールにより、変化した領域のみを最小コストで更新する -
React の活用:
useSyncExternalStore・useEffectEvent・useDeferredValueを組み合わせ、React 外部の状態変化を安全かつ低コストで UI に伝える -
React Compiler と制御された例外: 全体を自動メモ化しながら、アルゴリズム上の制約がある
StreamingMarkdownのみ'use no memo'でオプトアウトする - OSC シーケンス統合: アプリケーションの状態をターミナルエミュレータ本来の UI 要素(タブ、テーマ、リンク)と接続する
- 責務分離: コアエンジン・状態管理・コンポーネント・ツールの境界を明確にし、LLM 推論に関わる状態と純粋な表示状態を区別する
ターミナルという制約の中で、ウェブアプリケーション開発で確立されたテクニック(仮想スクロール、デザインシステム、自動メモ化)をそのまま移植し、ターミナル固有の問題(スクロールコスト、フリッカー、レンダリングコスト)に対して低レイヤーの最適化を重ねた結果が、Claude Code の TUI の正体だ。