toyotermというターミナルエミュレータをCodexと作った
toyotermというターミナルエミュレータを作っている。 Rustで動くネイティブなターミナルの上にmrubyを組み込み、設定だけでなくキーバインド、イベント、コマンド、ステータスバーなどをRubyでプログラムできるようにしたもの。
最初にこのプロジェクトでの役割分担について書いておくと、コンセプト、欲しい機能や振る舞い、Rubyから見えるAPIなどのプロダクトデザインは自分で考え、ターミナル内部の技術設計とコーディングを含む実装はすべてCodexにやってもらった。
「AIにターミナルエミュレータを作ってもらった」というより、自分がプロダクトオーナーとして外から見える姿を決め、Codexが内部の設計と実装を担当した、という表現が一番近い。
toyotermとは
toyotermは、自分で使うために作っているプログラム可能なターミナルエミュレータ。
今のところ、多くの人に使ってもらうことを目標にしたプロダクトではない。まずは自分が使いたいもの、自分にとって気持ちのよいAPIを持つ道具として開発している。リポジトリは公開しているが、誰にでも勧められる完成品を目指すというより、日常的に自分で使いながら育てていくためのプロジェクトである。
PTYやVT解析、描画といったターミナルのホットパスはRustでネイティブに処理し、ユーザーが振る舞いを変更するためのコントロールプレーンとしてmrubyを使っている。
基本的な構成は以下の通り。
- Rust 2024 Edition
- ネイティブPTY
alacritty_terminalによるVT解析wgpuとglyphonによるGPU描画winitによるウィンドウとイベント処理- 組み込みmrubyによる設定と拡張
Workspace、Tab、分割Paneはそれぞれ独立したPTYと端末セッションを持つ。 検索、選択、スクロールバック、IME、クリップボード、OSC 8リンク、Sixel・Kitty・iTerm2の画像表示、シェル連携など、普段使うために必要な機能も一通り実装している。
Linux、macOS、Windowsでビルドとテストを行っているが、まだバージョンは0.0.1であり、完成品というより実験中の個人プロジェクトという位置付け。
ターミナルエミュレータの中身は知らなかった
ここまで技術構成を書いたが、開発を始めるまで、自分にはターミナルエミュレータの内部に関する知識がほとんどなかった。VT parserやPTYが何をするものなのかも知らず、どのような部品や抽象化が必要なのかも分かっていなかった。
Rubyのコールバックへ不変なスナップショットを渡す設計についても、自分が必要性を考えて指定したものではない。そもそもスナップショットが必要だということ自体を知らなかった。自分が実現したい振る舞いとAPIを伝えた結果、Codexが内部の整合性やスレッド間のやり取りを考え、良しなに設計して実装したものになる。
そのため、この記事で紹介しているPTY、VT解析、スナップショット、型付きコマンド、スレッド構成といった内部アーキテクチャは、Codexがtoyotermを成立させるために選び、コードへ落とし込んだ設計である。自分が担当したプロダクトデザインと、Codexが担当した技術設計は、ここでは明確に分けておきたい。
WezTermを使ってきた
ここ数年、普段使いのターミナルエミュレータにはWezTermを使っている。
WezTermはRustで実装されたクロスプラットフォームのターミナルエミュレータ兼マルチプレクサで、Luaによる柔軟な設定APIを持っている。Workspace、Window、Tab、Paneというモデルを備え、設定ファイルからイベントやキーバインドを扱える。
そのため、toyotermのWorkspace、Tab、Paneという構成や、スクリプトでターミナルの振る舞いを組み立てるという方向性はWezTermと重なる部分が多い。実際に数年間使ってきたことで、ターミナルがプログラム可能であることの便利さを知ったのは、toyotermを考える上でも大きかった。
toyotermはWezTermへの不満から代替品を作ろうとしたものではない。WezTermという高機能な実用品を使いながら、自分ならどんな機能と操作体系が欲しいか、それを自分が書きたいRubyのAPIで表現するとどうなるかを試す個人プロジェクトである。
似た機能を持っていても、toyotermは組み込みのキーバインドを持たず、操作体系そのものをRuby APIで定義する。このあたりに、自分がプロダクトとして考えたかったことが出ていると思う。内部ではRubyからネイティブの状態を直接変更させず、スナップショットと型付きコマンドの境界を設けているが、こちらはCodexが考えた技術設計である。
名前について
WezTermは作者のWezさんの名前から「Wez’s Terminal Emulator」と名付けられている。それにあやかり、toyotermも自分の名字であるToyotaとterminalを合わせただけのもの。
同時にtoy、つまり「おもちゃ」という意味にも少しかけている。完成された万能のターミナルを目指すというより、自分で設計し、触りながら試し、好きなように作り変えていくためのおもちゃという、このプロジェクトの立ち位置にも合っていると思う。
なぜRubyとmrubyなのか
設定ファイルにはTOMLやYAMLではなくRubyを使う。
自分は社会人になってからずっと仕事でRuby on Railsを書いてきた。長く使ってきたというだけでなく、単純にRubyという言語が好きなので、毎日使うターミナルをプログラムするならRubyで書けるものにしたかった。
単にフォントや色を設定するだけならデータ形式で十分だが、やりたかったのは「設定可能なターミナル」ではなく「プログラム可能なターミナル」だった。
RubyはDSLを作ることに大変強い。ブロック、シンボル、自然なメソッド呼び出しを組み合わせることで、宣言的に読めるAPIを作りながら、その実体は普通のプログラムとして扱える。条件分岐や繰り返しはもちろん、メソッドを定義したり、メタプログラミングで自分用の抽象化を作ったりすることもできる。
設定を完成させて終わりではなく、使いながら振る舞いを考え、コードを書き、さらに自分の道具へ変えていける。そうやってプログラムすること自体が楽しいというのも、Rubyを選んだ大きな理由である。
たとえば最低限の設定はこのように書ける。
Toyoterm.configure do |config|
config.font.family = "monospace"
config.font.size = 14
config.keys do
ctrl_shift("t").new_tab
ctrl_shift("e").split(:right)
ctrl_shift("r").reload_config
primary_shift("c").copy_selection
primary_shift("v").paste_clipboard
end
end
Rubyなので、OSごとにキーバインドを変えたり、イベントに応じて処理を実行したり、端末内のオブジェクトを操作したりできる。
Toyoterm.on :command_finished do |event|
status = event.exit_status
event.pane.badge = status.nil? || status == 0 ? nil : "exit #{status}"
end
Toyoterm.command :monitor_window do |context|
context.workspace.new_window(command: "btop", cwd: context.pane.cwd)
end
シェルのコマンドが失敗したときだけPaneにバッジを出したり、現在のディレクトリを引き継いで特定のレイアウトを作ったりと、静的な設定ファイルでは表現しにくい振る舞いを普通のRubyとして記述できる。
Rubyの実行環境にはmruby 4.0を選んだ。mruby 4.0からamalgamationに対応し、ソース一式を単一のmruby.cとmruby.hへまとめられるようになったため、アプリケーションへかなり組み込みやすくなったことが大きい。toyotermでも生成した2ファイルをリポジトリに含め、Rustのビルド時にコンパイルしている。
mrubyはバイナリに組み込まれるため、実行環境にRubyを別途インストールする必要はない。一方でCRubyのgemがそのまま使えるわけではなく、設定やプラグインはサンドボックスでもない。このあたりは便利さだけを強調せず、意図した制約としてドキュメントに書いている。
自分が考えたAPIとCodexが設計した内部
自分が考えたのは、Rubyを書く人からWorkspace、Window、Tab、Paneがどのように見え、どんなコードで操作できたら気持ちよいかというAPIである。
一方、toyotermの内部ではRubyからネイティブの状態を直接触らせていない。
RubyのコールバックにはWorkspace、Window、Tab、Paneなどの不変なスナップショットを渡し、Ruby側で行われた操作を型の付いたコマンドへ変換する。そのコマンドをRust側が受け取り、メインスレッドで適用する。これは自分が指定した仕組みではなく、求めたAPIを安全に実現するためにCodexが設計したものだった。
Toyoterm.command :development_layout do |context|
context.pane.split(:right, command: "git status", cwd: context.pane.cwd)
context.workspace.new_tab(command: "cargo watch -x check", cwd: context.pane.cwd)
end
APIを使う側からはオブジェクトを自然に操作しているように見えるが、裏側ではRubyがMuxを直接変更しているわけではない。
この境界を設けたことで、Rubyのイベント、キーバインド、ライブコンソール、IPCといった異なる入口からの操作を同じコマンドモデルへ集約できる。Rubyのコールバックが例外になった場合は、その中でキューに積まれた変更を破棄できるため、設定や操作をトランザクショナルに扱える。
また、組み込みGUIキーバインドはあえて用意していない。タブの作成やPane分割といった操作もRubyの設定で定義する。 少し不親切にも見えるが、アプリケーションが決めた操作へユーザーを合わせるのではなく、同じAPIを使って自分のターミナルを組み立てられることを優先した。
設定の再読み込みも、既存のVMへ継ぎ足すのではなく、候補となるmruby VMで設定全体を評価・検証し、成功した場合だけ入れ替える。書きかけの設定や例外によって、動いているターミナルまで壊さないための設計である。
ネイティブ処理とスクリプトを分ける
mruby VMは専用のスクリプトスレッドに1つだけ置いている。 GUIのメインスレッドが不変なスナップショットを送り、スクリプトスレッドが実行結果とネイティブコマンドを返す。
このため遅いRubyコールバックがあっても、PTY出力の解析や画面の描画まで止まることはない。時間のかかる外部コマンドはToyoterm.asyncで別のワーカースレッドへ逃がせる。
config.window.bar :bottom, interval: 1.0 do |bar|
bar.section(:left) do |section|
section.add { |context| context.workspace.name }
end
bar.section(:right) do |section|
section.add_async("git", "branch", "--show-current",
interval: 2.0,
cwd: ->(context) { context.pane.cwd }) do |result|
result.success? ? result.stdout.strip : ""
end
end
end
Rubyは表現力のために使い、端末の性能や応答性に関わる部分へは入れない。この線引きもCodexが作った内部設計であり、toyotermの中でかなり重要な部分になっている。
Codexに実装を任せる
この規模のものを作るにあたり、自分はコードを書いていない。
何を作るか、どのような操作体系にするか、RubyからどんなAPIが見えるべきか、といったプロダクト側の判断を自分が行い、それをCodexへ伝えた。どこをネイティブ側の責務にするか、状態をどう保持してスレッド間でどう受け渡すか、といった技術的な判断と実装はCodexに任せた。
実装を任せるといっても、曖昧な一文から完成品が突然出てくるわけではない。機能を小さく分け、期待する振る舞いと制約を伝え、実装結果を動かして確認し、違えば設計や要求を修正してまた実装してもらう。その繰り返しだった。
特にターミナルエミュレータは、PTY、文字幅、IME、GPU、OSごとのウィンドウシステム、制御シーケンスなど、当初の自分が知らなかった領域ばかりである。Codexは実装する中で以下のような技術設計を選び、ADRとして残し、以降の変更でも守る前提にした。
- ネイティブアプリケーションはRustで実装する
- スクリプトには組み込みmrubyを使う
- GPU描画には
wgpuを使う - ターミナルバックエンドを独自の抽象化の裏へ隔離する
- 状態変更を型付きのネイティブコマンドへ正規化する
- mruby VMは専用スレッドで単一のインスタンスを所有する
Codexがコードを書く場合でも、プロダクトの設計と判断まで自動的に正しくなるわけではない。何を良しとするかを決める人と、その判断を実装へ落とし込む仕組みの両方が必要だった。
逆に、責務と制約が明確になっていれば、CodexはRustの各crate、テスト、CI、パッケージング、英語と日本語のドキュメントまで一貫して変更できる。自分はコードを書く作業から離れ、APIをどう使いたいか、機能同士が矛盾していないか、実際に触って気持ちよいか、というプロダクト側の検討に時間を使えた。
コードを書いていないから何もしていない、という感覚はない。むしろ、実装手段から距離を置いたことで、何を作るのか、どんな使い心地にしたいのか、どのAPIなら長く使えるのかを以前より強く意識することになった。
現在地
最初のコミットから約2週間で、リポジトリは10個のRust crateに分かれ、Workspace、Tab、Pane、画像プロトコル、シェル連携、Rubyプラグイン、ライブコンソールなどが動くところまで来た。
現在は、何よりもまず自分が使うことを目的に開発している。多くの人に使ってもらうことや、誰にでも使いやすい製品にすることを第一の目標にはしていない。自分が欲しい機能とAPIを考え、自分の環境で毎日使えるものにしていくことを優先している。
もちろん公開しているので、同じ方向性を面白いと思う人が試してくれるのは嬉しい。ただし、幅広いユースケースを取り込むためにコンセプトを薄めるよりは、自分のための道具として一貫した設計を保ちたいと思っている。
まだ複数OSウィンドウやセッション永続化は未実装で、各OSでの実機検証も続ける必要がある。日常的に使いながらAPIを整え、まずは最初のリリースを目指す。
興味があればGitHubのリポジトリを覗いてみてください。