fix(terminal): let the editor own the held line before it is submitted

Folding the typeahead seed in at submit time put it in front of whatever
the line had become, not in front of what the user typed. The editor is
live for the whole `D`-to-`B` window, and every one of its line-replacing
paths runs there: `↑` recall, the ghost suggestion, ⌃U, completion. So
`↑` then Enter ran the recalled entry with the gap text glued to its
front (`echo` + `echo from history`), and ⌃U then Enter brought the text
back that ⌃U had just cleared.

Take the record into the editor as soon as the editor has the prompt and
leave only the `^U` owed: `Typeahead::adopt` moves the seed out and keeps
the record in its tainted (wipe, seed nothing) shape, so the next drain
still produces the wipe, in the same place on the wire as before —
`render` pays it on `B`, `submit_command` and `handoff_line_to_shell` pay
it if `B` never comes.

`handoff_line_to_shell` also moves the wipe past its multi-line bail: it
hands nothing over there, so it must put nothing on the wire either.
This commit is contained in:
l0ng-ai
2026-09-07 22:16:39 +08:00
parent c32753b990
commit 15f020c19e
2 changed files with 150 additions and 11 deletions
+48
View File
@@ -44,6 +44,19 @@ impl Typeahead {
std::mem::take(self).flush()
}
/// Hand the seed to the local editor while leaving the wipe owed.
///
/// The shell is still sitting on this text, so the `^U` that erases it has
/// to go out eventually — but not necessarily now. Taking the seed out and
/// keeping the record in its tainted (wipe, seed nothing) shape lets the
/// editor own the whole line straight away, and the next `drain` still
/// produces the wipe.
pub fn adopt(&mut self) -> Option<String> {
let seed = self.drain()?;
self.tainted = true;
Some(seed)
}
fn record_text(&mut self, s: &str) {
if s.chars().any(char::is_control) {
self.tainted = true;
@@ -201,6 +214,41 @@ mod tests {
assert_eq!(Typeahead::new().drain(), None);
}
#[test]
fn adopting_moves_the_seed_out_and_leaves_the_wipe_owed() {
let mut t = Typeahead::new();
t.observe(RawInput::Text("echo"), false);
assert_eq!(t.adopt(), Some("echo".to_string()));
// The seed is the editor's now, but the shell is still holding it.
assert_eq!(t.drain(), Some(String::new()));
assert_eq!(t.drain(), None);
}
#[test]
fn adopting_an_empty_record_owes_nothing() {
let mut t = Typeahead::new();
assert_eq!(t.adopt(), None);
assert_eq!(t.drain(), None);
}
#[test]
fn adopting_twice_seeds_once() {
let mut t = Typeahead::new();
t.observe(RawInput::Text("echo"), false);
assert_eq!(t.adopt(), Some("echo".to_string()));
assert_eq!(t.adopt(), Some(String::new()));
assert_eq!(t.drain(), Some(String::new()));
}
#[test]
fn discarding_an_adopted_record_drops_the_owed_wipe() {
let mut t = Typeahead::new();
t.observe(RawInput::Text("echo"), false);
assert_eq!(t.adopt(), Some("echo".to_string()));
t.discard();
assert_eq!(t.drain(), None);
}
#[test]
fn typed_text_is_wiped_and_seeded() {
let mut p = Typeahead::new();
+102 -11
View File
@@ -3945,6 +3945,22 @@ impl TerminalView {
}
}
/// Take the record into the editor without paying the wipe yet.
///
/// `at_prompt` comes back on the `D` mark, a whole prompt draw ahead of the
/// `B` that arms `zle_reading`, and this editor is live for that whole
/// window. Everything it offers rewrites the line — history recall and the
/// ghost suggestion replace it wholesale, ⌃U empties it, completion filters
/// on it — so the line has to be whole *before* those run, not stitched
/// back together at submit time in front of whatever replaced it. The `^U`
/// stays owed until `flush_typeahead`, which keeps it where it has always
/// been on the wire: immediately before the line.
fn adopt_typeahead(&mut self) {
if let Some(seed) = self.typeahead.adopt() {
self.cmd.prepend_str(&seed);
}
}
fn wipe_pending_typeahead(&mut self) {
if self.typeahead.drain().is_some() {
self.terminal.write(vec![0x15]);
@@ -4055,12 +4071,13 @@ impl TerminalView {
}
// The shell is still holding the recorded text on its own line, and the
// ^U that erases it has not gone out yet: `at_prompt` comes back on the
// `D` mark, before the prompt is even drawn, while `flush_typeahead`
// has to wait for `B`. Every key typed in that window reaches this
// editor, so the drain here has to put the seed back in front of the
// line the way every other drain does. Dropping it submitted only what
// was typed after the handover, and an empty command when that was
// nothing, which is the blank line #433 reports.
// `D` mark, before the prompt is even drawn, while the wipe waits for
// `B`. `adopt_typeahead` has normally already folded the seed into the
// line by now, and this pays the wipe it left owed; on the frame where
// it has not, the drain here still puts the seed back the way every
// other drain does. Dropping it submitted only what was typed after the
// handover, and an empty command when that was nothing, which is the
// blank line #433 reports.
self.flush_typeahead();
let line = self.cmd.text();
if !line.trim().is_empty() {
@@ -4335,13 +4352,16 @@ impl TerminalView {
}
// Same reason as `submit_command`: what the record holds is on the
// shell's own line, so it belongs in front of the line handed back.
self.flush_typeahead();
// The wipe waits until past the multi-line bail, which hands nothing
// over and so must put nothing on the wire either.
self.adopt_typeahead();
let line = self.cmd.text();
if line.contains('\n') {
cx.notify();
return;
}
self.close_completion();
self.flush_typeahead();
let tail = line.chars().count().saturating_sub(self.cmd.cursor());
if !line.is_empty() {
self.terminal.write(line.into_bytes());
@@ -6340,6 +6360,8 @@ impl Render for TerminalView {
}
if self.terminal.zle_reading() {
self.flush_typeahead();
} else {
self.adopt_typeahead();
}
}
let entity = cx.entity();
@@ -13961,12 +13983,12 @@ mod prompt_handover_tests {
}
}
fn press_enter(window: &gpui::WindowHandle<TerminalView>, cx: &mut TestAppContext) {
fn press(window: &gpui::WindowHandle<TerminalView>, cx: &mut TestAppContext, key: &str) {
window
.update(cx, |view, window, cx| {
view.on_key_down(
&KeyDownEvent {
keystroke: gpui::Keystroke::parse("enter").unwrap(),
keystroke: gpui::Keystroke::parse(key).unwrap(),
is_held: false,
prefer_character_input: false,
},
@@ -14032,6 +14054,17 @@ mod prompt_handover_tests {
.unwrap(),
"this is the D-to-B window: the shell is not reading its line yet"
);
settle(
cx,
window,
"the editor adopts the line the shell is holding",
|view| view.cmd.text() == text,
);
assert_eq!(
drain(daemon),
Vec::<u8>::new(),
"adopting the line owes the wipe, it does not send it early"
);
}
#[gpui::test]
@@ -14041,7 +14074,7 @@ mod prompt_handover_tests {
let (window, mut daemon) = harness(cx);
typed_into_the_gap_then_handed_back(cx, &window, &mut daemon, "echo hi");
press_enter(&window, cx);
press(&window, cx, "enter");
cx.run_until_parked();
assert_eq!(
drain(&mut daemon),
@@ -14065,7 +14098,7 @@ mod prompt_handover_tests {
"the editor owns these keys, so none of them reach the PTY"
);
press_enter(&window, cx);
press(&window, cx, "enter");
cx.run_until_parked();
assert_eq!(
drain(&mut daemon),
@@ -14073,4 +14106,62 @@ mod prompt_handover_tests {
"what the shell was holding leads the line, not the tail alone"
);
}
/// The half of the window the seed alone does not cover: the editor is
/// live, so the user can *replace* the line before submitting it. Recalling
/// history and pressing Enter has to run the entry recalled — not that
/// entry with the text the shell was holding glued to its front.
#[gpui::test]
fn recalling_history_in_the_gap_window_replaces_the_held_line(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
typed_into_the_gap_then_handed_back(cx, &window, &mut daemon, "echo");
window
.update(cx, |view, _, _| {
view.history.push("echo from history".to_string());
})
.unwrap();
press(&window, cx, "up");
cx.run_until_parked();
window
.update(cx, |view, _, _| {
assert_eq!(
view.cmd.text(),
"echo from history",
"the recall searches on the whole line, held text included"
);
})
.unwrap();
press(&window, cx, "enter");
cx.run_until_parked();
assert_eq!(
drain(&mut daemon),
b"\x15echo from history\r".to_vec(),
"the recalled entry runs on its own, with the held text replaced \
rather than prefixed to it"
);
}
/// The same for an emptied line: ⌃U clears what the editor is holding, and
/// the shell's copy of it goes too instead of coming back at submit.
#[gpui::test]
fn clearing_the_line_in_the_gap_window_clears_the_held_text_too(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
typed_into_the_gap_then_handed_back(cx, &window, &mut daemon, "echo");
press(&window, cx, "ctrl-u");
cx.run_until_parked();
window
.update(cx, |view, _, _| assert_eq!(view.cmd.text(), ""))
.unwrap();
press(&window, cx, "enter");
cx.run_until_parked();
assert_eq!(
drain(&mut daemon),
b"\x15\r".to_vec(),
"an emptied line submits empty: the wipe is still owed, the seed is not"
);
}
}