From 2dbdfd9a17700f6512fc8e3b445124bc3469c3f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 00:55:04 +0000 Subject: [PATCH] dictate: make single-utterance the default, drop the interactive loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assembly dictate` now always auto-starts recording and exits after one utterance — the old idle-prompt loop (idle until Enter, record, transcribe, repeat) is gone, so dictation behaves the same whether stdout is a tty or a pipe. `--once` is kept as a hidden, deprecated no-op that warns it can be dropped, so existing scripts don't break. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RFDkryY7tiJMLs93ednr1G --- README.md | 2 +- aai_cli/commands/dictate/__init__.py | 25 ++-- aai_cli/commands/dictate/_exec.py | 79 ++++-------- .../test_snapshots_help_run.ambr | 21 ++-- tests/test_dictate_exec.py | 118 ++++++++---------- 5 files changed, 101 insertions(+), 144 deletions(-) diff --git a/README.md b/README.md index e3117282..7b51f88b 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ That's it. Run `assembly onboard` for a guided tour, or see [Installation](#-ins | :--- | :--- | | `assembly transcribe` | Transcribe files, URLs, YouTube/podcast pages, podcast RSS feeds, directories, globs, or bucket storage (`s3://`, `gs://`, `az://`) — with speaker labels, PII redaction, summarization, SRT/VTT captions, and resumable batch runs | | `assembly stream` | Real-time transcription from your microphone, a file, or a URL — on macOS it can capture system audio too | -| `assembly dictate` | Push-to-talk dictation: press Enter to record, Enter again for instant text (Sync STT API, up to 120 s per utterance) | +| `assembly dictate` | Push-to-talk dictation: recording starts immediately, press Enter for instant text (Sync STT API, up to 120 s per utterance) | | `assembly agent` | Full-duplex spoken conversation with a voice agent, right in your terminal | | `assembly agent-cascade` | Same live conversation, but wired client-side from Streaming STT + the LLM Gateway + streaming TTS, like the `agent-cascade` starter (sandbox-only) | | `assembly speak` | Synthesize text to speech over the streaming-TTS WebSocket (sandbox-only) | diff --git a/aai_cli/commands/dictate/__init__.py b/aai_cli/commands/dictate/__init__.py index b821b850..d99e8a3d 100644 --- a/aai_cli/commands/dictate/__init__.py +++ b/aai_cli/commands/dictate/__init__.py @@ -22,10 +22,9 @@ rich_help_panel=help_panels.TRANSCRIPTION, epilog=examples_epilog( [ - ("Dictate: Enter starts a recording, Enter transcribes it", "assembly dictate"), - ("One utterance, then exit", "assembly dictate --once"), + ("Dictate one utterance: recording starts, Enter transcribes it", "assembly dictate"), ( - "Pipe one utterance into another command", + "Pipe the utterance into another command", 'assembly dictate | assembly llm "write a conventional commit"', ), ("Dictate in Spanish", "assembly dictate --language es"), @@ -33,7 +32,7 @@ "Bias recognition toward tricky terms", "assembly dictate --word-boost AssemblyAI --word-boost LeMUR", ), - ("One JSON object per utterance", "assembly dictate --json"), + ("Emit the utterance as a JSON object", "assembly dictate --json"), ("Pipe the bare transcript onward", "assembly dictate -o text | assembly llm -f"), ] ), @@ -55,7 +54,12 @@ def dictate( None, "--word-boost", help="Bias recognition toward a term (repeatable)" ), device: int | None = typer.Option(None, "--device", help="Microphone device index"), - once: bool = typer.Option(False, "--once", help="Record one utterance immediately, then exit"), + once: bool = typer.Option( + False, + "--once", + hidden=True, + help="Deprecated: recording one utterance and exiting is now the default", + ), max_seconds: float = typer.Option( float(MAX_AUDIO_SECONDS), "--max-seconds", @@ -73,12 +77,11 @@ def dictate( ) -> None: """Push-to-talk dictation: record the mic, get the transcript back - Press Enter (or Space) to start recording and press it again to stop; the - utterance is sent to the AssemblyAI Sync API and the transcript prints - immediately — no polling. Press q (or Esc/Ctrl-C) to finish. Each utterance - can be up to 120 seconds long. With --once, or when stdout is piped, - recording starts immediately and dictate exits after one utterance so the - transcript flows to the next command. + Recording starts immediately; press Enter (or Space) to stop and the + utterance is sent to the AssemblyAI Sync API — the transcript prints right + away (no polling) and dictate exits, so it flows straight to the next + command in a pipe. The recording can be up to 120 seconds long. Press + Ctrl-C to cancel without transcribing. """ opts = dictate_exec.DictateOptions( language=language, diff --git a/aai_cli/commands/dictate/_exec.py b/aai_cli/commands/dictate/_exec.py index bcdb0f67..fba52d81 100644 --- a/aai_cli/commands/dictate/_exec.py +++ b/aai_cli/commands/dictate/_exec.py @@ -1,11 +1,12 @@ """Run logic for `assembly dictate`: the options/run split (see AGENTS.md). -Push-to-talk dictation over the Sync STT API: wait for a hotkey, record the -microphone until the hotkey is pressed again (or the duration cap), POST the -utterance to the Sync API, print the transcript, repeat. The command module -(aai_cli/commands/dictate/__init__.py) only parses argv into a ``DictateOptions``; tests -drive the session by constructing options directly and injecting the key/mic/ -HTTP boundaries, with no CliRunner argv round-trip and no real terminal. +Push-to-talk dictation over the Sync STT API: recording starts immediately, +runs until a hotkey is pressed (or the duration cap), then the utterance is +POSTed to the Sync API, the transcript is printed, and dictate exits. The +command module (aai_cli/commands/dictate/__init__.py) only parses argv into a +``DictateOptions``; tests drive the session by constructing options directly and +injecting the key/mic/HTTP boundaries, with no CliRunner argv round-trip and no +real terminal. """ from __future__ import annotations @@ -15,7 +16,7 @@ import typer from aai_cli.app.context import AppState -from aai_cli.core import choices, errors, stdio, sync_stt +from aai_cli.core import choices, errors, sync_stt from aai_cli.core.config_builder import split_csv from aai_cli.core.hotkey import CTRL_C, CTRL_D, ESC, TerminalKeys from aai_cli.core.microphone import MicrophoneSource @@ -27,10 +28,9 @@ TARGET_RATE = 16000 _BYTES_PER_SECOND = TARGET_RATE * 2 # PCM16 mono -# Enter or Space toggles recording; q / Esc / Ctrl-D ends the session at the -# idle prompt (Ctrl-C works anywhere — cbreak mode keeps SIGINT delivery). -TOGGLE_KEYS = frozenset({"\r", "\n", " "}) -QUIT_KEYS = frozenset({"q", "Q", ESC, CTRL_C, CTRL_D}) +# Enter or Space stops the (auto-started) recording; q / Esc / Ctrl-D also stop +# it (Ctrl-C cancels — cbreak mode keeps SIGINT delivery). +STOP_KEYS = frozenset({"\r", "\n", " ", "q", "Q", ESC, CTRL_C, CTRL_D}) @dataclass(frozen=True) @@ -42,6 +42,9 @@ class DictateOptions: prompt: str | None word_boost: list[str] | None device: int | None + # Deprecated no-op: recording one utterance and exiting is now the default, + # so --once is kept only so existing scripts don't break (it warns + does + # nothing). See run_dictate. once: bool max_seconds: float # -o/--output: text (the default bare-transcript shape) or json (== --json). @@ -78,8 +81,8 @@ def _record(keys: TerminalKeys, mic: MicrophoneSource, *, max_seconds: float) -> pcm += chunk if len(pcm) >= int(max_seconds * _BYTES_PER_SECOND): break - # None (no key pending) is simply not in either set. - if keys.read(0) in TOGGLE_KEYS | QUIT_KEYS: + # None (no key pending) is simply not in the set. + if keys.read(0) in STOP_KEYS: break finally: # MicrophoneSource yields from a generator whose cleanup releases the @@ -160,34 +163,6 @@ def _capture_and_transcribe( _transcribe_utterance(api_key, pcm, opts, state, json_mode=json_mode) -def _session( - keys: TerminalKeys, - api_key: str, - opts: DictateOptions, - state: AppState, - *, - json_mode: bool, - single: bool, -) -> None: - """Drive recording: one auto-started utterance, or the idle-toggle loop. - - ``single`` (a piped stdout or --once) starts recording immediately so a - one-off capture takes a single keystroke to stop and then exits — which - closes a piped stdout and unblocks the downstream command. Otherwise it's - the interactive loop: idle until a toggle key, record, transcribe, repeat. - """ - if single: - _capture_and_transcribe(keys, api_key, opts, state, json_mode=json_mode) - return - while True: - key = keys.read(None) - if key is None or key in QUIT_KEYS: - return - if key not in TOGGLE_KEYS: - continue - _capture_and_transcribe(keys, api_key, opts, state, json_mode=json_mode) - - def run_dictate(opts: DictateOptions, state: AppState, *, json_mode: bool) -> None: """Execute one `assembly dictate` invocation from already-parsed flags.""" # Fold -o/--output into json_mode (-o json == --json) and reject the @@ -209,20 +184,18 @@ def run_dictate(opts: DictateOptions, state: AppState, *, json_mode: bool) -> No "state the language inside the prompt.", json_mode=json_mode, ) - # A piped stdout (`assembly dictate | assembly llm …`) only closes when - # dictate exits, so a looping session would keep the downstream consumer - # blocked on stdin forever. Single-shot mode (piped or --once) records - # one utterance and exits so the transcript drains to the next stage. - single = opts.once or not stdio.stdout_is_tty() - if not single: - # Only the interactive loop needs a start prompt; single-shot - # auto-starts and announces "● Recording" when the mic opens. - _note( - "Press Enter to start recording, Enter again to transcribe. q quits.", + if opts.once and not state.quiet: + # Deprecation trap, not removal: --once still parses so old scripts + # don't break, but recording one utterance and exiting is now the + # default, so the flag does nothing — say so once (mirrors `login`). + output.emit_warning( + "--once is now the default and can be omitted.", json_mode=json_mode, - quiet=state.quiet, ) - _session(keys, api_key, opts, state, json_mode=json_mode, single=single) + # Recording auto-starts and exits after one utterance: a single + # keystroke stops the capture, which also closes a piped stdout so + # `assembly dictate | assembly llm …` unblocks the downstream command. + _capture_and_transcribe(keys, api_key, opts, state, json_mode=json_mode) except KeyboardInterrupt: # Ctrl-C cancels dictation, so it exits 130 (cancel) — distinct from `q`, which # ends the session normally (exit 0). The with-block above already restored the diff --git a/tests/__snapshots__/test_snapshots_help_run.ambr b/tests/__snapshots__/test_snapshots_help_run.ambr index 0e0d12db..84a1ddf0 100644 --- a/tests/__snapshots__/test_snapshots_help_run.ambr +++ b/tests/__snapshots__/test_snapshots_help_run.ambr @@ -350,12 +350,11 @@ Push-to-talk dictation: record the mic, get the transcript back - Press Enter (or Space) to start recording and press it again to stop; the - utterance is sent to the AssemblyAI Sync API and the transcript prints - immediately — no polling. Press q (or Esc/Ctrl-C) to finish. Each utterance - can be up to 120 seconds long. With --once, or when stdout is piped, - recording starts immediately and dictate exits after one utterance so the - transcript flows to the next command. + Recording starts immediately; press Enter (or Space) to stop and the + utterance is sent to the AssemblyAI Sync API — the transcript prints right + away (no polling) and dictate exits, so it flows straight to the next + command in a pipe. The recording can be up to 120 seconds long. Press + Ctrl-C to cancel without transcribing. ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --language TEXT ISO 639-1 language code, │ @@ -368,8 +367,6 @@ │ --word-boost TEXT Bias recognition toward a │ │ term (repeatable) │ │ --device INTEGER Microphone device index │ - │ --once Record one utterance │ - │ immediately, then exit │ │ --max-seconds FLOAT RANGE Auto-stop a recording │ │ [1.0<=x<=120.0] after this many seconds │ │ [default: 120.0] │ @@ -384,17 +381,15 @@ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples - Dictate: Enter starts a recording, Enter transcribes it + Dictate one utterance: recording starts, Enter transcribes it $ assembly dictate - One utterance, then exit - $ assembly dictate --once - Pipe one utterance into another command + Pipe the utterance into another command $ assembly dictate | assembly llm "write a conventional commit" Dictate in Spanish $ assembly dictate --language es Bias recognition toward tricky terms $ assembly dictate --word-boost AssemblyAI --word-boost LeMUR - One JSON object per utterance + Emit the utterance as a JSON object $ assembly dictate --json Pipe the bare transcript onward $ assembly dictate -o text | assembly llm -f diff --git a/tests/test_dictate_exec.py b/tests/test_dictate_exec.py index 112c4a71..c7e4c3c1 100644 --- a/tests/test_dictate_exec.py +++ b/tests/test_dictate_exec.py @@ -71,10 +71,6 @@ def seams(monkeypatch): harness = {"keys": FakeKeys([]), "chunks": [CHUNK, CHUNK], "mic": {}, "calls": []} monkeypatch.setattr(dictate_exec, "TerminalKeys", lambda: harness["keys"]) - # Default to interactive stdout (a real terminal); the piped tests flip this. - # capsys leaves stdout a non-tty, which would otherwise force single-utterance - # mode and end every looping session after one utterance. - monkeypatch.setattr(dictate_exec.stdio, "stdout_is_tty", lambda: True) def fake_mic(*, target_rate, device=None, on_open=None): harness["mic"].update(target_rate=target_rate, device=device) @@ -105,10 +101,10 @@ def test_options_are_immutable(): setattr(DICTATE_DEFAULTS, field_name, None) -def test_hotkey_records_then_prints_bare_transcript(seams, capsys): - # Enter starts; the in-recording poll sees nothing after chunk 1, Enter after - # chunk 2 stops; q at the idle prompt quits. - seams["keys"] = FakeKeys(["\r", None, "\r", "q"]) +def test_records_then_prints_bare_transcript(seams, capsys): + # Recording auto-starts; the in-recording poll sees nothing after chunk 1, + # Enter after chunk 2 stops the capture, then dictate exits. + seams["keys"] = FakeKeys([None, "\r"]) _run() # Both chunks were captured and uploaded as one utterance at the resampled rate. assert seams["calls"] == [ @@ -125,18 +121,18 @@ def test_hotkey_records_then_prints_bare_transcript(seams, capsys): captured = capsys.readouterr() # Human mode: the bare text on stdout (pipe-friendly), not a JSON object. assert captured.out.strip() == "hello world" - # The interactive hints (idle prompt + recording note) go to stderr only. - assert "Press Enter to start recording" in captured.err + # The mic-open note fires on stderr; there is no interactive start prompt. assert "Recording — press Enter to stop" in captured.err + assert "start recording" not in captured.err assert seams["mic"] == {"target_rate": 16000, "device": None} assert seams["keys"].entered and seams["keys"].exited # terminal restored - # Idle waits block (None); in-recording polls must not wait at all (0), or - # every audio chunk would stall behind the keyboard. - assert seams["keys"].timeouts == [None, 0, 0, None] + # Recording auto-started, so every read is the zero-timeout in-recording poll + # — no blocking idle read(None) waiting for a start keypress. + assert seams["keys"].timeouts == [0, 0] def test_json_mode_emits_one_ndjson_object_per_utterance(seams, capsys): - seams["keys"] = FakeKeys(["\r", "\r"]) + seams["keys"] = FakeKeys(["\r"]) _run(json_mode=True) captured = capsys.readouterr() assert json.loads(captured.out) == { @@ -153,14 +149,14 @@ def test_json_mode_emits_one_ndjson_object_per_utterance(seams, capsys): def test_output_json_folds_into_ndjson_without_the_json_flag(seams, capsys): # -o json must enable NDJSON on its own (json_mode stays the --json flag, # which is False here) — proving the -o/--output resolution runs. - seams["keys"] = FakeKeys(["\r", "\r"]) + seams["keys"] = FakeKeys(["\r"]) _run(dataclasses.replace(DICTATE_DEFAULTS, output_field=choices.TextOrJson.json)) assert json.loads(capsys.readouterr().out)["text"] == "hello world" def test_output_text_emits_bare_transcript(seams, capsys): # -o text is the explicit spelling of the human default: bare text, no JSON. - seams["keys"] = FakeKeys(["\r", "\r"]) + seams["keys"] = FakeKeys(["\r"]) _run(dataclasses.replace(DICTATE_DEFAULTS, output_field=choices.TextOrJson.text)) out = capsys.readouterr().out assert out.strip() == "hello world" @@ -179,59 +175,56 @@ def test_output_text_conflicts_with_json_flag(seams): def test_quiet_suppresses_the_interactive_hints(seams, capsys): - seams["keys"] = FakeKeys(["\r", "\r"]) + seams["keys"] = FakeKeys(["\r"]) _run(state=AppState(quiet=True)) captured = capsys.readouterr() assert captured.out.strip() == "hello world" assert captured.err == "" -def test_once_exits_after_a_single_utterance(seams): - seams["keys"] = FakeKeys(["\r", "\r", "\r", "\r", "\r", "\r"]) - _run(dataclasses.replace(DICTATE_DEFAULTS, once=True)) - assert len(seams["calls"]) == 1 - # The session ended on --once, not by draining the key script. - assert seams["keys"].script - - -def test_piped_stdout_auto_starts_one_utterance_then_exits(seams, monkeypatch, capsys): - # `assembly dictate | assembly llm …`: stdout is a pipe, not a tty. A looping - # session would keep the pipe open and hang the consumer, so recording - # auto-starts, the first Enter stops it, and the session exits on its own. - monkeypatch.setattr(dictate_exec.stdio, "stdout_is_tty", lambda: False) - # No leading toggle to *start* and no quit key: a single read(0) pops the - # Enter that stops the auto-started recording, then dictate exits. +def test_auto_starts_recording_then_exits_after_one_utterance(seams, capsys): + # Recording auto-starts: a single read(0) pops the Enter that stops the + # capture, then dictate exits — no blocking idle read(None) waiting for a + # start keypress, and the rest of the key script is left undrained. seams["keys"] = FakeKeys(["\r", "\r", "\r"]) _run() assert len(seams["calls"]) == 1 - # Ended on the single-shot, not by draining the key script. - assert seams["keys"].script - # Auto-start: the only key read is the zero-timeout in-recording poll — no - # blocking idle read(None) waiting for a start keypress. + assert seams["keys"].script # ended on the single utterance, not by draining keys assert seams["keys"].timeouts == [0] captured = capsys.readouterr() assert captured.out.strip() == "hello world" - # The mic-open note fires immediately; the interactive start prompt is absent. + # The mic-open note fires immediately; there is no interactive start prompt. assert "Recording — press Enter to stop" in captured.err assert "start recording" not in captured.err -@pytest.mark.parametrize("quit_key", ["q", "Q", "\x1b", "\x04"]) -def test_quit_keys_end_the_session_without_recording(seams, quit_key, capsys): - seams["keys"] = FakeKeys([quit_key, "\r", "\r"]) - _run() - assert seams["calls"] == [] - assert capsys.readouterr().out == "" +def test_once_flag_is_a_deprecated_noop_that_warns(seams, capsys): + # --once is kept only so old scripts don't break: it does nothing (single + # utterance is the default) but warns that it can be dropped. + seams["keys"] = FakeKeys(["\r"]) + _run(dataclasses.replace(DICTATE_DEFAULTS, once=True)) + assert len(seams["calls"]) == 1 + assert "--once is now the default" in capsys.readouterr().err -def test_unbound_keys_are_ignored_at_the_idle_prompt(seams): - seams["keys"] = FakeKeys(["x", "7", "q"]) +def test_once_warning_is_silenced_by_quiet(seams, capsys): + seams["keys"] = FakeKeys(["\r"]) + _run(dataclasses.replace(DICTATE_DEFAULTS, once=True), state=AppState(quiet=True)) + assert "--once" not in capsys.readouterr().err + + +@pytest.mark.parametrize("quit_key", ["q", "Q", "\x1b", "\x04"]) +def test_quit_keys_stop_recording_and_transcribe(seams, quit_key): + # No idle prompt anymore: q / Esc / Ctrl-D stop the auto-started recording + # and the captured utterance is still transcribed. + seams["keys"] = FakeKeys([quit_key]) _run() - assert seams["calls"] == [] + assert len(seams["calls"]) == 1 + assert seams["calls"][0]["pcm"] == CHUNK # stopped after the first chunk -def test_space_also_toggles_recording(seams): - seams["keys"] = FakeKeys([" ", " ", "q"]) +def test_space_also_stops_recording(seams): + seams["keys"] = FakeKeys([" "]) _run() assert len(seams["calls"]) == 1 @@ -239,23 +232,16 @@ def test_space_also_toggles_recording(seams): def test_unbound_keys_during_recording_do_not_stop_capture(seams): # A stray keystroke mid-utterance is ignored; only Enter/Space (or a quit # key) ends the capture. - seams["keys"] = FakeKeys(["\r", "x", "\r", "q"]) + seams["keys"] = FakeKeys(["x", "\r"]) seams["chunks"] = [CHUNK, CHUNK, CHUNK] _run() assert seams["calls"][0]["pcm"] == CHUNK + CHUNK -def test_quit_key_during_recording_still_transcribes_the_utterance(seams): - seams["keys"] = FakeKeys(["\r", "q"]) - _run() - assert len(seams["calls"]) == 1 - assert seams["calls"][0]["pcm"] == CHUNK # stopped after the first chunk - - def test_recording_stops_at_the_duration_cap(seams): # 0.2 s at 16 kHz PCM16 = 6400 bytes = exactly two chunks; the poll never # reports a key, so only the cap can stop the capture. - seams["keys"] = FakeKeys(["\r"]) + seams["keys"] = FakeKeys([]) seams["chunks"] = [CHUNK] * 5 _run(dataclasses.replace(DICTATE_DEFAULTS, max_seconds=0.2)) assert len(seams["calls"]) == 1 @@ -273,7 +259,7 @@ def chunk_gen(): finally: closed.append(True) - seams["keys"] = FakeKeys(["\r", "\r", "q"]) + seams["keys"] = FakeKeys(["\r"]) seams["chunks"] = chunk_gen() _run() assert closed == [True] # the device-releasing cleanup ran at stop, not at GC @@ -281,7 +267,7 @@ def chunk_gen(): @pytest.mark.parametrize("size", [200, 2558]) # 2558: just under the exact 2560-byte floor def test_too_short_recording_is_skipped_with_a_warning(seams, capsys, size): - seams["keys"] = FakeKeys(["\r", "\r", "q"]) + seams["keys"] = FakeKeys(["\r"]) seams["chunks"] = [b"\x01" * size] # below 80 ms of 16 kHz PCM16 (2560 bytes) _run() assert seams["calls"] == [] @@ -291,39 +277,39 @@ def test_too_short_recording_is_skipped_with_a_warning(seams, capsys, size): def test_recording_at_the_80ms_floor_is_transcribed(seams): - seams["keys"] = FakeKeys(["\r", "\r", "q"]) + seams["keys"] = FakeKeys(["\r"]) seams["chunks"] = [b"\x01" * 2560] # exactly 80 ms: allowed, not skipped _run() assert len(seams["calls"]) == 1 def test_language_and_boost_flags_are_forwarded(seams): - seams["keys"] = FakeKeys(["\r", "\r", "q"]) + seams["keys"] = FakeKeys(["\r"]) _run(dataclasses.replace(DICTATE_DEFAULTS, language="es", word_boost=["AssemblyAI"])) assert seams["calls"][0]["language_code"] == "es" assert seams["calls"][0]["word_boost"] == ["AssemblyAI"] def test_comma_separated_languages_become_a_list(seams): - seams["keys"] = FakeKeys(["\r", "\r", "q"]) + seams["keys"] = FakeKeys(["\r"]) _run(dataclasses.replace(DICTATE_DEFAULTS, language="en, es")) assert seams["calls"][0]["language_code"] == ["en", "es"] def test_blank_language_reads_as_unset(seams): - seams["keys"] = FakeKeys(["\r", "\r", "q"]) + seams["keys"] = FakeKeys(["\r"]) _run(dataclasses.replace(DICTATE_DEFAULTS, language=" , ")) assert seams["calls"][0]["language_code"] is None def test_prompt_with_language_warns_that_language_is_ignored(seams, capsys): - seams["keys"] = FakeKeys(["q"]) + seams["keys"] = FakeKeys(["\r"]) _run(dataclasses.replace(DICTATE_DEFAULTS, prompt="Verbatim.", language="es")) assert "--language is ignored when --prompt is set" in capsys.readouterr().err def test_prompt_alone_is_forwarded_without_warning(seams, capsys): - seams["keys"] = FakeKeys(["\r", "\r", "q"]) + seams["keys"] = FakeKeys(["\r"]) _run(dataclasses.replace(DICTATE_DEFAULTS, prompt="Verbatim.")) assert seams["calls"][0]["prompt"] == "Verbatim." assert "ignored" not in capsys.readouterr().err @@ -338,7 +324,7 @@ def fake_status(message, *, json_mode, quiet=False): yield monkeypatch.setattr(dictate_exec.output, "status", fake_status) - seams["keys"] = FakeKeys(["\r", "\r", "q"]) + seams["keys"] = FakeKeys(["\r"]) _run(state=AppState(quiet=True)) assert seen == {"message": "Transcribing…", "json_mode": False, "quiet": True}