//! Next-prompt prediction helpers (tab autocomplete ghost text). //! //! After a turn completes, the client asks the session to predict what the //! user is likely to type next. The prediction renders as dim ghost text in //! the empty prompt input; Tab accepts it. Modelled on common coding-agent //! prompt suggestion features, but instead of replaying the full conversation prefix //! it sends a *compact text-only transcript* — the call always routes to a //! dedicated model (configurable, [`default_suggest_model`] by //! default, never the session model — see [`effective_suggest_model`]), //! where the parent session's prompt cache would not apply anyway, so a //! small request wins on both cost and latency. //! //! The pure helpers here build the request items and filter the model output; //! the actual model call lives on the `SessionActor` //! (`handle_suggest_prompt`). use crate::config::PromptSuggestModelPin; use crate::sampling::ConversationItem; use crate::session::helpers::chat::floor_char_boundary; /// Model used for suggestion calls when nothing pins one (no env / /// `[models] prompt_suggestion` / remote setting / client hint — see /// [`effective_suggest_model`]). The Kimi catalog has no dedicated small /// suggestion model, so this is the bundled default coding model; the /// catalog guard still controls whether the request fires at all. pub(crate) fn default_suggest_model() -> &'static str { crate::models::default_model() } /// Resolve the model for one suggestion request, or `None` to skip the /// request entirely (controlled disable). /// /// Precedence: env pin > config.toml/remote pin > client hint (the request's /// `model` param) > [`default_suggest_model`]. Every tier except the env pin /// is catalog-guarded via `in_catalog`: firing an unavailable pin or default /// would send a doomed per-turn request /// that can never render ghost text. Skipping keeps the per-turn cost at /// zero; deliberately NOT a session-model fallback — a per-turn background /// call must stay on a small cheap model. The env pin bypasses the guard so /// `KIGI_PROMPT_SUGGESTIONS_MODEL` keeps working for models the catalog does /// not list (mirrors the pager, which forwards the env value unchecked). pub(crate) fn effective_suggest_model( pin: &PromptSuggestModelPin, client_hint: Option<&str>, in_catalog: impl Fn(&str) -> bool, ) -> Option { let client_hint = client_hint.map(str::trim).filter(|s| !s.is_empty()); let (model, catalog_guarded) = match pin { PromptSuggestModelPin::Env(m) => (m.as_str(), false), PromptSuggestModelPin::Pinned(m) => (m.as_str(), true), PromptSuggestModelPin::Unpinned => (client_hint.unwrap_or(default_suggest_model()), true), }; if catalog_guarded && !in_catalog(model) { return None; } Some(model.to_owned()) } /// Total character budget for the compact transcript (~6k tokens at the /// bytes/4 estimate). Keeps the per-turn cost of the feature trivial even on /// long sessions. const TRANSCRIPT_BUDGET_CHARS: usize = 24_000; /// Per-message character cap inside the transcript. Long messages (pasted /// logs, big diffs) carry little signal for next-prompt prediction. const MESSAGE_CAP_CHARS: usize = 1_500; /// Reject suggestions longer than this — a prompt suggestion should be a /// short, obvious next step, not an essay. const SUGGESTION_MAX_CHARS: usize = 120; /// Reject suggestions with more words than this (mirrors common /// "2-12 words" guidance with a little slack). const SUGGESTION_MAX_WORDS: usize = 16; /// Short replies that are useful suggestions despite being a single word. const ONE_WORD_ALLOWLIST: &[&str] = &[ "yes", "yeah", "yep", "no", "ok", "okay", "continue", "proceed", "push", "commit", "deploy", "stop", "check", "retry", "undo", "merge", ]; /// System prompt for the suggestion call. The model sees a compact transcript /// and must reply with ONLY the predicted next user message (or nothing). pub(crate) const SUGGEST_PROMPT_SYSTEM: &str = "You predict what the USER will type next into their coding agent CLI.\n\ You are shown a transcript of the conversation so far. The agent's latest reply ends the transcript.\n\n\ FIRST: look at the user's recent messages and original request.\n\ Your job is to predict what THEY would type next — not what you think they should do.\n\ THE TEST: would they think \"I was just about to type that\"?\n\n\ EXAMPLES:\n\ - User asked \"fix the bug and run tests\", bug is fixed -> \"run the tests\"\n\ - After code was written -> \"try it out\"\n\ - Agent offers options -> the option the user would likely pick, based on the conversation\n\ - Agent ends by asking a yes/no question (continue? delete it? print it?) -> the user's likely answer: \"yes\" or \"no\"\n\ - Task complete with an obvious follow-up -> \"commit this\" or \"push it\"\n\ - After an error or a misunderstanding -> NONE (let them assess)\n\n\ Be specific: \"run the tests\" beats \"continue\".\n\ When the agent's reply ends with a question, a suggestion almost always exists — predict the answer.\n\n\ NEVER SUGGEST:\n\ - A message the user already sent, or a rephrasing of one — the transcript is history, not a menu. \ Once a request was handled, predict the step AFTER it, never the request again \ (short confirmations like \"yes\" or \"continue\" are the only acceptable repeats)\n\ - Evaluative filler (\"looks good\", \"thanks\")\n\ - Questions back to the agent (\"what about...?\")\n\ - Agent-voice phrasing (\"Let me...\", \"I'll...\", \"Here's...\")\n\ - New ideas the user never asked about\n\ - Multiple sentences\n\n\ Stay silent if the next step is not obvious from what the user said: reply with the single word NONE.\n\n\ Format: 2-12 words, matching the user's own style and casing.\n\ Reply with ONLY the suggestion text (or NONE) — no quotes, no markdown, no explanation."; /// One transcript line: role label + flattened text content. fn transcript_line(role: &str, text: &str) -> Option { let text = text.trim(); if text.is_empty() { return None; } let mut text = text; if text.len() > MESSAGE_CAP_CHARS { let cut = floor_char_boundary(text, MESSAGE_CAP_CHARS); text = &text[..cut]; } Some(format!("{role}: {text}")) } /// Build the compact transcript from a conversation snapshot. /// /// Keeps genuine `User` messages (skipping runtime-synthesized ones) and /// `Assistant` text, newest-last, walking backwards until the character /// budget is exhausted. Tool calls/results, reasoning, and the system prompt /// are dropped — the user/assistant dialogue carries the signal for "what /// will the user type next", and dropping the rest keeps the request cheap. /// /// Returns `None` when the conversation has no assistant reply yet (nothing /// to predict from). pub(crate) fn build_transcript(conversation: &[ConversationItem]) -> Option { let mut lines: Vec = Vec::new(); let mut used = 0usize; let mut saw_assistant = false; for item in conversation.iter().rev() { let line = match item { ConversationItem::User(u) => { if u.synthetic_reason.is_some() { continue; } transcript_line("User", &item.text_content()) } ConversationItem::Assistant(_) => { let line = transcript_line("Agent", &item.text_content()); if line.is_some() { saw_assistant = true; } line } _ => continue, }; let Some(line) = line else { continue }; if used + line.len() > TRANSCRIPT_BUDGET_CHARS && !lines.is_empty() { break; } used += line.len(); lines.push(line); } if !saw_assistant || lines.is_empty() { return None; } lines.reverse(); Some(lines.join("\n\n")) } /// Build the user message for the suggestion request. pub(crate) fn suggest_prompt_user_message(transcript: &str, cwd: &str) -> String { format!( "CWD: {cwd}\n\nTranscript:\n\n{transcript}\n\n\ Predict the user's next message. Reply with ONLY the suggestion text." ) } /// Minimum word count for the deterministic repeat filter. Short /// command-like replies ("yes", "run tests", "try again") legitimately /// recur across a session; a repeated multi-word task prompt is the /// "it suggested my old prompt back to me" failure mode. const REPEAT_MIN_WORDS: usize = 4; /// Case- and whitespace-insensitive form used for repeat comparison, with /// trailing sentence punctuation dropped. fn normalize_for_repeat(text: &str) -> String { text.split_whitespace() .collect::>() .join(" ") .trim_end_matches(['.', '!', '?']) .to_ascii_lowercase() } /// Whether a sanitized suggestion merely repeats a message the user already /// sent. Deterministic backstop behind the system prompt's anti-repeat rule: /// prompt guidance reduces repeats, this guarantees an exact (normalized) /// re-suggestion of a past multi-word prompt never renders as ghost text. /// Short suggestions (< [`REPEAT_MIN_WORDS`] words) are exempt — repeating /// "yes" or "run tests" is often exactly what the user is about to type. pub(crate) fn is_repeat_of_user_message( suggestion: &str, conversation: &[ConversationItem], ) -> bool { if suggestion.split_whitespace().count() < REPEAT_MIN_WORDS { return false; } let needle = normalize_for_repeat(suggestion); conversation.iter().any(|item| match item { ConversationItem::User(u) if u.synthetic_reason.is_none() => { normalize_for_repeat(&item.text_content()) == needle } _ => false, }) } /// Filter/normalize the raw model output into a usable suggestion. /// /// Returns `None` for anything that should not be shown as ghost text: /// meta/no-op replies, agent-voice phrasing, multi-sentence or multi-line /// output, markdown, or over-long text. Mirrors typical coding-agent suggestion /// filters, adapted to the compact-transcript prompt above. pub(crate) fn sanitize_suggestion(raw: &str) -> Option { // First line only; the prompt asks for a single line but models drift. let line = raw.trim().lines().next()?.trim(); // Strip common wrappers the prompt forbids but models still emit. let line = line .trim_start_matches(['"', '\'', '`', '“', '‘']) .trim_end_matches(['"', '\'', '`', '”', '’']) .trim(); if line.is_empty() || line.len() >= SUGGESTION_MAX_CHARS { return None; } // Meta / "no suggestion" replies. let lowered = line.to_ascii_lowercase(); let meta = [ "none", "n/a", "no suggestion", "nothing", "(silence)", "silence", "null", ]; if meta .iter() .any(|m| lowered == *m || lowered.starts_with(&format!("{m}."))) { return None; } // Markdown / formatting — ghost text renders on a single styled line. if line.contains('*') || line.contains("```") || line.starts_with('#') || line.starts_with('-') { return None; } // Agent-voice phrasing — the suggestion must be in the USER's voice. let agent_voice = [ "i'll ", "i will ", "let me ", "here's ", "here is ", "i'm going to ", ]; if agent_voice.iter().any(|p| lowered.starts_with(p)) { return None; } // Parenthetical/bracketed meta replies like "(no suggestion)". if (line.starts_with('(') && line.ends_with(')')) || (line.starts_with('[') && line.ends_with(']')) { return None; } // Label prefixes like "Suggestion: ..." / "User: ...". if let Some((head, _)) = line.split_once(':') && !head.contains(' ') && head.chars().all(|c| c.is_ascii_alphabetic()) { return None; } // Multiple sentences read as agent prose, not a prompt. let multi_sentence = line .as_bytes() .windows(3) .any(|w| matches!(w[0], b'.' | b'!' | b'?') && w[1] == b' ' && w[2].is_ascii_uppercase()); if multi_sentence { return None; } // Word-count bounds: 1 word only from the allowlist, and never a wall of text. let words = line.split_whitespace().count(); if words > SUGGESTION_MAX_WORDS { return None; } if words == 1 { let bare = lowered.trim_end_matches(['.', '!']); if !ONE_WORD_ALLOWLIST.contains(&bare) && !bare.starts_with('/') { return None; } } Some(line.to_owned()) } #[cfg(test)] mod tests { use super::*; use crate::config::PromptSuggestModelPin as Pin; // -- effective_suggest_model --------------------------------------------- #[test] fn effective_model_default_requires_catalog() { // No pin, no hint: the built-in default fires only when this shell's // catalog can sample it. assert_eq!( effective_suggest_model(&Pin::Unpinned, None, |m| m == default_suggest_model()) .as_deref(), Some(default_suggest_model()) ); // OAuth catalogs exclude kigi-0.1 → skip the request entirely, // never a doomed call (and never the session model). assert_eq!( effective_suggest_model(&Pin::Unpinned, None, |_| false), None ); } #[test] fn effective_model_client_hint_beats_default_and_is_guarded() { assert_eq!( effective_suggest_model(&Pin::Unpinned, Some("hinted"), |m| m == "hinted").as_deref(), Some("hinted") ); // A hint the shell can't sample skips — no silent fall-through. assert_eq!( effective_suggest_model(&Pin::Unpinned, Some("hinted"), |_| false), None ); // Blank hints are ignored: the default tier applies. assert_eq!( effective_suggest_model(&Pin::Unpinned, Some(" "), |m| m == default_suggest_model()) .as_deref(), Some(default_suggest_model()) ); } #[test] fn effective_model_pin_beats_client_hint_and_is_guarded() { assert_eq!( effective_suggest_model(&Pin::Pinned("pinned".into()), Some("hinted"), |m| m == "pinned" || m == "hinted") .as_deref(), Some("pinned") ); // A pinned-but-unavailable model skips — the pin is an explicit // choice, not a preference list; no fall-through to hint or default. assert_eq!( effective_suggest_model(&Pin::Pinned("pinned".into()), Some("hinted"), |m| m == "hinted"), None ); } #[test] fn effective_model_env_pin_bypasses_catalog_guard() { // KIGI_PROMPT_SUGGESTIONS_MODEL is the explicit escape hatch: used // verbatim even when the catalog does not list the model (mirrors // the pager, which forwards the env value unchecked). assert_eq!( effective_suggest_model(&Pin::Env("custom-model".into()), Some("hinted"), |_| false) .as_deref(), Some("custom-model") ); } // -- sanitize_suggestion ------------------------------------------------ #[test] fn sanitize_accepts_short_imperative() { assert_eq!( sanitize_suggestion("run the tests").as_deref(), Some("run the tests") ); } #[test] fn sanitize_strips_quotes_and_backticks() { assert_eq!( sanitize_suggestion("\"commit this\"").as_deref(), Some("commit this") ); assert_eq!(sanitize_suggestion("`push it`").as_deref(), Some("push it")); } #[test] fn sanitize_takes_first_line_only() { assert_eq!( sanitize_suggestion("run the tests\nthen commit").as_deref(), Some("run the tests") ); } #[test] fn sanitize_rejects_none_and_meta() { for s in ["NONE", "none", "n/a", "no suggestion", "(silence)", ""] { assert_eq!(sanitize_suggestion(s), None, "should reject {s:?}"); } } #[test] fn sanitize_rejects_agent_voice() { for s in [ "I'll run the tests", "Let me check the output", "Here's what to do next", ] { assert_eq!(sanitize_suggestion(s), None, "should reject {s:?}"); } } #[test] fn sanitize_rejects_markdown_and_labels() { for s in [ "**run tests**", "- run tests", "# next", "Suggestion: run tests", "```run```", ] { assert_eq!(sanitize_suggestion(s), None, "should reject {s:?}"); } } #[test] fn sanitize_rejects_multi_sentence_and_overlong() { assert_eq!( sanitize_suggestion("Run the tests. Then commit the changes."), None ); let long = "word ".repeat(20); assert_eq!(sanitize_suggestion(&long), None); let chars = "x".repeat(200); assert_eq!(sanitize_suggestion(&chars), None); } #[test] fn sanitize_one_word_allowlist() { assert_eq!(sanitize_suggestion("yes").as_deref(), Some("yes")); assert_eq!(sanitize_suggestion("commit").as_deref(), Some("commit")); // Bare one-word verbs outside the allowlist are too ambiguous. assert_eq!(sanitize_suggestion("refactor"), None); // Slash commands are fine. assert_eq!(sanitize_suggestion("/review").as_deref(), Some("/review")); } #[test] fn sanitize_allows_colon_after_multiword_head() { // Only single-word alphabetic label heads are rejected. assert_eq!( sanitize_suggestion("fix the parse error: line 42").as_deref(), Some("fix the parse error: line 42") ); } // -- build_transcript --------------------------------------------------- fn user(text: &str) -> ConversationItem { ConversationItem::user(text.to_owned()) } fn assistant(text: &str) -> ConversationItem { ConversationItem::assistant(text.to_owned()) } // -- is_repeat_of_user_message ------------------------------------------- #[test] fn repeat_filter_rejects_verbatim_past_prompt() { let conv = vec![user("fix the flaky auth test"), assistant("Fixed it")]; assert!(is_repeat_of_user_message("fix the flaky auth test", &conv)); } #[test] fn repeat_filter_is_case_whitespace_and_punctuation_insensitive() { let conv = vec![user("Fix the flaky\nauth test."), assistant("Fixed it")]; assert!(is_repeat_of_user_message("fix the flaky auth test!", &conv)); } #[test] fn repeat_filter_exempts_short_suggestions() { let conv = vec![user("run the tests"), assistant("3 failures")]; // 3 words — legitimately recurs after new changes. assert!(!is_repeat_of_user_message("run the tests", &conv)); assert!(!is_repeat_of_user_message("yes", &conv)); } #[test] fn repeat_filter_allows_novel_suggestions() { let conv = vec![user("fix the flaky auth test"), assistant("Fixed it")]; assert!(!is_repeat_of_user_message("commit and push the fix", &conv)); } #[test] fn repeat_filter_ignores_synthetic_user_messages() { let mut synthetic = user("please review the changes now"); if let ConversationItem::User(u) = &mut synthetic { u.synthetic_reason = Some(crate::sampling::SyntheticReason::SystemReminder); } let conv = vec![synthetic, assistant("done")]; assert!(!is_repeat_of_user_message( "please review the changes now", &conv )); } #[test] fn transcript_keeps_user_and_assistant_in_order() { let conv = vec![ ConversationItem::system("sys".to_owned()), user("fix the bug"), assistant("Fixed it in foo.rs"), ]; let t = build_transcript(&conv).unwrap(); assert_eq!(t, "User: fix the bug\n\nAgent: Fixed it in foo.rs"); } #[test] fn transcript_requires_an_assistant_reply() { let conv = vec![ConversationItem::system("sys".to_owned()), user("hello")]; assert!(build_transcript(&conv).is_none()); assert!(build_transcript(&[]).is_none()); } #[test] fn transcript_skips_synthetic_user_messages() { let mut synthetic = ConversationItem::user("synthetic reminder".to_owned()); if let ConversationItem::User(u) = &mut synthetic { u.synthetic_reason = Some(crate::sampling::SyntheticReason::SystemReminder); } let conv = vec![user("real question"), synthetic, assistant("answer")]; let t = build_transcript(&conv).unwrap(); assert!(!t.contains("synthetic reminder")); assert!(t.contains("User: real question")); } #[test] fn transcript_caps_long_messages() { let long = "a".repeat(10_000); let conv = vec![user(&long), assistant("ok")]; let t = build_transcript(&conv).unwrap(); assert!( t.len() < 2_000, "long message must be truncated: {}", t.len() ); } #[test] fn transcript_budget_keeps_newest_messages() { let filler = "b".repeat(MESSAGE_CAP_CHARS); let mut conv = Vec::new(); for _ in 0..40 { conv.push(user(&filler)); conv.push(assistant(&filler)); } conv.push(user("newest question")); conv.push(assistant("newest answer")); let t = build_transcript(&conv).unwrap(); assert!(t.len() <= TRANSCRIPT_BUDGET_CHARS + MESSAGE_CAP_CHARS + 64); assert!(t.contains("newest question")); assert!(t.ends_with("Agent: newest answer")); } }