diff --git a/CHANGELOG.md b/CHANGELOG.md index 5941b457..e5c46891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ ## [Unreleased] +### Fixed +- **tmux の中で URL がクリックできなくなっていた**のを直しました。tmux は端末が `Hls` + 能力を持つときだけハイパーリンク (OSC 8) を書き出し、持たない端末ではリンクを捨てて + 文字列だけを描きます。tmux が `xterm*` へ既定で与える機能に `hyperlinks` は含まれない + ため、v3.1.0 で入れた `/etc/tmux.conf` の下では Claude Code などが出す URL が + クリックできない見た目だけのリンクになっていました。`terminal-features` へ + `,xterm-256color:hyperlinks` を追記します。 + + > **Note:** 反映には `devbase build base` によるイメージの再ビルドとコンテナの + > 作り直しが要ります。動作中のコンテナで今すぐ戻すには、`~/.tmux.conf` へ同じ 1 行を + > 書いて `tmux source-file ~/.tmux.conf` した後、**クライアントを繋ぎ直して**ください + > (端末の能力は接続時に決まるため、実行中のクライアントには反映されません)。 + ## [3.5.0] - 2026-09-17 `compose.yml` の `profiles` を付けたテスト用のサーバ群を、dev コンテナに触れずに後から起動・停止 diff --git a/containers/base/tmux.conf b/containers/base/tmux.conf index c5824a8f..82f80ff0 100644 --- a/containers/base/tmux.conf +++ b/containers/base/tmux.conf @@ -39,3 +39,9 @@ set -ga terminal-overrides ",xterm-256color:Tc" # 端末のフォーカスイン/アウト通知を tmux 内のアプリへ転送する。既定は off で、 # Claude Code はこれを検知して警告を出し、完了通知の出し分けが働かない。 set -g focus-events on + +# ハイパーリンク (OSC 8) を外側の端末へ書き出す。tmux は端末が Hls 能力を持つときだけ +# OSC 8 を出力し、持たない端末では下線付きの文字列だけを描いてリンクを捨てる。tmux が +# xterm* へ既定で与える機能は clipboard/ccolour/cstyle/focus/title だけで hyperlinks は +# 含まれないため、宣言しないと Claude Code などが出す URL がクリックできない。 +set -ga terminal-features ",xterm-256color:hyperlinks" diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 8e2b249f..2ad6018d 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -450,6 +450,7 @@ OAuth のログインはコンテナを作り直しても残ります。 | `focus-events` | `on` | 端末のフォーカス通知を中のアプリへ渡す。Claude Code の完了通知が正しく出し分けられる | | `default-terminal` | `tmux-256color` | 端末種別の固定 | | `terminal-overrides` | `,xterm-256color:Tc` を追記 | VS Code の統合ターミナルへ 24bit 色を通す | +| `terminal-features` | `,xterm-256color:hyperlinks` を追記 | OSC 8 のハイパーリンクを外側の端末へ渡す。宣言しないと tmux がリンクを捨て、Claude Code などが出す URL がクリックできない | ### 基本操作 diff --git a/tests/containers/test_tmux_conf.py b/tests/containers/test_tmux_conf.py index 4d0ae5dc..ca0257a5 100644 --- a/tests/containers/test_tmux_conf.py +++ b/tests/containers/test_tmux_conf.py @@ -13,10 +13,12 @@ import contextlib import os +import pty import re import shutil import subprocess import tempfile +import time import uuid from pathlib import Path @@ -77,6 +79,48 @@ def _effective_options(*configs: Path) -> dict[str, list[str]]: socket.unlink() +def _client_capabilities(config: Path) -> str: + """``config`` を読ませた tmux へ実際に接続し、クライアント端末の能力表を返す。 + + ``terminal-features`` の値は ``show-options`` から読めるが、tmux は綴りの違う + 機能名を黙って受け取る。宣言が能力として効いているかは、クライアントを繋いだ + ときの ``show-messages -JT`` でしか確かめられない。接続には端末が要るため + ``pty.openpty`` で用意する。 + """ + socket = Path(tempfile.gettempdir()) / f"dvb38-{uuid.uuid4().hex[:8]}" + env = {k: v for k, v in os.environ.items() if k != "TMUX"} + env["TERM"] = "xterm-256color" + base = ["tmux", "-S", str(socket)] + + started = subprocess.run([*base, "-f", str(config), "new-session", "-d", "-s", "p", + "sleep", "30"], capture_output=True, text=True, env=env) + assert started.returncode == 0, f"tmux の起動に失敗した: {started.stderr}" + + controller, terminal = pty.openpty() + client = subprocess.Popen([*base, "attach", "-t", "p"], + stdin=terminal, stdout=terminal, stderr=terminal, env=env) + os.close(terminal) + try: + for _ in range(50): + time.sleep(0.1) + shown = subprocess.run([*base, "show-messages", "-JT"], + capture_output=True, text=True, env=env) + if "Terminal 0:" in shown.stdout: + return shown.stdout + raise AssertionError("クライアントが接続しなかった") + finally: + # サーバーを先に落とす。クライアントは接続が切れて自ら終わるので wait が + # 即座に返り、待ち切れなかった場合でも残りの後始末を続けられるように + # TimeoutExpired は握り潰す (`sleep 30` を残さないため)。 + subprocess.run([*base, "kill-server"], capture_output=True, text=True, env=env) + client.terminate() + with contextlib.suppress(subprocess.TimeoutExpired): + client.wait(timeout=5) + os.close(controller) + with contextlib.suppress(FileNotFoundError): + socket.unlink() + + @pytest.fixture(scope="module") def options() -> dict[str, list[str]]: """``containers/base/tmux.conf`` だけを読ませた実効値 (利用側は needs_tmux 必須)。""" @@ -162,3 +206,19 @@ def test_conf_provides_windows_like_copy_bindings(): "bind-key -T copy-mode C-c send-keys -X copy-selection-and-cancel", "bind-key -T copy-mode-vi C-c send-keys -X copy-selection-and-cancel", ] + + +@needs_tmux +def test_hyperlinks_reach_the_outer_terminal(): + """OSC 8 のハイパーリンクを外側の端末へ書き出す。 + + tmux は端末が ``Hls`` 能力を持つときだけ OSC 8 を出力し、持たない端末では文字列 + だけを描いてリンクを捨てる。tmux が ``xterm*`` へ既定で与える機能は + ``clipboard/ccolour/cstyle/focus/title`` で ``hyperlinks`` を含まないため、 + 宣言しないと Claude Code などが出す URL がクリックできなくなる。 + """ + capabilities = _client_capabilities(TMUX_CONF) + + hls = [line.strip() for line in capabilities.splitlines() if ": Hls:" in line] + assert hls, "クライアントの能力表に Hls が無い" + assert "[missing]" not in hls[0], f"Hls が定義されていない: {hls[0]}"