Post
JA EN

VS Code のサイドバーに tmux を常駐させる——AI CLI の「終わった」を取りこぼさない作業環境を自作した

VS Code のサイドバーに tmux を常駐させる——AI CLI の「終わった」を取りこぼさない作業環境を自作した
  • 想定読者: VS Code で Claude Code などの AI CLI を日常的に走らせている開発者(Linux / macOS)
  • 前提知識: tmux の基本操作(detach / attach)、VS Code 拡張機能のインストール
  • 所要時間: 約20分

本記事の位置づけ: ここで紹介する拡張機能は、筆者(このブログの運営者)が開発して Visual Studio Marketplace に公開しているものである。第三者によるレビューではなく、作った側による紹介記事として読んでほしい。仕様の記述はリポジトリの実装と README を一次情報とし、外部の挙動(tmux、xterm.js、各 AI CLI)については公開ドキュメントとソースを引用する。

概要

AI CLI を日常的に使うようになると、ターミナルの役割が変わる。かつては自分がコマンドを打ち込む場所だったが、いまは走らせたエージェントを見張る場所になった。3つのリポジトリで Claude Code を動かしていれば、3つのターミナルが「そのうち何か言ってくる」状態で並ぶ。

誤解のないように書くと、VS Code の統合ターミナルはこの用途でも十分に強い。ウィンドウのリロードでは以前のプロセスに再接続して内容を復元し、VS Code の再起動時も内容を復元してプロセスを起動し直す(terminal.integrated.enablePersistentSessions、既定で有効)1。リロード後に戻るスクロールバックは既定で直近100行という制限はあるが、消えてしまうわけではない。ベルにも仕組みがあって、鳴ったターミナルの名前の横に印が出る(terminal.integrated.enableVisualBell は既定でオフ、音は accessibility.signals.terminalBell の側)。

それでも残る差が2つあった。ひとつは、VS Code を閉じると走っていたプログラムは終わること。復元されるのは内容と再起動されたシェルで、エージェントの実行そのものは続かない。tmux のセッションならサーバ側に残り、別のマシンから attach しても続いている。もうひとつは、気付き方だ。ターミナル名の横の印は、そのビューを見ていれば気付ける。エディタで別のファイルを読んでいる間、許可待ちのプロンプトは何分でも放置される。欲しいのは、どのリポジトリのセッションが呼んでいるのかを名前で言ってくる OS 側の通知だった。

そこで、tmux のセッションを VS Code のセカンダリサイドバーに置く拡張機能「Tmux Opener」を作り、Marketplace に公開した。統合ターミナルのラッパーではない。xterm.js を自分で持つ独立したターミナルビューで、ワークスペースフォルダ名のセッションに attach する。ビューが閉じても、ウィンドウをリロードしても、セッションとウィンドウレイアウトはサーバ側に残る2

そして、この拡張で一番効いているのは端末ベル(BEL、\a)の通知だ。セッションのどれかがベルを鳴らすと、そのセッション名を添えた VS Code のネイティブ通知と音が出る。AI CLI をバックグラウンドに置いたまま別の作業に戻れて、向こうが人間を必要とした瞬間に呼び戻される。音は webview で合成しているので、Remote-SSH でセッションが遠隔ホストにあっても手元のキーボードの前で鳴る。

もうひとつ、端末で日本語フォントを使うと全角記号が横に潰れる問題があり、これに対処した書体を5本、フォントリソース拡張として別途配布している。本記事はセットアップと使い方を中心に扱い、フォント側の実装(拡張機能どうしでフォントを受け渡す自作規約と、記号の幅を測って詰め直す変換)は姉妹記事に分けた。

AI CLI を常駐させると、ターミナルの前提が変わる

tmux を使う理由は昔から変わらない。セッションはクライアントから切り離しても生き続ける。tmux のマニュアルはこれを「各セッションは永続的であり、(ssh の接続タイムアウトのような)意図しない切断や、意図的な detach を生き延びる」と書いている2

では、AI CLI が加わって何が変わったのか。切断に強いことの価値ではなく、待ち時間の性質である。自分でビルドを回していた頃、待ち時間の長さは経験から見積もれた。エージェントは違う。5秒で許可を求めてくることもあるし、8分黙って作業を続けることもある。見積もれない待ちを人間が監視すると、待つこと自体が作業になる。

