Skip to content

fix(tui): stop the paste burst from swallowing Enter - #179

Open
BunsDev wants to merge 2 commits into
mainfrom
fix/paste-burst-swallows-enter
Open

fix(tui): stop the paste burst from swallowing Enter#179
BunsDev wants to merge 2 commits into
mainfrom
fix/paste-burst-swallows-enter

Conversation

@BunsDev

@BunsDev BunsDev commented Aug 11, 2026

Copy link
Copy Markdown
Member

The bug

The CLI could not get past the first turn of a conversation: you type a message, press Enter, and nothing is sent. The transcript stays frozen on turn 1 with the status stuck on "Waiting for you".

Root cause

try_detect_paste_burst (crates/tui/src/app.rs) drains the event queue into a single paste when a character arrives with more input already behind it. It appended KeyCode::Enter to the buffer as a literal '\n':

KeyCode::Char(c) => buf.push(c),
KeyCode::Enter => buf.push('\n'),   // ← the submit keystroke, consumed as text

The caller then hands the burst to handle_paste_data and continues, so the message lands in the prompt buffer with a trailing newline and is never submitted. The keystroke that would have sent it was eaten.

This fires deterministically whenever a whole line arrives at once:

  • a paste in a terminal without bracketed paste (the case this coalescing was originally written for)
  • a host app writing text + "\n" into the pane's PTY in a single write — which is how a desktop shell drives an embedded Coven pane

Typing by hand usually escapes it, since each keystroke arrives alone — which is why the first message of a session often goes through and everything after it appears dead.

The fix

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 is the keystroke that ends the line, so it is stashed in pending_key and replayed to the caller, which submits.

The reason the coalescing exists in the first place — a multi-line paste arriving as several separate messages — still holds: "a\nb\nc\n" now sends as one message instead of three.

Both event loops (crates/cli/src/main.rs and crates/tui/src/app.rs::run) share this helper, so one change covers both.

Second fix, same failure mode

The CLI's Enter handling gated on any_modal_open(), whose own doc comment says it counts passive banners "for rendering purposes only". Those banners never take a keystroke, so while one was visible Enter was neither submitted nor queued — it was dropped. Switched both input gates to any_blocking_modal_open().

This one is latent today (nothing currently calls show() on overage_upsell, voice_mode_notice, or memory_update_notification), but it is the same trap sitting on the same line of code.

Verification

  • cargo check -p claurst-tui -p claurst — clean
  • cargo test -p claurst-tui — 722 passed, 0 failed

Diagnosed by reading the event loop rather than reproducing interactively, so a hands-on confirmation that a pasted or host-injected message now sends is worth doing before merge.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 11, 2026 06:56
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 11, 2026 7:41am

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an input-handling bug where paste-burst coalescing could consume the Enter keystroke, leaving the first prompt turn “stuck” and never submitted. Also aligns CLI Enter/text gating with the app’s “blocking modal” predicate so passive overlay banners don’t drop Enter.

Changes:

  • Update try_detect_paste_burst to treat a terminal Enter as “submit” (replayed via pending_key) when it’s the trailing terminator of a burst.
  • Switch CLI text-input and Enter-submission gates from any_modal_open() to any_blocking_modal_open() to avoid passive banners eating keystrokes.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src-rust/crates/tui/src/app.rs Adjusts paste-burst draining logic to avoid swallowing the submit Enter by stashing it for replay.
src-rust/crates/cli/src/main.rs Uses the blocking-modal predicate when deciding whether to accept prompt text / Enter submission in the interactive loop.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +6236 to +6240
if crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) {
buf.push('\n');
} else {
self.pending_key = Some(k);
break;
) {
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() {
@BunsDev

BunsDev commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Verified in a real pty

The PR description noted this was diagnosed by reading the event loop rather than reproduced. It has now been reproduced and verified end to end, driving the built binary over a pty — the same input path a host pane uses.

Method

Spawned coven-code on a pty (132×46, TERM=xterm-256color) and injected /help, which is handled entirely inside the TUI — no provider auth, no API spend. The signal is unambiguous: if Enter reaches the submit path the help overlay opens and the screen renders Shortcuts & commands; if Enter is swallowed, the literal text sits in the composer.

Negative control

A green test proves nothing unless it can actually detect the bug, so the pre-fix app.rs was checked out (git checkout HEAD~1 -- crates/tui/src/app.rs), rebuilt, and run against the same harness:

--- mode=burst ---
boot bytes=6166  post bytes=332
help overlay opened   : False
'/help' left in prompt: True

❯ /help

FAIL: Enter was swallowed

That is the reported symptom exactly: 332 bytes of output — just the prompt redrawing with /help stranded in it — and no overlay. The harness detects the bug.

With the fix

Mode Input shape Result
burst /help\r in one write — how a pane drives the CLI PASS — overlay opened
typed one keystroke at a time, 150 ms apart — a human PASS — overlay opened
multiline ALPHA\rBRAVO, no trailing Enter PASS — both lines in the composer as one entry, nothing submitted

The burst case went from 332 bytes of output pre-fix to 6728 post-fix.

The multiline row is the guard on the original reason this coalescing exists: interior newlines still survive as text in a single composer entry rather than firing as separate messages, and the absence of a trailing Enter correctly means nothing is submitted.

Also still green

  • cargo check -p claurst-tui -p claurst — clean
  • cargo test -p claurst-tui — 722 passed, 0 failed

The harness is currently a scratch script and is not included in this PR — it needs a real pty, so landing it would want a scripted integration slot rather than a plain cargo test. Happy to add that as a follow-up if it's wanted.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
@BunsDev

BunsDev commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Regression test added — 7eb7f3c

The pty harness from the previous comment is now a permanent case in the tmux-driven interactive suite (scripts/tui-tests/), which already runs on every PR via the TUI Tests workflow. That suite was the right home: it drives the real binary through a pseudo-terminal, offline, with no credentials — so nothing new had to be built to host this.

scripts/tui-tests/cases/07_paste_burst.sh

Two assertions, payload is /help so it stays entirely inside the TUI:

  1. A burst-delivered line plus its Enter submits — the help overlay opens.
  2. A burst whose newline is interior does not submit — the first line is anchored to the composer prompt glyph (❯ ALPHAqzx). That anchor is what makes it discriminating: if an interior newline ever starts submitting, the text moves into the transcript and the prompt row carries the second line instead, so the assertion fails. This is the guard on why the coalescing exists at all.

New tui_paste helper

tui_type sends characters the way a human types them, one at a time — which is precisely why no existing case could have caught this bug. tui_paste delivers the whole string in a single write via tmux set-buffer / paste-buffer, reproducing how a host application drives an embedded pane.

Deliberately not paste-buffer -p: bracketed paste arrives as one Event::Paste and bypasses the burst detector entirely, which is the code under test.

Confirmed it detects the regression

Checked out the pre-fix app.rs, rebuilt, and ran the new case:

▸ Paste burst preserves Enter
  FAIL burst-delivered line submits (help overlay never opened)

       ❯ /help
         ALPHAqzx
         BRAVOqzx█

Summary  1 passed  2 failed  0 skipped

That capture is the bug in one frame: every burst piling into the composer, nothing ever submitted.

With the fix restored, the full suite is 44/44 — the shared lib.sh change breaks none of the existing cases.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants