From 5aab2c9ca71062ea8a383ebf87d21d542068757c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 9 Jun 2026 12:36:45 +0000 Subject: [PATCH] =?UTF-8?q?feat(list):=20list=20TUI=20=E3=81=AE=E3=83=A1?= =?UTF-8?q?=E3=83=8B=E3=83=A5=E3=83=BC=E3=82=92=20Esc=20=E5=8D=98=E7=8B=AC?= =?UTF-8?q?=E3=81=A7=E3=82=82=E4=B8=AD=E6=AD=A2=E5=8F=AF=E8=83=BD=E3=81=AB?= =?UTF-8?q?=20(i31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit questionary 2.x の select は Ctrl-C / Ctrl-Q しか中止に割り当てないため、 生成済み Question.application.key_bindings に Escape ハンドラを後付けする _with_escape_cancel() を追加し、プロジェクト選択 (_show_menu) と running 操作選択 (_show_action_menu) の両メニューに適用する。 - Esc は矢印キー等のエスケープシーケンス先頭でもあるため eager=False で登録し、 prompt_toolkit のフラッシュ待ちで単独 Esc のみを拾う (矢印キーと非衝突) - Ctrl-C と同じく KeyboardInterrupt で抜けるので ask() は None (中止) を返す - プロンプト案内を「Ctrl-C 中止」→「Esc・Ctrl-C 中止」に更新 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/commands/project.py | 37 +++++++++++++++++++++----- tests/cli/test_project_list.py | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/lib/devbase/commands/project.py b/lib/devbase/commands/project.py index c757f10..03357c0 100644 --- a/lib/devbase/commands/project.py +++ b/lib/devbase/commands/project.py @@ -190,6 +190,27 @@ def _start_project_up(name: str) -> int: return _start_project_action(name, "up") +def _with_escape_cancel(question): + """questionary の select に Esc 単独押下での中止を後付けする。 + + questionary 2.x の select は Ctrl-C / Ctrl-Q しか中止に割り当てないため、 + 生成済み ``Question.application`` の key_bindings に Escape ハンドラを足す。 + Ctrl-C と同じく ``KeyboardInterrupt`` で抜けるので ``ask()`` は ``None`` + (= 中止) を返す。 + + Escape は矢印キー等のエスケープシーケンス (``\\x1b[A`` 等) の先頭バイトでも + あるため、``eager=False`` で登録し prompt_toolkit のフラッシュ待ちで単独 Esc + のみを拾う (矢印キー移動と衝突させない)。 + """ + from prompt_toolkit.keys import Keys + + @question.application.key_bindings.add(Keys.Escape) + def _(event): + event.app.exit(exception=KeyboardInterrupt, style="class:aborting") + + return question + + def _show_menu(rows: list[dict]) -> int | None: """questionary の select を起動し、選択された rows の index を返す (中止時 None)。 @@ -198,33 +219,35 @@ def _show_menu(rows: list[dict]) -> int | None: entries = _build_menu_entries(rows, colorize=_STATUS_COLOR) choices = [questionary.Choice(title=entry, value=i) for i, entry in enumerate(entries)] - return questionary.select( - "起動するプロジェクトを選択 (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Ctrl-C 中止):", + question = questionary.select( + "起動するプロジェクトを選択 (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc・Ctrl-C 中止):", choices=choices, use_arrow_keys=True, use_jk_keys=False, # use_search_filter と併用不可のため False use_search_filter=True, # 文字入力でプロジェクト名等を部分一致絞り込み use_shortcuts=False, # 単一キーショートカットは使わない - ).ask() # 選択された value (= rows の index) / 中止時 None + ) + return _with_escape_cancel(question).ask() # value (= rows index) / 中止時 None def _show_action_menu(name: str) -> str | None: """running 中プロジェクトの操作 (up/rebuild/down) を選ぶサブメニュー。 選択された action 文字列 (``"up"`` / ``"rebuild"`` / ``"down"``) を返す。 - 中止 (Ctrl-C) 時は None。テストではこの関数を monkeypatch する。 + 中止 (Esc / Ctrl-C) 時は None。テストではこの関数を monkeypatch する。 """ choices = [ questionary.Choice(title="再起動 (up)", value="up"), questionary.Choice(title="再ビルド (rebuild --no-cache)", value="rebuild"), questionary.Choice(title="停止 (down)", value="down"), ] - return questionary.select( - f"'{name}' は起動中です。操作を選択 (↑↓ 移動 / Enter 決定 / Ctrl-C 中止):", + question = questionary.select( + f"'{name}' は起動中です。操作を選択 (↑↓ 移動 / Enter 決定 / Esc・Ctrl-C 中止):", choices=choices, use_arrow_keys=True, use_shortcuts=False, - ).ask() + ) + return _with_escape_cancel(question).ask() def _tui_select_and_up(rows: list[dict]) -> int: diff --git a/tests/cli/test_project_list.py b/tests/cli/test_project_list.py index 75c4526..afd7530 100644 --- a/tests/cli/test_project_list.py +++ b/tests/cli/test_project_list.py @@ -743,6 +743,52 @@ def test_tui_non_running_row_direct_up(monkeypatch, status): assert captured["name"] == "carmo" +def test_with_escape_cancel_registers_escape_binding(): + """_with_escape_cancel が select に単独 Esc 中止バインドを後付けすること。""" + questionary = pytest.importorskip("questionary") + from prompt_toolkit.keys import Keys + + from devbase.commands import project as project_mod + + q = questionary.select("t", choices=[questionary.Choice(title="a", value=0)]) + assert project_mod._with_escape_cancel(q) is q # 同じ question を返す + + esc = [b for b in q.application.key_bindings.bindings if Keys.Escape in b.keys] + assert len(esc) == 1 + # eager=False: 矢印キー等のエスケープシーケンス (\x1b[A 等) の先頭と衝突させない + assert esc[0].eager() is False + + # ハンドラは Ctrl-C と同様 KeyboardInterrupt で app を抜ける (= ask() が None) + captured = {} + fake_app = types.SimpleNamespace(exit=lambda **kw: captured.update(kw)) + esc[0].handler(types.SimpleNamespace(app=fake_app)) + assert captured["exception"] is KeyboardInterrupt + + +@pytest.mark.parametrize("call", [ + lambda m: m._show_menu([{"name": "carmo", "plugin": "-", "status": "stopped"}]), + lambda m: m._show_action_menu("carmo"), +]) +def test_select_menus_wire_escape_cancel(monkeypatch, call): + """_show_menu / _show_action_menu が select に Esc 中止を仕込んでから ask する。""" + pytest.importorskip("questionary") + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.keys import Keys + + from devbase.commands import project as project_mod + + kb = KeyBindings() + fake_q = types.SimpleNamespace( + application=types.SimpleNamespace(key_bindings=kb), + ask=lambda: "sentinel", + ) + monkeypatch.setattr(project_mod.questionary, "select", lambda *a, **k: fake_q) + + assert call(project_mod) == "sentinel" + esc = [b for b in kb.bindings if Keys.Escape in b.keys] + assert len(esc) == 1, "Esc 中止バインドが登録されていない" + + def test_interactive_falls_back_when_no_terminal_menu(tmp_path, monkeypatch): """questionary 未導入時は input() 番号入力にフォールバックして up する。""" from devbase.commands import project as project_mod