このブログでは以前、AI に複数タスクを任せた人間がマルチタスク地獄に陥る構図を扱った。並列化のコストが人間の注意に転嫁される話で、そこで必要になるのは「もっと頑張って監視する」ではなく「監視をやめられる仕組み」だった。BEL の通知はその仕組みのいちばん素朴な実装である。端末はもともと、注意を引きたいときに鳴らすためのバイトを1つ持っている。

Tmux Opener がやっていること

セカンダリサイドバーに置く独立したターミナル

ビューは VS Code 右側のセカンダリサイドバーに出る。パネル下部の統合ターミナルと場所を取り合わないので、エディタ・ターミナル・エージェントが横に並ぶ。

上部のツールバーに現在のセッション名、セッションを作る + ⌄ ボタン、通知音のトグル、セッション一覧の表示切り替え、現在のセッションの kill、設定を開くアクションが並ぶ。同じ操作はコマンドパレットの Tmux: 以下にも入っている。

セッション一覧は VS Code のターミナルタブと同じ操作にした。クリックで切り替え、×(またはミドルクリック)で kill、端をドラッグして幅を変える。レイアウトの数値(既定・最小・最大幅、ラベルや閉じるボタンが隠れる幅の境界、右側配置)も VS Code 本体のソースを参考に寄せてある。挙動が本体と揃っていて違和感が少ないなら、それは設計を借りているからだ。

attach されるのは常に1つだけで、切り替えると前のセッションは detach される。detach されたセッションは動き続けるので、切り替えは「見るのをやめる」だけの操作になる。

セッション名はワークスペースフォルダから作る。myrepomyrepo--2myrepo--3 と続く。どのウィンドウがどのリポジトリの tmux なのか、名前を見れば分かる。

プロファイルはコマンドをセッションそのものとして走らせる

+ は素のシェルを開く。隣の を押すとプロファイルのドロップダウンが出る。プロファイルはセッションの中でコマンドを実行するのではなく、コマンドをセッションそのものとして走らせる。だから exit ではなくコマンドの終了がセッションの終了になる。

既定で Claude Code、Codex、Gemini、Cursor、Grok のプロファイルが入っている。設定 tmuxOpener.profiles はこの既定にマージされるので、キーを null にすれば隠せるし、自分のキーを足せば増やせる。

1
2
3
4
5
"tmuxOpener.profiles": {
  "Gemini": null,                                    // 既定を隠す
  "Claude Code": { "command": "claude --continue" }, // 既定を上書き
  "Build": { "command": "npm run watch", "sessionSuffix": "build" }
}

プロファイルのセッションは myrepo--claude-code のように専用の名前を持ち、myrepo--2myrepo--3 の連番を消費しない。素のシェルとエージェントが番号を取り合わないので、一覧を見たときに何が何だか分かる。

コマンドが PATH に無いプロファイルはドロップダウンで印が付くが、選択はできる。tmux が起動するログインシェルのほうが解決するかもしれないからで、拡張側の探索結果を根拠に選択を封じない。

コピーと検索

Ctrl/Cmd+F で検索バーが開き、出力中の URL はクリックできる。コピーは tmux 自身のコピーモードから OSC 52 経由で取れるほか、Shift+ドラッグ / Option+ドラッグ のターミナル選択からも取れる。Ctrl/Cmd+C は選択があればコピー、無ければ SIGINT として通る。

ベルで気付く

ビューは、attach していないセッションも含めて、そのワークスペース由来のセッション(myrepo とその myrepo--… 名前空間)のベルを見張る。無関係な tmux セッションのベルは通知しない。鳴ったら、そのセッション名を含む VS Code のネイティブ通知を出す。通知の Open を押すとそのセッションへ飛ぶ。

flowchart TB
    A["AI CLI が完了<br>または許可待ち"] --> B["BEL を鳴らす"]
    B --> C["拡張がワークスペースの<br>全セッションを監視"]
    C --> D["ネイティブ通知<br>+ 手元で鳴る音"]
    D --> E["Open で該当セッションへ"]

既定で有効。tmuxOpener.notifyOnBell: false で止まる。音は tmuxOpener.notifySound で切れ、通知のポップアップだけ消して音を残すこともできる(tmuxOpener.notifyToast)。音のプリセットは5種類あり(classic / siren / sos / cascade / trill)、設定を変えてベルを鳴らせば試聴になる。

音を webview で合成しているのは、鳴る場所を手元に固定するためだ。webview は常にローカルのマシンで動くので、Remote-SSH でセッションが遠隔ホストにあっても、音は自分のキーボードの前で鳴る。

