diff --git a/CHANGELOG.md b/CHANGELOG.md
index fb0ff26..515e94f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,16 @@
処理からも見えます。設定を足すツールはこの変数の指す先へ 1 ファイル置けば、`~/.bashrc` を
書き換えずに済みます。zsh と `lfm` は対象外です。
**反映には `devbase build base --no-cache` と、使っている派生イメージの建て直しが要ります。**
+- **`prefix S` のセッションの一覧とメニューを、コマンド `tmux-menu` からも開けるようにしました
+ (PLAN71 / #270)。** tmux の中では今の pane に一覧を出し、tmux の外では attach して一覧を出します
+ (attach 先は tmux の既定。サーバが無ければ何も作らず終了コード 1)。本体は `tmux-session menu`
+ で、`tmux-session menu -c 端末 <セッション>` の形は変わりません。`prefix S` の割り当ては
+ `tmux-menu` を呼ぶ形になりました(開く一覧とメニューは同じ)。ホストの tmux で使う手順
+ (symlink の 5 つ目の `tmux-menu` と `~/.tmux.conf` の新しい行)は
+ `docs/user/environment-variables.md` の「セッションを名指しで扱う」にあります。
+ **反映には `devbase build base --no-cache` と、使っている派生イメージの建て直し、
+ コンテナの作り直し(`devbase down` → `devbase up`)が要ります。`devbase up` だけでは
+ 反映されません。**
### Changed
- **スナップショットの世代を、アカウントグループ(対象ボリュームの組)ごとの系列で持つように
diff --git a/containers/base/Dockerfile b/containers/base/Dockerfile
index 5386a61..e7cf2c7 100644
--- a/containers/base/Dockerfile
+++ b/containers/base/Dockerfile
@@ -260,7 +260,7 @@ COPY --chmod=0644 tmux.conf /etc/tmux.conf
# tmux セッションの整理コマンド。tmux-first は居座りクライアントを切断して
# 一番若い番号のセッションへ切り替え、tmux-clean は置き去りのセッションを削除する。
# tmux-session は 1 つのセッションを名指しで移る・調べる・落とす (#234)。prefix S の
-# メニュー (tmux.conf) からも呼ばれる。
+# 一覧とメニュー (tmux.conf) からも呼ばれる。一覧は tmux-menu でも開く (#270)。
# 背景と使い方は各スクリプト冒頭のコメントと docs/user/environment-variables.md を参照。
COPY --chmod=0755 tmux-first /usr/local/bin/tmux-first
COPY --chmod=0755 tmux-clean /usr/local/bin/tmux-clean
@@ -268,12 +268,13 @@ COPY --chmod=0755 tmux-session /usr/local/bin/tmux-session
# 短縮名は alias ではなく symlink で用意する。alias は bash の対話シェルにしか効かず、
# zsh などの他シェルや非対話実行 (docker exec など) では使えないため。
-# tmux-go / tmux-peek / tmux-kill は呼ばれた名前で tmux-session のサブコマンドになる。
+# tmux-go / tmux-peek / tmux-kill / tmux-menu は呼ばれた名前で tmux-session のサブコマンドになる。
RUN sudo ln -sf tmux-first /usr/local/bin/tmux1 \
&& sudo ln -sf tmux-clean /usr/local/bin/tmuxc \
&& sudo ln -sf tmux-session /usr/local/bin/tmux-go \
&& sudo ln -sf tmux-session /usr/local/bin/tmux-peek \
- && sudo ln -sf tmux-session /usr/local/bin/tmux-kill
+ && sudo ln -sf tmux-session /usr/local/bin/tmux-kill \
+ && sudo ln -sf tmux-session /usr/local/bin/tmux-menu
# フォントの既定。素の fontconfig は sans-serif を中国語フェイス (WenQuanYi Zen Hei) へ
# 向けるため、日本語を描くと中国語の字形で写る (#161)。/etc/fonts/local.conf は
diff --git a/containers/base/tmux-session b/containers/base/tmux-session
index 188cf88..24821be 100755
--- a/containers/base/tmux-session
+++ b/containers/base/tmux-session
@@ -8,9 +8,10 @@
# tmux-session go [-c 端末] <セッション> 移る。そのセッションの他の端末は外す
# tmux-session peek [-n 行数] <セッション> attach せずに端末・プロセス・画面を見る
# tmux-session kill [-n] [-f] [-c 端末] <セッション>... attach・実行中を問わず落とす
+# tmux-session menu セッションの一覧を開く (prefix S と同じ, #270)
# tmux-session menu -c 端末 <セッション> prefix S の一覧から呼ばれるメニュー
#
-# tmux-go / tmux-peek / tmux-kill という名前 (symlink) で呼ぶと、そのサブコマンドになる。
+# tmux-go / tmux-peek / tmux-kill / tmux-menu という名前 (symlink) で呼ぶと、そのサブコマンドになる。
# 短縮名を alias にしないのは、alias が bash の対話シェルにしか効かないため。
#
# セッションの指し方: `$数字` は ID、それ以外は名前の完全一致 (devbase-1 は devbase-10 に
@@ -25,6 +26,7 @@ case "$PROG" in
tmux-go) SUB="go" ;;
tmux-peek) SUB="peek" ;;
tmux-kill) SUB="kill" ;;
+tmux-menu) SUB="menu" ;;
*)
PROG=tmux-session
SUB=""
@@ -44,10 +46,13 @@ usage() {
tmux-session kill [-n] [-f] [-c 端末] <セッション>...
attach・実行中を問わず落とす。-n は落とさずに予定を出す。
自分の pane があるセッションは -f が無ければ落とさない。
+ tmux-session menu
+ セッションの一覧を開く (prefix S と同じ)。選ぶと下のメニューが出る。
+ tmux の外では attach して一覧を出す。
tmux-session menu -c 端末 <セッション>
移る・中身を見る・落とすのメニューを出す (prefix S の一覧から呼ばれる)。
- 短縮名: tmux-go = go / tmux-peek = peek / tmux-kill = kill
+ 短縮名: tmux-go = go / tmux-peek = peek / tmux-kill = kill / tmux-menu = menu
<セッション>: `$数字` は ID、それ以外は名前の完全一致。
-c 端末: この端末の代わりに操作する。知らせは端末の状態行へ出す。
@@ -124,14 +129,23 @@ case "$LINES_N" in
'' | *[!0-9]*) usage_error "-n は 0 以上の整数で指定してください: $LINES_N" ;;
esac
+# menu はセッションも -c も受け取らないときに限り一覧を開く (#270)。どちらか一方でも
+# あればメニューを出す形として読む。
+LIST=0
+[ "$SUB" != menu ] || [ $# -ne 0 ] || [ "$HAVE_CLIENT" = 1 ] || LIST=1
+
case "$SUB" in
kill) [ $# -ge 1 ] || usage_error "セッションを指定してください" ;;
+menu) [ "$LIST" = 1 ] || {
+ [ $# -ge 1 ] || usage_error "セッションを指定してください"
+ [ $# -eq 1 ] || usage_error "セッションは 1 つだけ指定してください"
+} ;;
*)
[ $# -ge 1 ] || usage_error "セッションを指定してください"
[ $# -eq 1 ] || usage_error "セッションは 1 つだけ指定してください"
;;
esac
-[ "$SUB" != menu ] || [ "$HAVE_CLIENT" = 1 ] || usage_error "menu には -c が要ります"
+[ "$SUB" != menu ] || [ "$LIST" = 1 ] || [ "$HAVE_CLIENT" = 1 ] || usage_error "menu には -c が要ります"
# 知らせの出し先。-c のときは端末の状態行へ出し、標準出力には何も出さない
# (UI から背景の run-shell で呼ばれるため、出力は誰にも見えない)。
@@ -157,6 +171,26 @@ command -v tmux >/dev/null 2>&1 || fail "tmux が見つかりません"
SESSIONS=$(tmux list-sessions -F '#{session_id} #{session_name}' 2>/dev/null) ||
fail "tmux サーバが起動していません"
+# 一覧を開く (prefix S と tmux-menu の共通の定義。/etc/tmux.conf の prefix S はここを呼ぶ)。
+# "%%%" は選んだセッションの =名前: に、" \ $ ; ~ を \ で守って置き換わる。'%%' だと ' を
+# 含む名前で壊れる。#{q:...} は選んだセッションの ID と、Enter を押した端末の名前をシェル
+# 向けに引用する。template を run-shell の文字列へ直接書くと #{...} が先に展開されて壊れる
+# ため、定義はこのスクリプトに置く。
+# 出す pane は TMUX_PANE で決める。prefix S は run-shell に TMUX_PANE=#{pane_id} を渡す
+# (run-shell の中には TMUX_PANE が無く、-t が無いと直近に操作された別の端末に出ることがある)。
+if [ "$LIST" = 1 ]; then
+ TREE_TEMPLATE='run-shell -t "%%%" "tmux-session menu -c #{q:client_name} #{q:session_id}"'
+ if [ -n "${TMUX:-}" ]; then
+ if [ -n "${TMUX_PANE:-}" ]; then
+ exec tmux choose-tree -t "$TMUX_PANE" -Zs -O name "$TREE_TEMPLATE"
+ fi
+ exec tmux choose-tree -Zs -O name "$TREE_TEMPLATE"
+ fi
+ # tmux の外: attach 先は tmux の既定 (端末の繋がっていないセッションを優先し、
+ # その中で直近のもの)。attach した画面に一覧を出す。
+ exec tmux attach-session \; choose-tree -Zs -O name "$TREE_TEMPLATE"
+fi
+
if [ "$HAVE_CLIENT" = 1 ]; then
tmux list-clients -F '#{client_name}' | grep -Fqx -- "$CLIENT" ||
fail "端末がありません: $CLIENT"
diff --git a/containers/base/tmux.conf b/containers/base/tmux.conf
index 8760e69..539a164 100644
--- a/containers/base/tmux.conf
+++ b/containers/base/tmux.conf
@@ -46,7 +46,8 @@ set -g focus-events on
# 含まれないため、宣言しないと Claude Code などが出す URL がクリックできない。
set -ga terminal-features ",xterm-256color:hyperlinks"
-# prefix S: セッションを選び、移る・中身を見る・落とすのメニューを出す (#234)。
-# "%%%" は選んだセッションの =名前: に、" \ $ ; ~ を \ で守って置き換わる。'%%' だと ' を
-# 含む名前で壊れる。#{q:...} は選んだセッションの ID と、押した端末の名前をシェル向けに引用する。
-bind-key S choose-tree -Zs -O name "run-shell -t \"%%%\" \"tmux-session menu -c #{q:client_name} #{q:session_id}\""
+# prefix S: セッションを選び、移る・中身を見る・落とすのメニューを出す (#234 / #270)。
+# 一覧の定義は tmux-session にある (tmux-menu と同じ)。run-shell は渡した文字列の #{…} を
+# 先に展開するため、template をここへ書かずにコマンドを呼ぶ。run-shell の中には TMUX_PANE が
+# 無いため、押した pane の ID を渡す (無いと直近に操作された別の端末に出ることがある)。
+bind-key S run-shell "TMUX_PANE=#{pane_id} tmux-menu"
diff --git a/docs/specifications/tmux-named-session.md b/docs/specifications/tmux-named-session.md
index 0c67067..15677e1 100644
--- a/docs/specifications/tmux-named-session.md
+++ b/docs/specifications/tmux-named-session.md
@@ -10,8 +10,9 @@ base イメージは、1 つの tmux セッションを名前か ID で指して
- 落とす(`kill`): attach 中・実行中を問わずセッションを終わらせる
tmux の中では `prefix S` でセッションの一覧(`choose-tree`)を出し、選んだセッションに同じ
-3 つをメニューから行える。UI は tmux 組み込みの `choose-tree` / `display-menu` /
-`display-popup` だけで組み、パッケージを足さない。
+3 つをメニューから行える。コマンド `tmux-menu`(`tmux-session menu`)からも同じ一覧とメニューを
+開ける。tmux の外で打つと attach して、attach した画面に一覧を出す。UI は tmux 組み込みの
+`choose-tree` / `display-menu` / `display-popup` だけで組み、パッケージを足さない。
`tmux-first` / `tmux-clean` は「同じベース名のセッション群」を、操作中の端末と実行中の
セッションを守りながら整理する道具である。`tmux-session` は 1 つを狙って強制的に効かせる道具で、
@@ -24,14 +25,15 @@ tmux の中では `prefix S` でセッションの一覧(`choose-tree`)を
## 対象範囲
-- コマンド `tmux-session` と短縮名 `tmux-go` / `tmux-peek` / `tmux-kill`
+- コマンド `tmux-session` と短縮名 `tmux-go` / `tmux-peek` / `tmux-kill` / `tmux-menu`
- `/etc/tmux.conf` の `prefix S` の割り当て
- base と、base を継ぐ派生イメージへの伝播の規則
- `containers/lfm` と `containers/snapshot` は base を継がないため対象に含まない
- ホストの `~/.local/bin` と `~/.tmux.conf` へ配る仕組みは持たない。devbase はホストの利用者の
ファイルへ書かず(`~/.tmux.conf` を書き換えると、消したつもりの行が戻るなどの食い違いを
生む)、手順を利用者向け文書に書いて利用者が置く
-- tmux の外で一覧から選ぶ UI は持たない。名指しの 3 操作は tmux の外からもコマンドで使える
+- tmux の外で、attach せずに一覧から選ぶ UI は持たない。`tmux-menu` は tmux の外では attach して
+ 一覧を出す。名指しの 3 操作は tmux の外からもコマンドで使える
- 読み取り専用の attach(`tmux attach -r`)の操作は持たない。`choose-tree` のプレビューが
同じ用途を満たす
@@ -40,9 +42,9 @@ tmux の中では `prefix S` でセッションの一覧(`choose-tree`)を
| 要素 | 置き場所 | 責務 |
| --- | --- | --- |
| 本体 | `containers/base/tmux-session` → `/usr/local/bin/tmux-session` | サブコマンド `go` / `peek` / `kill` / `menu` を持つ POSIX sh(`#!/bin/sh`、`set -eu`)の 1 ファイル |
-| 短縮名 | `/usr/local/bin/tmux-go` / `tmux-peek` / `tmux-kill` | `tmux-session` への symlink。呼ばれた名前でサブコマンドが決まる |
-| 導入 | `containers/base/Dockerfile` の「tmux セッションの整理コマンド」の節 | `COPY --chmod=0755 tmux-session /usr/local/bin/tmux-session` と、`tmux1` / `tmuxc` と同じ `RUN` の `ln -sf` 3 つ |
-| キーの割り当て | `containers/base/tmux.conf` の末尾 → `/etc/tmux.conf` | `bind-key S choose-tree … "tmux-session menu …"` の 1 行 |
+| 短縮名 | `/usr/local/bin/tmux-go` / `tmux-peek` / `tmux-kill` / `tmux-menu` | `tmux-session` への symlink。呼ばれた名前でサブコマンドが決まる |
+| 導入 | `containers/base/Dockerfile` の「tmux セッションの整理コマンド」の節 | `COPY --chmod=0755 tmux-session /usr/local/bin/tmux-session` と、`tmux1` / `tmuxc` と同じ `RUN` の `ln -sf` 4 つ |
+| キーの割り当て | `containers/base/tmux.conf` の末尾 → `/etc/tmux.conf` | `bind-key S run-shell "TMUX_PANE=#{pane_id} tmux-menu"` の 1 行。一覧の定義(`choose-tree` と template)は `tmux-session` が持つ |
| 静的検査 | `.github/workflows/ci.yml` の `shellcheck` ジョブ | `containers/base/tmux-first` / `tmux-clean` / `tmux-session` を既定の severity(style まで)で検査する |
| 回帰テスト | `tests/containers/test_tmux_session.py`、`tests/containers/test_tmux_conf.py` | 専用の tmux サーバでの振る舞いと、Dockerfile・tmux.conf の形を固定する |
@@ -62,21 +64,29 @@ tmux の中では `prefix S` でセッションの一覧(`choose-tree`)を
tmux-session go [-c 端末] <セッション>
tmux-session peek [-n 行数] <セッション>
tmux-session kill [-n] [-f] [-c 端末] <セッション>...
+tmux-session menu
tmux-session menu -c 端末 <セッション>
tmux-go … = tmux-session go …
tmux-peek … = tmux-session peek …
tmux-kill … = tmux-session kill …
+tmux-menu … = tmux-session menu …
```
-- `basename "$0"` が `tmux-go` / `tmux-peek` / `tmux-kill` なら第 1 引数をサブコマンドとして
- 読まない。それ以外の名前で呼ばれたときは第 1 引数をサブコマンドとして読む
+- `basename "$0"` が `tmux-go` / `tmux-peek` / `tmux-kill` / `tmux-menu` なら第 1 引数を
+ サブコマンドとして読まない。それ以外の名前で呼ばれたときは第 1 引数をサブコマンドとして読む
- `-h` / `--help` はどのサブコマンドでも使い方を標準出力へ出して終了コード 0 で終わる
- `kill` の `-n` は `--dry-run`、`-f` は `--force` とも書ける。`--` でオプションの終わりを示せる
- オプションはサブコマンドごとに受け付けるものが決まっている。`peek` は `-c` を、`go` は `-n` を
受け取らない(知らないオプションとして終了コード 2)
-- `go` / `peek` / `menu` はセッションをちょうど 1 つ、`kill` は 1 つ以上取る
+- `go` / `peek` / `menu` はセッションをちょうど 1 つ、`kill` は 1 つ以上取る。ただし `menu` は
+ セッションも `-c` も無いときに限り一覧を開く形になる。どちらか一方でもあればメニューを出す形として読む
- `-n 行数` は 0 以上の整数だけを受け付ける。既定は 20
+`tmux-menu` を `menu` の短縮名にし、一覧を開く動きを「セッションも `-c` も無い形」に割り当てるのは、
+`tmux-go` = `go` と同じ規則で名前から動きが読め、メニューを出す形(`menu -c 端末 <セッション>`)の
+呼び出し元を何も変えずに済むためである。メニューを出す形は、`/etc/tmux.conf` の一覧の template と、
+利用者がホストの `~/.tmux.conf` へ写した以前の割り当ての行が呼ぶため、形を変えない。
+
### セッションの指し方
| 引数の形 | 解決 |
@@ -182,9 +192,31 @@ screen (0.0 の直近 20 行)
`/etc/tmux.conf` の割り当ては次の 1 行である。
```tmux
-bind-key S choose-tree -Zs -O name "run-shell -t \"%%%\" \"tmux-session menu -c #{q:client_name} #{q:session_id}\""
+bind-key S run-shell "TMUX_PANE=#{pane_id} tmux-menu"
```
+一覧の定義は `tmux-session` の 1 か所にあり、`prefix S` とコマンド `tmux-menu`(`tmux-session menu`)が
+共有する。`run-shell` は渡した文字列の `#{…}` を先に展開するため、template を割り当てへ直接書かずに
+コマンドを呼ぶ。一覧を開く形の `tmux-session` は次を実行する。
+
+```sh
+TREE_TEMPLATE='run-shell -t "%%%" "tmux-session menu -c #{q:client_name} #{q:session_id}"'
+tmux choose-tree -t "$TMUX_PANE" -Zs -O name "$TREE_TEMPLATE" # TMUX と TMUX_PANE がある
+tmux choose-tree -Zs -O name "$TREE_TEMPLATE" # TMUX があり TMUX_PANE が空
+tmux attach-session \; choose-tree -Zs -O name "$TREE_TEMPLATE" # tmux の外
+```
+
+- 一覧を出す pane は `TMUX_PANE` で決める。`run-shell` の中には `TMUX_PANE` が無く、`-t` が無いと
+ 直近に操作された別の端末に出ることがあるため、`prefix S` は押した pane の ID を `TMUX_PANE` で渡す
+ (`run-shell` は `#{pane_id}` を先に展開し、`%3` の形の引用の要らない ID が渡る)
+- tmux は、端末を持たないクライアントから来たコマンドの現在の pane を、その環境の `TMUX_PANE` で
+ 決める。そのため `-t` が無くても `TMUX_PANE` の pane に出る。それでも `-t "$TMUX_PANE"` を付けるのは、
+ 出す pane をコマンドの行に書いて読めるようにし、tmux が環境から現在の pane を引く規則に頼らない
+ ためである。`-t` の有無で振る舞いは変わらない
+- tmux の外での attach 先は tmux の既定(端末の繋がっていないセッションを優先し、その中で直近のもの。
+ すべてに端末が繋がっていれば全体で直近のもの)に従い、attach した画面に一覧を出す。attach 先を
+ 引数で取らないのは、名指しで移るなら `tmux-go` があり、一覧からどのセッションへも移れるためである。
+ tmux のサーバが動いていなければ、サーバもセッションも作らずに終了コード 1 で終わる
- `choose-tree -Zs -O name` は、名前順のセッションの一覧を全画面で出す。tmux の既定の操作
(`v` でプレビューの切り替え、`f` で絞り込み、`x` で 1 つ落とす、`t` で印を付けて `X` で
まとめて落とす)はそのまま使える
@@ -235,9 +267,12 @@ tmux display-menu -c 端末 -t '$ID' -T '#[align=centre]#{session_name}' …
sequenceDiagram
participant U as 利用者の端末
participant T as tmux サーバ
+ participant L as tmux-menu(一覧)
participant M as tmux-session menu
participant G as tmux-session go
U->>T: prefix S
+ T->>L: run-shell
TMUX_PANE=pane tmux-menu
+ L->>T: choose-tree -t pane
T->>U: choose-tree(一覧とプレビュー)
U->>T: セッションを選んで Enter
T->>M: run-shell -t "%%%"
menu -c 端末 $ID
@@ -260,7 +295,7 @@ sequenceDiagram
| --- | --- |
| 0 | 成功。`-h` / `--help` |
| 1 | tmux が無い・サーバが無い・対象のセッションが無い・`-c` の端末が無い・実行元を特定できない・自分のセッションを `-f` なしで `kill` しようとした・tmux のコマンドが失敗した |
-| 2 | 使い方の誤り(サブコマンドが無い・知らないサブコマンドとオプション・オプションの値が無い・セッションの数が合わない・`-n` が 0 以上の整数でない・`-c` の値の形が外れた・`menu` に `-c` が無い) |
+| 2 | 使い方の誤り(サブコマンドが無い・知らないサブコマンドとオプション・オプションの値が無い・セッションの数が合わない・`-n` が 0 以上の整数でない・`-c` の値の形が外れた・メニューを出す形の `menu` に `-c` が無い) |
誤りは理由を標準エラーへ出す。終了コード 2 のときは `使い方は <呼ばれた名前> -h` を添える。
@@ -292,8 +327,11 @@ sequenceDiagram
(コマンドが `PATH` に無ければメニューは動かない)
- `containers/lfm` と `containers/snapshot` は base を継がないため入らない
- ホストの tmux で使うときは、利用者が devbase の checkout の `containers/base/tmux-session` を
- 指す symlink を `~/.local/bin` へ 4 つ張り、`~/.tmux.conf` へ上の 1 行を足す。複写ではなく
- symlink にすると `git pull` で更新が届く
+ 指す symlink を `~/.local/bin` へ 5 つ(`tmux-session` と短縮名 4 つ)張り、`~/.tmux.conf` へ上の
+ 1 行を足す。複写ではなく symlink にすると `git pull` で更新が届く
+- ホストの `~/.tmux.conf` に以前の割り当て(`bind-key S choose-tree …` で template を直接書く行)を
+ 残していても、`prefix S` はメニューを出す形を呼ぶためそのまま動く。`tmux-menu` を使うには symlink の
+ `tmux-menu` を足す
- 動作を確かめてある tmux は、コンテナの 3.6(Ubuntu 26.04)とホストの 3.7b。使う機能
(`choose-tree` の template・`display-menu`(3.0 以降)・`display-popup`(3.2 以降)・
書式の `q:`)は両方にある。`/bin/sh` は Ubuntu の dash と macOS の bash 3.2(POSIX モード)で
@@ -333,26 +371,49 @@ sequenceDiagram
あること
- `prefix S`
- `containers/base/tmux.conf` を読んだ tmux の `list-keys -T prefix` に `S` の割り当てがちょうど
- 1 つあり、`choose-tree` を呼ぶこと(`test_tmux_conf.py`)
+ 1 つあり、`run-shell "TMUX_PANE=#{pane_id} tmux-menu"` であること(`test_tmux_conf.py`)
- 引数を書き出すだけの偽の `tmux-session` を `PATH` の先頭に置き、`pty` から `C-b S` → 選択 →
Enter を送ると、選んだセッションの ID と押した端末の名前が渡ること。特殊文字の名前でも同じこと
- 本物の `tmux-session` で、メニューの「移る」「中身を見る」「落とす」(確認の `y`)がそれぞれ
効き、今いるセッションも同意すれば落ちること。背景の `run-shell` からの知らせが端末へ届くこと
+- `tmux-menu`(一覧を開く形)
+ - tmux の中で `tmux-menu` と `tmux-session menu` のどちらをプロンプトから打っても、その pane に
+ 一覧(`tree-mode`)が出ること
+ - `TMUX_PANE` の pane に一覧が出て、後から attach して直近に操作された端末の pane には出ないこと。
+ `TMUX` があり `TMUX_PANE` を消した環境でも、端末が 1 つだけなら、その端末の pane に一覧が出て
+ 終了コード 0 であること
+ - tmux の外で打つと、端末の繋がっていないセッションへ attach して一覧を出すこと
+ - 偽の `tmux-session` で、中・外のどちらで開いた一覧でも、選んで Enter を押すと
+ `menu -c <選んだ ID>` が渡ること。特殊文字の名前でも同じこと
+ - 本物の `tmux-session` で、一覧から出したメニューの「移る」で Enter を押した端末が移り、対象に
+ 繋がっていた他の端末が外れること
+ - メニューを出す形(`-c 端末 `)は、`tmux-menu` と `tmux-session menu` のどちらでもメニューを
+ 出すこと
+ - サーバが無いと、`tmux-menu` と `tmux-session menu` のどちらも終了コード 1 で標準エラーへ理由を
+ 出し、セッションを作らないこと
+ - `tmux-menu -h` が終了コード 0 で、`tmux-session -h` の使い方に `tmux-menu` が載ること。`-c` だけで
+ セッションが無い・`-c` が無い・余分な引数・知らないオプションは終了コード 2 であること
- 配布
- Dockerfile に `COPY --chmod=0755 tmux-session /usr/local/bin/tmux-session` が 1 行あり、
- 短縮名 3 つの `ln -sf tmux-session /usr/local/bin/<名前>` があること
+ 短縮名 4 つの `ln -sf tmux-session /usr/local/bin/<名前>` があること
- `containers/base/tmux-session` が `#!/bin/sh` で始まり実行権を持つこと
+一覧を開いて選ぶテストは、コマンド行とカーソルの移動を `send-keys` で pane へ送り、Enter だけは
+繋いだ端末から送る。`send-keys` で送った Enter では、template の `#{client_name}` が押した端末ではなく
+直近に操作された端末に展開され、渡った端末名を確かめられないためである。後から attach した端末は、
+打たなくても直近に操作された端末になる。
+
`test_tmux_conf.py` の copy-mode の割り当ての比較は `-T copy-mode` / `-T copy-mode-vi` の行だけを
対象にし、`prefix S` の行が混ざっても崩れない。
CI はイメージを建てないため、次は建てたイメージで手で確かめる。
- `docker run --rm --entrypoint /bin/bash devbase-base:latest -c 'ls -l /usr/local/bin/tmux-*; tmux-go -h; echo exit=$?'`
- で 4 つのコマンドがあり、`tmux-go -h` が終了コード 0 で終わること
+ で `tmux-session` と短縮名 4 つのコマンドがあり、`tmux-go -h` が終了コード 0 で終わること
- 建てた base の `shellcheck` で `containers/base/tmux-session` を既定の severity で検査すると、
指摘が 0 件であること
- 建て直した base のコンテナの tmux で、`prefix S` のメニューの 3 つの操作が効くこと
+- 建て直した base のコンテナで `tmux-menu` を打つと、セッションの一覧が開くこと
CI の ShellCheck ジョブは runner の shellcheck で `tmux-first` / `tmux-clean` / `tmux-session` を
検査する。
diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md
index d00f93d..a73c499 100644
--- a/docs/user/environment-variables.md
+++ b/docs/user/environment-variables.md
@@ -611,22 +611,34 @@ tmux の中では **`prefix S`**(既定では `Ctrl+b` → `Shift+s`)で、
| 中身を見る | `p` | `tmux-peek` の出力を浮いた窓に出す。`Enter` で閉じる |
| 落とす | `k` | 確認(`y/n`)を挟んで落とす。今いるセッションも、同意すれば落とす |
+キーを覚えていなくても、**`tmux-menu`** を打てば `prefix S` と同じ一覧とメニューが開きます(`tmux-session menu` と同じ)。
+
+```bash
+tmux-menu # tmux の中: 今の pane に一覧を出す
+ # tmux の外: attach して、attach した画面に一覧を出す
+```
+
+- tmux の外での attach 先は tmux の既定に従います(端末の繋がっていないセッションを優先し、その中で直近に使ったもの)。一覧からどのセッションへも移れます。名前の決まったセッションへ直接行くなら `tmux-go` を使います
+- tmux のサーバが動いていないときは、サーバもセッションも作らずに理由を出して終了コード 1 で終わります
+
`prefix S` の割り当ては `/etc/tmux.conf` にあります。`~/.tmux.conf` で `S` を別の操作に割り当てていれば、後から読むそちらが勝ちます(コマンドはそのまま使えます)。
**ホストの tmux で使う場合**は、devbase の checkout の中のファイルへ symlink を張り、`~/.tmux.conf` へ 1 行足します(devbase はホストのこれらのファイルへ書き込みません)。複写ではなく symlink にすると、devbase を `git pull` するだけで更新が届きます。`~/.local/bin` が `PATH` に入っている必要があります。
```bash
DEVBASE_DIR=~/devbase # devbase を clone した場所
-for n in tmux-session tmux-go tmux-peek tmux-kill; do
+for n in tmux-session tmux-go tmux-peek tmux-kill tmux-menu; do
ln -sf "$DEVBASE_DIR/containers/base/tmux-session" ~/.local/bin/$n
done
```
```tmux
# ~/.tmux.conf に足す (反映は tmux source-file ~/.tmux.conf か tmux kill-server)
-bind-key S choose-tree -Zs -O name "run-shell -t \"%%%\" \"tmux-session menu -c #{q:client_name} #{q:session_id}\""
+bind-key S run-shell "TMUX_PANE=#{pane_id} tmux-menu"
```
+以前の手順で `bind-key S choose-tree …` の行を足した人は、そのままでも `prefix S` は動きます。`tmux-menu` を使うには symlink の 5 つ目(`tmux-menu`)を足します。行は上の新しい形へ差し替えると、一覧の定義が `tmux-session` の 1 か所にそろいます。
+
dev コンテナで使うには、base イメージの建て直し(`devbase build base --no-cache`)と、使っている派生イメージの建て直し、コンテナの作り直し(`devbase down` → `devbase up`)が要ります。
サブコマンドごとの引数・終了コード・`peek` の出力・`prefix S` のメニューの仕様は
diff --git a/issues/PLAN71_tmux-menu-design.md b/issues/PLAN71_tmux-menu-design.md
deleted file mode 100644
index 010a7cb..0000000
--- a/issues/PLAN71_tmux-menu-design.md
+++ /dev/null
@@ -1,354 +0,0 @@
-# PLAN71: prefix S のセッションの一覧とメニューをコマンド tmux-menu からも開く の設計
-
-要求と受け入れ条件は [PLAN71_tmux-menu.md](PLAN71_tmux-menu.md) にある。この文書は
-「どう作るか」だけを扱う。土台の `tmux-session` の設計は PLAN69 にある。確定仕様は
-[docs/specifications/tmux-named-session.md](../docs/specifications/tmux-named-session.md) である。
-
-## 機能一覧
-
-| # | 機能 | 誰が使うか |
-| --- | --- | --- |
-| F1 | tmux の中で `tmux-menu` を打つと、今の pane にセッションの一覧を出す。選ぶと `prefix S` と同じメニューが出る | tmux の中にいる利用者 |
-| F2 | tmux の外で `tmux-menu` を打つと、attach と同時に一覧を出す | tmux の外にいる利用者 |
-| F3 | `prefix S` を F1 と同じ定義で開く(一覧を開く定義を 1 か所にする) | tmux の中にいる利用者 |
-| F4 | F1〜F3 を base イメージへ入れ、ホストで使う手順を示す | base を建てる利用者と、ホストの tmux の利用者 |
-
-## 構成要素
-
-| 要素 | 変更 | 責務 |
-| --- | --- | --- |
-| `containers/base/tmux-session` | 変える | 呼ばれた名前 `tmux-menu` を `menu` へ振り分ける。`menu` がセッションを受け取らないときは一覧を開く(決定 1)。一覧を開く `choose-tree` の定義をこのファイルだけに持つ(決定 2) |
-| `containers/base/tmux.conf` | 変える | `prefix S` の行を、キーを押した pane の ID を渡して `run-shell` で `tmux-menu` を呼ぶ形に変える(決定 2・決定 4) |
-| `containers/base/Dockerfile` の「tmux セッションの整理コマンド」の節 | 変える | symlink の `RUN` に `tmux-menu` を 1 つ足す |
-| `tests/containers/test_tmux_session.py` | 変える | F1・F2・F3 と、`menu -c 端末 <セッション>` の形が変わらないことを確かめる。`SHORT_NAMES` に `tmux-menu` を足す(下の「テスト基盤の更新」) |
-| `tests/containers/test_tmux_conf.py` | 変える | 既存の `test_prefix_s_opens_session_chooser` を書き換え、`prefix S` の割り当てが `TMUX_PANE=#{pane_id}` を付けて `tmux-menu` を呼ぶことを `list-keys` で確かめる |
-| `docs/user/environment-variables.md` の「セッションを名指しで扱う」 | 変える | `tmux-menu` の使い方を足す。ホストの手順の symlink を 5 つにし、`~/.tmux.conf` の行を新しい割り当てへ差し替える |
-| `docs/specifications/tmux-named-session.md` | 変える(確定仕様化で) | `menu` の 2 つの形と `tmux-menu`、`prefix S` の新しい行 |
-| `issues/PLAN71_tmux-menu-measure.py` | 足す(この設計で) | 「実測」の再現用スクリプト。確定仕様化のときに消す |
-| `CHANGELOG.md` | 変える | `[Unreleased]` の `### Added` に足す。反映に `devbase build base --no-cache` が要ることを書く |
-
-次のものは変えない。
-
-- `tmux-session` の `go` / `peek` / `kill` と、`menu -c 端末 <セッション>` が出すメニューの中身
-- `containers/base/tmux-first` / `containers/base/tmux-clean`
-- `prefix s`(tmux の既定の `choose-tree -Zs`)
-
-## 決定の記録
-
-### 決定 1: `tmux-menu` は `menu` サブコマンドの短縮名にし、セッションを受け取らない形を一覧を開く動きにする
-
-利用者が決めた名前 `tmux-menu` と、サブコマンドの名前 `menu` がそろう。`tmux-go` = `go` と同じ
-規則で読める。今の `menu -c 端末 <セッション>` はセッションを必ず受け取るため、受け取らない
-形は空いている。空いた形に一覧を割り当てれば、今の形の呼び出し元(今の `/etc/tmux.conf` と
-ホストへ写した行)は何も変えずに動く。
-
-新しいサブコマンド(`ui` など)を足して `tmux-menu` をそちらへ振り分ける案は採らない。
-`tmux-menu` と `menu` が別の動きになり、名前から動きが読めない。今の `menu` を別名へ改名する
-案も採らない。ホストへ写した `~/.tmux.conf` の行が壊れる。
-
-### 決定 2: 一覧を開く定義は `tmux-session` だけに持ち、`prefix S` は `run-shell` で `tmux-menu` を呼ぶ
-
-`prefix S` と `tmux-menu` が同じ一覧を開くことを、定義を 1 つにして保つ。2 か所に同じ template を
-持つと、片方だけを直したときに 2 つの入口の動きが分かれる。実測のとおり、template を
-`run-shell` の文字列へ直接書く形は `#{…}` の先の展開で壊れる。スクリプトの中に置けば
-`run-shell` の展開を通らない。ホストの手順も `~/.tmux.conf` の 1 行が短くなる。
-
-`prefix S` の行を今のまま残し、`tmux-session` にも同じ template を書く案は採らない。一致を
-テストで縛ることはできるが、同じ文字列を 2 か所で持つこと自体が残る。
-
-### 決定 3: tmux の外では attach 先を引数で取らない
-
-名前の決まったセッションへ行くなら `tmux-go` がある。`tmux-menu` は「まず一覧を見る」入口で、
-attach 先は tmux の既定(**繋がっている端末の無いセッションを優先し、その中で直近に使ったもの**。
-すべてに端末が繋がっていれば全体で直近に使ったもの)で足りる。一覧からどのセッションへも移れる。
-セッションを引数で取ると、決定 1 の「セッションも `-c` も受け取らない形だけが一覧を開く」規則が崩れる。
-
-### 決定 4: `prefix S` はキーを押した pane の ID を `TMUX_PANE` で渡し、`choose-tree` の `-t` に使う
-
-`run-shell` から起動したシェルには `TMUX_PANE` が無い。`-t` の無い `choose-tree` は、呼び出し元の
-pane を継がず、全セッションから直近に操作されたセッションを選び直す。`prefix S` を押してから
-`choose-tree` が走るまでの間に別のセッションの端末が操作されると、一覧がその端末に出る(実測の 5)。
-`run-shell` は渡された文字列の `#{…}` を先に展開するため、`TMUX_PANE=#{pane_id} tmux-menu` と
-書けば押した pane の ID(`%3` の形。引用の要らない文字だけ)が渡る(実測の 6)。
-
-一覧を出す pane を決めるのは `TMUX_PANE` そのものである。tmux は、端末を持たないクライアントから
-来たコマンドの現在の pane を、そのクライアントの環境の `TMUX_PANE` で決める(tmux の `cmd-find.c`
-の `cmd_find_inside_pane`)。`-t` の無い `choose-tree` でも、`TMUX_PANE` があればその pane に出る
-(実測の 7a)。それでも `tmux-menu` は `TMUX_PANE` を `-t` にも渡す。どの pane に出すかをコマンドの
-行に書いて意図を読めるようにし、tmux が環境から現在の pane を引く規則に頼らないためである。
-`-t` は保険であり、`-t` の有無で振る舞いは変わらない(実測の 7a・7b)。
-
-`tmux-menu` に `-t` のオプションを足して ID を渡す案は採らない。`menu` の受け付ける形が増え、
-tmux の中でコマンドを打つ形(`TMUX_PANE` がシェルにある)と同じ入口に揃わない。
-
-## 実測(2026-09-24、ホストの tmux 3.7b)
-
-再現用のスクリプトは [PLAN71_tmux-menu-measure.py](PLAN71_tmux-menu-measure.py) にある
-(`python3 issues/PLAN71_tmux-menu-measure.py`。一時ディレクトリのソケットでサーバを立て、利用者の
-サーバに触れない)。`python3` の `pty` で端末を繋ぎ、`tmux-session` の代わりに引数を書き出すだけの
-偽物を置く。一覧を開くスクリプト `open-list` は `tmux-menu` の代わりで、`TMUX_PANE` があれば
-`-t "$TMUX_PANE"` を付けて次の `` で `choose-tree -Zs -O name` を実行する。`open-list-no-t`
-は `open-list` から `-t` を落としたもので、`TMUX_PANE` があっても `-t` に使わない。`open-list-unset` は
-`TMUX_PANE` を `unset` してから `-t` を付けずに開き、`open-list-unset-t` は `unset` する前の値を `-t` に使う。セッションは
-`a`・`it's`(`'` を含む)・`b` の 3 つで、端末 `/dev/ttys023` が `a` に、`/dev/ttys024` が `b` に
-繋がっている(端末の名前は実行ごとに変わる)。9 だけは最後に `c`・`d` の 2 つを足し、端末を
-`c`→`d` の順に繋ぐ。一覧では 1 つ上を選んで Enter を押した。
-
-| # | 開き方 | 一覧が出た pane | `menu` へ渡った引数(ID → 名前) | 判定 |
-| --- | --- | --- | --- | --- |
-| 1 | `a` の端末のプロンプトで `open-list` を打つ | `a` | `menu -c /dev/ttys023 $1` → `it's` | 正しい |
-| 2 | tmux の外で `tmux attach \; choose-tree -Zs -O name ''` | 新しい端末 `/dev/ttys025` が `it's`(端末の無いセッション)へ繋がる | `menu -c /dev/ttys025 $2` → `b` | 正しい |
-| 3 | `bind-key S run-shell "tmux choose-tree -Zs -O name ''"`(template を `run-shell` の文字列へ直接埋め込む)を `a` の端末で押す | `a` | `menu -c /dev/ttys023 sh`。選ぶ前に押した端末のセッションの ID(`$0`)へ展開され、それをシェルが変数として `sh` に読んだと見られる | **誤り** |
-| 4 | `bind-key S run-shell open-list` を `a` の端末で押す | `a` | `menu -c /dev/ttys023 $1` → `it's` | 正しい |
-| 5 | 4 の形(1 秒待ってから開く `open-list-slow`)で、押した直後に `b` の端末へ 1 文字打つ | **`b`** | (選んでいない) | **誤り** |
-| 6 | `bind-key S run-shell "TMUX_PANE=#{pane_id} open-list-slow"` で 5 と同じ操作。続けて選んで Enter | `a` | `menu -c /dev/ttys023 $1` → `it's` | 正しい |
-| 7a | `b` の端末へ 1 文字打って直近を `b` にしてから、端末を持たないプロセスで `a` の pane の `TMUX`・`TMUX_PANE` を渡して `-t` を付けない `open-list-no-t` を実行する(テストの `tm.run("tmux-menu", env=tm.inside_env(home))` と同じ形) | `a` | (選んでいない) | 正しい(`-t` が無くても `TMUX_PANE` の pane に出る) |
-| 7b | 7a と同じ操作で、`-t "$TMUX_PANE"` を付ける `open-list` を実行する | `a` | (選んでいない) | 正しい |
-| 7c | 7a と同じ操作で、`TMUX_PANE` を渡さずに `open-list-no-t` を実行する | **`b`** | (選んでいない) | 直近の端末に出る |
-| 7d | 7a と同じ操作で、`TMUX_PANE` を渡して `open-list-unset` を実行する | **`b`** | (選んでいない) | 直近の端末に出る(7c と同じ) |
-| 7e | 7a と同じ操作で、`TMUX_PANE` を渡して `open-list-unset-t` を実行する | `a` | (選んでいない) | 正しい(消しても `-t` に値があれば出る) |
-| 8a | `a` の pane へ `send-keys -t =a: Enter` でコマンド行を打って一覧を開き、`send-keys -t =a: Up` で 1 つ上へ動かす。`b` の端末へ 1 文字打ってから `send-keys -t =a: Enter` | `a` | `menu -c /dev/ttys024 $1` → `it's`(端末は **`b`** のもの) | Enter を `send-keys` で送ると、`#{client_name}` は直近に操作された端末になる |
-| 8b | 8a と同じ操作で、Enter だけを `a` の端末から送る | `a` | `menu -c /dev/ttys023 $1` → `it's` | 正しい(`send-keys` で開いた一覧でも、`send-keys` の `Up` は効く) |
-| 9a | 新しいセッション `c`・`d` を作り、端末を `c`→`d` の順に attach する。**どの端末へも打たずに**、端末を持たないプロセスで `c` の `TMUX` だけを渡し、`TMUX_PANE` を渡さずに `open-list-no-t` を実行する(テストの `tm.attach(home)` → `tm.attach(other)` と同じ順) | **`d`** | (選んでいない) | 後に attach した端末が直近になる。打たなくても繋いだ順だけで決まる |
-| 9b | 9a に続けて、`c` の pane の `TMUX_PANE` を渡して `open-list` を実行する(行 11 の足すテストと同じ形) | `c` | (選んでいない) | 正しい(直近が `d` でも `TMUX_PANE` の pane に出る) |
-
-スクリプトの出力(抜粋。`panes` は `セッション:pane:モード`):
-
-```text
-## 2 outside: attach ; choose-tree
- clients: ['/dev/ttys023=a', '/dev/ttys024=b', "/dev/ttys025=it's"]
- menu args: 'menu -c /dev/ttys025 $2' -> 'menu -c /dev/ttys025 b'
-## 3 bind: run-shell with inline template
- menu args: 'menu -c /dev/ttys023 sh' -> 'menu -c /dev/ttys023 sh'
-## 5 bind: run-shell (no -t), other client typed during start
- panes: ['a:%0:', 'b:%2:tree-mode', "it's:%1:"]
-## 6 bind: run-shell TMUX_PANE=#{pane_id}, other client typed during start
- panes: ['a:%0:tree-mode', 'b:%2:', "it's:%1:"]
-## 6b same binding, pick and Enter
- menu args: 'menu -c /dev/ttys023 $1' -> "menu -c /dev/ttys023 it's"
-## 7 no tty, b typed last, TMUX_PANE=%0: 7a open-list-no-t, TMUX_PANE=a's pane
- panes: ['a:%0:tree-mode', 'b:%2:', "it's:%1:"]
-## 7 no tty, b typed last, TMUX_PANE=%0: 7b open-list (-t), TMUX_PANE=a's pane
- panes: ['a:%0:tree-mode', 'b:%2:', "it's:%1:"]
-## 7 no tty, b typed last, TMUX_PANE=None: 7c open-list-no-t, no TMUX_PANE
- panes: ['a:%0:', 'b:%2:tree-mode', "it's:%1:"]
-## 7 no tty, b typed last, TMUX_PANE=%0: 7d open-list-unset, TMUX_PANE=a's pane
- panes: ['a:%0:', 'b:%2:tree-mode', "it's:%1:"]
-## 7 no tty, b typed last, TMUX_PANE=%0: 7e open-list-unset-t, TMUX_PANE=a's pane
- panes: ['a:%0:tree-mode', 'b:%2:', "it's:%1:"]
-## 8a Enter by send-keys (open by send-keys, b typed last)
- menu args: 'menu -c /dev/ttys024 $1' -> "menu -c /dev/ttys024 it's"
-## 8b Enter by a's terminal (open by send-keys, b typed last)
- menu args: 'menu -c /dev/ttys023 $1' -> "menu -c /dev/ttys023 it's"
-## 9 no tty, attached c then d (no typing), TMUX_PANE=None: 9a open-list-no-t, no TMUX_PANE
- clients: ['/dev/ttys023=a', '/dev/ttys024=b', '/dev/ttys028=c', '/dev/ttys029=d']
- panes: ['a:%0:', 'b:%2:', 'c:%3:', 'd:%4:tree-mode', "it's:%1:"]
-## 9 no tty, attached c then d (no typing), TMUX_PANE=%3: 9b open-list (-t), TMUX_PANE=c's pane
- panes: ['a:%0:', 'b:%2:', 'c:%3:tree-mode', 'd:%4:', "it's:%1:"]
-```
-
-`` は今の `prefix S` の行と同じ次の文字列である。
-
-```tmux
-run-shell -t "%%%" "tmux-session menu -c #{q:client_name} #{q:session_id}"
-```
-
-- **`run-shell` は、渡された文字列の `#{…}` をシェルへ渡す前に展開する。** 3 で選ぶ前の ID が
- 入ったのはこのためである。4 ではスクリプトの本文が `run-shell` の展開を通らないため、
- template は `choose-tree` へそのまま届く。6 はこの展開を使って押した pane の ID を渡す
-- `run-shell` から起動したシェルでは `TMUX` はあり、`TMUX_PANE` は無い。`-t` の無い
- `choose-tree` は直近に操作されたセッションの pane に出る。4 で正しく見えたのは、押した端末が
- 直近だったからにすぎない(5)
-- 端末を持たないクライアントでは、`-t` が無くても環境の `TMUX_PANE` が現在の pane を決める(7a)。
- `TMUX_PANE` が無いときだけ直近の端末に出る(7c)。`-t "$TMUX_PANE"` を付けても付けなくても
- 結果は同じである(7a・7b)。実装の中で `TMUX_PANE` を消しても、消す前の値を `-t` に使えば
- 出る pane は変わらない(7d・7e)
-- 端末へ打たなくても、後に attach した端末が直近に操作された端末になる(9a)。そのうえで
- `TMUX_PANE` を渡せば、直近の端末ではなく `TMUX_PANE` の pane に出る(9b)
-- `send-keys` で pane へ送ったキーは、その pane の一覧を動かす。ただし一覧の Enter で展開される
- `#{client_name}` は、`send-keys` では pane に繋がった端末ではなく直近に操作された端末になる
- (8a)。押した端末の名前を確かめるには、Enter をその端末から送る(8b)
-- tmux の外からの `attach` は、端末の繋がっていないセッションを優先する(2)
-
-## 入出力の契約
-
-### コマンドの形
-
-```text
-tmux-session menu 一覧を開く(新しい形)
-tmux-session menu -c 端末 <セッション> メニューを出す(今の形。変えない)
-tmux-menu = tmux-session menu
-共通: -h / --help で使い方を出して終了コード 0
-```
-
-`menu` の 2 つの形は、**セッションも `-c` も受け取らないときに限り一覧を開く**。どちらか一方でも
-あれば今の形として読み、足りなければ今と同じく終了コード 2 で終わる。
-
-| 引数 | 動き |
-| --- | --- |
-| 無し | 一覧を開く |
-| `-c 端末` と `<セッション>` 1 つ | 今と同じメニュー |
-| `<セッション>` があり `-c` が無い | 終了コード 2(今と同じ「menu には -c が要ります」) |
-| `-c 端末` だけでセッションが無い | 終了コード 2(「セッションを指定してください」) |
-| セッションが 2 つ以上 | 終了コード 2(今と同じ) |
-| 知らないオプション | 終了コード 2(今と同じ) |
-
-`tmux-menu` は `tmux-go` などと同じく、呼ばれた名前で `menu` のサブコマンドとして動く。
-`tmux-menu -c 端末 <セッション>` も今の形のメニューを出す。
-
-### 一覧を開く形の動き
-
-| 状況 | 動き | 終了コード |
-| --- | --- | --- |
-| tmux が無い | 標準エラーへ「tmux が見つかりません」 | 1 |
-| サーバが無い | 標準エラーへ「tmux サーバが起動していません」。サーバもセッションも作らない | 1 |
-| tmux の中(`TMUX` がある) | `TMUX_PANE` があれば `-t "$TMUX_PANE"` を付けて `choose-tree` を実行する。`prefix S` は `TMUX_PANE=#{pane_id}` を渡す(決定 4)。無ければ `-t` を付けない(`run-shell` から素で呼ばれた場合。直近に操作されたセッションに出る) | `choose-tree` の終了コード |
-| tmux の外 | `exec tmux attach-session \; choose-tree …`。attach 先は tmux の既定(端末の繋がっていないセッションを優先し、その中で直近のもの) | `tmux` の終了コード |
-
-`choose-tree` の引数は今の `prefix S` と同じにする。
-
-```sh
-choose-tree -Zs -O name "run-shell -t \"%%%\" \"tmux-session menu -c #{q:client_name} #{q:session_id}\""
-```
-
-## `/etc/tmux.conf` の割り当てと互換性
-
-### 割り当て
-
-```tmux
-# prefix S: セッションを選び、移る・中身を見る・落とすのメニューを出す (#234 / #270)。
-# 一覧の定義は tmux-session にある (tmux-menu と同じ)。run-shell は渡した文字列の #{…} を
-# 先に展開するため、template をここへ書かずにコマンドを呼ぶ。run-shell の中には TMUX_PANE が
-# 無いため、押した pane の ID を渡す (無いと直近に操作された別の端末に出ることがある)。
-bind-key S run-shell "TMUX_PANE=#{pane_id} tmux-menu"
-```
-
-### 互換性
-
-- **`menu -c 端末 <セッション>` の形を変えない。** 今の `/etc/tmux.conf` の template と、
- 利用者がホストの `~/.tmux.conf` へ写した今の行は、この形を呼ぶ。変えると、`git pull` した
- ホストで `prefix S` のメニューが開かなくなる
-- ホストで今の行を残した利用者も、`tmux-menu` の symlink を足せば `tmux-menu` を使える。
- `prefix S` は今の行のまま動く
-
-## 処理の流れ
-
-```mermaid
-sequenceDiagram
- actor U as 利用者
- participant C as tmux-menu(tmux-session menu)
- participant T as tmux サーバ
- participant M as tmux-session menu -c … ID
- alt tmux の中でコマンドを打つ
- U->>C: tmux-menu
- C->>T: choose-tree -t TMUX_PANE …
- else prefix S
- U->>T: prefix S
- T->>C: run-shell "TMUX_PANE=#{pane_id} tmux-menu"
- C->>T: choose-tree -t 押した pane …
- else tmux の外
- U->>C: tmux-menu
- C->>T: exec tmux attach \; choose-tree …
- end
- T-->>U: セッションの一覧
- U->>T: 選んで Enter
- T->>M: run-shell -t "=名前:"
menu -c 押した端末 ID
- M->>T: display-menu(移る a / 中身を見る p / 落とす k)
-```
-
-Enter の後は PLAN69 の `menu` の流れと同じである。
-
-## テスト設計
-
-受け入れ条件の番号は [PLAN71_tmux-menu.md](PLAN71_tmux-menu.md) のもの。テストは
-`test_tmux_session.py` の流儀(専用のソケット、`pty` の端末、偽の `tmux-session`)に合わせる。
-
-### テスト基盤の更新
-
-`tests/containers/test_tmux_session.py` の `SHORT_NAMES`(38 行目)へ `tmux-menu` を足す。この
-タプルは 2 つの経路に効く。
-
-| 経路 | どこで使うか | 足した結果 |
-| --- | --- | --- |
-| Dockerfile の検査 | `test_dockerfile_links_short_names` | `ln -sf tmux-session /usr/local/bin/tmux-menu` を要求する(条件 10) |
-| テスト用の `bin` | `TmuxEnv.__init__` の `for name in ("tmux-session", *SHORT_NAMES)`(346 行目) | `bin/tmux-menu` が本物の `tmux-session` を指す symlink になる。`tm.run("tmux-menu", …)` と、`prefix S` の `run-shell` が `PATH` から引く `tmux-menu` はこれを使う |
-
-`fake_tm` フィクスチャ(943 行目)は、`TmuxEnv` の `bin` とは別の `fake/` を `PATH` の先頭に置き、
-そこへ引数を書き出すだけの `tmux-session` を置く。`fake_tm` も `TmuxEnv` を作るため、`bin/tmux-menu`
-は `SHORT_NAMES` の追加だけで `fake_tm` にもできる。**`fake/` には `tmux-menu` を置かない。** `prefix S`
-の `tmux-menu` は本物で一覧を開き、Enter の後に template が呼ぶ `tmux-session menu -c …` だけを
-`fake/` の偽物が受ける必要がある。`fake/` に偽の `tmux-session` を指す `tmux-menu` を置くと、
-`prefix S` の `tmux-menu` は偽物になり、空の引数を書き出すだけで一覧を開かない。
-`test_prefix_s_passes_selected_id_and_client` は `_open_tree` が `tree-mode` を待ち切れずに落ちる。
-
-`SHORT_NAMES` を足さないままでは `bin` に `tmux-menu` が無く、`prefix S` の `run-shell` がコマンドを
-見つけられない。`prefix S` から一覧を開く既存テストは、中身を変えずに、この基盤更新で通る。
-
-| 基盤更新で通る既存テスト(`prefix S` を `_open_tree` / `_open_menu` で送る) | フィクスチャ |
-| --- | --- |
-| `test_prefix_s_passes_selected_id_and_client` | `fake_tm` |
-| `test_menu_go_detaches_others_and_switches` | `ui_tm` |
-| `test_menu_kill_asks_then_kills` | `ui_tm` |
-| `test_menu_kill_own_session` | `ui_tm` |
-| `test_menu_peek_opens_popup` | `ui_tm` |
-
-`test_menu_notifies_client_on_failure` は `prefix S` を通らず `run-shell` から `tmux-go` を呼ぶため、
-この更新に関係なく通る。
-
-### 条件ごとの確かめ方
-
-表の行を読む前提として、一覧の開き方・選び方と、端末の置き方を先に決める。
-
-**一覧の開き方(tmux の中)。** pane のプロンプトへコマンド行を打つときは、繋いだ端末(`me.send`)
-ではなく pane へ直接送る。`tm.tmux("send-keys", "-t", home, f"{tm.bin}/tmux-menu", "Enter")`
-(`tmux-session` の側は `f"{tm.bin}/tmux-session menu"`)。`_open_tree` のコメントのとおり、繋いだ
-直後の端末は入力を捨てることがあり、コマンド行の一部が落ちると送り直しても残骸が行に残る。
-`send-keys` は端末を通らずに pane へ届き、pane のシェルは `home` の pane の `TMUX_PANE` を持つ。
-コマンドは名前ではなく `tm.bin` の絶対パスで打つ。pane のシェルはログインシェルで、`PATH` は
-テストの環境の `PATH`(`fake_tm` では `fake/` が先頭)が元になる。名前で打つと、`fake_tm` では
-`tmux-session` が `fake/` の偽物に解決されて一覧が開かず、`/etc/profile` が `PATH` を並べ替える
-環境では解決先がさらに変わりうる。一覧が開いたことは `tm.tmux("display-message", "-p", "-t", home,
-"#{pane_mode}")` が `tree-mode` になるのを `_wait` で待って確かめる。
-
-**一覧の開き方(tmux の外)。** `me = tm.spawn([str(tm.bin / "tmux-menu")])`。attach 先は tmux の
-既定で、端末の繋がっていないセッションを優先し、同じ条件の中では直近に使ったものになる(実測の 2)。
-行 4・5 は `target = tm.new(<名前>)`・`home = tm.new("zz-home")`・`tm.attach(target)` の順に作り、
-`zz-home` を端末の無いただ 1 つのセッションにする。`me` は `zz-home` に繋がり
-(`_wait(lambda: tm.all_clients().get(me.tty) == home)`)、その pane が `tree-mode` になる。
-
-**1 つ上を選ぶ(テストに足す補助 `_pick_above(tm, me, sid)`)。** 一覧は名前順(`-O name`)で、
-カーソルは開いた pane のセッション `zz-home` にある。どのテストでも選ぶセッションの名前は
-`zz-home` より前に並ぶため、1 つ上が選ぶセッションになる。
-
-1. `tm.tmux("send-keys", "-t", sid, "Up")` で 1 つ上へ動かす。`send-keys` のキーは端末を通らずに一覧を
- 動かす(実測の 8b)
-2. Enter は `me.send("\r")` で `me` の端末から送る。一覧の template の `#{client_name}` は Enter を押した
- 端末に展開されるが、`send-keys` で送った Enter では直近に操作された端末になり(実測の 8a)、押した
- 端末を確かめられない
-3. `me` は繋いだ直後で Enter を捨てることがある。`sid` の pane が `tree-mode` のままなら、2 秒待つごとに
- Enter を送り直す(`_open_tree` と同じ回数)。`tree-mode` を抜けたら送らない。抜けたことは `me` が入力を
- 受け付けている証拠になり、その後に `me` へ送る `a` / `p` / `k` は捨てられない
-
-| 受け入れ条件 | 何で確かめるか |
-| --- | --- |
-| 1 | `ui_tm` で `home = tm.new("zz-home")`・`tm.attach(home)` を作り、上の「tmux の中」の形で `tmux-menu` を打つ。`home` の pane の `#{pane_mode}` が `tree-mode` になることを見る。一覧が `TMUX_PANE` の pane に出る(直近に操作された別の端末には出ない)ことは行 11 の足すテストで縛る |
-| 2 | `fake_tm` で `SPECIAL_NAMES`(`'` を含む)を parametrize し、`target = tm.new(name)`・`home = tm.new("zz-home")`・`me = tm.attach(home)` を作る。1 と同じ形で開き、`_pick_above(tm, me, home)` で選び、`tm.record` が `["menu", "-c", me.tty, target]` になることを見る(`test_prefix_s_passes_selected_id_and_client` と同じ期待値)。打った絶対パスの `tmux-menu` は本物のスクリプトで一覧を開き、Enter の後に `run-shell` がサーバの `PATH`(`fake/` が先頭)から引く `tmux-session` だけが偽物になる |
-| 3 | `ui_tm`(本物の `tmux-session`)で `test_menu_go_detaches_others_and_switches` と同じく `target = tm.new(name)`・`home = tm.new("zz-home")`・`me = tm.attach(home)`・`stale = tm.attach(target)` を作る。1 と同じ形で開き、`_pick_above(tm, me, home)` の後に `_wait(lambda: "移る" in me.output())` を待って `me.send("a")` を送る。`tm.clients_of(target) == {me.tty}` になり、`stale.tty` が `tm.all_clients()` から消えることを見る |
-| 4 | `ui_tm` で上の「tmux の外」の形(`target = tm.new("devbase-3")`・`home = tm.new("zz-home")`・`tm.attach(target)` の後に `me = tm.spawn([str(tm.bin / "tmux-menu")])`)を作る。`me` が `home` に繋がり、`home` の pane の `#{pane_mode}` が `tree-mode` になることを見る |
-| 5 | `fake_tm` で `SPECIAL_NAMES` を parametrize し、4 と同じ組み方で `target = tm.new(name)` として開く。`me` が `home` に繋がり一覧が開くのを待ち、`_pick_above(tm, me, home)` で選ぶ。カーソルは `zz-home` にあるため 1 つ上は `target` で、`tm.record` が `["menu", "-c", me.tty, target]` になることを見る |
-| 6 | `tm` のサーバの無い環境で `tm.run("tmux-menu")` を実行し(`tm.run` は `bin` の絶対パスで起動する)、終了コード 1・標準エラーに「サーバ」を含むこと・実行後も `tm.sessions() == {}` であることを見る |
-| 7 | 既存の `menu` のテスト(`test_menu_*`)が中身を変えずに通る。`prefix S` を通るものは「テスト基盤の更新」の後に通る |
-| 8 | 2 つの形をどちらも両方の名前で走らせる。**一覧を開く形:** `tmux-menu` と `tmux-session menu` で 1・6 を走らせる(parametrize。1 は `send-keys` で `f"{tm.bin}/tmux-menu"` / `f"{tm.bin}/tmux-session menu"` を打ち、6 は `tm.run("tmux-menu")` / `tm.run("tmux-session", "menu")` で起動する)。**メニューを出す形:** `ui_tm` で `target = tm.new("devbase-3")` と `me = tm.attach(tm.new("zz-home"))` を作り、`cmd` = `f"tmux-menu -c {me.tty} {shlex.quote(target)}"` / `f"tmux-session menu -c {me.tty} {shlex.quote(target)}"` で parametrize し、`test_menu_notifies_client_on_failure` と同じく `tm.tmux("run-shell", "-b", cmd)` で呼ぶ。`run-shell` はサーバの環境の `PATH`(`bin` を含む)でコマンドを引く。`tm.new` はセッションの ID を返し、`ui_tm` で最初に作る `devbase-3` の ID は `$0` になる。`run-shell` は文字列を `sh -c` へ渡すため、引用しないと `$0` が `sh` に展開され(実測の 3 と同じ現象)、`tmux-session` は「セッションがありません: sh」で終了コード 1 になる(専用ソケットで `run-shell 'printf "<%s>\n" $0'` が ``、`'$0'` と引用すると `<$0>` になることを確かめた)。テストのファイルに `import shlex` を足す。`-b` で背景に回すため、`display-menu` が閉じるまでテストは止まらない。どちらの名前でも `me` の端末に同じメニュー(`test_menu_peek_opens_popup` と同じく `中身を見る` の項目)が出ることを `_wait(lambda: "中身を見る" in me.output())` で見る。`fake_tm` の record では確かめられない。偽物に置き換わるのは `tmux-session` だけで、`bin` の `tmux-menu` は本物のスクリプトへの symlink のまま `display-menu` を出し、`tmux-session` を呼ばないため |
-| 9 | `test_usage_errors_exit_two` の parametrize へ `("tmux-menu", "-c", "/dev/pts/1")`(`-c` だけ)・`("tmux-menu", "a")`(`-c` が無い)・`("tmux-menu", "-c", "/dev/pts/1", "a", "b")`(余分な引数)・`("tmux-menu", "-x")`(知らないオプション)を足し、終了コード 2 と標準エラーの理由を見る。`test_help_exits_zero` へ `("tmux-menu", "-h")` を足し、`tm.run("tmux-session", "-h")` の標準出力に `tmux-menu` が含まれることを見る |
-| 10 | Dockerfile の symlink の `RUN` に `tmux-menu` が含まれることを固定する(`SHORT_NAMES` を足した `test_dockerfile_links_short_names`)。建てたイメージで `command -v tmux-menu` を見る |
-| 11 | **書き換える既存テスト:** `test_prefix_s_opens_session_chooser`(`tests/containers/test_tmux_conf.py`)は割り当てに `choose-tree` と `tmux-session menu` が含まれることを見ており、決定 2 で落ちる。割り当てがちょうど 1 つで、`run-shell` が `TMUX_PANE=#{pane_id} tmux-menu` を呼ぶことを見る形へ改める。**基盤更新で通る既存テスト:** `test_prefix_s_passes_selected_id_and_client` と、`prefix S` から開く `test_menu_*` 4 件(「テスト基盤の更新」の表)。中身は変えない。**足すテスト:** 時間の競合に頼らず、`TMUX_PANE` の pane に一覧が出ることを見る。`tm` で `home` と `other` の 2 つのセッションを作り、`tm.attach(home)` の後に `tm.attach(other)` で繋ぐ。後に繋いだ `other` の端末が直近に操作された端末になり、端末へ打たなくてもこの順だけで決まる(実測の 9a。`TMUX_PANE` を渡さずに開くと後に繋いだ `d` に出る。9b で `TMUX_PANE` を渡すと先に繋いだ `c` に出る)。そのうえで `tm.run("tmux-menu", env=tm.inside_env(home))` を実行し、`home` の pane の `#{pane_mode}` が `tree-mode` になり、`other` の pane はならないことを見る。このテストが縛るのは「一覧が `TMUX_PANE` の pane に出る」という振る舞いであり、`-t "$TMUX_PANE"` の分岐ではない。`inside_env` は `TMUX_PANE` を必ず渡し、tmux は `-t` が無くても環境の `TMUX_PANE` で現在の pane を決めるため、実装が `-t` を落としても、`TMUX_PANE` を読まなくても通る(実測の 7a・7b)。落ちるのは、実装が `TMUX_PANE` を消し(`unset TMUX_PANE` や `env -u TMUX_PANE`)、かつその値を `-t` にも使わずに tmux を呼ぶ壊し方だけである(実測の 7d。消す前の値を `-t` に使えば通る、7e)。`prefix S` が `TMUX_PANE=#{pane_id}` を渡すことは、書き換える静的テストが縛る。実測の 5・6 のような、押した直後に別の端末へ打つテストは採らない。実測では 1 秒待つ `open-list-slow` で押してから `choose-tree` までの間を作ったが、本物の `tmux-menu` にはその待ちが無く、その間に別の端末を操作できる保証が無いため、割り当てが `TMUX_PANE` を渡さなくても通りうる。`prefix s` は既定のまま |
-| 12 | `git diff --stat main` に `tmux-first` / `tmux-clean` が現れない |
-| 13 | `shellcheck containers/base/tmux-*` と全体の pytest |
-| 14 | 文書の差分をレビューで見る |
-
-## 未確認のまま残ること
-
-| 項目 | 内容 |
-| --- | --- |
-| コンテナの tmux 3.6 での 4 つの開き方 | 実測はホストの 3.7b だけ。`attach \; choose-tree` と `run-shell` からの `choose-tree` は 3.6 でも使える機能だが、建てたイメージで確かめるのは実装の後 |
-| 人が実際の端末で操作する確認 | 条件 1〜5 の人手の確認。リリース後テストへ回す |
diff --git a/issues/PLAN71_tmux-menu-measure.py b/issues/PLAN71_tmux-menu-measure.py
deleted file mode 100755
index 22f5553..0000000
--- a/issues/PLAN71_tmux-menu-measure.py
+++ /dev/null
@@ -1,249 +0,0 @@
-#!/usr/bin/env python3
-"""PLAN71 の実測: choose-tree の開き方ごとに、一覧が出た pane と menu へ渡った引数を見る。
-
-使い方: python3 issues/PLAN71_tmux-menu-measure.py
-一時ディレクトリのソケットでサーバを立て、利用者の tmux サーバに触れない。
-"""
-
-import os
-import pty
-import select
-import subprocess
-import tempfile
-import time
-
-TEMPLATE = 'run-shell -t "%%%" "tmux-session menu -c #{q:client_name} #{q:session_id}"'
-WORK = tempfile.mkdtemp(prefix="plan71-")
-SOCK = os.path.join(WORK, "sock")
-LOG = os.path.join(WORK, "menu.log")
-BINDIR = os.path.join(WORK, "bin")
-
-SCRIPTS = {
- # 本物の代わりに、受け取った引数を書き出すだけの tmux-session
- "tmux-session": f'#!/bin/sh\nprintf "%s\\n" "$*" >> {LOG}\n',
- # tmux-menu の代わり。TMUX_PANE があれば -t に使う(設計の「一覧を開く形」と同じ分岐)
- "open-list": (
- "#!/bin/sh\n"
- f"exec tmux choose-tree ${{TMUX_PANE:+-t \"$TMUX_PANE\"}} -Zs -O name '{TEMPLATE}'\n"
- ),
- # open-list から -t を落としたもの(TMUX_PANE があっても -t に使わない)
- "open-list-no-t": f"#!/bin/sh\nexec tmux choose-tree -Zs -O name '{TEMPLATE}'\n",
- # TMUX_PANE を消してから -t を付けずに開く(TMUX_PANE を消す壊し方)
- "open-list-unset": (
- f"#!/bin/sh\nunset TMUX_PANE\nexec tmux choose-tree -Zs -O name '{TEMPLATE}'\n"
- ),
- # TMUX_PANE を消すが、消す前の値を -t に使う
- "open-list-unset-t": (
- "#!/bin/sh\n"
- 'pane=$TMUX_PANE\nunset TMUX_PANE\n'
- f"exec tmux choose-tree -t \"$pane\" -Zs -O name '{TEMPLATE}'\n"
- ),
- # 一覧が開くまでの間に別の端末を操作するため、1 秒待ってから開く
- "open-list-slow": "#!/bin/sh\nsleep 1\nexec open-list\n",
-}
-
-os.mkdir(BINDIR)
-for name, body in SCRIPTS.items():
- path = os.path.join(BINDIR, name)
- with open(path, "w") as f:
- f.write(body)
- os.chmod(path, 0o755)
-
-ENV = dict(
- os.environ, PATH=BINDIR + ":" + os.environ["PATH"], SHELL="/bin/sh", TERM="xterm"
-)
-ENV.pop("TMUX", None)
-ENV.pop("TMUX_PANE", None)
-TMUX = ["tmux", "-S", SOCK, "-f", "/dev/null"]
-
-
-def t(*args):
- return subprocess.run(
- TMUX + list(args), env=ENV, capture_output=True, text=True, check=False
- ).stdout.strip()
-
-
-def spawn(*args):
- """pty の端末で tmux を起動し、端末の fd を返す。"""
- pid, fd = pty.fork()
- if pid == 0:
- os.execvpe("tmux", TMUX + list(args), ENV)
- time.sleep(1)
- return fd
-
-
-def drain(fd):
- while select.select([fd], [], [], 0.05)[0]:
- try:
- os.read(fd, 65536)
- except OSError:
- break
-
-
-def pick(fd):
- """一覧で 1 つ上を選んで Enter を押す。"""
- time.sleep(0.8)
- os.write(fd, b"\x1b[A")
- time.sleep(0.3)
- os.write(fd, b"\r")
- time.sleep(0.8)
-
-
-def state(label):
- ids = dict(
- line.split(" ", 1)
- for line in t(
- "list-sessions", "-F", "#{session_id} #{session_name}"
- ).splitlines()
- )
- clients = t("list-clients", "-F", "#{client_name}=#{session_name}")
- panes = t("list-panes", "-a", "-F", "#{session_name}:#{pane_id}:#{pane_mode}")
- got = ""
- if os.path.exists(LOG):
- with open(LOG) as f:
- got = f.read().strip()
- named = " ".join(ids.get(w, w) if w.startswith("$") else w for w in got.split())
- print(f"## {label}")
- print(f" clients: {clients.split()}")
- print(f" panes: {panes.split()}")
- print(f" menu args: {got!r} -> {named!r}")
-
-
-def reset():
- if os.path.exists(LOG):
- os.remove(LOG)
- for line in t("list-panes", "-a", "-F", "#{pane_id} #{pane_mode}").splitlines():
- pane, _, mode = line.partition(" ")
- if mode:
- t("send-keys", "-t", pane, "q")
- time.sleep(0.3)
-
-
-def main():
- for name in ("a", "it's", "b"):
- t("new-session", "-d", "-s", name, "-x", "80", "-y", "24")
- c1 = spawn("attach", "-t", "=a")
- c2 = spawn("attach", "-t", "=b")
-
- # 1. 中のプロンプトで打つ(pane のシェルには TMUX_PANE がある)
- reset()
- drain(c1)
- os.write(c1, b"open-list\r")
- pick(c1)
- state("1 prompt: open-list (a's pane, TMUX_PANE set)")
-
- # 2. 外から attach \; choose-tree
- reset()
- c3 = spawn("attach", ";", "choose-tree", "-Zs", "-O", "name", TEMPLATE)
- pick(c3)
- state("2 outside: attach ; choose-tree")
- t("detach-client", "-t", t("list-clients", "-F", "#{client_name}").split()[-1])
-
- # 3. template を run-shell の文字列へ直接埋め込む
- reset()
- t("bind-key", "S", "run-shell", f"tmux choose-tree -Zs -O name '{TEMPLATE}'")
- os.write(c1, b"\x02S")
- pick(c1)
- state("3 bind: run-shell with inline template")
-
- # 4. run-shell からスクリプトを呼ぶ(-t なし)
- reset()
- t("bind-key", "S", "run-shell", "open-list")
- os.write(c1, b"\x02S")
- pick(c1)
- state("4 bind: run-shell open-list (no TMUX_PANE)")
-
- # 5. 4 の形で、一覧が開く前に別のセッションの端末が操作される
- reset()
- t("bind-key", "S", "run-shell", "open-list-slow")
- os.write(c1, b"\x02S")
- time.sleep(0.3)
- os.write(c2, b"x")
- time.sleep(1.5)
- state("5 bind: run-shell (no -t), other client typed during start")
-
- # 6. #{pane_id} を TMUX_PANE として渡す
- reset()
- t("bind-key", "S", "run-shell", "TMUX_PANE=#{pane_id} open-list-slow")
- os.write(c1, b"\x02S")
- time.sleep(0.3)
- os.write(c2, b"x")
- time.sleep(1.5)
- state("6 bind: run-shell TMUX_PANE=#{pane_id}, other client typed during start")
- reset()
- os.write(c1, b"\x02S")
- time.sleep(1)
- pick(c1)
- state("6b same binding, pick and Enter")
-
- # 7. 端末の無いプロセスから、a の pane の TMUX / TMUX_PANE を持って呼ぶ
- # (テストの tm.run(..., env=tm.inside_env(home)) と同じ形)。先に b の端末へ打って直近を b にする
- tmux_var = t("display-message", "-p", "-t", "=a:", "#{socket_path},#{pid},0")
- a_pane = t("display-message", "-p", "-t", "=a:", "#{pane_id}")
- for label, prog, pane in (
- ("7a open-list-no-t, TMUX_PANE=a's pane", "open-list-no-t", a_pane),
- ("7b open-list (-t), TMUX_PANE=a's pane", "open-list", a_pane),
- ("7c open-list-no-t, no TMUX_PANE", "open-list-no-t", None),
- ("7d open-list-unset, TMUX_PANE=a's pane", "open-list-unset", a_pane),
- ("7e open-list-unset-t, TMUX_PANE=a's pane", "open-list-unset-t", a_pane),
- ):
- reset()
- os.write(c2, b"x")
- time.sleep(0.5)
- env = dict(ENV, TMUX=tmux_var)
- if pane:
- env["TMUX_PANE"] = pane
- subprocess.Popen(
- [prog], env=env, stdin=subprocess.DEVNULL,
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
- )
- time.sleep(1)
- state(f"7 no tty, b typed last, TMUX_PANE={pane}: {label}")
-
- # 8. pane へ send-keys でコマンド行を打って一覧を開き、send-keys で 1 つ上へ動かす。
- # 直近を b の端末にしてから Enter を送り、#{client_name} がどの端末になるかを見る
- for label, enter_from_client in (
- ("8a Enter by send-keys", False),
- ("8b Enter by a's terminal", True),
- ):
- reset()
- t("send-keys", "-t", "=a:", os.path.join(BINDIR, "open-list"), "Enter")
- time.sleep(1)
- t("send-keys", "-t", "=a:", "Up")
- os.write(c2, b"x")
- time.sleep(0.5)
- if enter_from_client:
- os.write(c1, b"\r")
- else:
- t("send-keys", "-t", "=a:", "Enter")
- time.sleep(1)
- state(f"{label} (open by send-keys, b typed last)")
-
- # 9. 端末へ打たず、新しいセッション c・d へ c→d の順に attach しただけで直近が決まるか。
- # (テストの tm.attach(home) → tm.attach(other) と同じ形。7 と違って b の端末へ打たない)
- for name in ("c", "d"):
- t("new-session", "-d", "-s", name, "-x", "80", "-y", "24")
- spawn("attach", "-t", "=c")
- spawn("attach", "-t", "=d")
- tmux_var = t("display-message", "-p", "-t", "=c:", "#{socket_path},#{pid},0")
- c_pane = t("display-message", "-p", "-t", "=c:", "#{pane_id}")
- for label, prog, pane in (
- ("9a open-list-no-t, no TMUX_PANE", "open-list-no-t", None),
- ("9b open-list (-t), TMUX_PANE=c's pane", "open-list", c_pane),
- ):
- reset()
- env = dict(ENV, TMUX=tmux_var)
- if pane:
- env["TMUX_PANE"] = pane
- subprocess.Popen(
- [prog], env=env, stdin=subprocess.DEVNULL,
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
- )
- time.sleep(1)
- state(f"9 no tty, attached c then d (no typing), TMUX_PANE={pane}: {label}")
-
- t("kill-server")
-
-
-if __name__ == "__main__":
- main()
diff --git a/issues/PLAN71_tmux-menu.md b/issues/PLAN71_tmux-menu.md
deleted file mode 100644
index a95880a..0000000
--- a/issues/PLAN71_tmux-menu.md
+++ /dev/null
@@ -1,137 +0,0 @@
-# PLAN71: prefix S のセッションの一覧とメニューをコマンド tmux-menu からも開く
-
-対象 issue: devbasex/devbase#270
-
-- ワークフローモード: `standard`
- - 根拠: base イメージに新しいコマンド `tmux-menu`(公開インタフェース)が加わり、
- `tmux-session` の振る舞いが増える。変更は base を建て直した全員に届く(前例: PLAN69)
-- ベースブランチ: `main`(`.ndf/worktree.json` に起点の宣言が無く、既定ブランチに落ちる)
-
-## 依頼(原文)
-
-> Ctrl-b S をコマンドにできませんか?
-
-> tmux-menuというコマンドにする
-> issueを起こしてください
-
-## 目的
-
-- **`prefix S`(`Ctrl-b S`)と同じ「セッションの一覧 → 操作のメニュー」を、コマンド
- `tmux-menu` で開ける。** キーを覚えていない人も、打てば同じ UI に辿り着ける
-- **tmux の外からも開ける。** #234 の設計が弱点に挙げた「tmux の外からは呼べない」を、
- attach と同時に一覧を開くことで解く
-
-## 影響
-
-| 対象 | 影響 |
-| --- | --- |
-| 公開インタフェース | **増える。** コンテナの `PATH` に短縮名 `tmux-menu` が加わる。`tmux-session` のサブコマンドの受け付ける形が 1 つ増える。既存の `tmux-session menu -c 端末 <セッション>` の形は変えない |
-| データ | 変わらない |
-| 既存の振る舞い | `prefix S` で開く一覧とメニューは変えない(割り当ての行は `tmux-menu` を呼ぶ形に変わる)。`prefix s`・`tmux-go` / `tmux-peek` / `tmux-kill`・`tmux-first` / `tmux-clean` は変えない |
-| イメージのサイズ | symlink 1 つ。パッケージは足さない |
-| 利用者の操作 | **`devbase build base --no-cache` が要る。** 派生イメージも建て直し、稼働中のコンテナは `devbase down` → `devbase up` で作り直す。ホストでは symlink を 1 つ足す(利用者向け文書の手順) |
-
-## 前提
-
-- **前提 1: 対象の tmux は、コンテナの 3.6 とホストの 3.7b(PLAN69 と同じ)。**
-- **前提 2: 名前は `tmux-menu` で確定している**(利用者の指示、2026-09-24)。`tmux-session` の
- どのサブコマンドへ振り分けるか、既存の `menu` と意味をどう分けるかは設計で決める
-- **前提 3: tmux の外から開くときの attach 先は、tmux の `attach` の既定(端末の繋がって
- いないセッションを優先し、その中で直近に使ったもの)に従う。** セッション名を引数で取るかは設計で決める
-- **前提 4: CI はイメージを建てない。** 実イメージでの確認は手元で建てたイメージから採って
- Pull Request 本文へ載せる(PLAN67 / PLAN69 と同じ)
-
-## 対象範囲
-
-含む:
-
-- `containers/base/tmux-session` への一覧を開く振る舞いの追加と、短縮名 `tmux-menu` の振り分け
-- `containers/base/Dockerfile` への symlink `tmux-menu` の追加
-- `containers/base/tmux.conf` の `prefix S` の行(一覧を開く定義を 1 か所にまとめる)
-- 回帰テスト(`tests/containers/`)
-- 利用者向け文書・確定仕様(`docs/specifications/tmux-named-session.md`)・CHANGELOG
-
-含まない:
-
-- 一覧・メニューの中身(項目・キー・動き)の変更
-- `tmux-first` / `tmux-clean` の変更
-- ホストへの自動の配布(PLAN69 の前提 4 のまま)
-- tmux を使わない一覧の UI(`curses` など)
-
-## 受け入れ条件
-
-検証はすべて専用のソケットで起動した tmux で行い、利用者の tmux サーバに触れない。
-
-### tmux の中で開く
-
-- [ ] 1. tmux の中の pane で `tmux-menu` を実行すると、その pane にセッションの一覧
- (`choose-tree -Zs`、名前順)が出る
-- [ ] 2. 1 の一覧でセッションを選んで Enter を押すと、`prefix S` から開いたときと同じ
- メニュー(移る `a` / 中身を見る `p` / 落とす `k`)が、選んだセッションを対象に出る
-- [ ] 3. 2 のメニューの `a` は、Enter を押した端末を選んだセッションへ移し、その端末は外さない。
- 選んだセッションに繋がっていた他の端末は外れる(`prefix S` と同じ)
-
-### tmux の外で開く
-
-- [ ] 4. tmux の外で、サーバが動いているときに `tmux-menu` を実行すると、attach し、
- attach した画面にすぐセッションの一覧が出る
-- [ ] 5. 4 の一覧から開いたメニューの `a` / `p` / `k` が、3 と同じく選んだセッションに効く
-- [ ] 6. tmux の外で、サーバが無いときは、サーバもセッションも作らず、理由を標準エラーへ出して
- 終了コード 1 で終わる
-
-### 形と互換
-
-- [ ] 7. `tmux-session menu -c 端末 <セッション>`(`prefix S` の割り当てとホストの
- `~/.tmux.conf` の行が呼ぶ形)は、今までと同じメニューを出す
-- [ ] 8. `tmux-menu` と、それに当たる `tmux-session` のサブコマンドの形が同じ振る舞いをする。
- 一覧を開く形(引数なし)と、メニューを出す形(`-c 端末 <セッション>`)の両方で成り立つ
-- [ ] 9. 知らないオプション・余分な引数では終了コード 2 で、理由を標準エラーへ出す。
- `tmux-menu -h` は使い方を出して終了コード 0 で終わる。`tmux-session -h` の使い方に
- `tmux-menu` が載る
-- [ ] 10. `containers/base/Dockerfile` が `/usr/local/bin/tmux-menu` を `tmux-session` への
- symlink として作る。建てた base イメージの `PATH` から `tmux-menu` を呼べる
-- [ ] 11. `prefix S` と `prefix s` の割り当ては、変更前と同じ UI を、キーを押した端末の pane に
- 開く。`prefix S` の割り当ての文字列を見る既存の静的テスト `test_prefix_s_opens_session_chooser`
- は、新しい割り当て(`run-shell` で `tmux-menu` を呼ぶ)を見る形へ書き換える。テスト基盤の
- `SHORT_NAMES` に `tmux-menu` を足したうえで、それ以外の既存のテスト(`prefix S` から開く
- `test_prefix_s_passes_selected_id_and_client` と `test_menu_*` を含む)は中身を変えずに通る
-- [ ] 12. `tmux-first` / `tmux-clean` の差分が 0 行
-- [ ] 13. `containers/base/tmux-*` の shellcheck が 0 件、全体の pytest が通る
-- [ ] 14. 利用者向け文書に `tmux-menu` の使い方と、ホストで使うときの symlink の手順
- (5 つ目の `tmux-menu`)が載る。確定仕様と CHANGELOG に反映される
-
-## 検証手段
-
-| 項目 | 手段 |
-| --- | --- |
-| テスト | `env -u DEVBASE_ROOT uv run pytest -q`(`tests/containers/test_tmux_session.py` ほか) |
-| 静的解析 | `uvx --from shellcheck-py shellcheck containers/base/tmux-*`、`uv run ruff check` |
-| 実イメージ | `devbase build base --no-cache` の後、使い捨てのコンテナで `tmux-menu` を確かめる |
-| 手動確認 | 実際の端末で tmux の中と外から `tmux-menu` を打ち、一覧 → メニュー → `a` / `p` / `k` が効くことを利用者が見る(条件 2〜5 の人手の確認) |
-
-## 前提とする取り決め
-
-| 項目 | 参照先 / 決めたこと |
-| --- | --- |
-| プロジェクト構造 | base イメージの構成物は `containers/base/` の直下。コマンドは `/usr/local/bin`(PLAN69 と同じ) |
-| コーディング規約 | POSIX sh(`#!/bin/sh`、`set -eu`)。`tmux-session` の既存の書き方に合わせる |
-| テスト戦略 | `tests/containers/test_tmux_session.py` の流儀(専用のソケットの実物の tmux、pty からのキー入力)に合わせる |
-
-## 境界
-
-| 区分 | 内容 |
-| --- | --- |
-| 常に行う | 手元で全体テスト、shellcheck、`devbase build base --no-cache` と建てたイメージでの確認 |
-| 確認してから行う | 既存の `menu` の形を変えること(設計 Pull Request の承認で確かめる) |
-| 行わない | 一覧・メニューの中身の変更、`tmux-first` / `tmux-clean` の変更、ホストへの自動配布 |
-
-## 未決
-
-要求の時点で未決だった 4 点は、設計([PLAN71_tmux-menu-design.md](PLAN71_tmux-menu-design.md))で決めた。
-
-| 項目 | 決めたこと |
-| --- | --- |
-| `tmux-menu` を振り分けるサブコマンド | `menu` の短縮名にし、セッションを受け取らない形を「一覧を開く」にする(設計の決定 1) |
-| tmux の外での attach 先を引数で取るか | 取らない。tmux の既定の attach 先に従う(設計の決定 3) |
-| 一覧を開く定義をまとめるか | `tmux-session` だけに持ち、`prefix S` は `run-shell "TMUX_PANE=#{pane_id} tmux-menu"` で呼ぶ(設計の決定 2・決定 4) |
-| シェルから開いた `choose-tree` の `#{q:client_name}` | Enter を押した端末の名前に展開される。ホストの tmux 3.7b で実測した(設計の「実測」) |
diff --git a/tests/containers/test_tmux_conf.py b/tests/containers/test_tmux_conf.py
index 7f085d1..9b64e07 100644
--- a/tests/containers/test_tmux_conf.py
+++ b/tests/containers/test_tmux_conf.py
@@ -255,12 +255,14 @@ def _prefix_keys(config: Path) -> list[str]:
@needs_tmux
def test_prefix_s_opens_session_chooser():
- """PLAN69 条件 13: prefix S の割り当てがちょうど 1 つあり、choose-tree を呼ぶ。
+ """PLAN69 条件 13 / PLAN71 条件 11: prefix S の割り当てがちょうど 1 つあり、
+ 押した pane の ID を ``TMUX_PANE`` で渡して ``run-shell`` で ``tmux-menu`` を呼ぶ。
- 既定の tmux には prefix S の割り当てが無い (3.6・3.7b で確認)。
+ 既定の tmux には prefix S の割り当てが無い (3.6・3.7b で確認)。一覧の定義は
+ ``tmux-session`` にだけあり、割り当ては template を持たない (PLAN71 決定 2・決定 4)。
"""
bound = [line for line in _prefix_keys(TMUX_CONF)
if re.match(r"bind-key\s+(-r\s+)?-T prefix\s+S\s", line)]
assert len(bound) == 1, bound
- assert "choose-tree" in bound[0]
- assert "tmux-session menu" in bound[0]
+ assert re.search(r'\sS\s+run-shell\s+"TMUX_PANE=#\{pane_id\} tmux-menu"\s*$',
+ bound[0]), bound[0]
diff --git a/tests/containers/test_tmux_session.py b/tests/containers/test_tmux_session.py
index 8b2c98c..f6136b5 100644
--- a/tests/containers/test_tmux_session.py
+++ b/tests/containers/test_tmux_session.py
@@ -21,6 +21,7 @@
import os
import pty
import re
+import shlex
import shutil
import subprocess
import sys
@@ -35,7 +36,7 @@
SCRIPT = BASE_DIR / "tmux-session"
TMUX_CONF = BASE_DIR / "tmux.conf"
DOCKERFILE = BASE_DIR / "Dockerfile"
-SHORT_NAMES = ("tmux-go", "tmux-peek", "tmux-kill")
+SHORT_NAMES = ("tmux-go", "tmux-peek", "tmux-kill", "tmux-menu")
needs_tmux = pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux が無い環境")
@@ -445,13 +446,20 @@ def tm():
@pytest.mark.parametrize("argv", [("tmux-session", "-h"), ("tmux-session", "go", "-h"),
("tmux-go", "-h"), ("tmux-peek", "--help"),
- ("tmux-kill", "-h")])
+ ("tmux-kill", "-h"), ("tmux-menu", "-h")])
def test_help_exits_zero(tm, argv):
done = tm.run(*argv)
assert done.returncode == 0, done.stderr
assert "tmux-session" in done.stdout
+def test_help_lists_tmux_menu(tm):
+ """PLAN71 条件 9: ``tmux-session -h`` の使い方に ``tmux-menu`` が載る。"""
+ done = tm.run("tmux-session", "-h")
+ assert done.returncode == 0, done.stderr
+ assert "tmux-menu" in done.stdout
+
+
@pytest.mark.parametrize("argv", [
("tmux-session",), # サブコマンドが無い
("tmux-session", "nope", "x"), # 知らないサブコマンド
@@ -469,6 +477,10 @@ def test_help_exits_zero(tm, argv):
("tmux-kill",), # kill にセッションが無い
("tmux-peek", "-c", "/dev/pts/1", "x"), # peek は -c を受け取らない
("tmux-session", "menu", "-c", "/dev/pts/1", "a", "b"), # menu にセッションが複数
+ ("tmux-menu", "-c", "/dev/pts/1"), # PLAN71 条件 9: -c だけでセッションが無い
+ ("tmux-menu", "a"), # -c が無い
+ ("tmux-menu", "-c", "/dev/pts/1", "a", "b"), # 余分な引数
+ ("tmux-menu", "-x"), # 知らないオプション
])
def test_usage_errors_exit_two(tm, argv):
done = tm.run(*argv)
@@ -1072,6 +1084,190 @@ def test_menu_notifies_client_on_failure(ui_tm):
_wait(lambda: "no-such-session" in me.output())
+# --- tmux-menu: 一覧を開く形 (PLAN71) ---
+#
+# 一覧は名前順 (``-O name``) で、カーソルは開いた pane のセッション ``zz-home`` にある。
+# 選ぶセッションの名前はどれも ``zz-home`` より前に並ぶため、1 つ上が選ぶセッションになる。
+
+# 一覧を開く形を打つコマンド行 (``tm.bin`` からの相対)。条件 8 で両方の名前を走らせる。
+LIST_COMMANDS = ["tmux-menu", "tmux-session menu"]
+
+
+def _pane_mode(tm: TmuxEnv, sid: str) -> str:
+ return tm.tmux("display-message", "-p", "-t", sid, "#{pane_mode}").stdout.strip()
+
+
+def _type_list_command(tm: TmuxEnv, sid: str, command: str) -> None:
+ """``sid`` の pane のプロンプトへ一覧を開くコマンド行を打ち、一覧が出るまで待つ。
+
+ 繋いだ端末ではなく pane へ ``send-keys`` で直接送る (繋いだ直後の端末は入力を捨てる
+ ことがある)。コマンドは ``tm.bin`` の絶対パスで打つ (``fake_tm`` では名前の
+ ``tmux-session`` が ``fake/`` の偽物に解決されるため)。
+ """
+ tm.tmux("send-keys", "-t", sid, f"{tm.bin}/{command}", "Enter")
+ _wait(lambda: _pane_mode(tm, sid) == "tree-mode")
+
+
+def _pick_above(tm: TmuxEnv, me: Client, sid: str, attempts: int = 5) -> None:
+ """``sid`` の pane の一覧で 1 つ上を選び、``me`` の端末から Enter を送る。
+
+ Up は ``send-keys`` で送る (端末を通らずに一覧を動かす)。Enter は ``me`` から送る。
+ ``send-keys`` の Enter では template の ``#{client_name}`` が直近に操作された端末に
+ なり、押した端末を確かめられない。繋いだ直後の ``me`` は Enter を捨てることがあるため、
+ 一覧が残っていれば 2 秒ごとに送り直す。一覧を抜けたら ``me`` は入力を受け付けている。
+ """
+ tm.tmux("send-keys", "-t", sid, "Up")
+ for _ in range(attempts - 1):
+ me.send("\r")
+ with contextlib.suppress(AssertionError):
+ _wait(lambda: _pane_mode(tm, sid) != "tree-mode", timeout=2.0)
+ return
+ me.send("\r")
+ _wait(lambda: _pane_mode(tm, sid) != "tree-mode")
+
+
+@needs_tmux
+@pytest.mark.parametrize("command", LIST_COMMANDS)
+def test_list_inside_opens_tree_in_pane(ui_tm, command):
+ """PLAN71 条件 1・8: tmux の中で打つと、その pane に一覧が出る。"""
+ tm = ui_tm
+ home = tm.new("zz-home")
+ tm.attach(home)
+
+ _type_list_command(tm, home, command)
+
+
+@needs_tmux
+@pytest.mark.parametrize("name", SPECIAL_NAMES)
+def test_list_inside_passes_selected_id_and_client(fake_tm, name):
+ """PLAN71 条件 2: 一覧で選ぶと、prefix S と同じ引数で menu が呼ばれる。"""
+ tm = fake_tm
+ target = tm.new(name)
+ home = tm.new("zz-home")
+ me = tm.attach(home)
+
+ _type_list_command(tm, home, "tmux-menu")
+ _pick_above(tm, me, home)
+
+ _wait(lambda: tm.record.exists() and tm.record.read_text())
+ assert tm.record.read_text().splitlines() == ["menu", "-c", me.tty, target]
+
+
+@needs_tmux
+@pytest.mark.parametrize("name", ["devbase-3", "it's"])
+def test_list_inside_menu_go_detaches_others_and_switches(ui_tm, name):
+ """PLAN71 条件 3: メニューの ``a`` で Enter を押した端末が移り、他の端末は外れる。"""
+ tm = ui_tm
+ target = tm.new(name)
+ home = tm.new("zz-home")
+ me = tm.attach(home)
+ stale = tm.attach(target)
+
+ _type_list_command(tm, home, "tmux-menu")
+ _pick_above(tm, me, home)
+ _wait(lambda: "移る" in me.output())
+ me.send("a")
+
+ _wait(lambda: tm.clients_of(target) == {me.tty})
+ assert stale.tty not in tm.all_clients()
+
+
+def _spawn_outside(tm: TmuxEnv, home: str) -> Client:
+ """tmux の外で ``tmux-menu`` を起動し、``home`` に繋がって一覧が出るまで待つ。"""
+ me = tm.spawn([str(tm.bin / "tmux-menu")])
+ _wait(lambda: tm.all_clients().get(me.tty) == home)
+ _wait(lambda: _pane_mode(tm, home) == "tree-mode")
+ return me
+
+
+@needs_tmux
+def test_list_outside_attaches_and_opens_tree(ui_tm):
+ """PLAN71 条件 4: tmux の外では、端末の無いセッションへ attach して一覧を出す。"""
+ tm = ui_tm
+ target = tm.new("devbase-3")
+ home = tm.new("zz-home")
+ tm.attach(target)
+
+ _spawn_outside(tm, home)
+
+
+@needs_tmux
+@pytest.mark.parametrize("name", SPECIAL_NAMES)
+def test_list_outside_passes_selected_id_and_client(fake_tm, name):
+ """PLAN71 条件 5: 外から開いた一覧で選ぶと、attach した端末と選んだ ID が渡る。"""
+ tm = fake_tm
+ target = tm.new(name)
+ home = tm.new("zz-home")
+ tm.attach(target)
+
+ me = _spawn_outside(tm, home)
+ _pick_above(tm, me, home)
+
+ _wait(lambda: tm.record.exists() and tm.record.read_text())
+ assert tm.record.read_text().splitlines() == ["menu", "-c", me.tty, target]
+
+
+@needs_tmux
+@pytest.mark.parametrize("argv", [("tmux-menu",), ("tmux-session", "menu")])
+def test_list_no_server_exits_one(tm, argv):
+ """PLAN71 条件 6・8: サーバが無ければ、何も作らず 1 で終わる。"""
+ done = tm.run(*argv)
+ assert done.returncode == 1, (done.stdout, done.stderr)
+ assert "サーバ" in done.stderr
+ assert tm.sessions() == {}
+
+
+@needs_tmux
+@pytest.mark.parametrize("form", ["tmux-menu -c {tty} {sid}",
+ "tmux-session menu -c {tty} {sid}"])
+def test_menu_form_same_by_both_names(ui_tm, form):
+ """PLAN71 条件 8: メニューを出す形は、どちらの名前でも同じメニューを出す。
+
+ ``run-shell`` は文字列を ``sh -c`` へ渡すため、ID (``$0``) は引用して渡す。
+ """
+ tm = ui_tm
+ target = tm.new("devbase-3")
+ me = tm.attach(tm.new("zz-home"))
+
+ tm.tmux("run-shell", "-b", form.format(tty=me.tty, sid=shlex.quote(target)))
+
+ _wait(lambda: "中身を見る" in me.output())
+
+
+@needs_tmux
+def test_list_opens_in_tmux_pane_not_latest_client(tm):
+ """PLAN71 条件 11: 一覧は ``TMUX_PANE`` の pane に出て、直近に操作された端末には出ない。
+
+ 後に繋いだ ``other`` の端末が直近になる (設計の実測 9a)。
+ """
+ home = tm.new("home")
+ other = tm.new("other")
+ tm.attach(home)
+ tm.attach(other)
+
+ done = tm.run("tmux-menu", env=tm.inside_env(home))
+
+ assert done.returncode == 0, (done.stdout, done.stderr)
+ _wait(lambda: _pane_mode(tm, home) == "tree-mode")
+ assert _pane_mode(tm, other) == ""
+
+
+@needs_tmux
+def test_list_inside_without_tmux_pane_opens_tree(tm):
+ """PLAN71: ``TMUX`` があり ``TMUX_PANE`` が空なら、``-t`` なしの一覧へ落ちて開く。
+
+ 端末が 1 つだけ繋がっていれば、その端末の pane に一覧が出る。
+ """
+ home = tm.new("home")
+ tm.attach(home)
+ env = {k: v for k, v in tm.inside_env(home).items() if k != "TMUX_PANE"}
+
+ done = tm.run("tmux-menu", env=env)
+
+ assert done.returncode == 0, (done.stdout, done.stderr)
+ _wait(lambda: _pane_mode(tm, home) == "tree-mode")
+
+
# --- 配布 (受け入れ条件 16 のうちビルドの前に分かる部分) ---