From 0fb73ad25d8af0eddc55547ba2cff7730706f158 Mon Sep 17 00:00:00 2001 From: Mouhand-Kaddo Date: Mon, 14 Sep 2026 16:44:36 +0400 Subject: [PATCH] feat: pick ask_user answers with the arrow-key picker Replace the numbered 1-4 question prompt with the themed picker panel used by the slash/@/. menus: arrows move the highlight, enter selects (or confirms a multi-select), space toggles, esc dismisses. Add a trailing "Type your own answer..." row that switches to a free-text answer submitted with enter; toggled options are kept on a multi-select. The feed keeps a one-line record of each question. --- README.md | 8 +- src/lecode/tui/app.py | 135 +++++++++++++++++++---------- src/lecode/tui/question.py | 139 +++++++++++++++++++++++------- tests/test_tui_question.py | 171 ++++++++++++++++++++++++++++++------- 4 files changed, 345 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index b79de6d..d32d8c5 100644 --- a/README.md +++ b/README.md @@ -158,9 +158,11 @@ non-tty `--setup`) · `3` max turns / max loop iterations / context overflow. task: it compares your request with the agent's result and tells you plainly whether it delivered. `/pierre on|off|model`. - **Structured questions** — the `ask_user` tool lets the agent ask 1-4 - multiple-choice questions mid-turn instead of guessing; you answer inline - with the keyboard (`1`-`4` to pick, `enter` to confirm a multi-select, - `esc` to dismiss). Headless, loop, chain, and subagent runs get a + multiple-choice questions mid-turn instead of guessing; you pick with the + arrow-key picker (`↑`/`↓` to move, `enter` to select/confirm, `space` to + toggle a multi-select, `esc` to dismiss). The picker's last row, + "Type your own answer…", switches to a free-text answer that you type and + submit with `enter`. Headless, loop, chain, and subagent runs get a "use your best judgment" result instead of a prompt. - **Notifications** — sound (afplay/paplay/aplay, terminal bell fallback) and desktop notifications (osascript / notify-send) on turn finish, error, and diff --git a/src/lecode/tui/app.py b/src/lecode/tui/app.py index e5512b7..712f5b2 100644 --- a/src/lecode/tui/app.py +++ b/src/lecode/tui/app.py @@ -126,7 +126,7 @@ prefix_matches, trigger_token, ) -from lecode.tui.question import QuestionPrompt, question_prompt_text +from lecode.tui.question import QuestionPrompt, question_heading, question_hint, question_rows from lecode.tui.statusline import ( CachedGitInfo, StatusLineState, @@ -576,6 +576,20 @@ def _build_keybindings(self) -> KeyBindings: approval_pending = Condition(lambda: self._approval.is_pending) question_pending = Condition(lambda: self._question.is_pending) + def question_nav() -> bool: + # Arrows/space/tab drive the picker while the input is empty and + # the user is browsing options; once they start typing a custom + # answer (or pick the custom row) they should edit it normally. + pending = self._question.pending + return ( + self._input_area is not None + and not self._input_area.buffer.text.strip() + and pending is not None + and not pending.custom + ) + + question_picker = question_pending & Condition(question_nav) + @kb.add("y", filter=approval_pending) def _approve_once(event: Any) -> None: self._approval.resolve(AllowOnce()) @@ -594,25 +608,30 @@ def _deny(event: Any) -> None: def _deny_escape(event: Any) -> None: self._approval.resolve(Deny()) - @kb.add("1", filter=question_pending) - def _question_1(event: Any) -> None: - self._on_question_key(self._question.select(0)) + @kb.add("up", filter=question_picker) + def _question_up(event: Any) -> None: + self._on_question_key(self._question.move(-1)) - @kb.add("2", filter=question_pending) - def _question_2(event: Any) -> None: - self._on_question_key(self._question.select(1)) + @kb.add("down", filter=question_picker) + def _question_down(event: Any) -> None: + self._on_question_key(self._question.move(1)) - @kb.add("3", filter=question_pending) - def _question_3(event: Any) -> None: - self._on_question_key(self._question.select(2)) + @kb.add(" ", filter=question_picker) + def _question_space(event: Any) -> None: + self._on_question_key(self._question.activate()) - @kb.add("4", filter=question_pending) - def _question_4(event: Any) -> None: - self._on_question_key(self._question.select(3)) + @kb.add("tab", filter=question_picker) + def _question_tab(event: Any) -> None: + self._on_question_key(self._question.enter()) @kb.add("escape", filter=question_pending) - def _question_dismiss(event: Any) -> None: - self._question.dismiss() + def _question_escape(event: Any) -> None: + # Esc while typing a custom answer goes back to the options; from + # the options it dismisses the remaining questions. Either way the + # half-typed text is dropped. + if self._question.back() == "ignored": + self._question.dismiss() + event.current_buffer.reset() self._invalidate() @kb.add("escape", filter=~approval_pending & ~question_pending) @@ -641,8 +660,15 @@ def _enter(event: Any) -> None: return # y/a/n/ESC only while an approval is pending if self._question.is_pending: # A filtered binding would lose to this unfiltered one - # (later registration wins), so confirm is handled here. - self._on_question_key(self._question.confirm()) + # (later registration wins), so enter is handled here. A typed + # custom answer wins over the highlighted option. + buffer = event.current_buffer + text = buffer.text + if text.strip(): + buffer.reset() + self._on_question_key(self._question.custom(text)) + else: + self._on_question_key(self._question.enter()) return buffer = event.current_buffer if buffer.complete_state is not None: @@ -884,12 +910,14 @@ def _build_app(self, input: Input | None = None, output: Output | None = None) - @Condition def picker_menu_visible() -> bool: - # Every completion in this app comes from the trigger pickers - # (@/./commands), a command's argument picker, or the path - # completer, so the themed panel owns them all. Rows stream in - # asynchronously (the @/path pickers await the fd listing - # first), so wait for the first row; the no-match rows (slash - # and argument pickers) render without completion state. + # The themed panel owns every completion in this app (@/./commands, + # a command's argument picker, the path completer) and, mid-turn, + # the ask_user question picker. Completion rows stream in + # asynchronously (the @/path pickers await the fd listing first), + # so wait for the first row; the no-match rows (slash and argument + # pickers) render without completion state. + if self._question.is_pending: + return True state = buffer.complete_state return ( (state is not None and bool(state.completions)) @@ -898,6 +926,10 @@ def picker_menu_visible() -> bool: ) def menu_heading() -> str: + if self._question.is_pending: + question = self._question.current() + if question is not None: + return question_heading(question) state = buffer.complete_state count = len(state.completions) if state else 0 label = "commands" # default: the inert no-match row (slash picker) @@ -916,6 +948,11 @@ def menu_heading() -> str: return f" {label} {count} {'match' if count == 1 else 'matches'}" def menu_rows() -> list[tuple[str, str]]: + if self._question.is_pending: + pending = self._question.pending + question = self._question.current() + if pending is not None and question is not None: + return question_rows(question, pending.highlight, pending.selection) state = buffer.complete_state if state is None: if (no_match := self._arg_no_match()) is not None: @@ -940,6 +977,27 @@ def menu_rows() -> list[tuple[str, str]]: ) return rows + def menu_cursor() -> Point: + pending = self._question.pending + if pending is not None: + return Point(0, pending.highlight) + state = buffer.complete_state + return Point(0, (state.complete_index or 0) if state else 0) + + def menu_cursorline() -> bool: + if self._question.is_pending: + return True + state = buffer.complete_state + return state is not None and state.complete_index is not None + + def menu_footer() -> str: + if self._question.is_pending: + pending = self._question.pending + question = self._question.current() + if pending is not None and question is not None: + return question_hint(question, pending.custom) + return " ↑↓ navigate Enter/Tab select Esc close" + picker_panel = ConditionalContainer( Frame( HSplit( @@ -948,24 +1006,14 @@ def menu_rows() -> list[tuple[str, str]]: Window( FormattedTextControl( menu_rows, - get_cursor_position=lambda: Point( - 0, - (buffer.complete_state.complete_index or 0) - if buffer.complete_state - else 0, - ), + get_cursor_position=menu_cursor, ), height=Dimension(min=1, max=DROPDOWN_MAX_ROWS), dont_extend_height=True, - cursorline=Condition( - lambda: ( - buffer.complete_state is not None - and buffer.complete_state.complete_index is not None - ) - ), + cursorline=Condition(menu_cursorline), ), Window( - FormattedTextControl(" ↑↓ navigate Enter/Tab select Esc close"), + FormattedTextControl(menu_footer), height=1, ), ] @@ -1327,21 +1375,22 @@ async def _request_approval( self._invalidate() def _render_question(self) -> None: - """Print the current question block (numbered options) to the feed.""" - pending = self._question.pending + """Record the current question in the feed; its options live in the picker panel.""" question = self._question.current() - if pending is None or question is None: + if question is None: return - self._feed.permission(question_prompt_text(question, pending.selection)) + header = question.get("header") + first = f"[{header}] {question['question']}" if header else str(question["question"]) + self._feed.permission(first) def _on_question_key(self, outcome: str) -> None: - """After a question keypress: render the advance/toggle and repaint.""" - if outcome in ("advanced", "toggled"): + """After a question keypress: record an advance and repaint the panel.""" + if outcome == "advanced": self._render_question() self._invalidate() async def _request_question(self, questions: list[dict[str, Any]]) -> list[dict[str, Any]]: - """``ctx.question_callback``: inline 1-4/enter/ESC ask during a turn.""" + """``ctx.question_callback``: inline arrow-key picker during a turn.""" future = self._question.request(questions) self._render_question() self._status.state = StatusLineState.QUESTION diff --git a/src/lecode/tui/question.py b/src/lecode/tui/question.py index 580e1e0..e93e84b 100644 --- a/src/lecode/tui/question.py +++ b/src/lecode/tui/question.py @@ -1,10 +1,11 @@ -"""Inline structured-question prompt state (1-4/enter/ESC). - -Same shape as the permission prompt: rendering goes through the feed and -the statusline's ``awaiting answer`` state; keypresses are intercepted by -the main app's keybindings, filtered on :attr:`QuestionPrompt.is_pending`, -so no nested prompt_toolkit application ever fights over stdin. Questions -are handled sequentially — selecting an answer advances to the next one. +"""Inline structured-question picker (arrows/space/enter/ESC). + +Rendering reuses the themed picker panel: the options are rows and the +highlighted one is the cursor line, so the user picks with the arrow keys +instead of typing option numbers. Keypresses are intercepted by the main +app's keybindings, gated on :attr:`QuestionPrompt.is_pending`, so no nested +prompt_toolkit application ever fights over stdin. Questions are handled +sequentially — choosing an answer advances to the next one. """ from __future__ import annotations @@ -16,6 +17,13 @@ #: Cap on one rendered option line. _OPTION_MAX_LEN = 100 +#: Row styles, matching the picker panel's own classes. +_ROW_STYLE = "class:picker-menu.command" +_SELECTED_STYLE = "class:picker-menu.selected" + +#: The always-present row that switches to a free-text answer. +_CUSTOM_ROW = "✎ Type your own answer…" + @dataclass class PendingQuestion: @@ -25,29 +33,56 @@ class PendingQuestion: answers: list[dict[str, Any]] = field(default_factory=list) #: Toggled option indices for the current multi-select question. selection: set[int] = field(default_factory=set) + #: Highlighted option for the cursor line. + highlight: int = 0 + #: True while the user is typing a free-text answer (the custom row). + custom: bool = False def _clip(text: str) -> str: return text if len(text) <= _OPTION_MAX_LEN else text[: _OPTION_MAX_LEN - 1] + "…" -def question_prompt_text(question: dict[str, Any], selected: set[int] | None = None) -> str: - """The rendered block: header tag, question, numbered options, key hint.""" +def question_heading(question: dict[str, Any]) -> str: + """The picker panel's heading: header tag plus the question text.""" header = question.get("header") first = f"[{header}] {question['question']}" if header else str(question["question"]) - lines = [first] + return f" ask_user {_clip(first)} " + + +def question_hint(question: dict[str, Any], custom: bool = False) -> str: + """The picker panel's footer: the keys the current question accepts.""" + if custom: + return " Type your answer in the box, Enter to submit, Esc back" + if question.get("multi_select"): + return " ↑↓ navigate Space toggle Enter confirm Esc dismiss" + return " ↑↓ navigate Enter select Esc dismiss" + + +def question_rows( + question: dict[str, Any], highlight: int, selected: set[int] +) -> list[tuple[str, str]]: + """Styled ``(style, text)`` rows for the picker panel's option list. + + The final row is the always-present free-text affordance; its index is + ``len(options)`` and selecting it enters custom-answer mode. + """ multi = bool(question.get("multi_select")) - for i, option in enumerate(question["options"], start=1): - label = _clip(str(option["label"])) + rows: list[tuple[str, str]] = [] + for i, option in enumerate(question["options"]): + if i: + rows.append(("", "\n")) + style = _SELECTED_STYLE if i == highlight else _ROW_STYLE + marker = ("[x] " if i in selected else "[ ] ") if multi else "" + text = marker + _clip(str(option["label"])) if option.get("description"): - label += f" — {_clip(str(option['description']))}" - marker = "" - if multi: - marker = "[x] " if (i - 1) in (selected or set()) else "[ ] " - lines.append(f" {marker}{i}. {label}") - hint = "1-4 toggle, enter confirms" if multi else "1-4 select" - lines.append(f"{hint} — ESC dismisses") - return "\n".join(lines) + text += f" — {_clip(str(option['description']))}" + rows.extend([(style, "> " if i == highlight else " "), (style, text)]) + custom = len(question["options"]) + style = _SELECTED_STYLE if custom == highlight else _ROW_STYLE + rows.append(("", "\n")) + rows.extend([(style, "> " if custom == highlight else " "), (style, _CUSTOM_ROW)]) + return rows class QuestionPrompt: @@ -81,29 +116,75 @@ def _advance(self, pending: PendingQuestion, answers: list[str]) -> None: ) pending.index += 1 pending.selection = set() + pending.highlight = 0 + pending.custom = False if pending.index >= len(pending.questions): if not pending.future.done(): pending.future.set_result(pending.answers) self._pending = None - def select(self, option_index: int) -> str: - """Digit keypress: single-select answers and advances; multi toggles. + def move(self, delta: int) -> str: + """Arrow key: move the highlight over the options and the custom row.""" + pending = self._pending + question = self.current() + if pending is None or question is None or pending.custom: + return "ignored" + count = len(question["options"]) + 1 # + the custom-answer row + pending.highlight = (pending.highlight + delta) % count + return "moved" + + def activate(self) -> str: + """Enter/Tab/Space on the highlighted row: single-select answers and + advances; multi-select toggles; the custom row enters typing mode. - Returns ``"advanced"`` (render the next question), ``"toggled"`` - (re-render the current one), or ``"ignored"``. + Returns ``"advanced"``, ``"toggled"``, ``"custom"`` or ``"ignored"``. """ pending = self._pending question = self.current() - if pending is None or question is None: - return "ignored" - if not 0 <= option_index < len(question["options"]): + if pending is None or question is None or pending.custom: return "ignored" + if pending.highlight >= len(question["options"]): + pending.custom = True + return "custom" if question.get("multi_select"): - pending.selection ^= {option_index} + pending.selection ^= {pending.highlight} return "toggled" - self._advance(pending, [str(question["options"][option_index]["label"])]) + self._advance(pending, [str(question["options"][pending.highlight]["label"])]) return "advanced" + def enter(self) -> str: + """Enter key: confirm a multi-select question, else activate the row.""" + pending = self._pending + question = self.current() + if pending is None or question is None or pending.custom: + return "ignored" # custom mode waits for typed text + if question.get("multi_select"): + return self.confirm() + return self.activate() + + def custom(self, text: str) -> str: + """A typed custom answer (Enter with text in the input): use the text, + keeping any toggled options on a multi-select question.""" + pending = self._pending + question = self.current() + text = text.strip() + if pending is None or question is None or not text: + return "ignored" + answers = [text] + if question.get("multi_select"): + toggled = [str(question["options"][i]["label"]) for i in sorted(pending.selection)] + answers = [*toggled, text] + self._advance(pending, answers) + return "advanced" + + def back(self) -> str: + """Esc while typing a custom answer: return to the option list.""" + pending = self._pending + if pending is None or not pending.custom: + return "ignored" + pending.custom = False + return "back" + def confirm(self) -> str: """Enter on a multi-select question: record the toggled options.""" pending = self._pending diff --git a/tests/test_tui_question.py b/tests/test_tui_question.py index 9987b20..be41b18 100644 --- a/tests/test_tui_question.py +++ b/tests/test_tui_question.py @@ -1,4 +1,4 @@ -"""Tests for the inline question prompt (1-4/enter/ESC) and question callback.""" +"""Tests for the inline question picker (arrows/space/enter/ESC) and question callback.""" from __future__ import annotations @@ -17,7 +17,7 @@ from lecode.config.models import Config from lecode.session.storage import SessionStore from lecode.tui.app import TuiApp -from lecode.tui.question import QuestionPrompt, question_prompt_text +from lecode.tui.question import QuestionPrompt, question_heading, question_hint, question_rows from lecode.tui.statusline import StatusLineState QUESTIONS = [ @@ -30,55 +30,115 @@ ] +def rows_text(rows) -> str: + return "".join(text for _, text in rows) + + # -- prompt state + rendering ---------------------------------------------------- -def test_question_prompt_text(): - text = question_prompt_text(QUESTIONS[0]) - assert "[design] Which approach?" in text - assert "1. Alpha" in text and "2. Beta" in text and "3. Gamma" in text - assert "1-4 select" in text and "ESC dismisses" in text +def test_question_heading_and_hint(): + assert question_heading(QUESTIONS[0]) == " ask_user [design] Which approach? " + assert "↑↓ navigate" in question_hint(QUESTIONS[0]) + assert "Space toggle" not in question_hint(QUESTIONS[0]) + assert "Space toggle" in question_hint({**QUESTIONS[0], "multi_select": True}) + assert "Type your answer" in question_hint(QUESTIONS[0], custom=True) + + +def test_question_rows_highlight_and_custom_row(): + text = rows_text(question_rows(QUESTIONS[0], highlight=1, selected=set())) + assert "> Beta" in text + assert " Alpha" in text and " Gamma" in text + assert "Type your own answer" in text -def test_question_prompt_text_multi_select_marks(): +def test_question_rows_multi_select_marks(): question = {**QUESTIONS[0], "multi_select": True} - text = question_prompt_text(question, {0, 2}) - assert "[x] 1. Alpha" in text - assert "[ ] 2. Beta" in text - assert "[x] 3. Gamma" in text - assert "1-4 toggle, enter confirms" in text + text = rows_text(question_rows(question, highlight=1, selected={0, 2})) + assert " [x] Alpha" in text + assert "> [ ] Beta" in text + assert " [x] Gamma" in text async def test_question_prompt_single_select_resolves(): prompt = QuestionPrompt() future = prompt.request(QUESTIONS) assert prompt.is_pending - assert prompt.select(1) == "advanced" + assert prompt.move(1) == "moved" + assert prompt.enter() == "advanced" assert await future == [{"question": "Which approach?", "answers": ["Beta"]}] assert not prompt.is_pending -async def test_question_prompt_out_of_range_ignored(): +async def test_question_prompt_move_wraps(): prompt = QuestionPrompt() future = prompt.request(QUESTIONS) - assert prompt.select(3) == "ignored" # only 3 options - assert prompt.select(-1) == "ignored" - assert prompt.is_pending - prompt.dismiss() - await future + assert prompt.move(-1) == "moved" + assert prompt.pending is not None and prompt.pending.highlight == 3 # the custom row + assert prompt.move(-1) == "moved" + assert prompt.pending.highlight == 2 # wraps to Gamma + assert prompt.activate() == "advanced" + assert await future == [{"question": "Which approach?", "answers": ["Gamma"]}] async def test_question_prompt_multi_toggle_confirm(): prompt = QuestionPrompt() questions = [{**QUESTIONS[0], "multi_select": True}] future = prompt.request(questions) - assert prompt.select(0) == "toggled" - assert prompt.select(2) == "toggled" - assert prompt.select(2) == "toggled" # toggles back off - assert prompt.confirm() == "advanced" + assert prompt.activate() == "toggled" # Alpha + assert prompt.move(2) == "moved" + assert prompt.activate() == "toggled" # Gamma + assert prompt.activate() == "toggled" # toggles back off + assert prompt.enter() == "advanced" assert await future == [{"question": "Which approach?", "answers": ["Alpha"]}] +async def test_question_prompt_custom_answer(): + prompt = QuestionPrompt() + future = prompt.request(QUESTIONS) + assert prompt.custom("My own") == "advanced" + assert await future == [{"question": "Which approach?", "answers": ["My own"]}] + + +async def test_question_prompt_custom_row_enters_mode(): + prompt = QuestionPrompt() + future = prompt.request(QUESTIONS) + assert prompt.move(3) == "moved" # past the 3 options, onto the custom row + assert prompt.activate() == "custom" + assert prompt.pending is not None and prompt.pending.custom + assert prompt.custom("anything") == "advanced" + assert await future == [{"question": "Which approach?", "answers": ["anything"]}] + + +async def test_question_prompt_back_from_custom_mode(): + prompt = QuestionPrompt() + future = prompt.request(QUESTIONS) + prompt.move(3) + prompt.activate() + assert prompt.back() == "back" + assert prompt.pending is not None and not prompt.pending.custom + assert prompt.back() == "ignored" # no longer in custom mode + prompt.dismiss() + await future + + +async def test_question_prompt_custom_answer_multi_keeps_toggles(): + prompt = QuestionPrompt() + future = prompt.request([{**QUESTIONS[0], "multi_select": True}]) + assert prompt.activate() == "toggled" # Alpha + assert prompt.custom("something else") == "advanced" + assert await future == [{"question": "Which approach?", "answers": ["Alpha", "something else"]}] + + +async def test_question_prompt_custom_blank_ignored(): + prompt = QuestionPrompt() + future = prompt.request(QUESTIONS) + assert prompt.custom(" ") == "ignored" + assert prompt.is_pending + prompt.dismiss() + await future + + async def test_question_prompt_sequential_then_dismiss(): prompt = QuestionPrompt() second = { @@ -87,7 +147,7 @@ async def test_question_prompt_sequential_then_dismiss(): "multi_select": False, } future = prompt.request([QUESTIONS[0], second]) - assert prompt.select(0) == "advanced" + assert prompt.activate() == "advanced" # first option, default highlight assert prompt.current() == second # advanced to the second question prompt.dismiss() assert await future == [ @@ -141,20 +201,21 @@ async def test_request_question_renders_and_restores_state(tmp_path, monkeypatch await wait_for(lambda: "Which approach?" in out.getvalue()) assert app._status.state is StatusLineState.QUESTION assert app._question.is_pending - assert app._question.select(1) == "advanced" + assert app._question.move(1) == "moved" + assert app._question.enter() == "advanced" assert await task == [{"question": "Which approach?", "answers": ["Beta"]}] assert app._status.state is StatusLineState.RUNNING assert not app._question.is_pending -async def test_pipe_question_digit_answers(tmp_path, monkeypatch): - """Full flow: model calls ask_user, user presses '2', run completes.""" +async def test_pipe_question_arrow_answers(tmp_path, monkeypatch): + """Full flow: model calls ask_user, user picks with arrows, run completes.""" app, out = make_app(tmp_path, monkeypatch, ask_script(QUESTIONS)) with create_pipe_input() as inp: inp.send_text("help me choose\r") task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) await wait_for(lambda: "Which approach?" in out.getvalue()) - inp.send_text("2") + inp.send_text("\x1b[B\r") # down to Beta, enter await wait_for(lambda: "answered" in out.getvalue()) # turn fully done inp.send_text("/quit\r") assert await task == 0 @@ -162,6 +223,52 @@ async def test_pipe_question_digit_answers(tmp_path, monkeypatch): assert '"answers":["Beta"]' in rendered # tool result content +async def test_pipe_question_custom_answer(tmp_path, monkeypatch): + """Typing an answer during the picker submits it instead of an option.""" + app, out = make_app(tmp_path, monkeypatch, ask_script(QUESTIONS)) + with create_pipe_input() as inp: + inp.send_text("help me choose\r") + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: "Which approach?" in out.getvalue()) + inp.send_text("use rust\r") + await wait_for(lambda: "answered" in out.getvalue()) + inp.send_text("/quit\r") + assert await task == 0 + assert '"answers":["use rust"]' in out.getvalue() + + +async def test_pipe_question_custom_row(tmp_path, monkeypatch): + """Selecting the picker's custom row, then typing, submits the text.""" + app, out = make_app(tmp_path, monkeypatch, ask_script(QUESTIONS)) + with create_pipe_input() as inp: + inp.send_text("help me choose\r") + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: "Which approach?" in out.getvalue()) + inp.send_text("\x1b[B\x1b[B\x1b[B\r") # down onto the custom row, Enter + inp.send_text("use rust\r") + await wait_for(lambda: "answered" in out.getvalue()) + inp.send_text("/quit\r") + assert await task == 0 + assert '"answers":["use rust"]' in out.getvalue() + + +async def test_pipe_question_custom_escape_returns_to_options(tmp_path, monkeypatch): + """Esc while typing backs out to the options, dropping the draft.""" + app, out = make_app(tmp_path, monkeypatch, ask_script(QUESTIONS)) + with create_pipe_input() as inp: + inp.send_text("help me choose\r") + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: "Which approach?" in out.getvalue()) + inp.send_text("\x1b[B\x1b[B\x1b[B\r") # onto the custom row, Enter + inp.send_text("draft") + inp.send_text("\x1b") # back to options, draft dropped + inp.send_text("\x1b[B\r") # down wraps to Alpha, enter selects it + await wait_for(lambda: "answered" in out.getvalue()) + inp.send_text("/quit\r") + assert await task == 0 + assert '"answers":["Alpha"]' in out.getvalue() + + async def test_pipe_question_escape_dismisses(tmp_path, monkeypatch): app, out = make_app(tmp_path, monkeypatch, ask_script(QUESTIONS)) with create_pipe_input() as inp: @@ -184,10 +291,8 @@ async def test_pipe_question_multi_select_toggle_confirm(tmp_path, monkeypatch): inp.send_text("help me choose\r") task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) await wait_for(lambda: "Which approach?" in out.getvalue()) - inp.send_text("1") - await wait_for(lambda: "[x] 1. Alpha" in out.getvalue()) - inp.send_text("3") - await wait_for(lambda: "[x] 3. Gamma" in out.getvalue()) + inp.send_text(" ") # toggle Alpha + inp.send_text("\x1b[B\x1b[B ") # down to Gamma, toggle it inp.send_text("\r") # enter confirms await wait_for(lambda: "answered" in out.getvalue()) inp.send_text("/quit\r")