ひとつブラウザ由来の制約がある。音声はウィンドウが一度操作されるまで再生できない。リロード直後にまだ触っていないウィンドウで鳴ったベルは無音になるが、その場合の通知には Play Sound ボタンが付く。一度押せば聞き逃したベルが鳴り、以降は自動で鳴るようになる。

鳴らす側の設定は CLI ごとに違う

ここが実際のセットアップでいちばん詰まる。通知が出るかどうかは、走らせているツールがベルを鳴らすかどうかで決まる。多くの AI CLI は鳴らせるが、既定で鳴るとは限らない。

Claude Code は既定(preferredNotifChannel: "auto")では Ghostty、Kitty、iTerm2 でだけデスクトップ通知を出し、それ以外の端末では何も鳴らさない。公式ドキュメントも VS Code の統合ターミナルをその「通知が届かない側」として名指ししている3。だから ~/.claude/settings.json に次を書くか、セッション内で /config から同じ設定を選ぶ。

1
{ "preferredNotifChannel": "terminal_bell" }

他の CLI にもそれぞれのスイッチがある。Gemini CLI は Settings → Accessibility の enableTerminalBell(既定オフ)。Codex CLI は Linux では完了時に鳴ることがあり、環境に依存する。aider は --notifications-command に任意のコマンドを渡せるので、aider --notifications-command 'printf "\a"' のようにベルを鳴らすコマンドを指定すれば通知がベルになる(公式ドキュメントの例はシステム音声や Apprise 経由で、ベルは自分で指定する形になる)。

ベルの出どころは1つに絞る

Claude Code に関しては、リポジトリに参考実装のフック(StopNotification に噛ませるシェルスクリプト)を同梱していて、これを入れると「完了した」と「入力を待っている」で違う音を鳴らせる。Notification フックは通知の種類(許可プロンプト、アイドル、サブエージェント完了など)でマッチできる4ので、鳴らす理由をファイルに書き出してから鳴らす、という作りにしてある。

ただしフックを入れたら Claude Code 内蔵のベルは切る。両方鳴るとベルが2つの発生源から同じイベントで飛んでくる。拡張側はセッションごとに5秒のクールダウンでベルの対を1つの通知に畳むので、通知や音が二重になることはない。代わりに、判定されるのは対の最初のベルだけになる。

たいていはこれでも壊れない。判定の時点で直近30秒以内に書かれた理由ファイルがディスクにあれば、拡張は 250 ミリ秒待って読み直すので、内蔵ベルの数ミリ秒後に届くフックのファイルも間に合う。壊れるのはその待ち直しが走らない場合だ。許可プロンプトの直前に長いツール呼び出しがあると、判定の瞬間は「30秒間なにも書かれていない」ように見える。すると理由なしで判定が確定し、後から届いたフックのベルはクールダウンで捨てられる。結果として「入力待ち」の音が、まさに席を外していたプロンプトで出ない。

サブエージェントの完了を待ってから鳴らすゲートまで入れている場合はもっと単純に壊れる。内蔵ベルはゲートを一切参照しないので、サブエージェントが働いている最中に通知してしまう。

フックを使うなら、通知チャンネルはベル以外(例: 通知を無効にする設定値)にして、スクリプトだけがベルを鳴らす状態にする。フックは完了と入力待ちの両方で鳴らすので、内蔵ベルを切って失うものはない。

BEL は AI 専用の信号ではない

シェルの補完ベル、vim のエラービープ、tput bel も同じバイトを鳴らし、拡張はそのすべてで通知する。うるさければシェルの補完ベルを切る(readline 系なら ~/.inputrcset bell-style none)か、tmux 側のベル設定を見直すことになる。この素朴さは仕様で、拡張はベルの意味を推測しない。

コンテキスト残量とコストを通知に乗せる

Claude Code のステータスライン用スクリプトを参考実装として同梱しており、これを入れると通知の文面にコンテキストの使用率とコストが付く(tmuxOpener.notifyBridgeStatusline、既定で有効)。「終わった」だけでなく「あとどれくらい入るのか」が通知の時点で見える。

端末用フォントを5本、別の拡張として配布している

VS Code の webview で任意のフォントを使うには、フォントのバイト列をどこかから読んで @font-face を書く必要がある。OS にインストール済みのフォント名を設定するのとは別の話で、拡張機能どうしでフォントを共有する公式の仕組みは無い5。そこで、フォントを同梱するだけの拡張(UI もコマンドも持たない)を作り、package.json に自作の契約フィールドを宣言させて、消費側の webview がそこからバイト列を借りる形にした。

配布しているのは次の5本。いずれも OFL 1.1 のフォントを元にしている。

