diff --git a/src/native/composer.rs b/src/native/composer.rs index b052c14..61b7433 100644 --- a/src/native/composer.rs +++ b/src/native/composer.rs @@ -3,6 +3,8 @@ //! selections Tern keeps come back as edit events (in UTF-16 units) and the //! undo key as an undo event, both applied here. +use std::time::{Duration, Instant}; + use tern_sdk::Key; use super::model::Image; @@ -10,6 +12,49 @@ use super::model::Image; /// Undo steps kept. const HISTORY: usize = 200; +/// A paste longer than this many chars, or with more line breaks than +/// `PASTE_BREAKS`, shows as a placeholder (as in Claude Code). +const PASTE_CHARS: usize = 800; +const PASTE_BREAKS: usize = 2; +/// A paste up to this many chars can be expanded in place by pasting it +/// again, which a hint says for `EXPAND_HINT`. +const EXPAND_CHARS: usize = 100_000; +const EXPAND_HINT: Duration = Duration::from_secs(8); + +/// The placeholder for paste `id` of `breaks` line breaks, as Claude Code writes it. +fn placeholder(id: usize, breaks: usize) -> String { + match breaks { + 0 => format!("[Pasted text #{id}]"), + _ => format!("[Pasted text #{id} +{breaks} lines]"), + } +} + +/// Where `[Pasted text #N]` or `[Pasted text #N +M lines]` placeholders sit +/// in `text`: their byte ranges and `N`. +fn placeholders(text: &str) -> Vec<(std::ops::Range, usize)> { + const OPEN: &str = "[Pasted text #"; + let mut found = Vec::new(); + let mut from = 0; + while let Some(at) = text[from..].find(OPEN).map(|i| from + i) { + from = at + OPEN.len(); + let rest = &text[from..]; + let digits = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()); + let Ok(id) = rest[..digits].parse() else { continue }; + let mut end = digits; + if let Some(lines) = rest[end..].strip_prefix(" +") { + let n = lines.find(|c: char| !c.is_ascii_digit()).unwrap_or(lines.len()); + if n > 0 && lines[n..].starts_with(" lines") { + end += 2 + n + " lines".len(); + } + } + if rest[end..].starts_with(']') { + from += end + 1; + found.push((at..from, id)); + } + } + found +} + #[derive(Debug, Default)] pub struct Composer { pub draft: String, @@ -24,6 +69,13 @@ pub struct Composer { typing: bool, /// Images pasted for the next message. pub images: Vec, + /// Texts pasted as placeholders, `[Pasted text #N]` being `pastes[N - 1]`; + /// kept so an undone or restored draft still expands. + pastes: Vec, + /// The paste last made a placeholder, which pasting again expands. + last_paste: Option, + /// When that paste was made, for the hint that says so. + hint: Option<(usize, Instant)>, } impl Composer { @@ -112,6 +164,80 @@ impl Composer { std::mem::take(&mut self.draft) } + /// Pastes `text`: a long one as a placeholder, which `expand` turns back. + /// Pasting the same text again expands its placeholder in place. + pub fn paste(&mut self, text: &str) { + let text = text.replace("\r\n", "\n").replace('\r', "\n").replace('\t', " "); + if self.last_paste.is_some_and(|id| self.pastes[id - 1] == text) && self.expand_last() { + return; + } + let breaks = text.matches('\n').count(); + let chars = text.chars().count(); + if chars <= PASTE_CHARS && breaks <= PASTE_BREAKS { + return self.insert(&text); + } + self.pastes.push(text); + let id = self.pastes.len(); + self.insert(&placeholder(id, breaks)); + self.last_paste = Some(id); + self.hint = (chars <= EXPAND_CHARS).then(|| (id, Instant::now())); + } + + /// Puts the newest placeholder back as its text, the caret after it, if + /// it is the last paste's; false if not. + fn expand_last(&mut self) -> bool { + let newest = placeholders(&self.draft) + .into_iter() + .filter(|(_, id)| (1..=self.pastes.len()).contains(id)) + .max_by_key(|(_, id)| *id); + let Some((range, id)) = newest.filter(|(_, id)| Some(*id) == self.last_paste) else { return false }; + let text = self.pastes[id - 1].clone(); + if text.chars().count() > EXPAND_CHARS { + return false; + } + self.remember(false); + self.draft.replace_range(range.clone(), &text); + self.cursor = self.draft[..range.start + text.len()].chars().count(); + self.last_paste = None; + self.hint = None; + self.edited(); + true + } + + /// The paste whose "paste again to expand" hint is showing, for a while + /// after it was made and while there is a draft. + pub fn expand_hint(&self) -> Option { + let (id, at) = self.hint?; + (at.elapsed() < EXPAND_HINT && !self.draft.is_empty()).then_some(id) + } + + /// `text` with the placeholders of pastes put back as what was pasted; + /// one edited out of shape stays as it is. + pub fn expand(&self, text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut from = 0; + for (range, id) in placeholders(text) { + if let Some(pasted) = id.checked_sub(1).and_then(|i| self.pastes.get(i)) { + out.push_str(&text[from..range.start]); + out.push_str(pasted); + from = range.end; + } + } + out.push_str(&text[from..]); + out + } + + /// Where the draft's placeholders of pastes are, in UTF-16 units, for the + /// editor to mark them. + pub fn placeholders_utf16(&self) -> Vec<(usize, usize)> { + let utf16 = |at: usize| self.draft[..at].encode_utf16().count(); + placeholders(&self.draft) + .into_iter() + .filter(|(_, id)| (1..=self.pastes.len()).contains(id)) + .map(|(range, _)| (utf16(range.start), utf16(range.end))) + .collect() + } + /// Applies a native edit: UTF-16 `[from, to)` replaced with `text`, the /// caret then at `cursor`. Ignored when the draft changed since Tern saw /// it (`len`), as keys in flight would have. @@ -164,9 +290,14 @@ impl Composer { "enter" if key.shift || key.alt => self.insert("\n"), "backspace" if self.cursor > 0 => { self.remember(false); - let at = self.byte(self.cursor - 1); - self.draft.remove(at); - self.cursor -= 1; + let end = self.byte(self.cursor); + // A paste's placeholder goes whole. + let start = placeholders(&self.draft[..end]) + .last() + .filter(|(range, id)| range.end == end && (1..=self.pastes.len()).contains(id)) + .map_or_else(|| self.byte(self.cursor - 1), |(range, _)| range.start); + self.cursor -= self.draft[start..end].chars().count(); + self.draft.replace_range(start..end, ""); self.edited(); } "delete" if self.cursor < self.len() => { @@ -187,7 +318,7 @@ impl Composer { self.edited(); } "z" if key.ctrl || key.meta => self.undo(), - "paste" => self.insert(key.text.as_deref().unwrap_or_default()), + "paste" => self.paste(key.text.as_deref().unwrap_or_default()), _ => match key.typed() { Some(text) => { let word = text.chars().all(char::is_alphanumeric); @@ -245,6 +376,63 @@ mod tests { assert!(c.is_empty()); } + #[test] + fn long_pastes_show_as_placeholders_and_expand_to_what_was_pasted() { + let mut c = Composer::default(); + let paste = |text: &str| Key { text: Some(text.into()), ..key("paste") }; + c.insert("see "); + c.key(&paste("a\nb\nc")); + assert_eq!(c.draft, "see a\nb\nc", "two line breaks stay inline"); + c.set("see "); + c.key(&paste("a\r\nb\nc\nd")); + c.insert(" and "); + let long = "x".repeat(801); + c.key(&paste(&long)); + assert_eq!(c.draft, "see [Pasted text #1 +3 lines] and [Pasted text #2]"); + assert_eq!(c.expand(&c.draft), format!("see a\nb\nc\nd and {long}")); + // A placeholder edited out of shape, or of no paste, is sent as typed. + assert_eq!(c.expand("[Pasted text #1 +3 line] [Pasted text #9]"), "[Pasted text #1 +3 line] [Pasted text #9]"); + + // Backspace takes a placeholder whole; undo brings it back, still expanding. + assert!(c.key(&key("backspace"))); + assert_eq!(c.draft, "see [Pasted text #1 +3 lines] and "); + assert!(c.key(&key("backspace"))); + assert_eq!(c.draft, "see [Pasted text #1 +3 lines] and"); + assert_eq!(c.take(), "see [Pasted text #1 +3 lines] and"); + c.undo(); + assert_eq!(c.expand(&c.draft), "see a\nb\nc\nd and"); + // The paste itself is one undo step. + c.set(""); + c.key(&paste(&long)); + c.undo(); + assert_eq!(c.draft, ""); + } + + #[test] + fn pasting_the_same_text_again_expands_its_placeholder() { + let mut c = Composer::default(); + let paste = |text: &str| Key { text: Some(text.into()), ..key("paste") }; + c.key(&paste("a\tb\nc\nd\ne")); + c.insert(" tail"); + assert_eq!(c.expand_hint(), Some(1)); + c.key(&Key::named("home")); + c.key(&paste("a\tb\nc\nd\ne")); + assert_eq!(c.draft, "a b\nc\nd\ne tail", "tabs go as four spaces"); + assert_eq!(c.cursor_utf16(), 12, "the caret ends after the expanded text"); + assert_eq!(c.expand_hint(), None); + c.undo(); + assert_eq!(c.draft, "[Pasted text #1 +3 lines] tail"); + + // Another text, or a paste that is not the newest, collapses anew. + c.set(""); + c.key(&paste("1\n2\n3\n4")); + c.key(&paste("5\n6\n7\n8")); + c.key(&paste("1\n2\n3\n4")); + assert_eq!(c.draft, "[Pasted text #2 +3 lines][Pasted text #3 +3 lines][Pasted text #4 +3 lines]"); + c.take(); + assert_eq!(c.expand_hint(), None, "no hint without a draft"); + } + #[test] fn native_edits_replace_utf16_ranges() { let mut c = Composer::default(); diff --git a/src/native/driver.rs b/src/native/driver.rs index ad127ea..ae460b1 100644 --- a/src/native/driver.rs +++ b/src/native/driver.rs @@ -711,10 +711,11 @@ impl Driver { self.t.error(format!("{product} does not take images over ACP; remove them to send"), None); return; } - let text = self.composer.take(); + let draft = self.composer.take(); + let text = self.composer.expand(&draft); if commands::parse(&text).is_some() && self.composer.images.is_empty() { // Commands run as Enter runs them. - self.composer.set(&text); + self.composer.set(&draft); return self.submit(); } let links = self.links(&text); @@ -920,11 +921,13 @@ impl Driver { self.t.error(format!("{product} does not take images over ACP; remove them to send"), None); return; } - let text = self.composer.take(); + // Pastes' placeholders go as what was pasted; a draft kept keeps them. + let draft = self.composer.take(); + let text = self.composer.expand(&draft); match commands::parse(&text).filter(|_| self.composer.images.is_empty()) { Some(command) if self.t.turn == Turn::Idle => self.builtin(command), // sand's commands wait for the session; the agent's go as a prompt. - Some(_) => self.composer.set(&text), + Some(_) => self.composer.set(&draft), None => { let links = self.links(&text); let prompt = Prompt { @@ -934,7 +937,7 @@ impl Driver { context: Vec::new(), }; if !self.send(prompt.clone()) { - self.composer.set(&text); + self.composer.set(&draft); self.composer.images = prompt.images; self.t .error(format!("{QUEUE} follow-ups are waiting already; this one stays in the composer"), None); @@ -1489,6 +1492,28 @@ mod tests { d.response(Call::Prompt, Ok(serde_json::json!({"stopReason": stop})), Instant::now()); } + #[test] + fn a_long_paste_goes_whole_and_a_kept_draft_keeps_its_placeholder() { + let mut d = working(); + let paste = Key { text: Some("one\ntwo\nthree\nfour".into()), ..Key::named("paste") }; + d.key(&Key { text: Some("x".into()), ..Key::named("x") }); + d.key(&paste); + assert_eq!(d.composer.draft, "x[Pasted text #1 +3 lines]"); + d.key(&Key::named("enter")); + assert!(matches!(d.t.entries.last(), Some(Entry::User(text, _)) if text == "xone\ntwo\nthree\nfour")); + + // With the queue full, the draft stays, placeholder and all; it is queued whole later. + for i in 0..QUEUE { + type_and_send(&mut d, &format!("q{i}")); + } + d.key(&paste); + d.key(&Key::named("enter")); + assert_eq!(d.composer.draft, "[Pasted text #2 +3 lines]"); + d.msg(Msg::Unqueue(0)); + d.msg(Msg::Send(d.composer.draft.clone())); + assert_eq!(d.t.queue.last().map(|p| p.text.as_str()), Some("one\ntwo\nthree\nfour")); + } + #[test] fn follow_ups_queue_while_the_agent_works_and_go_in_order() { let mut d = working(); diff --git a/src/native/view.rs b/src/native/view.rs index 5119057..c22c408 100644 --- a/src/native/view.rs +++ b/src/native/view.rs @@ -315,8 +315,17 @@ pub fn completions(t: &Transcript, composer: &Composer) -> Vec { } /// What floats over the session: the completions of a `/command` or an -/// `@` mention being typed, under the caret, and the session picker. +/// `@` mention being typed, under the caret, and the session picker; and +/// for a while after a long paste, that pasting it again expands it. fn layer(t: &Transcript, composer: &Composer, info: &Info) -> Vec> { + let mut nodes = floating(t, composer, info); + if let Some(id) = composer.expand_hint().filter(|_| t.exit_armed.is_none()) { + nodes.push(ui::toast("Paste again to expand").key(format!("paste-{id}")).ttl(8000u64).into()); + } + nodes +} + +fn floating(t: &Transcript, composer: &Composer, info: &Info) -> Vec> { if let Some((_, key)) = &t.exit_armed { let what = if t.turn == Turn::Setup { "stop setting up" } else { "exit" }; // As long as the second press is awaited. @@ -1240,14 +1249,17 @@ fn composer_node(t: &Transcript, composer: &Composer, info: &Info) -> Node .on_undo(Msg::Undo) .on_focus(Msg::Focus); // A command typed whole, marked as one that runs: no completion says so. - let editor = match commands::recognized(&composer.draft, &t.commands) { - Some(range) => { - let utf16 = |at: usize| composer.draft[..at].encode_utf16().count(); - let s = "accent strong".to_string(); - editor.decor(vec![ui::Decor { from: utf16(range.start), to: utf16(range.end), s, fx: None }]) - } - None => editor, - }; + let mut decor = Vec::new(); + if let Some(range) = commands::recognized(&composer.draft, &t.commands) { + let utf16 = |at: usize| composer.draft[..at].encode_utf16().count(); + let s = "accent strong".to_string(); + decor.push(ui::Decor { from: utf16(range.start), to: utf16(range.end), s, fx: None }); + } + // Pastes' placeholders in bold, to tell them from typed text. + for (from, to) in composer.placeholders_utf16() { + decor.push(ui::Decor { from, to, s: "strong".to_string(), fx: None }); + } + let editor = if decor.is_empty() { editor } else { editor.decor(decor) }; // The rest of the highlighted command, faint after the caret. let completions = if composer.dismissed { Vec::new() } else { commands::complete(&composer.draft, &t.commands) }; let editor = match completions.get(composer.pick.min(completions.len().saturating_sub(1))) { @@ -1569,6 +1581,18 @@ mod tests { assert_eq!(main[1].props()["role"], "omp.welcome"); } + #[test] + fn a_long_paste_says_pasting_again_expands_it() { + let mut composer = Composer::default(); + composer.key(&tern_sdk::Key { text: Some("a\nb\nc\nd".into()), ..tern_sdk::Key::named("paste") }); + let v = view(&Transcript::default(), &composer, &info()); + let toast = ®ion(&v, 2)[0]; + assert_eq!((toast.kind(), toast.props()["ttl"].as_u64()), ("toast", Some(8000))); + assert!(format!("{:?}", toast.props()).contains("Paste again to expand")); + let editor = find(region(&v, 1), "sand.composer").unwrap().child_nodes().iter().find(|n| n.kind() == "editor"); + assert_eq!(editor.unwrap().props()["decor"], json!([{"from": 0, "to": 25, "s": "strong"}]), "in bold"); + } + #[test] fn setup_questions_offer_a_button_per_answer() { let (mut t, now) = (Transcript::default(), Instant::now());