From 65dda5c2b5004d16cd1128d9f06f6856cbe679e4 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:53:56 -0500 Subject: [PATCH 1/2] fix(tui): stop the paste burst from swallowing Enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit try_detect_paste_burst drains the event queue into one paste when a character arrives with more input already behind it. It appended KeyCode::Enter to the buffer as a literal '\n', so the keystroke that submits the line was consumed as text: the message landed in the prompt with a trailing newline and was never sent. Enter appeared dead and the conversation could not advance past its first turn. This fires deterministically whenever a whole line arrives at once — a paste in a terminal without bracketed paste, or a host app writing `text + "\n"` into the pane's PTY in a single write. Peek before absorbing: a newline with more input behind it is an interior line break of a multi-line paste and stays in the text; a newline with nothing behind it ends the line, so it is stashed in pending_key and replayed to the caller, which submits. The reason the coalescing exists — a multi-line paste arriving as several separate messages — still holds. Also gate the CLI's Enter handling on any_blocking_modal_open() rather than any_modal_open(). The latter also counts passive banners that render as overlays but never take a keystroke; while one was visible, Enter was neither submitted nor queued. Latent today, same failure mode. Co-Authored-By: Claude Opus 5 (1M context) --- src-rust/crates/cli/src/main.rs | 12 ++++++++---- src-rust/crates/tui/src/app.rs | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) 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); From 7eb7f3ce0db304ff35a0e9f5e6bac6aa0366daae Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:40:59 -0500 Subject: [PATCH 2/2] test(tui): cover the paste-burst Enter regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds cases/07_paste_burst.sh to the tmux-driven interactive suite, guarding the fix in 65dda5c. The bug only reproduces when a whole line lands in the pty in one write — how a host application drives an embedded pane, and how a terminal without bracketed paste delivers a clipboard paste. Typing keystroke by keystroke escapes it entirely, so none of the existing cases could have caught it. New tui_paste helper delivers a string in a single write via tmux set-buffer/paste-buffer. Deliberately not paste-buffer -p: bracketed paste arrives as one Paste event and bypasses the burst detector, which is the code under test. Two assertions, both offline (the payload is /help, handled inside the TUI): 1. A burst-delivered line plus its Enter submits — the overlay opens. 2. A burst whose newline is interior does NOT submit. The first line is anchored to the composer prompt glyph, so it fails if an interior newline ever starts submitting: the text would move to the transcript and the prompt would carry the second line instead. This is the guard on why the coalescing exists at all. Verified against the pre-fix binary: assertion 1 fails with '/help' stranded on the prompt row. Full suite 44/44 with the fix in place. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/tui-tests/README.md | 10 +++- scripts/tui-tests/cases/07_paste_burst.sh | 62 +++++++++++++++++++++++ scripts/tui-tests/lib.sh | 16 ++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100755 scripts/tui-tests/cases/07_paste_burst.sh 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}"; }