拡張機能 ID供給する書体元フォント備考
shirokuma-library.font-shirokumagenShirokumaGen TermHackGen Console(白源 Console)v2.10.0上流は Hack と源柔ゴシックの合成6。端末向けに幅を詰め直した改変版
shirokuma-library.font-mgenplus-1mnMgen+ 1mn TermMgen+ 1mn端末向けに幅を詰め直した改変版
shirokuma-library.font-rounded-mgenplus-1mnRounded Mgen+ 1mn TermRounded Mgen+ 1mn丸ゴシック。同じく幅を詰め直した改変版
shirokuma-library.font-mplus1codeM PLUS 1 Code TermM PLUS 1 Code可変フォントを 400 / 700 の静的フェイスに固定してから変換
shirokuma-library.font-plemoljp-consolePlemolJP Console HSPlemolJP Console HS非改変。上流のバイト列をそのまま同梱

末尾の ` Term は、端末の升目に対する主張として付けている。**幅1列と数えられるコードポイントが、ちょうど1セル分だけ進み、その輪郭が潰される閾値に達しない**という2つの事実だ(セルに完全に収まるという意味ではない。はみ出しは中央寄せで左右対称に少し残る)。なぜそれが問題になるかというと、xterm.js は1セル幅の記号の輪郭がセル幅の1.5倍(切り上げ)を超えたとき、**横方向だけを縮める**[^7]。日本語フォントの全角記号(①` のような Unicode の曖昧幅7の文字)はこれに引っかかって、平たく潰れた形で描かれる。

変換はその閾値の下に輪郭を収める。ただし単純に横を縮めると同じ率で高さも失われて、記号が英大文字より低くなる。だから縦だけ別の倍率で伸ばし、伸ばしすぎないための上限をそのフォント自身の漢字から実測している。この辺りの設計と実測値は姉妹記事に書いた。

フォント拡張は入れるだけで効くわけではなく、tmuxOpener.terminal.fontFamily に書体名を設定する(インストールされていなければ OS の monospace に落ちる)。fontFallback に2つ目のファミリを指定すると、1つ目にグリフが無い文字だけそちらで描く。

インストールと要件

tmuxPATH にあることが前提になる。拡張は tmux を直接起動するので、対応プラットフォームは Linux(x64 / arm64)と macOS(Intel / Apple Silicon)で、Windows は対象外。Remote-SSH で使う場合、拡張はリモート側で動き、音はローカルの webview で鳴る。

Marketplace で shirokuma-library.tmux-opener を入れ、使いたいフォント拡張を追加で入れる。ソースは GitHub にある8。ライセンスは MIT で、拡張のアイコンは Jason Long による tmux 公式ロゴを ISC ライセンスのまま使っている。tmux プロジェクトとは無関係の非公式な拡張である点は明記しておく。

主な設定は次のとおり。

設定内容
tmuxOpener.terminal.fontFamilyターミナルのフォント。空なら terminal.integrated.fontFamily、それも空なら組み込みの monospace(editor.fontFamily は見ない。プロポーショナルやセリフを継いでしまうため)
tmuxOpener.terminal.fontFallback2つ目のファミリ。1つ目にグリフが無い文字だけに使う
tmuxOpener.terminal.fontSize未設定なら、明示設定された terminal.integrated.fontSize → 明示設定された editor.fontSize → 14 の順で決まる
tmuxOpener.profiles ドロップダウンのコマンド
tmuxOpener.notifyOnBellベルで通知する(既定 on)
tmuxOpener.notifySound通知に音を付ける(既定 on)
tmuxOpener.notifyToastポップアップを出す(off にすると音だけ)
tmuxOpener.notifySoundPresetclassic / siren / sos / cascade / trill
tmuxOpener.notifySoundPresetOnAsk入力待ちのときの音(既定 siren、参考実装のフックが必要)
tmuxOpener.notifyBridgeStatusline通知にコンテキスト率とコストを足す(既定 on、参考実装のスクリプトが必要)

向いていない場合

正直に書いておくと、この拡張が効くのは条件が揃ったときだけだ。

tmux を使っていない人には、まず tmux を入れる理由が要る。統合ターミナルはウィンドウ内で完結する使い勝手が良く、VS Code を閉じたときにエージェントが止まることを不便だと感じていないなら、ここで得るものは少ない。

