Skip to content
Merged
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
196 changes: 192 additions & 4 deletions src/native/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,58 @@
//! 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;

/// 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>, 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,
Expand All @@ -24,6 +69,13 @@ pub struct Composer {
typing: bool,
/// Images pasted for the next message.
pub images: Vec<Image>,
/// Texts pasted as placeholders, `[Pasted text #N]` being `pastes[N - 1]`;
/// kept so an undone or restored draft still expands.
pastes: Vec<String>,
/// The paste last made a placeholder, which pasting again expands.
last_paste: Option<usize>,
/// When that paste was made, for the hint that says so.
hint: Option<(usize, Instant)>,
}

impl Composer {
Expand Down Expand Up @@ -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<usize> {
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.
Expand Down Expand Up @@ -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() => {
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
35 changes: 30 additions & 5 deletions src/native/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
42 changes: 33 additions & 9 deletions src/native/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,17 @@ pub fn completions(t: &Transcript, composer: &Composer) -> Vec<Completion> {
}

/// 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<Node<Msg>> {
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<Node<Msg>> {
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.
Expand Down Expand Up @@ -1240,14 +1249,17 @@ fn composer_node(t: &Transcript, composer: &Composer, info: &Info) -> Node<Msg>
.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))) {
Expand Down Expand Up @@ -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 = &region(&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());
Expand Down
Loading