From ab7a9942b6177ca5e3dc24f6116ea8acf98ad64d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 29 Jun 2026 18:17:01 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(tui):=20=E5=86=8D=E3=83=93=E3=83=AB?= =?UTF-8?q?=E3=83=89=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC=E3=82=92=20build?= =?UTF-8?q?=20--no-cache=20=E3=81=B8=E5=A7=94=E8=AD=B2=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TUI の「再ビルド (rebuild --no-cache)」は CLI の `rebuild` を呼んでおり、 rebuild は期限判定 (build --expires=7) でキャッシュを使うためラベルが謳う --no-cache を満たさなかった。no-cache の正規経路である `build --no-cache` へ委譲し、base/project とも無条件で再ビルドされるよう修正する。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/tui/actions_project.py | 12 ++++++++---- tests/cli/tui/test_actions_project.py | 15 ++++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/devbase/tui/actions_project.py b/lib/devbase/tui/actions_project.py index 8d5d736..74fd67d 100644 --- a/lib/devbase/tui/actions_project.py +++ b/lib/devbase/tui/actions_project.py @@ -119,12 +119,16 @@ def _op_build(devbase_root: Path, name: str): _OP_HANDLERS = { - # up/down/rebuild/ps は引数なしで即実行。up/rebuild は scale 属性を参照する - # (常に None。他コマンドは無視する)。down はデータを失わない (volume 保持・ - # up で復旧可能) ためメニュー選択を意思表示とみなし、確認プロンプトを出さない。 + # up/down/ps は引数なしで即実行。up は scale 属性を参照する (常に None。他 + # コマンドは無視する)。down はデータを失わない (volume 保持・up で復旧可能) + # ためメニュー選択を意思表示とみなし、確認プロンプトを出さない。 # ps の --all は CLI 既定 (False) に揃える。 "up": lambda root, name: dispatch_lifecycle("up", name, scale=None), - "rebuild": lambda root, name: dispatch_lifecycle("rebuild", name, scale=None), + # 「再ビルド (rebuild --no-cache)」はラベル通り base/project とも無条件 no-cache + # で再ビルドする。CLI の `rebuild` は期限判定 (= build --expires=7) でキャッシュ + # を使い、新しいイメージはスキップするため --no-cache を満たさない。no-cache の + # 正規経路は `build --no-cache` なので build に no_cache=True を渡して委譲する。 + "rebuild": lambda root, name: dispatch_lifecycle("build", name, no_cache=True), "down": lambda root, name: dispatch_lifecycle("down", name), "login": _op_login, "ps": lambda root, name: dispatch_lifecycle("ps", name, all=False), diff --git a/tests/cli/tui/test_actions_project.py b/tests/cli/tui/test_actions_project.py index e949a0e..d50cef3 100644 --- a/tests/cli/tui/test_actions_project.py +++ b/tests/cli/tui/test_actions_project.py @@ -19,8 +19,10 @@ def _row(status): return {"name": "carmo", "plugin": "p", "status": status} -@pytest.mark.parametrize("action", ["up", "rebuild"]) -def test_handle_row_running_shows_action_menu(monkeypatch, tmp_path, action): +# rebuild メニューは build --no-cache へ委譲するため subcommand は "build" になる。 +@pytest.mark.parametrize("action,expected_subcommand", [("up", "up"), ("rebuild", "build")]) +def test_handle_row_running_shows_action_menu(monkeypatch, tmp_path, action, + expected_subcommand): """running 行はサブメニューで操作を選び、引数不要の up/rebuild は即起動する。""" from devbase.commands import container as container_mod @@ -35,7 +37,7 @@ def test_handle_row_running_shows_action_menu(monkeypatch, tmp_path, action): result = actions_project.handle_row(tmp_path, _row("running (2 containers)")) assert result == 0 # 操作完了 → dispatch の rc を返す assert seen["name"] == "carmo" - assert captured == {"subcommand": action, "name": "carmo"} + assert captured == {"subcommand": expected_subcommand, "name": "carmo"} def test_handle_row_propagates_nonzero_dispatch_rc(monkeypatch, tmp_path): @@ -126,7 +128,8 @@ def _capture_dispatch(monkeypatch): def _spy(args): captured["subcommand"] = args.subcommand captured["name"] = args.name - for k in ("scale", "index", "all", "follow", "tail", "new_scale", "image"): + for k in ("scale", "index", "all", "follow", "tail", "new_scale", "image", + "no_cache"): if hasattr(args, k): captured[k] = getattr(args, k) return 0 @@ -142,9 +145,11 @@ def test_run_operation_up_passes_scale_none(monkeypatch, tmp_path): def test_run_operation_rebuild(monkeypatch, tmp_path): + """「再ビルド (rebuild --no-cache)」は build --no-cache へ委譲する (no-cache 正規経路)。""" captured = _capture_dispatch(monkeypatch) assert actions_project._run_operation(tmp_path, "carmo", "rebuild") == 0 - assert captured["subcommand"] == "rebuild" and captured["name"] == "carmo" + assert captured["subcommand"] == "build" and captured["name"] == "carmo" + assert captured["no_cache"] is True def test_run_operation_down_runs_without_confirm(monkeypatch, tmp_path): From 066bde20636e388e6404ceaec92b288e501716e4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 29 Jun 2026 18:17:08 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(base):=20Node.js=20=E3=82=92=20Active?= =?UTF-8?q?=20LTS=20(setup=5Flts.x)=20=E3=81=B8=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EOL を迎えた Node 20 から NodeSource の setup_lts.x へ切り替え、Active LTS (現在 v24 / Krypton) を追従する。バージョンは固定しない。 あわせて Node24/npm11 がデフォルトで作成する /tmp/node-compile-cache や playwright `--with-deps` の sudo apt が残す root 所有ファイルにより、 非 root の `rm -rf /tmp/*` が Permission denied でビルド失敗する問題を クリーンアップの sudo 化で解消する。build --no-cache で v24.18.0 を確認済み。 Co-Authored-By: Claude Opus 4.8 (1M context) --- containers/base/Dockerfile | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/containers/base/Dockerfile b/containers/base/Dockerfile index 1d3a995..4d70183 100644 --- a/containers/base/Dockerfile +++ b/containers/base/Dockerfile @@ -36,8 +36,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg; \ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ > /etc/apt/sources.list.d/github-cli.list; \ - # NodeSource リポジトリ - curl -fsSL https://deb.nodesource.com/setup_20.x | bash -; \ + # NodeSource リポジトリ (Active LTS を追従。バージョン固定しない) + curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -; \ # ブラウザ: amd64はChrome+Chromium、arm64はChromium arch="$(dpkg --print-architecture)"; \ BROWSER_PKG=""; \ @@ -144,8 +144,10 @@ RUN set -eux; \ fi; \ # Playwright browsers npx playwright install --with-deps chromium; \ - # クリーンアップ - rm -rf /tmp/* \ + # クリーンアップ。--with-deps の sudo apt や Node24/npm11 の + # /tmp/node-compile-cache が root 所有ファイルを残すため sudo で削除する + # (非 root の rm では Permission denied になりビルドが失敗する)。 + sudo rm -rf /tmp/* \ ~/.cache \ ~/.npm \ ~/.config/yarn \ From f58eae6c9452eb3e75caf593ad0960554a00b98d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 30 Jun 2026 07:17:58 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(tui):=20=E3=82=B5=E3=83=96=E3=83=A1?= =?UTF-8?q?=E3=83=8B=E3=83=A5=E3=83=BC=E3=81=AB=E7=95=99=E3=81=BE=E3=82=8B?= =?UTF-8?q?=E6=8C=99=E5=8B=95=E3=81=A8=E3=82=AB=E3=83=BC=E3=82=BD=E3=83=AB?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E5=BE=A9=E5=85=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 操作の使い勝手を 2 点改善する。 - サブメニューで操作を実行した後、トップ一覧へ戻らず同じサブメニューを 再表示して留まる (flow.menu_loop)。出力を読めるよう Enter 待ちを挟み、 Esc/← で初めて一覧へ戻る。env/plugin/snapshot/project/plugin-repo の 全サブメニューが menu_loop 経由のため一括で適用。 - サブメニューから一覧へ戻る際、直前に選んだプロジェクト行へカーソルを 復元する (questionary.select の default に rows index を渡す)。 サブメニュー内で実行した操作の rc は devbase list の終了コードへは伝搬 しなくなる (実行後も留まる設計のため戻り値は MENU_BACK)。stopped/unknown 行の直接 up と status 表示の rc 伝搬は従来どおり維持する。 影響テストを新挙動へ追従 (戻り値 MENU_BACK / 選択スタブ末尾に MENU_BACK / pause 無効化の autouse fixture) し、カーソル復元の新規テストを追加。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/tui/actions_env.py | 8 +-- lib/devbase/tui/actions_plugin.py | 21 +++--- lib/devbase/tui/actions_project.py | 22 +++--- lib/devbase/tui/actions_snapshot.py | 8 +-- lib/devbase/tui/app.py | 24 +++++-- lib/devbase/tui/flow.py | 37 ++++++++-- lib/devbase/tui/menu.py | 17 ++++- tests/cli/tui/test_actions_env.py | 55 +++++++++++---- tests/cli/tui/test_actions_plugin.py | 94 ++++++++++++++++++-------- tests/cli/tui/test_actions_project.py | 57 ++++++++++++---- tests/cli/tui/test_actions_snapshot.py | 46 ++++++++++--- tests/cli/tui/test_app.py | 22 +++++- 12 files changed, 298 insertions(+), 113 deletions(-) diff --git a/lib/devbase/tui/actions_env.py b/lib/devbase/tui/actions_env.py index 5741100..2164019 100644 --- a/lib/devbase/tui/actions_env.py +++ b/lib/devbase/tui/actions_env.py @@ -179,13 +179,11 @@ def run(devbase_root: Path): """環境変数カテゴリのエントリ。操作選択 → 引数収集 → cmd_env へ委譲。 戻り値プロトコル (``flow.menu_loop``。トップループが ``is`` 同一性で判定する): - - **操作を実行した場合**: dispatch の rc (``int``) を返す。「実行したのでトップへ - 戻る、rc は呼び出し側が記憶」の意味で、失敗が ``devbase list`` の終了コードへ - 伝搬する。 - - ``menu.MENU_BACK``: サブメニューで Esc/← (操作なしでトップへ)。 + - ``menu.MENU_BACK``: サブメニューで Esc/← (トップへ戻る)。操作を実行しても + (出力確認の一時停止後) サブメニューに留まり、Esc/← で初めてトップへ戻る。 - ``None``: Ctrl-C による全体中止。 - 引数収集を中止 (``_ARG_CANCEL``) した場合はサブメニューを再表示する。 + 操作実行後・引数収集中止 (``_ARG_CANCEL``) のいずれもサブメニューを再表示する。 """ return flow.menu_loop(_select_action, lambda op: _run_operation(devbase_root, op)) diff --git a/lib/devbase/tui/actions_plugin.py b/lib/devbase/tui/actions_plugin.py index 6cc6815..01a26cd 100644 --- a/lib/devbase/tui/actions_plugin.py +++ b/lib/devbase/tui/actions_plugin.py @@ -269,9 +269,9 @@ def _run_repo_operation(devbase_root: Path, op: str): def _repo_menu(devbase_root: Path): """plugin repo のサブ階層メニューを回す。 - 戻り値 (``flow.menu_loop`` のプロトコル): dispatch の rc (``int``) / - ``menu.MENU_BACK`` (Esc・← で plugin メニューへ戻る) / ``None`` (Ctrl-C 全体中止)。 - 引数収集を中止 (``_ARG_CANCEL``) した場合はサブ階層メニューを再表示する。 + 戻り値 (``flow.menu_loop`` のプロトコル): ``menu.MENU_BACK`` (Esc・← で plugin + メニューへ戻る) / ``None`` (Ctrl-C 全体中止)。操作実行後は (出力確認の一時停止 + 後) サブ階層メニューに留まり、引数収集を中止 (``_ARG_CANCEL``) した場合も再表示する。 """ return flow.menu_loop(_select_repo_operation, lambda op: _run_repo_operation(devbase_root, op)) @@ -281,19 +281,20 @@ def run(devbase_root: Path): """プラグイン操作カテゴリ。操作選択 → 引数収集 → ``cmd_plugin`` へ委譲。 戻り値プロトコル (トップループが ``is`` 同一性で判定する。actions_project と同じ): - - **操作を実行した場合**: dispatch の rc (``int``) を返す。失敗 (非0) は - ``devbase list`` の終了コードへ伝搬する。 - - ``menu.MENU_BACK``: 操作なしでトップメニューへ戻る (Esc/←)。 + - ``menu.MENU_BACK``: plugin メニューで Esc/← (トップメニューへ戻る)。操作を + 実行しても (出力確認の一時停止後) plugin メニューに留まり、Esc/← で初めて + トップへ戻る。 - ``None``: Ctrl-C による全体中止。 - repo はサブ階層メニュー (``_repo_menu``) へ分岐し、Esc/← で plugin メニューへ - 戻れる (``MENU_BACK`` を ``_ARG_CANCEL`` 相当に読み替えて再表示する)。 - 操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。 + repo はサブ階層メニュー (``_repo_menu``) へ分岐する。repo 側も実行後はそこに + 留まり、Esc/← (``MENU_BACK``) で plugin メニューへ戻る (``_ARG_CANCEL`` 相当に + 読み替えて再表示する)。 """ def _run(op): if op == "repo": rc = _repo_menu(devbase_root) - # repo 階層から Esc で戻ったら plugin メニューを再表示する。 + # repo 階層は自身で操作を完結する。Esc/← (MENU_BACK) で戻ったら plugin + # メニューを再表示し、Ctrl-C (None) はそのまま全体中止へ伝搬する。 return _ARG_CANCEL if rc is menu.MENU_BACK else rc return _run_operation(devbase_root, op) diff --git a/lib/devbase/tui/actions_project.py b/lib/devbase/tui/actions_project.py index 74fd67d..a671719 100644 --- a/lib/devbase/tui/actions_project.py +++ b/lib/devbase/tui/actions_project.py @@ -156,9 +156,10 @@ def _run_operation(devbase_root: Path, name: str, op: str): def _operation_menu(devbase_root: Path, name: str): """running 行の操作サブメニューを回す。 - 戻り値 (``flow.menu_loop`` のプロトコル): dispatch の rc (``int``) / - ``menu.MENU_BACK`` (Esc・← で一覧へ戻る) / ``None`` (Ctrl-C 全体中止)。 - 引数収集を中止 (``_ARG_CANCEL``) した場合はサブメニューを再表示する。 + 戻り値 (``flow.menu_loop`` のプロトコル): ``menu.MENU_BACK`` (Esc・← で一覧へ + 戻る) / ``None`` (Ctrl-C 全体中止)。操作を実行した後はトップ一覧へ戻らず、 + 出力を読めるよう一時停止してから**同じサブメニューを再表示する** (留まる)。 + 引数収集を中止 (``_ARG_CANCEL``) した場合も同じサブメニューを再表示する。 """ return flow.menu_loop( lambda: _select_action(name), @@ -169,14 +170,17 @@ def handle_row(devbase_root: Path, row: dict): """一覧で選択された 1 プロジェクト行を処理する (トップ画面から呼ばれる)。 戻り値プロトコル (トップループが ``is`` 同一性で判定する): - - **操作を実行した場合**: ``dispatch_lifecycle`` の rc (``int``) を返す。 - 「実行したので一覧へ戻る、rc は呼び出し側が記憶」の意味。これにより - project 操作の失敗が ``devbase list`` の終了コードへ伝搬する。 - - ``menu.MENU_BACK``: 操作サブメニューで Esc/← (操作なしで一覧へ)。 + - ``menu.MENU_BACK``: 操作サブメニューで Esc/← (一覧へ戻る)。running 行は操作を + 実行しても (出力確認の一時停止後) サブメニューに留まり、Esc/← で初めて一覧へ + 戻るため、この値を返す。 + - **直接 up を実行した場合** (stopped / unknown 行): ``dispatch_lifecycle`` の + rc (``int``) を返す。トップループが rc を記憶し ``devbase list`` の終了コードへ + 伝搬する。 - ``None``: サブメニューで Ctrl-C による全体中止。 - 選択行が running 中なら ``_operation_menu`` で全操作を選ばせ、それ以外 - (stopped / unknown) は従来どおり直接 ``project up`` を起動する (PR1 非回帰)。 + 選択行が running 中なら ``_operation_menu`` で全操作を選ばせ (実行後もサブ + メニューに留まる)、それ以外 (stopped / unknown) は従来どおり直接 ``project up`` + を起動して一覧へ戻る (PR1 非回帰)。 """ name = row["name"] if str(row.get("status", "")).startswith("running"): diff --git a/lib/devbase/tui/actions_snapshot.py b/lib/devbase/tui/actions_snapshot.py index 2a099de..b863b80 100644 --- a/lib/devbase/tui/actions_snapshot.py +++ b/lib/devbase/tui/actions_snapshot.py @@ -178,13 +178,11 @@ def run(devbase_root: Path): """スナップショット操作カテゴリ。操作選択 → 引数収集 → 実行。 戻り値プロトコル (``flow.menu_loop``。トップループが ``is`` 同一性で判定する): - - **操作を実行した場合**: ``dispatch_group`` の rc (``int``) を返す。 - 「実行したのでトップへ戻る、rc は呼び出し側が記憶」の意味。 - - ``menu.MENU_BACK``: 操作メニューで Esc/← (操作なしでトップへ)。 + - ``menu.MENU_BACK``: 操作メニューで Esc/← (トップへ戻る)。操作を実行しても + (出力確認の一時停止後) 操作メニューに留まり、Esc/← で初めてトップへ戻る。 - ``None``: Ctrl-C による全体中止。 - 引数収集を中止 (``_ARG_CANCEL``) した場合は操作メニューを再表示する。 - 操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。 + 操作実行後・引数収集中止 (``_ARG_CANCEL``) のいずれも操作メニューを再表示する。 """ return flow.menu_loop(_select_operation, lambda op: _run_operation(devbase_root, op)) diff --git a/lib/devbase/tui/app.py b/lib/devbase/tui/app.py index 1f00fa1..459874f 100644 --- a/lib/devbase/tui/app.py +++ b/lib/devbase/tui/app.py @@ -16,8 +16,12 @@ ナビ規約: トップ (プロジェクト一覧) は Esc / Ctrl-C で中止 (戻り先なし)。 各カテゴリ・サブメニュー内では Esc / ← で 1 つ前へ戻る (``menu.MENU_BACK``)、 -Ctrl-C で全体中止 (``None``)。操作を実行した後は出力を読めるよう Enter を -待ってから一覧を再表示する (``_pause_for_review``)。 +Ctrl-C で全体中止 (``None``)。サブメニューで操作を実行した後はトップへ戻らず、 +出力を読めるよう Enter を待ってから**同じサブメニューを再表示する** (実行を +繰り返せる。``flow.pause_for_review`` / ``flow.menu_loop``)。Esc / ← で一覧へ +戻ると、直前に選んだプロジェクト行へカーソルを復元する。直接 up する stopped/ +unknown 行と status 表示だけは実行後に Enter を待って一覧へ戻る +(``_pause_for_review``)。 """ from __future__ import annotations @@ -108,12 +112,15 @@ def _pause_for_review() -> bool: _NO_PROJECTS = object() -def _select_top(rows: list[dict]): +def _select_top(rows: list[dict], default=None): """トップ画面: プロジェクト一覧 + 最下部の常設カテゴリメニューから 1 件選ばせる。 カテゴリ (env/plugin/snapshot/status) は一覧の行ではなく、画面最下部に 横並びで常設するメニューバーに置く (←→ で項目間を移動、Enter で決定)。 + ``default`` には前回選択したプロジェクトの rows index を渡し、サブメニュー + から一覧へ戻ったときに同じ行へカーソルを復元する (範囲外なら無視)。 + 戻り値: rows の index (``int`` = プロジェクト選択) / カテゴリ key (``str``) / ``_NO_PROJECTS`` (プレースホルダ選択 = 再表示) / ``None`` (Esc・Ctrl-C → 終了)。 プロジェクトとカテゴリは値の型で判別する。件数が多いため文字入力での @@ -125,10 +132,13 @@ def _select_top(rows: list[dict]): else: # _build_menu_entries は 0 件を想定しない (max() が落ちる) ため迂回する。 choices = [("(プロジェクトがありません)", _NO_PROJECTS)] + # default は choice value (= rows index) と一致したときだけハイライト初期値に + # 使う。行数が変わって範囲外になった場合は先頭ハイライト (None) に戻す。 + initial = default if isinstance(default, int) and 0 <= default < len(rows) else None return menu.select_with_menubar( "プロジェクトまたは操作を選択 " "(↑↓ 移動 / 名前で絞り込み / ←→ 下部メニュー / Enter 決定 / Esc・Ctrl-C 終了):", - choices, [(label, key) for key, label in TOP_CATEGORIES]) + choices, [(label, key) for key, label in TOP_CATEGORIES], default=initial) def _top_menu_loop(devbase_root: Path) -> int: @@ -141,6 +151,7 @@ def _top_menu_loop(devbase_root: Path) -> int: 判定は必ず ``is`` 同一性で行う (rc=0 を ``None`` / ``MENU_BACK`` と誤マッチさせない)。 """ last_rc = 0 + cursor = None # 直近に選んだプロジェクトの rows index (一覧再表示時のカーソル復元用) projects_dir = Path(devbase_root) / "projects" while True: _clear_screen() @@ -150,7 +161,7 @@ def _top_menu_loop(devbase_root: Path) -> int: # 終了せず案内だけ出して一覧 (カテゴリのみ) を表示する。 logger.info("プロジェクトがありません (%s)。", projects_dir) - sel = _select_top(rows) + sel = _select_top(rows, default=cursor) if sel is None: # トップで Esc / Ctrl-C → これまでの実行 rc を返して終了 logger.info("中止しました。") @@ -162,6 +173,9 @@ def _top_menu_loop(devbase_root: Path) -> int: if isinstance(sel, str): result = _route(sel, devbase_root) else: + # 選択したプロジェクト位置を記憶し、サブメニューから戻ったとき同じ行へ + # カーソルを復元する。 + cursor = sel result = actions_project.handle_row(devbase_root, rows[sel]) if result is None: diff --git a/lib/devbase/tui/flow.py b/lib/devbase/tui/flow.py index b28f3bd..6ab6e95 100644 --- a/lib/devbase/tui/flow.py +++ b/lib/devbase/tui/flow.py @@ -133,6 +133,26 @@ def optional_int(message: str, *, min_value: int = 0): return value +def pause_for_review() -> bool: + """操作出力を読めるよう、メニュー再表示の前に Enter を待つ。 + + 操作実行直後にメニューを再描画すると、list 等の表示系操作の出力が一瞬で + 流れて読めない。questionary 系プロンプトは画面を書き換えるため、stdlib の + ``input()`` で素朴に待ち、出力をそのまま画面に残す。 + + 戻り値: ``True`` = 続行 (メニュー再表示) / ``False`` = Ctrl-C (全体中止)。 + 非 TTY 等で stdin を読めない場合 (EOFError/OSError) は待たずに続行する。 + """ + try: + input("Enter キーで操作メニューへ戻ります...") + except KeyboardInterrupt: + print() + return False + except (EOFError, OSError): + pass + return True + + def menu_loop(select_op, run_op): """「操作選択 → 実行」のサブメニューループ (actions_* の ``run`` 共通骨格)。 @@ -143,8 +163,12 @@ def menu_loop(select_op, run_op): Returns ------- - rc (``int``, 操作を実行) / ``menu.MENU_BACK`` (Esc・← で 1 つ上へ) / ``None`` - (Ctrl-C 全体中止)。``run_op`` が ``ARG_CANCEL`` を返したら同じメニューを再表示する。 + ``menu.MENU_BACK`` (Esc・← で 1 つ上へ戻る) / ``None`` (Ctrl-C 全体中止)。 + + 操作を実行した後はトップへ戻らず、出力を読めるよう ``pause_for_review`` で + Enter を待ってから**同じサブメニューを再表示する** (サブメニューに留まる)。 + 上位 (トップ一覧) へ戻るのは Esc/← (``MENU_BACK``) を押したときだけ。 + ``run_op`` が ``ARG_CANCEL`` を返したら一時停止せず同じメニューを再表示する。 判定は必ず ``is`` 同一性で行う (rc=0 を番兵と誤マッチさせない)。 """ while True: @@ -155,5 +179,10 @@ def menu_loop(select_op, run_op): return None rc = run_op(op) if rc is ARG_CANCEL: - continue - return rc + continue # 引数収集中止 → 一時停止せず同じメニューを再表示 + if rc is None: + return None # run_op 内の Ctrl-C → 全体中止 + # 操作を実行した: 出力を読めるよう一時停止し、同じサブメニューを再表示する + # (実行のたびにトップ一覧へ戻らない)。一時停止中の Ctrl-C は全体中止。 + if not pause_for_review(): + return None diff --git a/lib/devbase/tui/menu.py b/lib/devbase/tui/menu.py index 1e78988..455ad75 100644 --- a/lib/devbase/tui/menu.py +++ b/lib/devbase/tui/menu.py @@ -232,13 +232,16 @@ def select(message: str, choices, *, back: bool = False, search: bool = False): # 最下部メニューバー付き select (トップ画面用) # --------------------------------------------------------------------------- -def _build_menubar_question(message: str, choices, menu_items): +def _build_menubar_question(message: str, choices, menu_items, default=None): """一覧 select の最下部に横並びメニューバーを組み込んだ question を構築する。 ``select_with_menubar`` の構築部分。テストが実 TTY なしでキーバインドと バー描画を検証できるよう、ask せずに ``(question, focus)`` を返す。 ``focus["tab"]`` が ``None`` なら一覧、``int`` ならバーの該当項目に フォーカスがある。 + + ``default`` は一覧で初期ハイライトする choice の value (一覧へ戻ったときの + カーソル復元用)。``None`` なら questionary 既定の先頭ハイライト。 """ from prompt_toolkit.filters import Condition from prompt_toolkit.key_binding import KeyBindings @@ -251,6 +254,10 @@ def _build_menubar_question(message: str, choices, menu_items): else questionary.Choice(title=c[0], value=c[1]) for c in choices ] + # questionary.select の default は choice の value で初期カーソルを指定する。 + # 一致する value が無いと例外になるため、呼び出し側 (app) で範囲検証済みの + # value のみ渡す契約とし、ここでは None のとき引数を省く。 + select_kwargs = {} if default is None else {"default": default} question = questionary.select( message, choices=norm, @@ -258,6 +265,7 @@ def _build_menubar_question(message: str, choices, menu_items): use_jk_keys=False, use_search_filter=True, use_shortcuts=False, + **select_kwargs, ) count = len(menu_items) @@ -316,7 +324,7 @@ def _bar_fragments(): return question, focus -def select_with_menubar(message: str, choices, menu_items): +def select_with_menubar(message: str, choices, menu_items, default=None): """最下部に常設メニューバーを付けた選択メニュー (トップ画面用)。 Parameters @@ -324,6 +332,8 @@ def select_with_menubar(message: str, choices, menu_items): message: プロンプト文言。 choices: 一覧部分の選択肢 (``select`` と同じ形式)。 menu_items: バー項目の ``(label, value)`` リスト。 + default: 一覧で初期ハイライトする choice の value (カーソル復元用。``None`` + なら先頭)。 キー操作: - ↑↓ / 文字入力: 一覧の移動・絞り込み (questionary 既定) @@ -337,7 +347,8 @@ def select_with_menubar(message: str, choices, menu_items): 一覧の choice value / バー項目の value / ``None`` (Esc・Ctrl-C 中止)。 テストではこの関数自体を monkeypatch して questionary の実起動を避ける。 """ - question, _focus = _build_menubar_question(message, choices, menu_items) + question, _focus = _build_menubar_question(message, choices, menu_items, + default=default) return _ask_erased(with_escape_cancel(question)) diff --git a/tests/cli/tui/test_actions_env.py b/tests/cli/tui/test_actions_env.py index f36038e..27ddd10 100644 --- a/tests/cli/tui/test_actions_env.py +++ b/tests/cli/tui/test_actions_env.py @@ -15,7 +15,29 @@ import pytest -from devbase.tui import actions_env, menu +from devbase.tui import actions_env, flow, menu + + +@pytest.fixture(autouse=True) +def _no_pause(monkeypatch): + """サブメニューは実行後に留まる。各テストの一時停止 (Enter 待ち) は無効化する。""" + monkeypatch.setattr(flow, "pause_for_review", lambda: True) + + +def _seq(*values): + """呼ばれるたび values を順に返し、尽きたら最後の値を返すコールバックを作る。 + + サブメニューは操作実行後に再表示されるため、選択スタブは「操作 → … → + MENU_BACK」のように最後に MENU_BACK を置いてループを終わらせる。 + """ + box = {"i": 0} + + def _next(*_a, **_k): + i = box["i"] + box["i"] = min(i + 1, len(values) - 1) + return values[i] + + return _next def _make_plugin_project(root, plugin_path, proj): @@ -50,23 +72,26 @@ def _spy(devbase_root, args): # run(): 操作選択 → 実行 / Esc / Ctrl-C / 引数収集中止 # --------------------------------------------------------------------------- -def test_run_executes_and_returns_rc(monkeypatch, tmp_path): - """操作を選んで実行したら dispatch の rc を返す (トップへ復帰)。""" +def test_run_executes_and_stays_in_submenu(monkeypatch, tmp_path): + """操作を選んで実行 → サブメニューに留まり、Esc/← で初めてトップへ戻る。""" captured = _capture_dispatch(monkeypatch) - monkeypatch.setattr(actions_env, "_select_action", lambda: "sync") + # sync を実行 → サブメニュー再表示 → MENU_BACK でトップへ。 + monkeypatch.setattr(actions_env, "_select_action", _seq("sync", menu.MENU_BACK)) - assert actions_env.run(tmp_path) == 0 + assert actions_env.run(tmp_path) is menu.MENU_BACK assert captured["root"] == tmp_path assert captured["attrs"] == {"subcommand": "sync"} -def test_run_propagates_nonzero_dispatch_rc(monkeypatch, tmp_path): - """dispatch が非0 (失敗) を返したら run() もその rc を返す (終了コード伝搬)。""" +def test_run_executes_then_back_runs_operation(monkeypatch, tmp_path): + """非0 を返す操作でも実行後はサブメニューに留まり、戻りは MENU_BACK。""" from devbase.commands import env as env_mod - monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: 1) - monkeypatch.setattr(actions_env, "_select_action", lambda: "sync") + calls = [] + monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: calls.append(1) or 1) + monkeypatch.setattr(actions_env, "_select_action", _seq("sync", menu.MENU_BACK)) - assert actions_env.run(tmp_path) == 1 + assert actions_env.run(tmp_path) is menu.MENU_BACK + assert calls == [1], "操作は実行される (rc は終了コードへは伝搬しない)" def test_run_back_returns_to_top(monkeypatch, tmp_path): @@ -93,11 +118,11 @@ def test_run_ctrl_c_aborts(monkeypatch, tmp_path): def test_run_arg_cancel_reshows_submenu(monkeypatch, tmp_path): """引数収集を中止 (_ARG_CANCEL) するとサブメニューを再表示し、再選択で実行する。""" + # 1 回目: project (→ 引数収集中止) / 2 回目: sync (→ 実行) / 3 回目: MENU_BACK + select = _seq("project", "sync", menu.MENU_BACK) select_calls = [] - # 1 回目: project を選ぶ (→ 引数収集中止) / 2 回目: sync を選ぶ (→ 実行) monkeypatch.setattr(actions_env, "_select_action", - lambda: (select_calls.append(1), - "project" if len(select_calls) == 1 else "sync")[1]) + lambda: select_calls.append(1) or select()) run_calls = [] @@ -107,9 +132,9 @@ def fake_run_op(root, op): monkeypatch.setattr(actions_env, "_run_operation", fake_run_op) - assert actions_env.run(tmp_path) == 0 + assert actions_env.run(tmp_path) is menu.MENU_BACK assert run_calls == ["project", "sync"] - assert len(select_calls) == 2, "引数中止でサブメニューが再表示される" + assert len(select_calls) == 3, "引数中止と実行後にサブメニューが再表示される" def test_run_propagates_ctrl_c_from_operation(monkeypatch, tmp_path): diff --git a/tests/cli/tui/test_actions_plugin.py b/tests/cli/tui/test_actions_plugin.py index dbcc586..4e7a363 100644 --- a/tests/cli/tui/test_actions_plugin.py +++ b/tests/cli/tui/test_actions_plugin.py @@ -11,7 +11,28 @@ import pytest import yaml -from devbase.tui import actions_plugin, menu +from devbase.tui import actions_plugin, flow, menu + + +@pytest.fixture(autouse=True) +def _no_pause(monkeypatch): + """サブメニューは実行後に留まる。各テストの一時停止 (Enter 待ち) は無効化する。""" + monkeypatch.setattr(flow, "pause_for_review", lambda: True) + + +def _seq(*values): + """呼ばれるたび values を順に返し、尽きたら最後の値を返すコールバックを作る。 + + 操作メニューは実行後に再表示されるため、選択スタブは末尾に MENU_BACK を置く。 + """ + box = {"i": 0} + + def _next(*_a, **_k): + i = box["i"] + box["i"] = min(i + 1, len(values) - 1) + return values[i] + + return _next def _seed_registry(root, plugins=(), repos=()): @@ -63,21 +84,23 @@ def _no_dispatch(monkeypatch): # run(): plugin メニューのループと戻り値プロトコル # --------------------------------------------------------------------------- -def test_run_executes_and_returns_rc(monkeypatch, tmp_path): - """操作を実行したら dispatch の rc を返してトップへ復帰する。""" +def test_run_executes_then_stays_in_submenu(monkeypatch, tmp_path): + """操作を実行 → plugin メニューに留まり、Esc/← (MENU_BACK) で初めてトップへ戻る。""" captured = _capture_dispatch(monkeypatch) - monkeypatch.setattr(actions_plugin, "_select_operation", lambda: "sync") - assert actions_plugin.run(tmp_path) == 0 + monkeypatch.setattr(actions_plugin, "_select_operation", _seq("sync", menu.MENU_BACK)) + assert actions_plugin.run(tmp_path) is menu.MENU_BACK assert captured["subcommand"] == "sync" assert captured["devbase_root"] == tmp_path -def test_run_propagates_nonzero_dispatch_rc(monkeypatch, tmp_path): - """dispatch が非0 (失敗) を返したら run() もその rc を返す (終了コード伝搬)。""" +def test_run_executes_nonzero_then_back(monkeypatch, tmp_path): + """非0 を返す操作でも実行後は plugin メニューに留まり、戻りは MENU_BACK。""" from devbase.commands import plugin as plugin_mod - monkeypatch.setattr(plugin_mod, "cmd_plugin", lambda root, args: 1) - monkeypatch.setattr(actions_plugin, "_select_operation", lambda: "sync") - assert actions_plugin.run(tmp_path) == 1 + calls = [] + monkeypatch.setattr(plugin_mod, "cmd_plugin", lambda root, args: calls.append(1) or 1) + monkeypatch.setattr(actions_plugin, "_select_operation", _seq("sync", menu.MENU_BACK)) + assert actions_plugin.run(tmp_path) is menu.MENU_BACK + assert calls == [1], "操作は実行される (rc は終了コードへは伝搬しない)" def test_run_back_returns_menu_back(monkeypatch, tmp_path): @@ -98,11 +121,11 @@ def test_run_ctrl_c_aborts(monkeypatch, tmp_path): def test_run_arg_cancel_reshows_menu(monkeypatch, tmp_path): """引数収集を中止 (_ARG_CANCEL) すると plugin メニューを再表示し、再選択で実行する。""" + # 1 回目: install (→ 引数収集中止) / 2 回目: sync (→ 実行) / 3 回目: MENU_BACK + select = _seq("install", "sync", menu.MENU_BACK) select_calls = [] - # 1 回目: install (→ 引数収集中止) / 2 回目: sync (→ 実行) monkeypatch.setattr(actions_plugin, "_select_operation", - lambda: (select_calls.append(1), - "install" if len(select_calls) == 1 else "sync")[1]) + lambda: select_calls.append(1) or select()) run_calls = [] @@ -112,9 +135,9 @@ def fake_run_op(root, op): monkeypatch.setattr(actions_plugin, "_run_operation", fake_run_op) - assert actions_plugin.run(tmp_path) == 0 + assert actions_plugin.run(tmp_path) is menu.MENU_BACK assert run_calls == ["install", "sync"] - assert len(select_calls) == 2, "引数中止で plugin メニューが再表示される" + assert len(select_calls) == 3, "引数中止と実行後に plugin メニューが再表示される" def test_run_repo_back_reshows_plugin_menu(monkeypatch, tmp_path): @@ -129,11 +152,19 @@ def test_run_repo_back_reshows_plugin_menu(monkeypatch, tmp_path): assert len(select_calls) == 2, "repo から戻ると plugin メニューが再表示される" -def test_run_repo_rc_propagates(monkeypatch, tmp_path): - """repo サブ階層で操作を実行したらその rc を返してトップへ復帰する。""" - monkeypatch.setattr(actions_plugin, "_select_operation", lambda: "repo") - monkeypatch.setattr(actions_plugin, "_repo_menu", lambda root: 1) - assert actions_plugin.run(tmp_path) == 1 +def test_run_repo_back_then_plugin_back(monkeypatch, tmp_path): + """repo サブ階層は自身で操作を完結し、戻ると plugin メニューを再表示する。 + + 新仕様では repo サブ階層 (``_repo_menu``) は操作実行後もそこに留まり、戻りは + MENU_BACK のみ (rc を上位へ伝搬しない)。plugin 側はそれを受けてメニューを + 再表示し、さらに MENU_BACK でトップへ戻る。 + """ + repo_calls = [] + monkeypatch.setattr(actions_plugin, "_select_operation", _seq("repo", menu.MENU_BACK)) + monkeypatch.setattr(actions_plugin, "_repo_menu", + lambda root: repo_calls.append(1) or menu.MENU_BACK) + assert actions_plugin.run(tmp_path) is menu.MENU_BACK + assert repo_calls == [1], "repo サブ階層へ 1 度遷移してから plugin メニューへ戻る" def test_run_repo_ctrl_c_aborts(monkeypatch, tmp_path): @@ -475,11 +506,11 @@ def test_repo_menu_ctrl_c_aborts(monkeypatch, tmp_path): def test_repo_menu_arg_cancel_reshows_submenu(monkeypatch, tmp_path): """引数収集を中止 (_ARG_CANCEL) するとサブ階層メニューを再表示し、再選択で実行する。""" + # 1 回目: add (→ 引数収集中止) / 2 回目: list (→ 実行) / 3 回目: MENU_BACK + select = _seq("add", "list", menu.MENU_BACK) select_calls = [] - # 1 回目: add (→ 引数収集中止) / 2 回目: list (→ 実行) monkeypatch.setattr(actions_plugin, "_select_repo_operation", - lambda: (select_calls.append(1), - "add" if len(select_calls) == 1 else "list")[1]) + lambda: select_calls.append(1) or select()) run_calls = [] @@ -489,15 +520,20 @@ def fake_run_op(root, op): monkeypatch.setattr(actions_plugin, "_run_repo_operation", fake_run_op) - assert actions_plugin._repo_menu(tmp_path) == 0 + assert actions_plugin._repo_menu(tmp_path) is menu.MENU_BACK assert run_calls == ["add", "list"] - assert len(select_calls) == 2, "引数中止でサブ階層メニューが再表示される" + assert len(select_calls) == 3, "引数中止と実行後にサブ階層メニューが再表示される" -def test_repo_menu_returns_rc(monkeypatch, tmp_path): - monkeypatch.setattr(actions_plugin, "_select_repo_operation", lambda: "list") - monkeypatch.setattr(actions_plugin, "_run_repo_operation", lambda root, op: 1) - assert actions_plugin._repo_menu(tmp_path) == 1 +def test_repo_menu_executes_then_stays(monkeypatch, tmp_path): + """repo 操作を実行 → サブ階層に留まり、Esc/← (MENU_BACK) で plugin メニューへ。""" + monkeypatch.setattr(actions_plugin, "_select_repo_operation", + _seq("list", menu.MENU_BACK)) + calls = [] + monkeypatch.setattr(actions_plugin, "_run_repo_operation", + lambda root, op: calls.append(op) or 1) + assert actions_plugin._repo_menu(tmp_path) is menu.MENU_BACK + assert calls == ["list"], "操作は実行される (rc は終了コードへは伝搬しない)" # --------------------------------------------------------------------------- diff --git a/tests/cli/tui/test_actions_project.py b/tests/cli/tui/test_actions_project.py index d50cef3..d3722d1 100644 --- a/tests/cli/tui/test_actions_project.py +++ b/tests/cli/tui/test_actions_project.py @@ -8,7 +8,29 @@ import pytest -from devbase.tui import actions_project, menu +from devbase.tui import actions_project, flow, menu + + +@pytest.fixture(autouse=True) +def _no_pause(monkeypatch): + """サブメニューは実行後に留まる。各テストの一時停止 (Enter 待ち) は無効化する。""" + monkeypatch.setattr(flow, "pause_for_review", lambda: True) + + +def _seq(*values): + """呼ばれるたび values を順に返し、尽きたら最後の値を返すコールバックを作る。 + + サブメニューは操作実行後に再表示されるため、選択スタブは末尾に MENU_BACK を + 置いてループを終わらせる。 + """ + box = {"i": 0} + + def _next(*_a, **_k): + i = box["i"] + box["i"] = min(i + 1, len(values) - 1) + return values[i] + + return _next # --------------------------------------------------------------------------- @@ -23,31 +45,38 @@ def _row(status): @pytest.mark.parametrize("action,expected_subcommand", [("up", "up"), ("rebuild", "build")]) def test_handle_row_running_shows_action_menu(monkeypatch, tmp_path, action, expected_subcommand): - """running 行はサブメニューで操作を選び、引数不要の up/rebuild は即起動する。""" + """running 行はサブメニューで操作を選び、引数不要の up/rebuild は即起動する。 + + 実行後はサブメニューに留まり、Esc/← (MENU_BACK) で初めて一覧へ戻る。 + """ from devbase.commands import container as container_mod seen = {} + _sel = _seq(action, menu.MENU_BACK) monkeypatch.setattr(actions_project, "_select_action", - lambda name: seen.update(name=name) or action) + lambda name: seen.update(name=name) or _sel()) captured = {} monkeypatch.setattr(container_mod, "cmd_project", lambda args: captured.update( subcommand=args.subcommand, name=args.name) or 0) result = actions_project.handle_row(tmp_path, _row("running (2 containers)")) - assert result == 0 # 操作完了 → dispatch の rc を返す + assert result is menu.MENU_BACK # 実行後サブメニューに留まり、戻りは MENU_BACK assert seen["name"] == "carmo" assert captured == {"subcommand": expected_subcommand, "name": "carmo"} -def test_handle_row_propagates_nonzero_dispatch_rc(monkeypatch, tmp_path): - """dispatch が非0 (失敗) を返したら handle_row もその rc を返す (終了コード伝搬)。""" +def test_handle_row_executes_then_stays_in_submenu(monkeypatch, tmp_path): + """非0 を返す操作でも実行後はサブメニューに留まり、戻りは MENU_BACK。""" from devbase.commands import container as container_mod - monkeypatch.setattr(actions_project, "_select_action", lambda name: "up") - monkeypatch.setattr(container_mod, "cmd_project", lambda args: 1) + monkeypatch.setattr(actions_project, "_select_action", _seq("up", menu.MENU_BACK)) + calls = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: calls.append(1) or 1) - assert actions_project.handle_row(tmp_path, _row("running (1 containers)")) == 1 + result = actions_project.handle_row(tmp_path, _row("running (1 containers)")) + assert result is menu.MENU_BACK + assert calls == [1], "操作は実行される (rc は終了コードへは伝搬しない)" @pytest.mark.parametrize("status", ["stopped", "unknown"]) @@ -368,11 +397,11 @@ def test_select_build_image_cancel(monkeypatch, tmp_path, sel): def test_operation_menu_arg_cancel_reshows_submenu(monkeypatch, tmp_path): """引数収集を中止 (_ARG_CANCEL) するとサブメニューを再表示し、再選択で実行する。""" + # 1 回目: scale (→ 引数収集中止) / 2 回目: up (→ 実行) / 3 回目: MENU_BACK + select = _seq("scale", "up", menu.MENU_BACK) select_calls = [] - # 1 回目: scale を選ぶ (→ 引数収集中止) / 2 回目: up を選ぶ (→ 実行) monkeypatch.setattr(actions_project, "_select_action", - lambda name: (select_calls.append(1), - "scale" if len(select_calls) == 1 else "up")[1]) + lambda name: select_calls.append(1) or select()) run_calls = [] @@ -382,9 +411,9 @@ def fake_run_op(root, name, op): monkeypatch.setattr(actions_project, "_run_operation", fake_run_op) - assert actions_project._operation_menu(tmp_path, "carmo") == 0 + assert actions_project._operation_menu(tmp_path, "carmo") is menu.MENU_BACK assert run_calls == ["scale", "up"] - assert len(select_calls) == 2, "引数中止でサブメニューが再表示される" + assert len(select_calls) == 3, "引数中止と実行後にサブメニューが再表示される" # --------------------------------------------------------------------------- diff --git a/tests/cli/tui/test_actions_snapshot.py b/tests/cli/tui/test_actions_snapshot.py index fd00c75..0804a16 100644 --- a/tests/cli/tui/test_actions_snapshot.py +++ b/tests/cli/tui/test_actions_snapshot.py @@ -9,7 +9,28 @@ import pytest -from devbase.tui import actions_snapshot, menu +from devbase.tui import actions_snapshot, flow, menu + + +@pytest.fixture(autouse=True) +def _no_pause(monkeypatch): + """サブメニューは実行後に留まる。各テストの一時停止 (Enter 待ち) は無効化する。""" + monkeypatch.setattr(flow, "pause_for_review", lambda: True) + + +def _seq(*values): + """呼ばれるたび values を順に返し、尽きたら最後の値を返すコールバックを作る。 + + 操作メニューは実行後に再表示されるため、選択スタブは末尾に MENU_BACK を置く。 + """ + box = {"i": 0} + + def _next(*_a, **_k): + i = box["i"] + box["i"] = min(i + 1, len(values) - 1) + return values[i] + + return _next def _capture_dispatch(monkeypatch): @@ -82,20 +103,23 @@ def test_run_ctrl_c_aborts(monkeypatch, tmp_path): assert called == [] -def test_run_executes_and_returns_rc(monkeypatch, tmp_path): - """操作を実行したら dispatch の rc を返す (非0 もそのまま伝搬)。""" - monkeypatch.setattr(actions_snapshot, "_select_operation", lambda: "list") - monkeypatch.setattr(actions_snapshot, "_run_operation", lambda root, op: 1) - assert actions_snapshot.run(tmp_path) == 1 +def test_run_executes_then_stays_in_submenu(monkeypatch, tmp_path): + """操作を実行 → 操作メニューに留まり、Esc/← (MENU_BACK) で初めてトップへ戻る。""" + monkeypatch.setattr(actions_snapshot, "_select_operation", _seq("list", menu.MENU_BACK)) + calls = [] + monkeypatch.setattr(actions_snapshot, "_run_operation", + lambda root, op: calls.append(op) or 1) + assert actions_snapshot.run(tmp_path) is menu.MENU_BACK + assert calls == ["list"], "操作は実行される (rc は終了コードへは伝搬しない)" def test_run_arg_cancel_reshows_menu(monkeypatch, tmp_path): """引数収集を中止 (_ARG_CANCEL) すると操作メニューを再表示し、再選択で実行する。""" + # 1 回目: delete (→ 引数収集中止) / 2 回目: list (→ 実行) / 3 回目: MENU_BACK + select = _seq("delete", "list", menu.MENU_BACK) select_calls = [] - # 1 回目: delete (→ 引数収集中止) / 2 回目: list (→ 実行) monkeypatch.setattr(actions_snapshot, "_select_operation", - lambda: (select_calls.append(1), - "delete" if len(select_calls) == 1 else "list")[1]) + lambda: select_calls.append(1) or select()) run_calls = [] @@ -105,9 +129,9 @@ def fake_run_op(root, op): monkeypatch.setattr(actions_snapshot, "_run_operation", fake_run_op) - assert actions_snapshot.run(tmp_path) == 0 + assert actions_snapshot.run(tmp_path) is menu.MENU_BACK assert run_calls == ["delete", "list"] - assert len(select_calls) == 2, "引数中止で操作メニューが再表示される" + assert len(select_calls) == 3, "引数中止と実行後に操作メニューが再表示される" # --------------------------------------------------------------------------- diff --git a/tests/cli/tui/test_app.py b/tests/cli/tui/test_app.py index 25a6335..2aea2cd 100644 --- a/tests/cli/tui/test_app.py +++ b/tests/cli/tui/test_app.py @@ -149,7 +149,7 @@ def _patch_loop(monkeypatch, selects, rows=None): monkeypatch.setattr(app, "list_projects", lambda projects_dir: list(_ROWS) if rows is None else rows) it = iter(selects) - monkeypatch.setattr(app, "_select_top", lambda r: next(it)) + monkeypatch.setattr(app, "_select_top", lambda r, default=None: next(it)) pauses = [] monkeypatch.setattr(app, "_pause_for_review", lambda: pauses.append(1) or True) return pauses @@ -159,7 +159,7 @@ def test_select_top_projects_in_list_categories_in_menubar(monkeypatch): """トップは一覧にプロジェクト行のみ、カテゴリは最下部メニューバーに並ぶ。""" captured = {} - def fake_menubar(message, choices, menu_items): + def fake_menubar(message, choices, menu_items, default=None): captured.update(values=[c[1] for c in choices], menu_labels=[m[0] for m in menu_items], menu_values=[m[1] for m in menu_items]) @@ -180,7 +180,7 @@ def test_select_top_empty_projects_uses_placeholder(monkeypatch): """ captured = {} - def fake_menubar(message, choices, menu_items): + def fake_menubar(message, choices, menu_items, default=None): captured.update(titles=[c[0] for c in choices], values=[c[1] for c in choices]) return captured["values"][0] @@ -214,6 +214,22 @@ def test_top_loop_project_selection_delegates_handle_row(monkeypatch, tmp_path): assert handled == [(tmp_path, "beta")], "選択 index の行が handle_row へ渡る" +def test_top_loop_restores_cursor_to_selected_project(monkeypatch, tmp_path): + """プロジェクト選択 → サブメニューから戻ると同じ行 (index) へカーソルを復元する。""" + monkeypatch.setattr(app, "list_projects", lambda projects_dir: list(_ROWS)) + monkeypatch.setattr(app, "_pause_for_review", lambda: True) + # サブメニュー (handle_row) は MENU_BACK で一覧へ戻る。 + monkeypatch.setattr(actions_project, "handle_row", lambda root, row: menu.MENU_BACK) + + defaults = [] + selects = iter([1, None]) # index 1 を選択 → 戻り → トップで終了 + monkeypatch.setattr(app, "_select_top", + lambda r, default=None: defaults.append(default) or next(selects)) + + assert app._top_menu_loop(tmp_path) == 0 + assert defaults == [None, 1], "初回は先頭、戻った後は選択した index=1 を既定にする" + + def test_top_loop_propagates_executed_rc(monkeypatch, tmp_path): """操作実行で非0 rc が返ると、その後トップで中止しても rc がループ戻り値へ伝搬する。""" _patch_loop(monkeypatch, [0, None]) From 72c7f1c17d87c9c2628cb6c065e6fa778a191f2f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 30 Jun 2026 09:06:08 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(tui):=20=E3=82=B5=E3=83=96=E3=83=A1?= =?UTF-8?q?=E3=83=8B=E3=83=A5=E3=83=BC=E5=86=8D=E8=A1=A8=E7=A4=BA=E3=81=AE?= =?UTF-8?q?=E5=89=8D=E3=81=AB=E7=94=BB=E9=9D=A2=E3=82=92=E3=82=AF=E3=83=AA?= =?UTF-8?q?=E3=82=A2=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit サブメニューから image build 等を実行すると大量の出力が残り、再表示した 操作メニューがその下に埋もれて見づらかった。flow.menu_loop の操作実行後 (Enter 待ちの後) に画面をクリアし、サブメニューを先頭行から描き直す。 画面クリア処理を menu.clear_screen に集約し、トップ一覧の app._clear_screen からも委譲する (サブメニュー再表示と同じ消去処理を共有)。引数収集中止 (_ARG_CANCEL) では出力が無いためクリアしない。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/tui/app.py | 14 +++----------- lib/devbase/tui/flow.py | 3 +++ lib/devbase/tui/menu.py | 15 +++++++++++++++ tests/cli/tui/test_actions_project.py | 17 +++++++++++++++++ 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/lib/devbase/tui/app.py b/lib/devbase/tui/app.py index 459874f..d84e926 100644 --- a/lib/devbase/tui/app.py +++ b/lib/devbase/tui/app.py @@ -73,17 +73,9 @@ def _route(category: str, devbase_root: Path): def _clear_screen() -> None: - """端末をクリアしてカーソルを先頭行へ戻す。 - - トップ一覧 (メニュープロンプト) を画面の先頭行から表示するため、再描画の - 直前に呼ぶ。``\\033[2J`` で表示領域を消去、``\\033[3J`` でスクロールバック - も消去、``\\033[H`` でカーソルを左上へ移動する。stdout が非 TTY の場合は - 何もしない。 - """ - if not sys.stdout.isatty(): - return - sys.stdout.write("\033[3J\033[2J\033[H") - sys.stdout.flush() + """端末をクリアしてカーソルを先頭行へ戻す (トップ一覧の再描画前)。実体は + ``menu.clear_screen`` に集約 (サブメニュー再表示と同じ消去処理)。""" + menu.clear_screen() def _pause_for_review() -> bool: diff --git a/lib/devbase/tui/flow.py b/lib/devbase/tui/flow.py index 6ab6e95..b5c4b7c 100644 --- a/lib/devbase/tui/flow.py +++ b/lib/devbase/tui/flow.py @@ -186,3 +186,6 @@ def menu_loop(select_op, run_op): # (実行のたびにトップ一覧へ戻らない)。一時停止中の Ctrl-C は全体中止。 if not pause_for_review(): return None + # 直前の操作出力 (image build 等) の下にメニューが埋もれないよう、再表示の + # 前に画面をクリアしてサブメニューを先頭行から描き直す。 + menu.clear_screen() diff --git a/lib/devbase/tui/menu.py b/lib/devbase/tui/menu.py index 455ad75..7713072 100644 --- a/lib/devbase/tui/menu.py +++ b/lib/devbase/tui/menu.py @@ -26,10 +26,25 @@ from __future__ import annotations +import sys + from devbase.log import get_logger logger = get_logger(__name__) + +def clear_screen() -> None: + """端末をクリアしてカーソルを先頭行へ戻す。 + + メニュー (トップ一覧 / サブメニュー) を画面の先頭行から表示するため、再描画の + 直前に呼ぶ。``\\033[2J`` で表示領域を消去、``\\033[3J`` でスクロールバックも + 消去、``\\033[H`` でカーソルを左上へ移動する。stdout が非 TTY の場合は何もしない。 + """ + if not sys.stdout.isatty(): + return + sys.stdout.write("\033[3J\033[2J\033[H") + sys.stdout.flush() + # questionary は任意依存。未導入時は選択メニュー不可 / 引数収集は input() 代替。 try: import questionary diff --git a/tests/cli/tui/test_actions_project.py b/tests/cli/tui/test_actions_project.py index d3722d1..fe2f64b 100644 --- a/tests/cli/tui/test_actions_project.py +++ b/tests/cli/tui/test_actions_project.py @@ -416,6 +416,23 @@ def fake_run_op(root, name, op): assert len(select_calls) == 3, "引数中止と実行後にサブメニューが再表示される" +def test_operation_menu_clears_screen_after_execution(monkeypatch, tmp_path): + """操作実行 (image build 等) の後、サブメニュー再表示の前に画面をクリアする。 + + 引数収集中止 (_ARG_CANCEL) では出力が無いためクリアしない。 + """ + select = _seq("scale", "build", menu.MENU_BACK) + monkeypatch.setattr(actions_project, "_select_action", lambda name: select()) + monkeypatch.setattr(actions_project, "_run_operation", + lambda root, name, op: + actions_project._ARG_CANCEL if op == "scale" else 0) + clears = [] + monkeypatch.setattr(menu, "clear_screen", lambda: clears.append(1)) + + assert actions_project._operation_menu(tmp_path, "carmo") is menu.MENU_BACK + assert clears == [1], "実行 (build) の 1 回のみクリア (scale の中止ではしない)" + + # --------------------------------------------------------------------------- # fallback_select_and_up: 番号入力 (questionary 不在) の非回帰 # --------------------------------------------------------------------------- From f4d8819ee21c5c5ecec7b21565c8591fed499e15 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 30 Jun 2026 09:12:26 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(tui):=20up/down=20=E3=81=AF=E5=AE=9F?= =?UTF-8?q?=E8=A1=8C=E5=BE=8C=E3=83=88=E3=83=83=E3=83=97=E4=B8=80=E8=A6=A7?= =?UTF-8?q?=E3=81=B8=E6=88=BB=E3=82=8B=20(=E4=BB=96=E3=81=AF=E3=82=B5?= =?UTF-8?q?=E3=83=96=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC=E3=81=AB=E7=95=99?= =?UTF-8?q?=E3=81=BE=E3=82=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 再起動 (up) / 停止 (down) はコンテナの起動状態が大きく変わるため、実行後は サブメニューに留まらずトップ一覧へ戻り最新状態を再表示する。それ以外の login/ps/logs/scale/build/rebuild は連続操作できるようサブメニューに留まる。 flow.menu_loop に back_after 述語を追加し、True を返す操作は実行後 (出力確認の 一時停止のあと) MENU_BACK を返してトップへ戻す。actions_project は _BACK_TO_TOP_OPS={up, down} を渡す。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/tui/actions_project.py | 14 ++++++++--- lib/devbase/tui/flow.py | 34 +++++++++++++++---------- tests/cli/tui/test_actions_project.py | 36 ++++++++++++++++++++++----- 3 files changed, 62 insertions(+), 22 deletions(-) diff --git a/lib/devbase/tui/actions_project.py b/lib/devbase/tui/actions_project.py index a671719..96ffe80 100644 --- a/lib/devbase/tui/actions_project.py +++ b/lib/devbase/tui/actions_project.py @@ -40,6 +40,11 @@ ("再ビルド (rebuild --no-cache)", "rebuild"), ] +# 実行後にサブメニューへ留まらずトップ一覧へ戻る操作。up/down はコンテナの +# 起動状態が大きく変わるため、最新状態の一覧を見せる方が自然 (それ以外の +# login/ps/logs/scale/build/rebuild は連続操作できるようサブメニューに留まる)。 +_BACK_TO_TOP_OPS = frozenset({"up", "down"}) + # 中止系番兵は flow と同一オブジェクトを再公開する (呼び出し側・テストの契約)。 _ARG_CANCEL = flow.ARG_CANCEL _ABORT = flow.ABORT @@ -157,13 +162,16 @@ def _operation_menu(devbase_root: Path, name: str): """running 行の操作サブメニューを回す。 戻り値 (``flow.menu_loop`` のプロトコル): ``menu.MENU_BACK`` (Esc・← で一覧へ - 戻る) / ``None`` (Ctrl-C 全体中止)。操作を実行した後はトップ一覧へ戻らず、 + 戻る) / ``None`` (Ctrl-C 全体中止)。多くの操作は実行後もトップ一覧へ戻らず、 出力を読めるよう一時停止してから**同じサブメニューを再表示する** (留まる)。 - 引数収集を中止 (``_ARG_CANCEL``) した場合も同じサブメニューを再表示する。 + ただし up/down (``_BACK_TO_TOP_OPS``) は状態が大きく変わるため実行後はトップ + 一覧へ戻り最新状態を再表示する。引数収集を中止 (``_ARG_CANCEL``) した場合も + 同じサブメニューを再表示する。 """ return flow.menu_loop( lambda: _select_action(name), - lambda op: _run_operation(devbase_root, name, op)) + lambda op: _run_operation(devbase_root, name, op), + back_after=lambda op: op in _BACK_TO_TOP_OPS) def handle_row(devbase_root: Path, row: dict): diff --git a/lib/devbase/tui/flow.py b/lib/devbase/tui/flow.py index b5c4b7c..809c155 100644 --- a/lib/devbase/tui/flow.py +++ b/lib/devbase/tui/flow.py @@ -153,22 +153,27 @@ def pause_for_review() -> bool: return True -def menu_loop(select_op, run_op): +def menu_loop(select_op, run_op, *, back_after=None): """「操作選択 → 実行」のサブメニューループ (actions_* の ``run`` 共通骨格)。 Parameters ---------- - select_op: 操作値 / ``MENU_BACK`` / ``None`` を返す選択関数。 - run_op: 選択された操作値を受け取り rc / ``ARG_CANCEL`` / ``None`` を返す実行関数。 + select_op: 操作値 / ``MENU_BACK`` / ``None`` を返す選択関数。 + run_op: 選択された操作値を受け取り rc / ``ARG_CANCEL`` / ``None`` を返す実行関数。 + back_after: ``op -> bool`` の述語 (省略可)。``True`` を返す操作は実行後に + サブメニューへ留まらず**上位 (トップ一覧) へ戻る**。状態が大きく + 変わる操作 (project の up/down 等) で最新状態を再表示するために使う。 Returns ------- - ``menu.MENU_BACK`` (Esc・← で 1 つ上へ戻る) / ``None`` (Ctrl-C 全体中止)。 - - 操作を実行した後はトップへ戻らず、出力を読めるよう ``pause_for_review`` で - Enter を待ってから**同じサブメニューを再表示する** (サブメニューに留まる)。 - 上位 (トップ一覧) へ戻るのは Esc/← (``MENU_BACK``) を押したときだけ。 - ``run_op`` が ``ARG_CANCEL`` を返したら一時停止せず同じメニューを再表示する。 + ``menu.MENU_BACK`` (Esc・← で 1 つ上へ戻る / back_after な操作の実行後) / + ``None`` (Ctrl-C 全体中止)。 + + 既定 (``back_after=None``) では、操作を実行した後もトップへ戻らず、出力を + 読めるよう ``pause_for_review`` で Enter を待ってから**同じサブメニューを + 再表示する** (サブメニューに留まる)。上位へ戻るのは Esc/← を押したときか、 + ``back_after(op)`` が ``True`` の操作を実行したとき。``run_op`` が + ``ARG_CANCEL`` を返したら一時停止せず同じメニューを再表示する。 判定は必ず ``is`` 同一性で行う (rc=0 を番兵と誤マッチさせない)。 """ while True: @@ -182,10 +187,13 @@ def menu_loop(select_op, run_op): continue # 引数収集中止 → 一時停止せず同じメニューを再表示 if rc is None: return None # run_op 内の Ctrl-C → 全体中止 - # 操作を実行した: 出力を読めるよう一時停止し、同じサブメニューを再表示する - # (実行のたびにトップ一覧へ戻らない)。一時停止中の Ctrl-C は全体中止。 + # 操作を実行した: まず出力を読めるよう一時停止する (Ctrl-C は全体中止)。 if not pause_for_review(): return None - # 直前の操作出力 (image build 等) の下にメニューが埋もれないよう、再表示の - # 前に画面をクリアしてサブメニューを先頭行から描き直す。 + if back_after is not None and back_after(op): + # up/down 等は状態が大きく変わるため、サブメニューに留まらずトップ一覧へ + # 戻り、最新状態を再表示する (トップ側が画面クリア + 一覧描画を行う)。 + return menu.MENU_BACK + # それ以外はサブメニューに留まる。直前の操作出力 (image build 等) の下に + # メニューが埋もれないよう、再表示の前に画面をクリアして先頭行から描き直す。 menu.clear_screen() diff --git a/tests/cli/tui/test_actions_project.py b/tests/cli/tui/test_actions_project.py index fe2f64b..e7057a5 100644 --- a/tests/cli/tui/test_actions_project.py +++ b/tests/cli/tui/test_actions_project.py @@ -66,17 +66,40 @@ def test_handle_row_running_shows_action_menu(monkeypatch, tmp_path, action, assert captured == {"subcommand": expected_subcommand, "name": "carmo"} -def test_handle_row_executes_then_stays_in_submenu(monkeypatch, tmp_path): - """非0 を返す操作でも実行後はサブメニューに留まり、戻りは MENU_BACK。""" +def test_handle_row_stays_in_submenu_for_non_updown_op(monkeypatch, tmp_path): + """up/down 以外 (例: build) は実行後もサブメニューに留まり再表示される。""" from devbase.commands import container as container_mod - monkeypatch.setattr(actions_project, "_select_action", _seq("up", menu.MENU_BACK)) + select_calls = [] + sel = _seq("build", menu.MENU_BACK) + monkeypatch.setattr(actions_project, "_select_action", + lambda name: select_calls.append(1) or sel()) calls = [] monkeypatch.setattr(container_mod, "cmd_project", lambda args: calls.append(1) or 1) result = actions_project.handle_row(tmp_path, _row("running (1 containers)")) assert result is menu.MENU_BACK assert calls == [1], "操作は実行される (rc は終了コードへは伝搬しない)" + assert len(select_calls) == 2, "build 実行後はサブメニューを再表示する (留まる)" + + +@pytest.mark.parametrize("op", ["up", "down"]) +def test_handle_row_up_down_return_to_top(monkeypatch, tmp_path, op): + """再起動 (up) / 停止 (down) は実行後サブメニューに留まらずトップ一覧へ戻る。""" + from devbase.commands import container as container_mod + + select_calls = [] + # MENU_BACK を後ろに置くが、up/down は 1 回の実行でトップへ戻るため使われない。 + sel = _seq(op, menu.MENU_BACK) + monkeypatch.setattr(actions_project, "_select_action", + lambda name: select_calls.append(1) or sel()) + calls = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: calls.append(1) or 0) + + result = actions_project.handle_row(tmp_path, _row("running (1 containers)")) + assert result is menu.MENU_BACK + assert calls == [1] + assert len(select_calls) == 1, "up/down は実行後トップへ戻り再選択させない" @pytest.mark.parametrize("status", ["stopped", "unknown"]) @@ -397,8 +420,9 @@ def test_select_build_image_cancel(monkeypatch, tmp_path, sel): def test_operation_menu_arg_cancel_reshows_submenu(monkeypatch, tmp_path): """引数収集を中止 (_ARG_CANCEL) するとサブメニューを再表示し、再選択で実行する。""" - # 1 回目: scale (→ 引数収集中止) / 2 回目: up (→ 実行) / 3 回目: MENU_BACK - select = _seq("scale", "up", menu.MENU_BACK) + # 1 回目: scale (→ 引数収集中止) / 2 回目: build (→ 実行・留まる) / 3 回目: MENU_BACK + # build は up/down と違いサブメニューに留まるため、再表示で 3 回目の選択が起きる。 + select = _seq("scale", "build", menu.MENU_BACK) select_calls = [] monkeypatch.setattr(actions_project, "_select_action", lambda name: select_calls.append(1) or select()) @@ -412,7 +436,7 @@ def fake_run_op(root, name, op): monkeypatch.setattr(actions_project, "_run_operation", fake_run_op) assert actions_project._operation_menu(tmp_path, "carmo") is menu.MENU_BACK - assert run_calls == ["scale", "up"] + assert run_calls == ["scale", "build"] assert len(select_calls) == 3, "引数中止と実行後にサブメニューが再表示される"