From 035c6181f855a52890a6ac9fd5b4e6b5f6d311db Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 3 Sep 2026 01:16:27 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E6=AD=A3:=20tmux=20=E3=81=8C=20OS?= =?UTF-8?q?C=208=20=E3=81=AE=E3=83=8F=E3=82=A4=E3=83=91=E3=83=BC=E3=83=AA?= =?UTF-8?q?=E3=83=B3=E3=82=AF=E3=82=92=E5=A4=96=E5=81=B4=E3=81=AE=E7=AB=AF?= =?UTF-8?q?=E6=9C=AB=E3=81=B8=E6=B8=A1=E3=81=99=E3=82=88=E3=81=86=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tmux は端末が Hls 能力を持つときだけ OSC 8 を書き出し、持たない端末ではリンクを 捨てて文字列だけを描く。tmux が xterm* へ既定で与える機能は clipboard/ccolour/ cstyle/focus/title で hyperlinks を含まないため、/etc/tmux.conf を焼き込んで以降、 Claude Code などが出す URL がクリックできない見た目だけのリンクになっていた。 terminal-features へ ",xterm-256color:hyperlinks" を追記する。tmux は綴りの違う 機能名を黙って受け取るため、show-options の文字列ではなく pty でクライアントを 繋いで Hls が定義されることを検査する。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UtHB25awYxRd5kKJr6vc8b --- CHANGELOG.md | 13 +++++++ containers/base/tmux.conf | 6 ++++ docs/user/container-operations.md | 1 + tests/containers/test_tmux_conf.py | 56 ++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d0811e2..9f216984 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.1.0] - 2026-09-02 `devbase up` でコンテナを作り直しても VS Code Server が残るようになり、 diff --git a/containers/base/tmux.conf b/containers/base/tmux.conf index a700d7bf..69cc232e 100644 --- a/containers/base/tmux.conf +++ b/containers/base/tmux.conf @@ -28,3 +28,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 12ac6d55..053b5617 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -449,6 +449,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 c74e0e39..4171d376 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,44 @@ 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: + client.terminate() + client.wait(timeout=5) + os.close(controller) + subprocess.run([*base, "kill-server"], capture_output=True, text=True, env=env) + with contextlib.suppress(FileNotFoundError): + socket.unlink() + + @pytest.fixture(scope="module") def options() -> dict[str, list[str]]: """``containers/base/tmux.conf`` だけを読ませた実効値 (利用側は needs_tmux 必須)。""" @@ -153,3 +193,19 @@ def test_conf_does_not_change_key_bindings(): assert directives, "設定が 1 行も無い" assert all(line.startswith("set ") for line in directives), \ f"set 以外の指示が含まれる: {[x for x in directives if not x.startswith('set ')]}" + + +@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]}" From 95217106cdc891247f30a6b6a4ab4ca16f864d86 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 3 Sep 2026 11:12:46 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(tmux):=20=E3=82=AF=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=82=A2=E3=83=B3=E3=83=88=E5=BE=85=E3=81=A1=E3=81=8C=20timeou?= =?UTF-8?q?t=20=E3=81=97=E3=81=A6=E3=82=82=20tmux=20=E3=82=B5=E3=83=BC?= =?UTF-8?q?=E3=83=90=E3=83=BC=E3=82=92=E6=AE=8B=E3=81=95=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _client_capabilities の finally で client.wait(timeout=5) が TimeoutExpired を 投げると、以降の kill-server まで到達せず `sleep 30` を抱えた tmux サーバーが 残る。kill-server を先に実行してクライアントを自発的に終わらせ、なお待ち切れ なかった場合も TimeoutExpired を握り潰して後始末を続けるようにする。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017GRDMb2WUxD8aenYmZfKzj --- tests/containers/test_tmux_conf.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/containers/test_tmux_conf.py b/tests/containers/test_tmux_conf.py index 4171d376..e88f4aaa 100644 --- a/tests/containers/test_tmux_conf.py +++ b/tests/containers/test_tmux_conf.py @@ -109,10 +109,14 @@ def _client_capabilities(config: Path) -> str: return shown.stdout raise AssertionError("クライアントが接続しなかった") finally: + # サーバーを先に落とす。クライアントは接続が切れて自ら終わるので wait が + # 即座に返り、待ち切れなかった場合でも残りの後始末を続けられるように + # TimeoutExpired は握り潰す (`sleep 30` を残さないため)。 + subprocess.run([*base, "kill-server"], capture_output=True, text=True, env=env) client.terminate() - client.wait(timeout=5) + with contextlib.suppress(subprocess.TimeoutExpired): + client.wait(timeout=5) os.close(controller) - subprocess.run([*base, "kill-server"], capture_output=True, text=True, env=env) with contextlib.suppress(FileNotFoundError): socket.unlink()