Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion scripts/tui-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)"
Expand All @@ -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 <args...>` and read `$RUN_OUT` / `$RUN_RC`.

Expand Down
62 changes: 62 additions & 0 deletions scripts/tui-tests/cases/07_paste_burst.sh
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions scripts/tui-tests/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -165,6 +166,21 @@ tui_keys() { _tmux send-keys -t "$TUI_SESSION" "$@"; }
# tui_type <literal-string> (typed verbatim, no Enter)
tui_type() { _tmux send-keys -t "$TUI_SESSION" -l -- "$1"; }

# tui_paste <literal-string>
# 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}"; }

Expand Down
12 changes: 8 additions & 4 deletions src-rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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).
Expand Down
16 changes: 15 additions & 1 deletion src-rust/crates/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +6236 to +6240
}
}
_ => {
// Non-character key — save it for replay.
self.pending_key = Some(k);
Expand Down