Skip to content

Commit a4cbc2b

Browse files
takemi-ohamaclaude
andauthored
feat(list): list TUI のメニューを Esc 単独でも中止可能に (i31) (#44)
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) <noreply@anthropic.com>
1 parent 5870d6f commit a4cbc2b

2 files changed

Lines changed: 76 additions & 7 deletions

File tree

lib/devbase/commands/project.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,27 @@ def _start_project_up(name: str) -> int:
190190
return _start_project_action(name, "up")
191191

192192

193+
def _with_escape_cancel(question):
194+
"""questionary の select に Esc 単独押下での中止を後付けする。
195+
196+
questionary 2.x の select は Ctrl-C / Ctrl-Q しか中止に割り当てないため、
197+
生成済み ``Question.application`` の key_bindings に Escape ハンドラを足す。
198+
Ctrl-C と同じく ``KeyboardInterrupt`` で抜けるので ``ask()`` は ``None``
199+
(= 中止) を返す。
200+
201+
Escape は矢印キー等のエスケープシーケンス (``\\x1b[A`` 等) の先頭バイトでも
202+
あるため、``eager=False`` で登録し prompt_toolkit のフラッシュ待ちで単独 Esc
203+
のみを拾う (矢印キー移動と衝突させない)。
204+
"""
205+
from prompt_toolkit.keys import Keys
206+
207+
@question.application.key_bindings.add(Keys.Escape)
208+
def _(event):
209+
event.app.exit(exception=KeyboardInterrupt, style="class:aborting")
210+
211+
return question
212+
213+
193214
def _show_menu(rows: list[dict]) -> int | None:
194215
"""questionary の select を起動し、選択された rows の index を返す (中止時 None)。
195216
@@ -198,33 +219,35 @@ def _show_menu(rows: list[dict]) -> int | None:
198219
entries = _build_menu_entries(rows, colorize=_STATUS_COLOR)
199220
choices = [questionary.Choice(title=entry, value=i)
200221
for i, entry in enumerate(entries)]
201-
return questionary.select(
202-
"起動するプロジェクトを選択 (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Ctrl-C 中止):",
222+
question = questionary.select(
223+
"起動するプロジェクトを選択 (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc・Ctrl-C 中止):",
203224
choices=choices,
204225
use_arrow_keys=True,
205226
use_jk_keys=False, # use_search_filter と併用不可のため False
206227
use_search_filter=True, # 文字入力でプロジェクト名等を部分一致絞り込み
207228
use_shortcuts=False, # 単一キーショートカットは使わない
208-
).ask() # 選択された value (= rows の index) / 中止時 None
229+
)
230+
return _with_escape_cancel(question).ask() # value (= rows index) / 中止時 None
209231

210232

211233
def _show_action_menu(name: str) -> str | None:
212234
"""running 中プロジェクトの操作 (up/rebuild/down) を選ぶサブメニュー。
213235
214236
選択された action 文字列 (``"up"`` / ``"rebuild"`` / ``"down"``) を返す。
215-
中止 (Ctrl-C) 時は None。テストではこの関数を monkeypatch する。
237+
中止 (Esc / Ctrl-C) 時は None。テストではこの関数を monkeypatch する。
216238
"""
217239
choices = [
218240
questionary.Choice(title="再起動 (up)", value="up"),
219241
questionary.Choice(title="再ビルド (rebuild --no-cache)", value="rebuild"),
220242
questionary.Choice(title="停止 (down)", value="down"),
221243
]
222-
return questionary.select(
223-
f"'{name}' は起動中です。操作を選択 (↑↓ 移動 / Enter 決定 / Ctrl-C 中止):",
244+
question = questionary.select(
245+
f"'{name}' は起動中です。操作を選択 (↑↓ 移動 / Enter 決定 / Esc・Ctrl-C 中止):",
224246
choices=choices,
225247
use_arrow_keys=True,
226248
use_shortcuts=False,
227-
).ask()
249+
)
250+
return _with_escape_cancel(question).ask()
228251

229252

230253
def _tui_select_and_up(rows: list[dict]) -> int:

tests/cli/test_project_list.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,52 @@ def test_tui_non_running_row_direct_up(monkeypatch, status):
743743
assert captured["name"] == "carmo"
744744

745745

746+
def test_with_escape_cancel_registers_escape_binding():
747+
"""_with_escape_cancel が select に単独 Esc 中止バインドを後付けすること。"""
748+
questionary = pytest.importorskip("questionary")
749+
from prompt_toolkit.keys import Keys
750+
751+
from devbase.commands import project as project_mod
752+
753+
q = questionary.select("t", choices=[questionary.Choice(title="a", value=0)])
754+
assert project_mod._with_escape_cancel(q) is q # 同じ question を返す
755+
756+
esc = [b for b in q.application.key_bindings.bindings if Keys.Escape in b.keys]
757+
assert len(esc) == 1
758+
# eager=False: 矢印キー等のエスケープシーケンス (\x1b[A 等) の先頭と衝突させない
759+
assert esc[0].eager() is False
760+
761+
# ハンドラは Ctrl-C と同様 KeyboardInterrupt で app を抜ける (= ask() が None)
762+
captured = {}
763+
fake_app = types.SimpleNamespace(exit=lambda **kw: captured.update(kw))
764+
esc[0].handler(types.SimpleNamespace(app=fake_app))
765+
assert captured["exception"] is KeyboardInterrupt
766+
767+
768+
@pytest.mark.parametrize("call", [
769+
lambda m: m._show_menu([{"name": "carmo", "plugin": "-", "status": "stopped"}]),
770+
lambda m: m._show_action_menu("carmo"),
771+
])
772+
def test_select_menus_wire_escape_cancel(monkeypatch, call):
773+
"""_show_menu / _show_action_menu が select に Esc 中止を仕込んでから ask する。"""
774+
pytest.importorskip("questionary")
775+
from prompt_toolkit.key_binding import KeyBindings
776+
from prompt_toolkit.keys import Keys
777+
778+
from devbase.commands import project as project_mod
779+
780+
kb = KeyBindings()
781+
fake_q = types.SimpleNamespace(
782+
application=types.SimpleNamespace(key_bindings=kb),
783+
ask=lambda: "sentinel",
784+
)
785+
monkeypatch.setattr(project_mod.questionary, "select", lambda *a, **k: fake_q)
786+
787+
assert call(project_mod) == "sentinel"
788+
esc = [b for b in kb.bindings if Keys.Escape in b.keys]
789+
assert len(esc) == 1, "Esc 中止バインドが登録されていない"
790+
791+
746792
def test_interactive_falls_back_when_no_terminal_menu(tmp_path, monkeypatch):
747793
"""questionary 未導入時は input() 番号入力にフォールバックして up する。"""
748794
from devbase.commands import project as project_mod

0 commit comments

Comments
 (0)