Skip to content

Commit 0fb0082

Browse files
takemi-ohamaclaude
andauthored
fix(tui): 操作実行後に Enter 待ちを挟み出力が流れるのを防止 (#64)
devbase list の TUI で操作実行後 (plugin list 等の表示系操作を含む) に即座に トップのプロジェクト一覧を再描画していたため、操作の出力が一瞬で流れて 読めなかった。操作を実行した場合は一覧の再表示前に 「Enter キーで一覧へ戻ります...」で入力を待つようにする。 - 操作を実行したとき (rc が返ったとき) のみ Enter を待つ。 Esc/← で操作せず戻った場合は待たない (読むべき出力がない) - Enter 待ち中の Ctrl-C は既存ナビ規約どおり全体中止 (直近 rc で終了) - 非 TTY 等で stdin を読めない場合 (EOFError/OSError) は待たずに戻り ハングしない - questionary ではなく stdlib input() を使用し、画面の書き換えなしで 出力をそのまま残す Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e8af7e3 commit 0fb0082

2 files changed

Lines changed: 94 additions & 3 deletions

File tree

lib/devbase/tui/app.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
1616
ナビ規約: トップ (プロジェクト一覧) は Esc / Ctrl-C で中止 (戻り先なし)。
1717
各カテゴリ・サブメニュー内では Esc / ← で 1 つ前へ戻る (``menu.MENU_BACK``)、
18-
Ctrl-C で全体中止 (``None``)。
18+
Ctrl-C で全体中止 (``None``)。操作を実行した後は出力を読めるよう Enter を
19+
待ってから一覧を再表示する (``_pause_for_review``)。
1920
"""
2021

2122
from __future__ import annotations
@@ -66,6 +67,26 @@ def _route(category: str, devbase_root: Path):
6667
return module.run(devbase_root)
6768

6869

70+
def _pause_for_review() -> bool:
71+
"""操作出力を読めるよう、一覧の再表示前に Enter を待つ。
72+
73+
操作実行直後にトップ一覧を再描画すると、plugin list 等の表示系操作の出力が
74+
一瞬で流れて読めない。questionary 系プロンプトは画面を書き換えるため、
75+
stdlib の ``input()`` で素朴に待ち、出力をそのまま画面に残す。
76+
77+
戻り値: ``True`` = 一覧へ戻る / ``False`` = Ctrl-C (全体中止)。
78+
非 TTY 等で stdin を読めない場合 (EOFError/OSError) は待たずに戻る。
79+
"""
80+
try:
81+
input("Enter キーで一覧へ戻ります...")
82+
except KeyboardInterrupt:
83+
print()
84+
return False
85+
except (EOFError, OSError):
86+
pass
87+
return True
88+
89+
6990
def _select_top(rows: list[dict]):
7091
"""トップ画面: プロジェクト一覧 + カテゴリ項目から 1 件選ばせる。
7192
@@ -118,8 +139,12 @@ def _top_menu_loop(devbase_root: Path) -> int:
118139
if result is menu.MENU_BACK:
119140
# 操作なしで一覧へ戻り再表示 (rc は更新しない)
120141
continue
121-
# int rc: 操作を実行した → rc を記憶して一覧を再表示
142+
# int rc: 操作を実行した → rc を記憶し、出力を読めるよう Enter を
143+
# 待ってから一覧を再表示する (即時再描画で出力が流れるのを防ぐ)。
122144
last_rc = result
145+
if not _pause_for_review():
146+
logger.info("中止しました。")
147+
return last_rc
123148

124149

125150
def run(devbase_root: Path, args) -> int:

tests/cli/tui/test_app.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import types
1010

11+
import pytest
12+
1113
from devbase.tui import actions_plugin, actions_project, app, menu
1214

1315

@@ -139,11 +141,18 @@ def test_run_interactive_opens_top_menu(tmp_path, monkeypatch):
139141

140142

141143
def _patch_loop(monkeypatch, selects, rows=None):
142-
"""_top_menu_loop の入力 (一覧と選択値) を注入する共通ヘルパ。"""
144+
"""_top_menu_loop の入力 (一覧と選択値) を注入する共通ヘルパ。
145+
146+
操作実行後の Enter 待ち (`_pause_for_review`) は即継続にスタブし、
147+
呼び出し回数を返す (pause 自体の挙動は専用テストで検証する)。
148+
"""
143149
monkeypatch.setattr(app, "list_projects",
144150
lambda projects_dir: list(_ROWS) if rows is None else rows)
145151
it = iter(selects)
146152
monkeypatch.setattr(app, "_select_top", lambda r: next(it))
153+
pauses = []
154+
monkeypatch.setattr(app, "_pause_for_review", lambda: pauses.append(1) or True)
155+
return pauses
147156

148157

149158
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):
243252
assert ran == [1], "プロジェクト無しでもカテゴリへ遷移できる"
244253

