diff --git a/scripts/tui-tests/README.md b/scripts/tui-tests/README.md index ce30ecd..1e77df2 100644 --- a/scripts/tui-tests/README.md +++ b/scripts/tui-tests/README.md @@ -54,6 +54,7 @@ screen, and runs `run.sh`. No secrets or network access required. | `cases/04_help_overlay.sh` | Help overlay | `?` opens keybinding + command reference, `Esc` closes it | | `cases/05_input_editing.sh` | Prompt input | typed text echoes into the buffer, `Ctrl+U` clears it | | `cases/06_quit.sh` | Shutdown | `Ctrl+C` twice exits cleanly back to the shell | +| `cases/07_paste_burst.sh` | Paste burst | a line delivered in one write still submits; interior newlines coalesce without submitting | ## Configuration @@ -83,6 +84,7 @@ tc_mything() { tui_keys C-k # send a binding (tmux key tokens) tui_type "some text" # type literal characters + tui_paste "line"$'\r' # deliver bytes in ONE write (paste / host pane) tui_settle # let it redraw local s; s="$(tui_capture)" @@ -92,8 +94,14 @@ tc_mything() { } ``` +`tui_type` sends characters the way a human types them; `tui_paste` delivers +the whole string in a single write, the way a host application drives an +embedded pane or a terminal without bracketed paste delivers a clipboard +paste. The distinction matters: only the second shape engages the TUI's +paste-burst detector. + Helpers from [`lib.sh`](lib.sh): `tui_start` / `tui_stop`, `tui_keys`, -`tui_type`, `tui_settle`, `tui_capture`, `wait_for`, and the assertions +`tui_type`, `tui_paste`, `tui_settle`, `tui_capture`, `wait_for`, and the assertions `assert_contains` / `assert_absent` / `assert_matches` / `assert_eq`. For headless checks, call `run_bin ` and read `$RUN_OUT` / `$RUN_RC`. diff --git a/scripts/tui-tests/cases/07_paste_burst.sh b/scripts/tui-tests/cases/07_paste_burst.sh new file mode 100755 index 0000000..26b3505 --- /dev/null +++ b/scripts/tui-tests/cases/07_paste_burst.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Paste burst: a whole line delivered in one write must still submit. +# +# try_detect_paste_burst drains the event queue into a single paste whenever a +# character arrives with more input already behind it. It used to append the +# terminating Enter to that buffer as a literal '\n', so the keystroke that +# submits the line was consumed as text: the message sat in the composer with a +# trailing newline and was never sent. Enter looked dead and the conversation +# could not advance past its first turn. +# +# That shape is not exotic — it is how a host application drives an embedded +# pane (one write of `text + "\r"`) and how a terminal without bracketed paste +# delivers a clipboard paste. Typing by hand escapes it, which is why the bug +# hid for so long. +# +# `/help` is used as the payload because it is handled entirely inside the TUI: +# no network, no credentials, no model call. + +register_case tc_paste_burst + +tc_paste_burst() { + describe "Paste burst preserves Enter" + if ! have_tmux; then _skip "tmux not installed"; return 0; fi + tui_start || { tui_stop; return 0; } + + # ---- 1. A line plus its Enter, delivered in one write, must submit ------- + tui_paste "/help"$'\r' + # The overlay is tall; poll for a late-rendered item so the assertion does + # not race the draw (same guard as the help-overlay case). + if wait_for "/permissions"; then + _pass "burst-delivered line submits (help overlay opened)" + local s; s="$(tui_capture)" + assert_contains "$s" "Toggle help" "burst submit reached the slash-command path" + else + _fail "burst-delivered line submits (help overlay never opened)" "$(tui_capture)" + fi + + tui_keys Escape + wait_absent "Toggle help" 5 + + # ---- 2. A multi-line burst with no trailing Enter must NOT submit -------- + # Interior newlines belong in the text: coalescing them is the whole reason + # the burst detector exists (without it a pasted block arrives as several + # separate messages). With no trailing Enter, nothing may be sent. + local a="ALPHAqzx" b="BRAVOqzx" + tui_paste "$a"$'\r'"$b" + if wait_for "$b" 8; then + local s2; s2="$(tui_capture)" + # Anchoring the first line to the composer prompt is what makes this + # discriminating: an unsent buffer renders as "> ALPHAqzx" on the prompt + # row, whereas a submitted one moves into the transcript and the prompt + # would carry the second line instead. + assert_contains "$s2" "$TUI_PROMPT $a" "interior newline did not submit the first line" + assert_contains "$s2" "$b" "multi-line burst keeps the second line" + else + _fail "multi-line burst lands in the composer" "$(tui_capture)" + fi + + tui_stop +} diff --git a/scripts/tui-tests/lib.sh b/scripts/tui-tests/lib.sh index 7d3d25f..8f27892 100755 --- a/scripts/tui-tests/lib.sh +++ b/scripts/tui-tests/lib.sh @@ -21,6 +21,7 @@ set -uo pipefail : "${TUI_WIDTH:=120}" # terminal columns : "${TUI_HEIGHT:=40}" # terminal rows : "${TUI_BOOT_STRING:=Coven v}" # string proving the TUI has drawn +: "${TUI_PROMPT:=❯}" # composer prompt glyph (anchors input-buffer assertions) : "${TUI_WAIT_TIMEOUT:=20}" # seconds to wait for a string : "${TUI_POLL_INTERVAL:=0.4}" # seconds between capture polls : "${TUI_SETTLE:=0.6}" # seconds to let a keypress redraw @@ -165,6 +166,21 @@ tui_keys() { _tmux send-keys -t "$TUI_SESSION" "$@"; } # tui_type (typed verbatim, no Enter) tui_type() { _tmux send-keys -t "$TUI_SESSION" -l -- "$1"; } +# tui_paste +# Delivers the string to the pane in a SINGLE write, so every byte lands in the +# child's pty at once. That is how a host application drives an embedded pane +# (it writes `text + "\r"` in one go) and how a terminal without bracketed +# paste delivers a clipboard paste. It is also the only way to exercise the +# TUI's paste-burst detector, which only engages when more input is already +# queued behind the first character. +# +# Deliberately NOT `paste-buffer -p`: bracketed paste arrives as a single Paste +# event and bypasses the burst detector entirely, which is the code under test. +tui_paste() { + _tmux set-buffer -b tui_burst -- "$1" + _tmux paste-buffer -b tui_burst -t "$TUI_SESSION" -d +} + # tui_settle [seconds] tui_settle() { sleep "${1:-$TUI_SETTLE}"; } diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index e8f6cc7..c5cb844 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -2560,7 +2560,7 @@ async fn run_interactive( crossterm::event::KeyModifiers::NONE | crossterm::event::KeyModifiers::SHIFT ) { if let KeyCode::Char(c) = key.code { - if app.prompt_is_accepting_text() && !app.any_modal_open() { + if app.prompt_is_accepting_text() && !app.any_blocking_modal_open() { if let Some(burst) = app.try_detect_paste_burst(c) { app.handle_paste_data(burst); app.refresh_prompt_input(); @@ -2579,9 +2579,13 @@ async fn run_interactive( continue; } - // Enter => submit input (but NOT when ANY dialog/overlay is open — - // dialogs handle their own Enter in handle_key_event). - let any_dialog_open = app.any_modal_open(); + // Enter => submit input (but NOT when a dialog/overlay that + // captures input is open — those handle their own Enter in + // handle_key_event). Gate on the *blocking* predicate: + // `any_modal_open` also counts passive banners that render + // as overlays but never take a keystroke, and those would + // silently eat Enter and strand the conversation. + let any_dialog_open = app.any_blocking_modal_open(); if key.code == KeyCode::Enter && app.is_streaming && !any_dialog_open { // Queue the message: it will auto-submit once the // current turn finishes (issue #149). diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 33aee9b..b173594 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -6225,7 +6225,21 @@ impl App { match crossterm::event::read() { Ok(Event::Key(k)) if k.kind == KeyEventKind::Press => match k.code { KeyCode::Char(c) => buf.push(c), - KeyCode::Enter => buf.push('\n'), + KeyCode::Enter => { + // A newline with more input behind it is an interior + // line break of a multi-line paste, so it belongs in + // the text. A newline with nothing behind it is the + // keystroke that ends the line — replay it so the + // caller submits. Swallowing it here is how a pasted + // (or programmatically typed) message ends up sitting + // in the prompt with a trailing '\n', never sent. + if crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { + buf.push('\n'); + } else { + self.pending_key = Some(k); + break; + } + } _ => { // Non-character key — save it for replay. self.pending_key = Some(k);