Read OSS

Gemini CLI の二つの顔:React/Ink ターミナル UI とプログラマブル SDK

中級

前提知識

  • 第1回:アーキテクチャとナビゲーションガイド
  • 第2回:エージェントループ
  • React の基礎知識
  • async iterable の理解

Gemini CLI の二つの顔:React/Ink ターミナル UI とプログラマブル SDK

Gemini CLI のアーキテクチャは、core と CLI の明確な分離、型付きイベントストリーム、そしてプロトコルインターフェースという設計原則のもとに成り立っています。この構造により、同一のバックエンドを基盤としながら、まったく異なる2つのインターフェースが実現されています。インタラクティブなターミナル UI は、コンテキストプロバイダー、キーボードハンドリング、ストリーミングコンテンツ表示を備えた本格的な React アプリケーションをターミナル上でレンダリングします。一方 SDK は、同じエンジンをプログラマブル API としてラップし、他のツールへの組み込みを可能にします。本記事では、この2つのインターフェースを詳しく見ていきましょう。

React/Ink インタラクティブ UI

Gemini CLI のターミナル UI は、React コンポーネントをターミナル出力にマッピングするライブラリ Ink を使った本格的な React アプリケーションです。UI はレイジーローディングで読み込まれます。第1回で触れたように、gemini.tsx の 167〜185 行目にある startInteractiveUI は、重量のあるモジュールを動的インポートで遅延読み込みしています。

export async function startInteractiveUI(/* ... */) {
  const { startInteractiveUI: doStartUI } = await import('./interactiveCli.js');
  await doStartUI(config, settings, startupWarnings, workspaceRoot, ...);
}

このアプローチにより、非インタラクティブパスの起動を高速に保てます。インタラクティブモードが起動すると、interactiveCli.tsxstartInteractiveUI がマウスイベントの有効化、キーマッチャーの読み込み、コンソール出力のパッチ適用、stdio ストリームの生成を行い、React ツリーをレンダリングします。

graph TD
    subgraph "Terminal Setup"
        ME[Enable mouse events]
        KM[Load key matchers]
        CP[Patch console]
        WS[Create working stdio]
    end
    
    subgraph "React Tree"
        AW[AppWrapper]
        AW --> SC[SettingsContext.Provider]
        SC --> KMP[KeyMatchersProvider]
        KMP --> KP[KeypressProvider]
        KP --> MP[MouseProvider]
        MP --> TP[TerminalProvider]
        TP --> SP[ScrollProvider]
        SP --> OP[OverflowProvider]
        OP --> SSP[SessionStatsProvider]
        SSP --> VMP[VimModeProvider]
        VMP --> AC[AppContainer]
    end
    
    ME --> AW
    KM --> AW
    CP --> AW
    WS --> AW

コンテキストプロバイダーの階層構造

UI は深い階層の React コンテキストプロバイダーを使って状態を管理しています。interactiveCli.tsx の 100〜120 行目にある AppWrapper コンポーネントを見てみましょう。

classDiagram
    class SettingsContext {
        LoadedSettings
        Multi-scope settings
    }
    class KeyMatchersProvider {
        Key binding definitions
        Custom shortcuts
    }
    class KeypressProvider {
        Priority-based key handling
        useKeypress hook
    }
    class MouseProvider {
        Mouse event state
        Click and scroll tracking
    }
    class TerminalProvider {
        Terminal dimensions
        Capability detection
    }
    class ScrollProvider {
        Scroll position
        Virtual scroll management
    }
    class OverflowProvider {
        Content overflow detection
        Truncation management
    }
    class SessionStatsProvider {
        Token counts
        Turn statistics
    }
    class VimModeProvider {
        Vi keybinding mode
        Normal/insert state
    }
    
    SettingsContext --> KeyMatchersProvider
    KeyMatchersProvider --> KeypressProvider
    KeypressProvider --> MouseProvider
    MouseProvider --> TerminalProvider
    TerminalProvider --> ScrollProvider
    ScrollProvider --> OverflowProvider
    OverflowProvider --> SessionStatsProvider
    SessionStatsProvider --> VimModeProvider

