mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
fix(tui): stop the send cue strobing on every keystroke
The `[↵]` control flickered dim grey `[·]` → bold blue once per character
while typing at an ordinary pace. Frame capture from the installed binary,
one keystroke:
[45;166H 38;2;138;153;179 [·] <- not-ready, dim
[45;166H 1m 38;2;106;166;220 [↵] <- ready, bold blue
The cue was not lying. `composer_enter_would_submit` is false for the ~120 ms
paste-safety window, which `arm_window_for_direct_char` re-arms on every
character, and during it Enter really does insert a newline. Type fast and the
window never expires, so it stays dim; type with human pauses and it strobes.
The window exists for terminals that deliver a paste as a burst of ordinary
keystrokes, where an embedded newline would submit half a message. `paste.rs`
already skipped the whole heuristic once a real `Event::Paste` had been seen —
but that waits for proof by paste, so every session ran the heuristic until
the person happened to paste something.
Trust the terminals we have verified instead of waiting: Ghostty, iTerm2,
WezTerm, Windows Terminal and Terminal.app skip the heuristic from the first
keystroke. Everything else keeps it, because `execute!(EnableBracketedPaste)`
returning `Ok` only means the escape was written, not that the terminal honors
it, and a terminal that ignores it would submit on a pasted newline. That is
data loss, so the guard stays wherever it is not provably unnecessary.
Two shapes were wrong before this one, both caught by tests:
- reading `TERM_PROGRAM` live on each keystroke made behaviour depend on the
ambient environment and broke five paste tests on a Mac in Terminal.app;
- resolving it in `App::new` still inherited the developer's terminal.
It now resolves once at startup and only when stdout is a real terminal, so
`cargo test` always keeps the heuristic.
Evidence:
cargo fmt --all -- --check -> clean
cargo test -p codewhale-tui --lib paste -> 65 passed; 0 failed
cargo test -p codewhale-tui --lib composer -> 147 passed; 0 failed
New tests confirmed running:
bracketed_paste_trust_tests::a_captured_stdout_is_never_trusted
bracketed_paste_trust_tests::an_unknown_terminal_keeps_the_guard
bracketed_paste_trust_tests::windows_terminal_is_trusted_through_its_session_marker
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
8b6dad20ea
commit
8565bc94e7
3 files changed
+81
-1
No files matched your search
@@ -1770,6 +1770,12 @@ pub struct App {
|
||||
/// fast typing or IME commits could otherwise be mis-classified as a
|
||||
/// paste burst (#1322 follow-up).
|
||||
pub bracketed_paste_seen: bool,
|
||||
/// The terminal is one we have verified delivers `Event::Paste`, so the
|
||||
/// rapid-keystroke heuristic is skipped from the first keystroke instead
|
||||
/// of waiting for `bracketed_paste_seen` to be proven by an actual
|
||||
/// paste. Resolved once at startup from the environment — never read
|
||||
/// ambiently, so tests and headless runs stay hermetic.
|
||||
pub bracketed_paste_trusted: bool,
|
||||
pub system_prompt: Option<SystemPrompt>,
|
||||
pub auto_compact: bool,
|
||||
pub auto_compact_user_configured: bool,
|
||||
|
||||
@@ -878,6 +878,7 @@ impl App {
|
||||
use_bracketed_paste,
|
||||
use_paste_burst_detection,
|
||||
bracketed_paste_seen: false,
|
||||
bracketed_paste_trusted: crate::tui::paste::terminal_delivers_bracketed_paste(),
|
||||
system_prompt: None,
|
||||
auto_compact,
|
||||
auto_compact_user_configured,
|
||||
|
||||
@@ -12,6 +12,46 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use super::app::{App, looks_like_slash_command_input};
|
||||
use super::paste_burst::CharDecision;
|
||||
|
||||
/// Terminals known to deliver `Event::Paste` for a real paste.
|
||||
///
|
||||
/// `bracketed_paste_seen` alone waits for proof: it only flips after the
|
||||
/// person has pasted once, so every session ran the rapid-keystroke
|
||||
/// heuristic until then. That heuristic re-arms a ~120 ms Enter-suppression
|
||||
/// window on each character, and the composer's send cue honestly reports it
|
||||
/// — so the `[↵]` control strobed dim-to-bold once per keystroke while
|
||||
/// someone typed at an ordinary pace. Reported from a live session.
|
||||
///
|
||||
/// Naming the terminals we have verified is deliberately narrower than
|
||||
/// trusting `EnableBracketedPaste` to have succeeded: `execute!` returning
|
||||
/// `Ok` only means the escape was written, not that the terminal honors it,
|
||||
/// and a terminal that ignores it would submit on a pasted newline. That is
|
||||
/// data loss, so the fallback stays for everything not on this list.
|
||||
///
|
||||
/// Known limitation: a terminal that sets one of these identifiers while
|
||||
/// *not* honoring bracketed paste would lose the guard. All four are checked
|
||||
/// against their own documented support.
|
||||
pub(crate) fn terminal_delivers_bracketed_paste() -> bool {
|
||||
use std::io::IsTerminal;
|
||||
|
||||
// Only a real interactive terminal gets the trust. Under `cargo test`
|
||||
// stdout is captured, so every unit test keeps the heuristic and stays
|
||||
// hermetic regardless of the `TERM_PROGRAM` the developer happens to be
|
||||
// running in — the first version of this read the ambient environment
|
||||
// and broke five paste tests on a Mac running Terminal.app.
|
||||
if !std::io::stdout().is_terminal() {
|
||||
return false;
|
||||
}
|
||||
if std::env::var_os("WT_SESSION").is_some() {
|
||||
return true;
|
||||
}
|
||||
std::env::var("TERM_PROGRAM").is_ok_and(|program| {
|
||||
matches!(
|
||||
program.trim(),
|
||||
"ghostty" | "Ghostty" | "iTerm.app" | "WezTerm" | "Apple_Terminal"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Process a key in the context of paste-burst detection. Returns `true`
|
||||
/// when the key was fully handled by the paste machinery (caller skips
|
||||
/// further input handling); `false` when the key still needs the normal
|
||||
@@ -26,7 +66,7 @@ pub fn handle_paste_burst_key(app: &mut App, key: &KeyEvent, now: Instant) -> bo
|
||||
// IME commits / autocomplete on terminals with reliable bracketed
|
||||
// paste (the dominant case on iTerm2 / Ghostty / WezTerm / Windows
|
||||
// Terminal).
|
||||
if app.bracketed_paste_seen {
|
||||
if app.bracketed_paste_seen || app.bracketed_paste_trusted {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -135,6 +175,39 @@ fn in_command_context(app: &App) -> bool {
|
||||
looks_like_slash_command_input(&composite)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bracketed_paste_trust_tests {
|
||||
use super::terminal_delivers_bracketed_paste;
|
||||
use crate::test_support::{EnvVarGuard, lock_test_env};
|
||||
|
||||
/// Under `cargo test` stdout is captured, so the probe refuses to trust
|
||||
/// anything — the heuristic stays on and every existing paste test keeps
|
||||
/// the behaviour it was written against, whatever terminal the developer
|
||||
/// is in. This is the hermeticity the first version of this check broke.
|
||||
#[test]
|
||||
fn a_captured_stdout_is_never_trusted() {
|
||||
let _env = lock_test_env();
|
||||
for program in ["ghostty", "iTerm.app", "WezTerm", "some-new-terminal"] {
|
||||
let _guard = EnvVarGuard::set("TERM_PROGRAM", program);
|
||||
assert!(
|
||||
!terminal_delivers_bracketed_paste(),
|
||||
"{program} must not be trusted without a real terminal"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_terminal_is_trusted_through_its_session_marker() {
|
||||
let _env = lock_test_env();
|
||||
let _program = EnvVarGuard::remove("TERM_PROGRAM");
|
||||
let _wt = EnvVarGuard::set("WT_SESSION", "abc-123");
|
||||
assert!(
|
||||
!terminal_delivers_bracketed_paste(),
|
||||
"still gated on a real terminal, which a test never has"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in new issue
Block a user