🖥️

Claude Code のデスクトップアプリを使うのをやめた(tmux 中心の TUI 4ツール構成)

に公開

デスクトップアプリの方が上位互換だと思っていた

Claude Code にはデスクトップアプリとターミナル版があります。しばらく前まで、私はデスクトップアプリの方が上位互換だと思っていました。

これはそれなりに理由のある見方だと思います。セッションの一覧が最初から画面にあり、過去の会話に戻る手段が用意されていて、差分もそれなりに読める形で表示されます。ターミナルでやるなら、そのどれもが自前の用意を必要とします。実際、ターミナル版を使っている人でも「アプリでできることをわざわざ手元で組んでいる」という感覚を持っている人は多いのではないかと思います。

いまはデスクトップアプリを開いていません。ターミナル側に4つのツールを入れたところ、アプリを開く理由がなくなりました。

ツール 機能 使っている理由
tmux-claude-session-manager tmux セッションの popup 起動とエージェントピッカー リポジトリと作業場を1対1にし、稼働中のエージェントをステータスバーで把握するため
hunk 差分レビュー用 TUI。--watch でライブ表示し、TUI 上でコメントを残せる 会話のタイムラインに埋もれず、実装中の差分を隣のペインで読むため
ctx セッション履歴を SQLite にインデックスして検索する CLI エージェント自身に過去の議論を検索させ、人間の記憶に頼らないため
claude-history TUI でセッション履歴を検索し、resume・fork する デスクトップアプリなしで前のセッションに復帰するため

この4つが何を消したかを順に書きます。環境そのものは dotfiles-public で公開していて、キーバインドの全体像は docs/tui_environment.md にあります。

リポジトリごとに作業場が固定される

最初に変わったのは、エージェントの機能ではなく作業場の方でした。これは4つのうち唯一 Claude Code と直接関係のない層ですが、後半に出てくる導線が全部 tmux の中にいることを前提にするので、先に置きます。

私の開発は複数のリポジトリとアプリを行き来する形になりがちです。以前はターミナルで cd を打ち、目的のファイルを検索し直すところから毎回始めていました。いまは Alt+s で fzf のリストが popup で出て、選んだディレクトリの tmux セッションへ切り替わります。セッションが無ければ作られ、あれば既存のものに戻ります。

# home-manager/modules/tmux.nix
sessionizer = pkgs.writeShellScript "tmux-sessionizer.sh" ''
  sel="$(
    {
      find "$HOME" -mindepth 1 -maxdepth 1 -type d
      find "$HOME"/github-* -mindepth 1 -maxdepth 1 -type d
    } 2>/dev/null | sed "s|^$HOME/||" | ${pkgs.fzf}/bin/fzf --reverse
  )" || exit 0

  path="$HOME/$sel"
  name="$(basename "$path" | tr '.:' '__')"

  # 同名セッションが無ければ作り、あれば切り替えるだけ
  tmux has-session -t "=$name" 2>/dev/null || tmux new-session -d -s "$name" -c "$path"
  tmux switch-client -t "=$name"
'';

これ自体は tmux sessionizer としてよく知られた形です。それでも効果が大きかったのは、リポジトリと作業場が1対1で対応するようになったからです。あるリポジトリに戻れば、そこで開いていたペインとエージェントがそのまま残っています。ディレクトリを移動するのではなく、作業場ごと頭を切り替える感覚になります。

tmux-claude-session-manager が足すのは、その上の層です。Alt+y で現在のディレクトリの Claude Code セッションを popup で起動し、Alt+u で走っているエージェントのピッカーを開きます。popup なので、出して閉じても下の作業は崩れません。

ピッカーを開かなくても状況が見えるように、ステータスバーにエージェントの数を出しています。

# waiting(黄)/idle(緑)/busy(灰) の数を status-right に出す
agents="$(claude agents --json 2>/dev/null)" || exit 0

claude agents --json が各エージェントの状態を返すので、フックを仕込まなくても手が必要なエージェントがいるかどうかが常時見えます。

tmux のステータスバー右端。緑の丸が idle のエージェント数、右隣は使用率

差分は会話を流れて消える

作業場が固まると、次に困るのは差分の読み方でした。

エージェントの出力は会話として流れていきます。ファイルを書いた報告も、その中身も、スクロールすれば上に消えます。意図通りに実装されたかを確認したいのに、確認したい対象がタイムラインの中に埋もれている状態でした。

hunk は差分レビュー用の TUI ビューアで、これを d というシェル関数から起動しています。引数を考えずに、何をレビューするかだけ選びます。

# zsh/functions/git.sh
d() {
  print "hunk — what to review?"
  print "  1) working    uncommitted changes"
  print "  2) unpushed    committed but not pushed"
  print "  3) commit     pick from recent 5 commits"
  print "  4) date       pick from recent 5 dates (grouped)"
  # ...
}

4番の日付単位は、その日のコミット群をまとめて1つの差分として読む形です。

# 選んだ日の最古コミットの親から最新コミットまでを1つの patch にして渡す
git diff "$base" "$latest" | hunk patch -

Issue 駆動で動かしていない普通の作業でも、レビューの入口はここに統一されています。

エージェントが書いている横で差分を読む

ここからが、デスクトップアプリでは作れなかった部分です。

私はエージェントの起動を issue() というシェル関数に任せています。worktree を切り、Issue ファイルを渡して Claude Code を実行する流れです(この分業自体は別記事に書きました)。この関数がエージェントを起動する直前に、隣のペインを開きます。

# zsh/functions/aiagent.sh
# tmux内なら別ペインでBuilderの変更をライブ表示する
if [[ -n "$TMUX" ]]; then
  tmux split-window -h -c "$wt_app_dir" "hunk diff main --watch"
