diff --git a/lib/devbase/tui/app.py b/lib/devbase/tui/app.py index 37be6e7..e7d5955 100644 --- a/lib/devbase/tui/app.py +++ b/lib/devbase/tui/app.py @@ -15,7 +15,8 @@ ナビ規約: トップ (プロジェクト一覧) は Esc / Ctrl-C で中止 (戻り先なし)。 各カテゴリ・サブメニュー内では Esc / ← で 1 つ前へ戻る (``menu.MENU_BACK``)、 -Ctrl-C で全体中止 (``None``)。 +Ctrl-C で全体中止 (``None``)。操作を実行した後は出力を読めるよう Enter を +待ってから一覧を再表示する (``_pause_for_review``)。 """ from __future__ import annotations @@ -66,6 +67,26 @@ def _route(category: str, devbase_root: Path): return module.run(devbase_root) +def _pause_for_review() -> bool: + """操作出力を読めるよう、一覧の再表示前に Enter を待つ。 + + 操作実行直後にトップ一覧を再描画すると、plugin 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 _select_top(rows: list[dict]): """トップ画面: プロジェクト一覧 + カテゴリ項目から 1 件選ばせる。 @@ -118,8 +139,12 @@ def _top_menu_loop(devbase_root: Path) -> int: if result is menu.MENU_BACK: # 操作なしで一覧へ戻り再表示 (rc は更新しない) continue - # int rc: 操作を実行した → rc を記憶して一覧を再表示 + # int rc: 操作を実行した → rc を記憶し、出力を読めるよう Enter を + # 待ってから一覧を再表示する (即時再描画で出力が流れるのを防ぐ)。 last_rc = result + if not _pause_for_review(): + logger.info("中止しました。") + return last_rc def run(devbase_root: Path, args) -> int: diff --git a/tests/cli/tui/test_app.py b/tests/cli/tui/test_app.py index d2192ac..d91e2a1 100644 --- a/tests/cli/tui/test_app.py +++ b/tests/cli/tui/test_app.py @@ -8,6 +8,8 @@ import types +import pytest + from devbase.tui import actions_plugin, actions_project, app, menu @@ -139,11 +141,18 @@ def test_run_interactive_opens_top_menu(tmp_path, monkeypatch): def _patch_loop(monkeypatch, selects, rows=None): - """_top_menu_loop の入力 (一覧と選択値) を注入する共通ヘルパ。""" + """_top_menu_loop の入力 (一覧と選択値) を注入する共通ヘルパ。 + + 操作実行後の Enter 待ち (`_pause_for_review`) は即継続にスタブし、 + 呼び出し回数を返す (pause 自体の挙動は専用テストで検証する)。 + """ 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)) + pauses = [] + monkeypatch.setattr(app, "_pause_for_review", lambda: pauses.append(1) or True) + return pauses def test_select_top_appends_categories_after_projects(monkeypatch): @@ -243,6 +252,63 @@ def test_top_loop_empty_projects_still_offers_categories(monkeypatch, tmp_path): assert ran == [1], "プロジェクト無しでもカテゴリへ遷移できる" +# --------------------------------------------------------------------------- +# 操作実行後の Enter 待ち (_pause_for_review): 出力が流れる前に読めるようにする +# --------------------------------------------------------------------------- + +def test_top_loop_pauses_after_execution(monkeypatch, tmp_path): + """操作を実行したら一覧の再表示前に Enter を待つ (出力を読めるようにする)。""" + pauses = _patch_loop(monkeypatch, ["plugin", None]) + from devbase.tui import actions_plugin + monkeypatch.setattr(actions_plugin, "run", lambda root: 0) + + assert app._top_menu_loop(tmp_path) == 0 + assert pauses == [1], "実行後は一覧再表示の前に Enter を待つ" + + +def test_top_loop_no_pause_on_menu_back(monkeypatch, tmp_path): + """操作なし (MENU_BACK) で戻ったときは Enter を待たない (出力がないため)。""" + pauses = _patch_loop(monkeypatch, ["plugin", None]) + from devbase.tui import actions_plugin + monkeypatch.setattr(actions_plugin, "run", lambda root: menu.MENU_BACK) + + assert app._top_menu_loop(tmp_path) == 0 + assert pauses == [], "MENU_BACK では Enter を待たない" + + +def test_top_loop_pause_ctrl_c_exits_with_last_rc(monkeypatch, tmp_path): + """Enter 待ちで Ctrl-C (False) を受けたら直近の実行 rc で全体中止する。""" + _patch_loop(monkeypatch, ["plugin"]) + from devbase.tui import actions_plugin + monkeypatch.setattr(actions_plugin, "run", lambda root: 1) + monkeypatch.setattr(app, "_pause_for_review", lambda: False) + + assert app._top_menu_loop(tmp_path) == 1 + + +def test_pause_for_review_enter_returns_true(monkeypatch): + monkeypatch.setattr("builtins.input", lambda *a: "") + assert app._pause_for_review() is True + + +def test_pause_for_review_ctrl_c_returns_false(monkeypatch): + def _interrupt(*a): + raise KeyboardInterrupt + + monkeypatch.setattr("builtins.input", _interrupt) + assert app._pause_for_review() is False + + +@pytest.mark.parametrize("exc", [EOFError, OSError]) +def test_pause_for_review_unreadable_stdin_returns_true(monkeypatch, exc): + """非 TTY 等で stdin を読めない場合は待たずに一覧へ戻る (ハングしない)。""" + def _unreadable(*a): + raise exc + + monkeypatch.setattr("builtins.input", _unreadable) + assert app._pause_for_review() is True + + def test_route_plugin_delegates(monkeypatch, tmp_path): """PR4: plugin カテゴリは actions_plugin.run へ routing される。""" monkeypatch.setattr(actions_plugin, "run", lambda root: "RESULT")