もっと軽い代替もある。 統合ターミナルのプロファイルで tmux new-session -A -s <名前> を走らせれば、セッションの永続だけなら手に入る。通知だけが欲しいなら、Claude Code の Notification フックから OS の通知コマンド(macOS の osascript、Linux の notify-send)を叩くほうが、拡張を1つ増やさずに済む。この拡張が足しているのは、その2つを1つのビューにまとめたうえで、セッション名を通知に載せることと、鳴る理由で音を変えることだ。前者2つで足りているなら、それで足りている。

ベルを鳴らさないツールを使っているなら、通知は永遠に来ない。鳴らす側の設定が済むまでは、この拡張の中心機能は動かない状態にある。

統合ターミナル固有の機能も引き継がない。shell integration(コマンドの区切り検出やその履歴)、タスクの problem matcher との連携、ペインの分割、スクリーンリーダー対応は、独立した webview の側には無い。それらを日常的に使っているなら、統合ターミナルを併用する形になる。

そして、公開して日が浅く、インストール数は各拡張とも一桁から十数件という規模である。実質的に作者の環境で磨かれたツールで、他の環境での実績はまだ薄い。tmux のバージョンやシェルの構成による相性は出るはずで、そこは Issue を待っている段階だ。

まとめ

AI CLI を常駐させる作業は、ターミナルを「打つ場所」から「見張る場所」に変えた。見張りを人間の注意でやると、並列化して浮いた時間が監視で消える。

やったことは3つ。tmux のセッションを VS Code のセカンダリサイドバーに独立したターミナルとして置いて、エディタを閉じてもエージェントが止まらないようにした。端末ベルをネイティブ通知と音に変えて、見張りをやめられるようにした。そして端末で潰れずに読める日本語フォントを、拡張機能から拡張機能へ渡す仕組みごと作って配った。

どれも派手な技術ではない。端末が注意を引くために1バイト鳴らす仕組みは ASCII の時代からあり、tmux のセッション永続も昔からの性質だ。新しいのは、それを必要とする理由のほうである。

実装の詳細を知りたい方へ: 拡張機能どうしでフォントを受け渡す自作規約(VS Code に公式機構がない中でどう設計したか)と、全角記号が潰れる問題を実測値で詰め直した変換については、姉妹記事「拡張機能から拡張機能へフォントを渡す——VS Code に公式機構がない場所で規約を作り、全角記号の幅を実測で詰め直す」で扱っています。

関連記事

このテーマに関連する他の記事もご覧ください:

参考資料

本文中の引用番号に対応する参考資料を番号順に記載しています。

その他参考資料(本文中で番号引用なし)

  1. Terminal Advanced / Terminal Appearance - Visual Studio Code Docs. 「When reloading a window (for example, after installing an extension), reconnect to the previous process and restore its content」「When restarting VS Code, a terminal’s content is restored and the process is relaunched using its original environment」。視覚ベルの設定(terminal.integrated.enableVisualBell、既定オフ)も同ドキュメント【信頼性: 高】 ↩︎

  2. tmux(1) manual page - OpenBSD manual pages. 「Each session is persistent and will survive accidental disconnection (such as ssh(1) connection timeout) or intentional detaching …」【信頼性: 高】 ↩︎ ↩︎2

  3. Configure your terminal for Claude Code - Anthropic, Claude Code documentation. 「By default Claude Code sends a desktop notification only in Ghostty, Kitty, and iTerm2. In other terminals, set preferredNotifChannel to "terminal_bell"」。通知が届かない端末として VS Code の統合ターミナルを名指ししている【信頼性: 高】 ↩︎

  4. Hooks reference - Anthropic, Claude Code documentation. Stop(応答終了時)/ Notification(通知送信時、permission_prompt などでマッチ)/ SubagentStop(サブエージェント終了時)の発火条件【信頼性: 高】 ↩︎

  5. Webview API / Contribution Points - Visual Studio Code Extension API. webview からのリソース読み込みと、拡張が宣言できる貢献点の一覧。フォントを他の拡張へ供給する貢献点は存在しない【信頼性: 高】 ↩︎

  6. HackGen(白源) - yuru7. Hack と源柔ゴシックを合成したプログラミングフォント。Console 版は記号を半角寄りに扱う【信頼性: 高】 ↩︎

  7. UAX #11: East Asian Width - Unicode Consortium. 曖昧幅(Ambiguous)の文字は文脈によって全角・半角のどちらにも解決され、コードポイントだけでは幅が決まらない【信頼性: 高】 ↩︎

  8. ShirokumaLibrary/tmux-opener - 本記事で紹介する拡張機能のソースリポジトリ(MIT)【信頼性: 高(一次情報)】 ↩︎

This post is licensed under CC BY 4.0 by the author.