fi

--watch が付いているので、エージェントがファイルを書くたびに右のペインの差分が更新されます。比較対象が main なので、まだコミットされていない変更もコミット済みの変更も同じ画面に出ます。

つまり、書き終わるのを待ってからレビューを始めるのではありません。エージェントが実装している最中の差分を、横で読んでいます。

左ペインで Claude Code が実装とコミットを進め、右ペインの hunk が同じ変更の差分を表示している

左では git diff --cached やコミットが流れていき、右では同じ変更が差分として並んでいます。左の出力を追いかけて何が変わったかを再構成しなくても、右を読めば済みます。

この行が if [[ -n "$TMUX" ]] で囲まれているのが実情を表しています。tmux の中にいなければこの導線は成立せず、コードはエージェント終了後に hunk を開くフォールバックへ落ちます。

# tmux外だった場合のみ、実行者の終了後にmain未マージのコミットがあればhunkでレビューを開く
if [[ -z "$TMUX" ]] && [[ -n "$(git log main.."$branch_name" --oneline 2>/dev/null | grep -v 'chore(issues): open')" ]]; then
  (cd "$wt_dir" && hunk diff main)
fi

作業場を固定した話と差分を読む話は別々の改善だと思っていましたが、実装の上では前者が後者の条件になっていました。

レビューコメントをエージェントが拾って直す

横で差分を読んでいると、当然その場で指摘したくなります。hunk は TUI 上で c を押すとコメントを残せます。

そのコメントは、エージェント側が終了前に読みにいきます。手順を固定してある skill の中に、この一手が入っています。

hunk session comment list --repo . --type all

--type all は省略できません。省くと agent 向けの live comment しか返らず、TUI 上で人間が残した note が出力に含まれないためです。

エージェントはこの出力を読み、修正依頼や質問に対応して、必要なら追加でコミットします。確認は1回だけと決めています。何度も見にいく形にすると、コメントを待ち続けて終わらなくなるためです。

指摘を伝えるためにチャット欄へ戻って文章を書く必要がなく、差分の当該行にコメントを置けば、エージェントが終了処理の一部として拾います。デスクトップアプリのチャット欄では、この導線は原理的に作れません。差分を見る場所と指摘を書く場所が同じであることが前提になっているからです。

毎回記憶を失う相手に、過去の決定を渡す

差分が読めるようになっても、まだ残る問題がありました。エージェントはセッションが変わると記憶を失います。こちらは前回何を決めたかを覚えているのに、相手は毎回まっさらな状態から始まります。

以前はこれを手作業で埋めていました。過去のチャットを探し、必要な部分をコピーして貼り付ける、という手順です。同じ説明を何度も書き直していました。

いま使っているのは2つで、探す対象は同じセッション履歴ですが、使い手が違います。

ツール 使い手 用途
ctx エージェント ctx-history-search skill 経由で過去セッションを検索する
claude-history 人間 TUI で対話的に探し、セッションを再開する

ctx はセッション履歴を SQLite にインデックスして検索できるようにする CLI です。これを私が直接叩くことはあまりなく、skill として登録しておき、エージェント自身に過去の議論を探させています。「これは前に決めたはず」という場面で、こちらが記憶を頼りに再説明しなくてよくなりました。私の記憶と、毎回リフレッシュされるエージェントの状態を突き合わせる役割です。

ただし万能ではありません。skill として置いてあっても、明示的に指示しないとエージェントが自分から履歴を読みにいかない場面があります。過去の経緯が要る作業だと分かっているときは、こちらから促しています。

claude-history の方は人間が使います。fzf 風の TUI で過去のセッションを検索し、その場で resume や fork ができます。デスクトップアプリの運用をやめたあとも、CLI から前のセッションに復帰できます。

claude-history のセッション一覧。リポジトリ名、セッションの要旨、メッセージ数と経過時間が並ぶ

一覧にはリポジトリ名とセッションの要旨、メッセージ数、最終更新からの経過時間が出ます。上の検索欄に入力すると全セッションの本文を横断して絞り込めるので、「あの話をしたのはどのセッションだったか」を思い出せなくても辿り着けます。

同じ履歴を2つのツールで扱うのは重複に見えるかもしれません。ただ、探し方の要求がまるで違います。エージェントに要るのはトークン効率よく構造化された検索結果で、人間に要るのは目で見て選べる一覧です。分けた結果、どちらも無理なく使えています。

並列でエージェントを回す段階には来ていない

tmux-claude-session-manager の中心的な機能は、複数のエージェントを並列で走らせ、どれが終わってどれが待っているかをピッカーで一望することです。issue() も worktree を切るので、複数の Issue を同時に走らせられる作りになっています。

その並列運用を、私はまだしていません。

いま享受しているのは、作業場を切り替えられることと、前のセッションから続けられることで、これは並列というより tmux 本来の良さです。並列でエージェントを回すのは、トークンを使って並行開発する段階に入ってから生きる機能だと思っています。現時点では将来性として持っているだけで、それを前提に構成を組んではいません。

デスクトップアプリを開くのをやめた

結果として、デスクトップアプリを起動しなくなりました。

「CLI の方が優れている」という一般論を言いたいわけではありません。デスクトップアプリの方が上位互換だという見方は、何も足さなければ正しいです。ただ、ターミナル側は足せます。差分の隣にエージェントを置くとか、レビューコメントを実装フローに繋ぐといったことは、アプリの機能追加を待つ話ではなく、こちらが自分で組める領域にありました。組んでみたら、アプリに戻る理由がなくなったというのが実際のところです。

GitHubで編集を提案

Discussion