問題点:コマンドラインツールにおけるUXのギャップ
flagやCobraといった標準的なGoのツールは、エコシステムの主力です。これらは引数の処理を完璧にこなしますが、一方でユーザーは静的なテキストの壁を見つめることになりがちです。単純なタスクなら問題ありませんが、リソースの選択やライブデータの監視といった複雑なユーザー入力を必要とする場合、古臭く使いにくい印象を与えてしまいます。
先日、私は複数のクラスターにまたがる60以上のマイクロサービスを管理するチームのために、デプロイツールを構築しました。DevOpsエンジニアに32文字のデプロイIDをコピー&ペーストさせるのは、作業が遅くなるだけでなく、午前2時に本番障害を引き起こす原因を自ら作っているようなものです。
彼らが必要としていたのは、生きたインターフェースでした。リストをスクロールし、瞬時に結果をフィルタリングし、「デプロイ」ボタンを押す前に視覚的な確認ができること. これを生のANSIエスケープコードを使って一から書くのは、カーソル座標の手動追跡や行のクリアが伴う、ある種の地獄です。ほとんどの開発者は、最初のメニューを完成させる前に諦めてしまいます。
根本的な原因:ターミナルの状態管理が厄介な理由
インタラクティブなCLIが難しいのは、処理が直線的ではないからです。標準的なスクリプトは上から下へと実行されます。入力を読み取り、ロジックを実行し、結果を出力して終了します。対して、ターミナルUI(TUI)はビデオゲームのように機能します。継続的なループを実行し、入力を処理して画面を毎秒少なくとも60回描画し直すことで、スムーズな操作感を実現しなければなりません。
このループを手動で管理するには、3つの大きな壁があります。
- イベントの衝突: UIをロックすることなく、キー入力、ウィンドウのリサイズ、バックグラウンドプロセスの完了を同時に処理する必要があります。
- 画面のちらつき: 毎フレーム画面全体をクリアして再描画すると、ターミナルが激しくちらつきます。実際に変更された文字だけを更新する方法が必要です。
- 状態の非同期: 特に非同期のAPIコールを扱う場合、内部データとユーザーが画面で見ている内容が同期しなくなることがよくあります。
選択肢の検討
Goでこれを処理するより良い方法を探したところ、3つの異なる道が見つかりました。
1. 生のANSIコード
\033[2J(画面クリア)や\033[H(カーソル移動)といった文字列を手動で出力する方法です。完全な制御が可能ですが、抽象化はゼロです。すべての文字のピクセルオフセットを手動で計算してモダンなWebアプリを作ろうとするようなもので、スケールしません。
2. 命令型ライブラリ (Tview/Termbox)
これらのライブラリはボタンやフォームなどのウィジェットを提供します。単純なレイアウトには適していますが、深くネストされたコールバックに依存しがちです。アプリが1,000行を超える規模になると、どのコールバックがどの変数を更新したかを追跡するのは苦行になります。
3. Bubble Tea (The Elm Architecture)
Charmのチームが開発したBubble Teaは、The Elm Architecture (TEA)を採用しています。ターミナルに「どう」変更するかを指示するのではなく、特定の状態に対してUIが「どうあるべきか」を記述します。関数型で予測可能、かつテストが非常に容易です。いくつかの社内ツールを構築した結果、複雑なTUIコードの保守性を保つ唯一の方法だと確信しました。
The Elm Architectureの実装
Bubble Teaはアプリケーションを、Model(データ)、**Update**(ロジック)、**View**(UI)の3つの部分に分割します。この分離により、機能が増えてもコードをクリーンに保てます。
はじめに
プロジェクトを初期化し、フレームワークを導入します。
go mod init deploy-tool
go get github.com/charmbracelet/bubbletea
1. Model:信頼できる唯一の情報源
Modelは単純な構造体です。UIが必要とするすべてのデータを保持します。サービス選択ツールを作る場合、Modelはリスト、現在の選択位置、どのアイテムがチェックされているかを管理します。
type model struct {
services []string
cursor int
checked map[int]bool
}
2. Update関数:イベントの処理
Update関数はアプリの頭脳です。キー入力やAPIレスポンスなどのメッセージを受け取り、Modelの新しいバージョンを返します。ここでナビゲーションロジックを処理します。switch文がいかにクリーンに保たれているか注目してください。
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up", "k":
if m.cursor >

