Windows でも動く、のタネ明かし

ccmux は単一バイナリで Windows / macOS / Linux どれでも動く。でも「シェルを動かす仕組み」は OS ごとに全然違う。どう隠してるか。

ccmux は、どの OS でも同じ 2MB のバイナリひとつ。Windows でも WSL なしで動きます。

でも実のところ、シェルと会話する仕組みは OS ごとにてんで違うんです。そのギャップを何で埋めているかという話。

「シェルに会話してもらう」とは

bash とか zsh は、相手が人間のターミナルじゃないと察すると挙動を変えます:

  • プロンプト ($ ) を出さなくなる
  • 色を省く
  • 行ごとの入出力を諦めて、全部まとめてしまう

パイプ (ls | grep foo) にすると色が消えるの、経験ありませんか? あれは bash が「相手はターミナルじゃなくてパイプだな」と気づいて、出力の見た目を変えているから。

ccmux のようなツールがシェルを内部で動かす時、シェルにそのことを気づかせずに**「いや、ちゃんとターミナルで動いてるよ」と信じさせる**必要があります。そのための仕組みが PTY(pseudo-terminal、擬似端末)。

OS ごとの実態

PTY の実装は、OS ごとに別物です:

OS実装
Linuxopenpty() + fork() + setsid()
macOS同じような Unix 系 API
Windows 10+ConPTY(CreatePseudoConsole

ConPTY は 2018 年の Windows 10 1809 で追加された、比較的新しい仕組み。それ以前は WinPTY というサードパーティ実装が必要で、この出現が「Windows でも tmux 的なものが作れる」時代を開きました。

portable-pty が全部飲み込んでくれる

Rust には portable-pty というクレートがあって、OS ごとの差を吸収してくれます。wezterm(Rust 製のターミナルエミュレータ)の作者が作ってるもので、完成度が高い。

同じ API で、裏では OS ごとに違うものが動きます:

src/pane.rs
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
fn spawn_shell(rows: u16, cols: u16) -> anyhow::Result<...> {
let pty = native_pty_system(); // ← OS を自動判定
let pair = pty.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })?;
let shell = detect_shell();
let mut cmd = CommandBuilder::new(&shell);
cmd.env("TERM", "xterm-256color");
cmd.env("CCMUX", "1"); // ← 後で説明
let child = pair.slave.spawn_command(cmd)?;
drop(pair.slave); // slave は子プロセスが持つ。親では閉じる。
let writer = pair.master.take_writer()?;
let reader = pair.master.try_clone_reader()?;
// ...
}

#[cfg(windows)]#[cfg(unix)] も、一度も書いていません。これがクロスプラットフォームの気持ちよさ。

どのシェルを起動するか

ccmux は OS ごとにシェルの優先順位を変えます。

Windows:

  1. Git Bash (C:\Program Files\Git\bin\bash.exe) を最優先
  2. PATH 上の bash
  3. 最終手段で PowerShell

Windows で Git Bash を優先している理由は、Claude Code を含む多くの Unix 系 CLI が bash 前提に作られているから。PowerShell だと ls -lagrep がそのまま通らない場面が多くて不便です。

Unix系(macOS / Linux):

  1. $SHELL 環境変数(ユーザーの普段使いのシェル)
  2. 無ければ /bin/sh

シェルに「今どこにいるか」を聞く小ワザ

シェルで cd しても、ccmux からはそのイベントが見えません。シェルは黙って自分の内部状態を変えるだけ。

そこでプロンプトが出るたびに、シェル自身から現在のパスを放送してもらうように仕込みます:

let setup = " __ccmux_osc7() { \
printf '\\033]7;file://%s%s\\007' \"$HOSTNAME\" \"$PWD\"; \
}; \
PROMPT_COMMAND=\"__ccmux_osc7;${PROMPT_COMMAND}\"; \
clear\n";
writer.write_all(setup.as_bytes())?;

\033]7;file://...\007OSC 7 という特殊なエスケープ。「今いるディレクトリはここ」とターミナルに伝える合言葉です。

ccmux はこれを vt100 パーサで拾って、ペインごとの cwd を最新に保ちます。ファイルツリーや新しいペインの起動ディレクトリが、**自動で「今の場所」**に追従するのはこのおかげ。

ちなみに、setup の先頭に スペース を入れているのは、bash の HISTCONTROL=ignorespace が効いてる環境で履歴を汚さないため。こういう細かい気遣いが効きます。

ネストした起動を拒否する

ccmux の中でさらに ccmux を起動したら、PTY の中で PTY が走ることになって入力が二重処理され、キー入力が壊れて動かなくなります

これを防ぐため、子シェルに CCMUX=1 という環境変数を仕込んでおいて、ccmux 自体が起動時にチェックします:

src/main.rs
if std::env::var("CCMUX").is_ok() {
eprintln!("ccmux: already running inside a ccmux pane.");
std::process::exit(1);
}

なぜか動かない」系のバグを事前に潰せる、地味だけど価値ある一行。


クロスプラットフォームの PTY は普通に書こうとすると #[cfg(...)] が大量に出てきて辛いんですが、portable-pty に任せると一切出てこない。ccmux が「Windows でも動く」のは、小さいなりにこのクレートの恩恵が大きいです。