Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 コンテナに触れずに後から起動・停止
Expand Down
6 changes: 6 additions & 0 deletions containers/base/tmux.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions docs/user/container-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 がクリックできない |

### 基本操作

Expand Down
60 changes: 60 additions & 0 deletions tests/containers/test_tmux_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 必須)。"""
Expand Down Expand Up @@ -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]}"
Loading