245254

255+
# ---------------------------------------------------------------------------
256+
# 操作実行後の Enter 待ち (_pause_for_review): 出力が流れる前に読めるようにする
257+
# ---------------------------------------------------------------------------
258+
259+
def test_top_loop_pauses_after_execution(monkeypatch, tmp_path):
260+
"""操作を実行したら一覧の再表示前に Enter を待つ (出力を読めるようにする)。"""
261+
pauses = _patch_loop(monkeypatch, ["plugin", None])
262+
from devbase.tui import actions_plugin
263+
monkeypatch.setattr(actions_plugin, "run", lambda root: 0)
264+
265+
assert app._top_menu_loop(tmp_path) == 0
266+
assert pauses == [1], "実行後は一覧再表示の前に Enter を待つ"
267+
268+
269+
def test_top_loop_no_pause_on_menu_back(monkeypatch, tmp_path):
270+
"""操作なし (MENU_BACK) で戻ったときは Enter を待たない (出力がないため)。"""
271+
pauses = _patch_loop(monkeypatch, ["plugin", None])
272+
from devbase.tui import actions_plugin
273+
monkeypatch.setattr(actions_plugin, "run", lambda root: menu.MENU_BACK)
274+
275+
assert app._top_menu_loop(tmp_path) == 0
276+
assert pauses == [], "MENU_BACK では Enter を待たない"
277+
278+
279+
def test_top_loop_pause_ctrl_c_exits_with_last_rc(monkeypatch, tmp_path):
280+
"""Enter 待ちで Ctrl-C (False) を受けたら直近の実行 rc で全体中止する。"""
281+
_patch_loop(monkeypatch, ["plugin"])
282+
from devbase.tui import actions_plugin
283+
monkeypatch.setattr(actions_plugin, "run", lambda root: 1)
284+
monkeypatch.setattr(app, "_pause_for_review", lambda: False)
285+
286+
assert app._top_menu_loop(tmp_path) == 1
287+
288+
289+
def test_pause_for_review_enter_returns_true(monkeypatch):
290+
monkeypatch.setattr("builtins.input", lambda *a: "")
291+
assert app._pause_for_review() is True
292+
293+
294+
def test_pause_for_review_ctrl_c_returns_false(monkeypatch):
295+
def _interrupt(*a):
296+
raise KeyboardInterrupt
297+
298+
monkeypatch.setattr("builtins.input", _interrupt)
299+
assert app._pause_for_review() is False
300+
301+
302+
@pytest.mark.parametrize("exc", [EOFError, OSError])
303+
def test_pause_for_review_unreadable_stdin_returns_true(monkeypatch, exc):
304+
"""非 TTY 等で stdin を読めない場合は待たずに一覧へ戻る (ハングしない)。"""
305+
def _unreadable(*a):
306+
raise exc
307+
308+
monkeypatch.setattr("builtins.input", _unreadable)
309+
assert app._pause_for_review() is True
310+
311+
246312
def test_route_plugin_delegates(monkeypatch, tmp_path):
247313
"""PR4: plugin カテゴリは actions_plugin.run へ routing される。"""
248314
monkeypatch.setattr(actions_plugin, "run", lambda root: "RESULT")

0 commit comments

Comments
 (0)