この階層の最下層に位置する AppContainer コンポーネントが、実際の処理の中心です。数百行にも及ぶ大型コンポーネントで、以下の責務を担っています。

  • 認証状態と認証コマンドの処理
  • イベント処理のための Gemini ストリームフック(useGeminiStream
  • useHistory によるヒストリー管理
  • スラッシュコマンドの処理
  • 確認リクエストのハンドリング
  • クォータとフォールバックの管理
  • ストリーミング状態、入力フォーカス、ツールアクションといった UI 状態

AppContainer はこれらすべてをさらに別のコンテキストプロバイダーでラップしてから、実際の App コンポーネントをレンダリングします。具体的には AppContextUIStateContextUIActionsContextConfigContextToolActionsProvider が使われています。

ヒント: UI の問題をデバッグする際は、ConfigContextUIStateContext が最もよく参照するコンテキストです。ConfigContext はコアの Config オブジェクトを提供し、UIStateContextStreamingState、現在の確認リクエスト、ヒストリーアイテムといったミュータブルな状態を保持しています。

ストリーミングイベントから React 状態へ

エージェントループ(第2回)と React UI をつなぐのが useGeminiStream フックです。このフックは GeminiClient.sendMessageStream() から ServerGeminiStreamEvent を受け取り、React の状態更新に変換します。

sequenceDiagram
    participant User as User Input
    participant AC as AppContainer
    participant GS as useGeminiStream
    participant GC as GeminiClient
    participant UI as React Components
    
    User->>AC: Submit prompt
    AC->>GS: sendMessage(prompt)
    GS->>GC: sendMessageStream()
    
    loop For each event
        GC-->>GS: ServerGeminiStreamEvent
        alt Content event
            GS->>UI: Update streaming text
        else ToolCallRequest
            GS->>UI: Show tool call in progress
        else ToolCallConfirmation
            GS->>UI: Show confirmation dialog
        else Thought event
            GS->>UI: Update thinking indicator
        else Error event
            GS->>UI: Display error
        end
    end
    
    GC-->>GS: Finished event
    GS->>UI: Set streaming state to idle

フックは StreamingState enum の状態遷移を管理しています。

  • メッセージ送信時:IdleStreaming
  • レスポンス完了時:StreamingIdle
  • ユーザーが中断した時:StreamingCancelled

ストリームから届いたツール呼び出しリクエストは Scheduler に渡されます(第3回参照)。Scheduler がユーザーの確認を必要とする場合は MessageBus 経由でイベントを発行し、UI は TOOL_CONFIRMATION_REQUEST イベントを受け取って適切な確認ダイアログを表示します。

非インタラクティブモード

stdin がパイプされているか --prompt フラグが使用されている場合、Gemini CLI は React を使わず非インタラクティブモードで動作します。packages/cli/src/nonInteractiveCli.tsrunNonInteractive() 関数は、よりシンプルなイベント処理パスを提供します。

flowchart TD
    A[main() detects non-interactive] --> B[Read stdin if piped]
    B --> C[Fire SessionStart hook]
    C --> D[Log user prompt telemetry]
    D --> E[runNonInteractive()]
    E --> F[Consume ServerGeminiStreamEvent]
    F --> G[Write Content to stdout]
    F --> H[Auto-confirm tools per policy]
    F --> I[Write errors to stderr]
    G --> J[Exit on Finished]

非インタラクティブモードでは、gemini.tsx の 720〜754 行目にある出力リスナーが coreEvents を直接 stdout と stderr に振り分けます。React レンダリングもスクロール管理もキーハンドリングも一切なく、イベント駆動で出力するだけです。

確認ダイアログを表示する UI がないため、非インタラクティブモードはポリシーエンジンと承認モードに完全に依存します。YOLO モードではすべてのツールが自動承認されます。DEFAULT モードでは確認が必要なツールは自動的に拒否されます(確認できるユーザーがいないため)。これは MessageBus のリスナーチェックで制御されており、TOOL_CONFIRMATION_REQUEST リスナーが登録されていない場合、confirmed: false, requiresUserConfirmation: true をただちに返します。

エージェントプロトコルと SDK

packages/sdk/ の SDK は、AgentProtocol インターフェースを基盤としたプログラマブル API を提供します。

export interface AgentProtocol extends Trajectory {
  send(payload: AgentSend): Promise<{ streamId: string | null }>;
  subscribe(callback: (event: AgentEvent) => void): Unsubscribe;
  abort(): Promise<void>;
  readonly events: readonly AgentEvent[];
}

三つのメソッドがインターフェース全体の契約を定義しています。

  • send() — エージェントにデータを送信します(メッセージ、エリシテーションレスポンス、設定更新、アクション)。相関付けのための streamId を返します。
  • subscribe() — すべてのエージェントイベントをリッスンします。購読解除用の関数を返します。
  • abort() — 現在のエージェント処理をキャンセルします。

AgentSessionAgentProtocol をより便利な API でラップし、AsyncIterable として sendStream() を追加しています。

async *sendStream(payload: AgentSend): AsyncIterable<AgentEvent> {
    const result = await this._protocol.send(payload);
    const streamId = result.streamId;
    if (streamId === null) return;
    yield* this.stream({ streamId });
}

これにより、for await...of ループでエージェントイベントを処理できます。

const session = agent.session();
for await (const event of session.sendStream({ message: [{ text: "Fix the bug" }] })) {
    switch (event.type) {
        case 'content': console.log(event.text); break;
        case 'agent_end': console.log('Done'); break;
    }
}

SDK のエントリーポイントである GeminiCliAgent はセッションを管理します。

const agent = new GeminiCliAgent({ cwd: '/path/to/project' });
const session = agent.session();
// 既存セッションを再開する場合:
const resumed = await agent.resumeSession(previousSessionId);
graph TD
    subgraph SDK Package
        GCA[GeminiCliAgent<br/>Session factory]
        GCS[GeminiCliSession<br/>Session lifecycle]
    end
    
    subgraph Core Package
        AP[AgentProtocol<br/>send/subscribe/abort]
        AS[AgentSession<br/>AsyncIterable wrapper]
        ET[Event Translator<br/>ServerGeminiStreamEvent → AgentEvent]
    end
    
    GCA --> GCS
    GCS --> AP
    AP --> AS
    AS --> ET

Event Translator レイヤーは、内部の ServerGeminiStreamEvent ユニオン(第2回で触れた 18 種類)を、外部利用者向けに最適化された別の分類体系を持つ SDK の AgentEvent ユニオンに変換します。

A2A サーバーと VS Code 拡張機能

SDK の上にはさらに二つのパッケージが構築されています。

packages/a2a-server — Gemini CLI の機能を HTTP 経由で公開する実験的な Agent-to-Agent プロトコルサーバーです。他のエージェントは標準化された API を通じて、プロンプトの送信、ストリーミングレスポンスの受信、ツールの呼び出しを行えます。エージェント間通信のための Google の A2A プロトコル仕様に準拠しています。

packages/vscode-ide-companion — エディター連携を実現する VS Code 拡張機能です。開いているファイル、カーソル位置、選択テキストといった IDE のコンテキスト情報を ideContextStore 経由で Gemini CLI バックエンドに送信します。第2回の GeminiClient.getIdeContextParts() で見たように、このコンテキストは構造化 JSON として会話に挿入され、モデルがエディター上で何を見ているかを把握できるようになります。

ヒント: SDK を使って開発する際は、send()subscribe() を手動で組み合わせるよりも AgentSession.sendStream() を使いましょう。sendStream() はストリームの追跡、streamId によるイベントフィルタリング、async iterator プロトコルを通じた適切なクリーンアップをすべて自動で処理してくれます。

シリーズのまとめ

6回にわたって、Gemini CLI のコードベースを端から端まで追ってきました。

  1. アーキテクチャ — 7パッケージのモノレポ、Config ゴッドオブジェクト、二系統のイベントシステム
  2. エージェントループ — GeminiClient → Turn → GeminiChat のストリーミングイベント、圧縮、ループ検出
  3. ツールと Scheduler — ツール定義のビルダーパターンとイベント駆動のオーケストレーション
  4. セキュリティ — ポリシーエンジンのルール、プラットフォームサンドボックス、プラガブルな安全性チェッカー
  5. 拡張性 — フック、スキル、MCP、HMAC 整合性検証付き拡張機能、モデルルーティング戦略
  6. UI と SDK — React/Ink によるターミナルレンダリングとプログラマブルな AgentProtocol

一貫して貫かれている設計思想は、明確な契約による階層的な抽象化です。ServerGeminiStreamEvent ユニオンはバックエンドとあらゆるフロントエンドをつなぎます。AgentLoopContext インターフェースは実行コンテキストをスコープします。ToolInvocation ライフサイクルはツール実行を標準化します。そして MessageBus が、エージェントの自律的な判断とユーザーのコントロールを仲介します。

Gemini CLI へのコントリビュート、拡張機能の開発、SDK での組み込みのいずれにおいても、これらの層を理解していれば自信を持ってコードベースを歩き回れるはずです。