diff --git a/client/src/config.rs b/client/src/config.rs index 703f2e401..747695c1e 100644 --- a/client/src/config.rs +++ b/client/src/config.rs @@ -18,6 +18,7 @@ //! task_effort = "high" # each new task's //! lead_profile = "my-lead" # the role a new lead starts in, not lead //! task_profile = "worker" # what every other agent is told, not task +//! suggest = false # no guess at the next message, on by default //! //! [sonny.mcp_servers.linear] # an MCP server Sonny's agents reach //! url = "https://mcp.linear.app/mcp" @@ -70,7 +71,7 @@ pub struct Judge { } /// What Sonny starts its agents with. -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct Sonny { pub lead_effort: Option, pub task_effort: Option, @@ -81,6 +82,22 @@ pub struct Sonny { pub task_profile: Option, /// The MCP servers Sonny's agents reach, by name. pub mcp_servers: BTreeMap, + /// Whether a copy of the agent guesses the person's next message after + /// a turn they started, shown in the empty composer for Tab to take. + pub suggest: bool, +} + +impl Default for Sonny { + fn default() -> Self { + Sonny { + lead_effort: None, + task_effort: None, + lead_profile: None, + task_profile: None, + mcp_servers: BTreeMap::new(), + suggest: true, + } + } } /// An MCP server, under Codex's key names for `[mcp_servers.NAME]`: a @@ -289,7 +306,13 @@ fn parse(text: &str) -> Result { .ok_or_else(|| format!("unknown key {name}"))?; let keys: &[&str] = match name.as_str() { "judge" => &["model", "effort"], - "sonny" => &["lead_effort", "task_effort", "lead_profile", "task_profile"], + "sonny" => &[ + "lead_effort", + "task_effort", + "lead_profile", + "task_profile", + "suggest", + ], _ => return Err(format!("unknown table [{name}]")), }; for (key, value) in section { @@ -306,6 +329,12 @@ fn parse(text: &str) -> Result { if !keys.contains(&key.as_str()) { return Err(format!("unknown key {name}.{key}")); } + if key == "suggest" { + config.sonny.suggest = value + .as_bool() + .ok_or_else(|| format!("{name}.{key} must be true or false"))?; + continue; + } let value = string(&format!("{name}.{key}"), value)?; // The daemon judges models and levels when the agents are made. *match (name.as_str(), key.as_str()) { @@ -477,6 +506,12 @@ mod tests { parse("[sonny]\nlead_profile = \"my-lead\"\ntask_profile = \"worker\"").unwrap(); assert_eq!(roles.sonny.lead_profile.as_deref(), Some("my-lead")); assert_eq!(roles.sonny.task_profile.as_deref(), Some("worker")); + assert!(config.sonny.suggest); + assert!(!parse("[sonny]\nsuggest = false").unwrap().sonny.suggest); + assert_eq!( + parse("[sonny]\nsuggest = \"no\"").unwrap_err(), + "sonny.suggest must be true or false" + ); let off = parse("web_search = \"disabled\"").unwrap(); assert_eq!(off.web_search, WebSearch::Disabled); assert!(!config.hide_senders); diff --git a/docs/sonny/suggest-shown.png b/docs/sonny/suggest-shown.png new file mode 100644 index 000000000..6d5541d4c Binary files /dev/null and b/docs/sonny/suggest-shown.png differ diff --git a/docs/sonny/suggest-side.png b/docs/sonny/suggest-side.png new file mode 100644 index 000000000..f1fb8ebe6 Binary files /dev/null and b/docs/sonny/suggest-side.png differ diff --git a/docs/sonny/suggest-taken.png b/docs/sonny/suggest-taken.png new file mode 100644 index 000000000..03b43727b Binary files /dev/null and b/docs/sonny/suggest-taken.png differ diff --git a/sonny/README.md b/sonny/README.md index 5631e5e6f..2e6a551d8 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -138,6 +138,7 @@ at its model's own effort (`AGENT_EFFORT` when its model is | Each new task's model | (the lead's) | `threads_model` | | Each new task's effort | `[sonny] task_effort` | `threads_effort` | | The approver's and lookout's judge | `[judge] model`, `effort` | | +| A guess at your next message | `[sonny] suggest` (true, the default, or false) | | `start_task` applies them in code, so the lead's prompt says nothing about effort; the lead names a `model` or `effort` only for one the person asks @@ -259,6 +260,27 @@ out, Ctrl-W, Ctrl-U or Ctrl-K cut it for Ctrl-Y, and Esc lets go of it. ![A selection in the composer](../docs/sonny/composer-selection.png) +After a turn you started from this window ends well, a copy of the agent +guesses what you will type next, as Claude Code's prompt suggestions and +Codex's composer predictions do, and the empty composer shows it dim for +Tab to take. Tab puts it in the composer to edit or send; it never sends +itself. The copy is forked where the turn ended, with the agent's tools and +model so its prompt cache is warm, but allowed no call; it is asked for 2 to +12 words in your style, or nothing when the next step is not obvious, and is +deleted once it answers. That is one small model call per turn of yours +(a steer counts for the turn it joins): none follows a turn the lead, the +lookout or another window started, one that failed or was stopped, or one +that ended on a question, so watching a task costs nothing. +`[sonny] suggest = false` turns it off. Typing hides the guess, a new turn +drops it, and Tab does what it does otherwise while none shows. Every Sonny +has it, so a side chat or a task opened in the pane does too. + +![The guess, dim in the empty composer after a reply](../docs/sonny/suggest-shown.png) + +![Tab took it into the composer, unsent](../docs/sonny/suggest-taken.png) + +![The same in a side chat in the pane](../docs/sonny/suggest-side.png) + Pictures go with a message: Ctrl-V takes the one on the clipboard, as in Codex and Claude Code (terminals paste only text), and an image file dropped on the terminal, or its path pasted, is attached rather than typed. diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 0a3e7e9a6..fe5fdbf93 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -53,6 +53,7 @@ mod shell; mod skills; mod sound; mod status; +mod suggest; mod t3; mod table; mod task; @@ -121,6 +122,8 @@ const OLDER_ITEMS: u64 = 50; /// width), so they stay whole at 100 and prose stays near the measure. const PROSE: usize = 100; /// How long a side chat's copy stays once idle. +/// What follows a guess at the next message in the composer. +const TAB: &str = " tab"; const SIDE_EXPIRE: Duration = Duration::from_secs(15 * 60); /// This window is a side chat (`--side`). static SIDE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); @@ -1594,6 +1597,13 @@ struct App { /// How many triggers and subscriptions run for this agent, as the /// footer's `↻` says, and when they were last counted. behind: (usize, Option), + /// The turn the person's words last went to from this window: only + /// its end is followed by a guess at their next message. + yours: Option, + /// That guess, being made. + suggesting: Option>>, + /// The guess, shown in the empty composer for Tab to take. + suggestion: Option, } /// The lead's tasks, laid out in the project's Rex session: a window for @@ -2123,6 +2133,9 @@ impl App { pick: 0, watch: None, behind: (0, None), + yours: None, + suggesting: None, + suggestion: None, asks_seen: HashMap::new(), redraw: false, sounds: sound::Sounds::new(false), @@ -2361,6 +2374,7 @@ impl App { } } self.side_closed(); + self.suggested_now().await; self.shell_ran().await; self.listed(); self.follow_place().await; @@ -3889,6 +3903,9 @@ impl App { self.draw_bot_reactions(); self.approvals.clear(); self.notice = None; + if status == "completed" && !quiet && turn.is_some() && turn == self.yours { + self.suggest(); + } status_report( match status { _ if quiet => "idle", @@ -5009,6 +5026,20 @@ impl App { key.code = code; key.modifiers -= KeyModifiers::CONTROL; } + // Tab takes the guess at the next message into the composer, as + // Claude Code and Codex take theirs; it is not sent. Tab does what + // it does otherwise when none shows. + if let Event::Key(key) = &event + && key.kind != KeyEventKind::Release + && key.code == KeyCode::Tab + && !self.pane_keys + && let Some(guess) = self.suggested() + { + let guess = guess.to_owned(); + self.input.set(&guess); + self.suggestion = None; + return; + } if full() && !self.watching() && self.full_screen(&event) { return; } @@ -5667,6 +5698,16 @@ impl App { .await .map_err(|error| error.to_string())?; if let Some(turn) = reply["turn"].as_i64() { + // A steer is a submission of its own that ends as steered; the + // person's is the turn it joins: the running one, or the one the + // message before it started. + if origin.is_none() { + self.yours = match delivery { + None => Some(turn), + Some(_) => (self.running.as_ref().map(|r| r.turn)) + .or_else(|| self.staged.last().map(|(started, _)| *started)), + }; + } self.staged.push((turn, prompt)); } if workspace.is_some() { @@ -5875,6 +5916,58 @@ impl App { self.commit(transcript::quiet(rows)); } + /// A guess at the person's next message, after a turn of theirs ended + /// well, made by a copy of this bot on this window's model; unless the + /// config's `[sonny] suggest` is off, or the bot asked a question, + /// which its own card answers. + fn suggest(&mut self) { + self.suggestion = None; + if !self.asks.is_empty() || !agent_client::config::load().is_ok_and(|c| c.sonny.suggest) { + return; + } + self.suggesting = Some(tokio::spawn(suggest::guess( + self.client.clone(), + self.bot.clone(), + self.turn_model.clone(), + ))); + } + + /// The guess, once made. A turn started since lets it go; a copy + /// still guessing finishes and is deleted on its own. + async fn suggested_now(&mut self) { + if self.running.is_some() || !self.staged.is_empty() { + self.suggesting = None; + self.suggestion = None; + return; + } + if self + .suggesting + .as_ref() + .is_some_and(|job| job.is_finished()) + { + let job = self.suggesting.take().unwrap(); + self.suggestion = job.await.ok().flatten(); + } + } + + /// The guess, while the composer shows it: empty, with nothing running + /// and nothing open over it. + fn suggested(&self) -> Option<&str> { + let open = self.picker.is_some() + || self.choosing.is_some() + || self.approving.is_some() + || self.reacting.is_some() + || self.finding.is_some() + || self.react_to.is_some(); + let shows = self.input.is_empty() + && self.running.is_none() + && self.asks.is_empty() + && self.approvals.is_empty() + && !open + && !self.watching(); + self.suggestion.as_deref().filter(|_| shows) + } + /// `/side`: a side chat with a copy of this bot, taken where it is now, /// mid-turn included, and opened in a Sonny of its own on the alternate /// screen. The copy may call no tool, so it answers from what the bot @@ -6555,6 +6648,20 @@ impl App { fn composer(&self, width: usize) -> (Vec>, (usize, usize)) { // What is sent answers the lead's first open question. let answering = (!self.chatting).then(|| self.asks.first()).flatten(); + // The guess at the next message, for Tab to take. + if let Some(guess) = self.suggested() { + let room = width.saturating_sub(prompt::LEAD + TAB.width()); + return ( + vec![Line::from(vec![ + Span::raw("› ").bold(), + // Italic too: a side chat's screen, read through vt100, + // loses the dim. + Span::raw(transcript::cut(guess, room)).dim().italic(), + Span::raw(TAB).dim(), + ])], + (0, prompt::LEAD), + ); + } if self.input.is_empty() { // Named, so a message typed in the wrong pane is caught first. let name = match &self.project { @@ -7152,6 +7259,124 @@ mod tests { json!({"event": event, "bot": "b", "turn": turn, "data": data}) } + #[tokio::test] + async fn after_a_turn_of_the_persons_a_copy_guesses_their_next_message_for_tab() { + let asked = Arc::new(std::sync::Mutex::new(Vec::new())); + let seen = asked.clone(); + let client = daemon_answering(move |asked| { + seen.lock().unwrap().push(asked.clone()); + match (asked["op"].as_str(), asked["bot"].as_str()) { + (Some("fork"), _) => json!({"bot_id": 9, "head": 4}), + (Some("submit"), Some("b")) => json!({"turn": 1}), + (Some("submit"), _) => json!({"turn": 1, "handle": "turn:copy/1"}), + (Some("wait"), _) => json!({"results": {"turn:copy/1": + {"status": "completed", "text": "Suggestion: run the tests"}}}), + _ => json!({}), + } + }) + .await; + let mut app = app(client); + app.input.set("fix the bug and run tests"); + app.submit().await; + app.daemon(turn_event("accepted", 1, json!({}))).await; + app.daemon(turn_event( + "turn_finished", + 1, + json!({"status": "completed"}), + )) + .await; + for _ in 0..200 { + app.suggested_now().await; + if app.suggestion.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(app.suggested(), Some("run the tests")); + let (rows, _) = app.composer(80); + let row: String = rows[0].spans.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(row, "› run the tests tab"); + // The copy forked from the bot, allowed no call, and was deleted. + let ops = asked.lock().unwrap().clone(); + let fork = ops.iter().find(|op| op["op"] == "fork").unwrap(); + assert_eq!(fork["source"], "b"); + assert_eq!(fork["allow"], json!([])); + assert!(fork["expire_ms"].is_u64()); + let copy = fork["bot"].as_str().unwrap(); + assert!( + ops.iter() + .any(|op| op["op"] == "delete" && op["bot"] == copy) + ); + // Something typed hides it; emptied again, it is back. + app.input.set("x"); + assert_eq!(app.suggested(), None); + app.input.clear(); + // Tab takes it into the composer, unsent. + app.key(Event::Key(ratatui::crossterm::event::KeyEvent::new( + KeyCode::Tab, + KeyModifiers::NONE, + ))) + .await; + assert_eq!(&*app.input, "run the tests"); + assert_eq!(app.suggestion, None); + assert_eq!( + ops.iter().filter(|op| op["op"] == "submit").count(), + 2, + "the person's and the copy's" + ); + } + + #[tokio::test] + async fn a_turn_the_person_steered_from_here_is_theirs() { + let next = std::sync::atomic::AtomicI64::new(2); + let client = daemon_answering(move |asked| match asked["op"].as_str() { + Some("submit") => { + json!({"turn": next.fetch_add(1, std::sync::atomic::Ordering::SeqCst)}) + } + _ => json!({}), + }) + .await; + let mut app = app(client); + // The lead's turn 1 runs; the person steers it, a submission of its own. + app.daemon(turn_event("accepted", 1, json!({}))).await; + app.input.set("and the reversed range too"); + app.submit().await; + assert_eq!(app.yours, Some(1)); + app.daemon(turn_event("turn_finished", 2, json!({"status": "steered"}))) + .await; + assert!(app.suggesting.is_none()); + app.daemon(turn_event( + "turn_finished", + 1, + json!({"status": "completed"}), + )) + .await; + assert!(app.suggesting.is_some()); + } + + #[tokio::test] + async fn a_turn_the_person_did_not_start_here_is_not_followed_by_a_guess() { + let client = daemon_answering(|_| json!({})).await; + let mut app = app(client); + // Started by the lead, the lookout, or another window. + app.daemon(turn_event("accepted", 3, json!({}))).await; + app.daemon(turn_event( + "turn_finished", + 3, + json!({"status": "completed"}), + )) + .await; + assert!(app.suggesting.is_none()); + // Nor one of the person's that failed or was stopped. + for status in ["failed", "interrupted"] { + app.yours = Some(4); + app.daemon(turn_event("accepted", 4, json!({}))).await; + app.daemon(turn_event("turn_finished", 4, json!({"status": status}))) + .await; + assert!(app.suggesting.is_none(), "{status}"); + } + } + #[tokio::test] async fn what_is_typed_answers_the_leads_first_question_unless_set_aside() { let sent = Arc::new(std::sync::Mutex::new(Vec::new())); diff --git a/sonny/src/suggest.rs b/sonny/src/suggest.rs new file mode 100644 index 000000000..86c6dda9d --- /dev/null +++ b/sonny/src/suggest.rs @@ -0,0 +1,341 @@ +//! The person's likely next message, guessed after a turn they started +//! and shown dim in the empty composer for Tab to take, as Claude Code's +//! prompt suggestions and Codex's composer predictions are (Claude Code +//! 2.1.312, `[SUGGESTION MODE` and its filters; Codex's help article +//! "Composer predictions in Codex"). +//! +//! A copy of the agent guesses: forked where the turn ended, with its +//! tools and model, so the request reads as the agent's next would and its +//! prompt cache is warm, but allowed no call. The daemon deletes it once +//! idle a minute, should Sonny not get to. +use agent_client::Client; +use serde_json::json; +use std::sync::Arc; +use std::time::Duration; + +/// How long the guess may take; a later one is not shown. +const WAIT: Duration = Duration::from_secs(60); +/// A copy left behind is deleted once idle this long. +const EXPIRE: Duration = Duration::from_secs(60); + +/// Claude Code's own ask, in Sonny's words. +const ASK: &str = "[SUGGESTION MODE: Suggest what the person might naturally type next into Sonny.] +FIRST: Look at the person's recent messages and original request. +Your job is to predict what THEY would type - not what you think they should do. +THE TEST: Would they think \"I was just about to type that\"? +EXAMPLES: +They asked \"fix the bug and run tests\", bug is fixed → \"run the tests\" +After code written → \"try it out\" +You offered options → the one they would likely pick, based on the conversation +You asked to continue → \"yes\" or \"go ahead\" +Task complete, obvious follow-up → \"commit this\" or \"push it\" +After an error or misunderstanding → nothing (let them assess and correct) +Be specific: \"run the tests\" beats \"continue\". +NEVER SUGGEST: +- Evaluative (\"looks good\", \"thanks\") +- Questions (\"what about...?\") +- Your own voice (\"Let me...\", \"I'll...\", \"Here's...\") +- New ideas they didn't ask about +- Multiple sentences +Say nothing if the next step isn't obvious from what they said. +Say nothing if a suggestion could be unsafe or inappropriate, including any sensitive topic (security incidents, credentials, harm, private data). +Format: 2-12 words, match the person's style. Or nothing. +Call no tool. Reply with ONLY the suggestion, no quotes or explanation."; + +/// The guess of a copy of `bot` at its head, on `model` when the window's +/// turns run on one of their own; `None` when it says nothing, or nothing +/// worth showing. +pub async fn guess(client: Arc, bot: String, model: Option) -> Option { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let name = format!("{bot}.suggest-{:x}", stamp % 0x100_0000); + let forked = client + .request( + "fork", + json!({"source": bot, "bot": name, "allow": [], + "expire_ms": EXPIRE.as_millis() as u64, "request_id": name}), + ) + .await + .ok()?; + let reply = ask(&client, &name, &forked["bot_id"], model).await; + let _ = client + .request("delete", json!({"bot": name, "bot_id": forked["bot_id"]})) + .await; + clean(&reply?) +} + +/// The copy's reply, interrupting a turn that runs past `WAIT`. +async fn ask( + client: &Client, + name: &str, + id: &serde_json::Value, + model: Option, +) -> Option { + let submitted = client + .request( + "submit", + json!({"bot": name, "bot_id": id, "request_id": format!("{name}.1"), + "prompt": ASK, "model": model}), + ) + .await + .ok()?; + let handle = submitted["handle"].as_str()?; + let waited = client + .request( + "wait", + json!({"handles": [handle], "timeout_ms": WAIT.as_millis() as u64}), + ) + .await + .ok()?; + let outcome = &waited["results"][handle]; + if outcome["pending"] == true { + if let Some(turn) = submitted["turn"].as_i64() { + let _ = client + .request("interrupt", json!({"bot": name, "turn": turn})) + .await; + let _ = client + .request("wait", json!({"handles": [handle], "timeout_ms": 5_000})) + .await; + } + return None; + } + (outcome["status"] == "completed" && outcome["text_truncated"] != true) + .then(|| outcome["text"].as_str().map(str::to_owned)) + .flatten() +} + +/// The reply as a message the person might send, or `None` for one that +/// is not: Claude Code's filters, as it reads its own model's guesses. +pub fn clean(reply: &str) -> Option { + let text = unwrap(reply.trim()); + let text = unlabel(text).trim(); + let text = text + .strip_prefix('"') + .and_then(|t| t.strip_suffix('"')) + .unwrap_or(text) + .trim(); + let lower = text.to_lowercase(); + let words = text.split_whitespace().count(); + let refused = text.is_empty() + || lower == "done" + // Words about the guess, not one. + || matches!(lower.as_str(), "nothing" | "nothing." | "nothing found" | "nothing found.") + || lower.starts_with("nothing to suggest") + || lower.starts_with("no suggestion") + || lower.contains("silence is") + || ["stay silent", "stays silent", "staying silent"].iter().any(|s| lower.contains(s)) + || lower.trim_matches(|c: char| !c.is_alphanumeric()) == "silence" + || wrapped(text) + || ["api error:", "prompt is too long", "request timed out", "invalid api key"] + .iter() + .any(|s| lower.starts_with(s)) + || labelled(text) + || (words < 2 && !text.starts_with('/') && !ONE_WORD.contains(&lower.as_str())) + || words > 12 + || text.chars().count() >= 100 + || sentences(text) + || text.contains(['\n', '*']) + || EVALUATIVE.iter().any(|s| contains_word(&lower, s)) + || AGENT_VOICE.iter().any(|s| lower.starts_with(s)); + (!refused).then(|| text.to_owned()) +} + +/// Replies of one word a person sends. +const ONE_WORD: [&str; 17] = [ + "yes", "yeah", "yep", "yea", "yup", "sure", "ok", "okay", "push", "commit", "deploy", "stop", + "continue", "check", "exit", "quit", "no", +]; + +const EVALUATIVE: [&str; 15] = [ + "thanks", + "thank you", + "looks good", + "sounds good", + "that works", + "that worked", + "that's all", + "nice", + "great", + "perfect", + "makes sense", + "awesome", + "excellent", + "lgtm", + "well done", +]; + +/// How the agent, not the person, begins. +const AGENT_VOICE: [&str; 18] = [ + "let me", + "i'll", + "i've", + "i'm", + "i can", + "i would", + "i think", + "i notice", + "here's", + "here is", + "here are", + "that's", + "this is", + "this will", + "you can", + "you should", + "you could", + "certainly", +]; + +/// The text inside one tag that wraps it all, as `…`. +fn unwrap(text: &str) -> &str { + for tag in ["suggestion", "response", "output", "answer", "result"] { + let (open, close) = (format!("<{tag}>"), format!("")); + if let Some(inner) = text + .get(..open.len()) + .filter(|head| head.eq_ignore_ascii_case(&open)) + .and_then(|_| text.get(open.len()..text.len().checked_sub(close.len())?)) + .filter(|_| text[text.len() - close.len()..].eq_ignore_ascii_case(&close)) + .filter(|inner| !inner.to_lowercase().contains(&close)) + { + return inner; + } + } + text +} + +/// The text after a label the model put before it, as `Suggestion:`. +fn unlabel(text: &str) -> &str { + for label in [ + "suggested response", + "suggested reply", + "suggested input", + "suggested prompt", + "suggestion", + "response", + "reply", + "answer", + "output", + "result", + ] { + // The labels are ASCII, so the label's bytes are the text's. + if let Some(after) = text + .get(..label.len()) + .filter(|head| head.eq_ignore_ascii_case(label)) + .and_then(|_| text[label.len()..].trim_start().strip_prefix(':')) + { + return after; + } + } + text +} + +/// Wholly in brackets, as a note about the guess is. +fn wrapped(text: &str) -> bool { + [('(', ')'), ('[', ']'), ('(', ')'), ('【', '】')] + .iter() + .any(|(open, close)| text.starts_with(*open) && text.ends_with(*close)) +} + +/// Begun with one word and a colon, as `Note: …`. +fn labelled(text: &str) -> bool { + text.split_once(':').is_some_and(|(head, rest)| { + !head.is_empty() + && head.chars().all(|c| c.is_alphanumeric() || c == '_') + && rest.starts_with(char::is_whitespace) + }) +} + +/// More than one sentence: an end, a space, and a capital. +fn sentences(text: &str) -> bool { + let chars: Vec = text.chars().collect(); + chars + .windows(3) + .any(|w| matches!(w[0], '.' | '!' | '?') && w[1].is_whitespace() && w[2].is_uppercase()) +} + +/// `phrase` in `text`, starting and ending on a word's edge. +fn contains_word(text: &str, phrase: &str) -> bool { + text.match_indices(phrase).any(|(at, _)| { + let before = text[..at].chars().next_back(); + let after = text[at + phrase.len()..].chars().next(); + !before.is_some_and(char::is_alphanumeric) && !after.is_some_and(char::is_alphanumeric) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_short_message_in_the_person_s_words_is_kept() { + assert_eq!(clean("run the tests").as_deref(), Some("run the tests")); + assert_eq!(clean(" commit this\n").as_deref(), Some("commit this")); + assert_eq!(clean("yes").as_deref(), Some("yes")); + assert_eq!(clean("/review").as_deref(), Some("/review")); + } + + #[test] + fn what_wraps_or_labels_it_is_taken_off() { + assert_eq!( + clean("push it").as_deref(), + Some("push it") + ); + assert_eq!(clean("Suggestion: push it").as_deref(), Some("push it")); + assert_eq!(clean("\"try it out\"").as_deref(), Some("try it out")); + // A letter whose lowercase is longer, as İ's is. + assert_eq!(clean("Suggestion: İleri git").as_deref(), Some("İleri git")); + assert_eq!(clean("SUGGESTION: İİİ İİİ").as_deref(), Some("İİİ İİİ")); + } + + #[test] + fn saying_nothing_shows_nothing() { + for said in [ + "", + "Nothing.", + "nothing to suggest here", + "No suggestion", + "(staying silent)", + "[none]", + "Silence", + "done", + ] { + assert_eq!(clean(said), None, "{said}"); + } + } + + #[test] + fn what_the_person_would_not_type_is_refused() { + for said in [ + "looks good", + "Thanks, that worked", + "Let me run the tests", + "I'll commit this now", + "You should run the tests", + "Note: run the tests", + "Run the tests. Then push.", + "run **all** the tests", + "run the tests\nand push", + "continue with the remaining eleven steps of the plan as we discussed earlier", + "tests", + "API error: overloaded", + ] { + assert_eq!(clean(said), None, "{said}"); + } + assert!(clean(&"word ".repeat(30)).is_none()); + } + + #[test] + fn a_word_inside_another_is_not_judged() { + // "nice" inside "niceties", "great" inside "greater". + assert_eq!( + clean("drop the niceties from the greeter").as_deref(), + Some("drop the niceties from the greeter") + ); + assert_eq!( + clean("make the timeout greater").as_deref(), + Some("make the timeout greater") + ); + } +}