//! Next-prompt suggestion controller (tab autocomplete ghost text). //! //! After a turn completes, the pager asks the shell (`suggestPrompt`) //! to predict the user's likely next prompt. The prediction renders as dim //! ghost text in the (empty) prompt input: //! //! - **Tab** or **Right arrow** accepts it (the ghost only shows with the //! cursor at end-of-text, where Right is otherwise a no-op — the fish/zsh //! autosuggestion convention). //! - Typing a matching prefix *shrinks* the ghost; typing it out fully //! consumes it; any divergent text hides it (it comes back if the user //! clears the input, matching common agent-CLI autosuggest behavior). //! - **Esc** on an empty prompt dismisses it for the rest of the turn. //! //! Visibility is *derived* from the current prompt text each frame //! ([`PromptSuggestionController::ghost_for`]) rather than mutated on each //! keystroke — there is no per-keystroke state machine to drift. Stale //! responses are discarded via a generation counter, mirroring //! `SuggestionController` (shell command suggestions). /// Env override for the whole feature: `KIGI_PROMPT_SUGGESTIONS=0/1`. /// When unset, the persisted `prompt_suggestions` setting applies. pub const PROMPT_SUGGESTIONS_ENV: &str = "KIGI_PROMPT_SUGGESTIONS"; /// Env override for the model used by the suggestion call: /// `KIGI_PROMPT_SUGGESTIONS_MODEL=`. pub const PROMPT_SUGGESTIONS_MODEL_ENV: &str = "KIGI_PROMPT_SUGGESTIONS_MODEL"; /// Preferred model for suggestion calls when the server catalog offers it. /// The session model is never used: when this is absent from the catalog the /// request carries no model hint and the shell resolves (or skips) it — see /// [`resolve_model`]. pub fn preferred_suggestion_model() -> &'static str { kigi_shell::models::default_model() } /// Controller for the predicted-next-prompt ghost text. #[derive(Debug, Default)] pub struct PromptSuggestionController { /// Full suggestion text from the model. Empty = no suggestion. full_text: String, /// Request generation counter; responses carrying a stale generation are /// discarded (a newer turn ended, or the suggestion was invalidated). generation: u64, /// Set when the user dismissed the current suggestion (Esc). Cleared by /// the next loaded suggestion. dismissed: bool, /// Whether the feature is enabled. Resolved via /// `KIGI_PROMPT_SUGGESTIONS` env var, falling back to the persisted /// `prompt_suggestions` setting. pub enabled: bool, } impl PromptSuggestionController { pub fn new() -> Self { Self { full_text: String::new(), generation: 0, dismissed: false, enabled: resolve_enabled(), } } /// Begin a new fetch: invalidates any in-flight request and returns the /// generation to thread through the effect pipeline. pub fn begin_fetch(&mut self) -> u64 { self.generation = self.generation.wrapping_add(1); self.generation } /// A suggestion arrived from the shell. Discards stale generations and /// empty payloads. Returns `true` when the suggestion was installed. pub fn on_loaded(&mut self, suggestion: Option, generation: u64) -> bool { if generation != self.generation { return false; } match suggestion { Some(text) if !text.trim().is_empty() && !text.contains('\n') => { self.full_text = text; self.dismissed = false; true } _ => { self.full_text.clear(); false } } } /// The ghost text to render for the current prompt text, if any. /// /// Derived: the suggestion is visible iff the current text is a proper /// prefix of it (including the empty prompt). Typing matching characters /// shrinks the ghost; typing it out fully (or diverging) hides it; /// clearing the input brings the full suggestion back. pub fn ghost_for(&self, text: &str) -> Option<&str> { if !self.enabled || self.dismissed || self.full_text.is_empty() { return None; } let rest = self.full_text.strip_prefix(text)?; if rest.is_empty() { None } else { Some(rest) } } /// Accept the suggestion against the current prompt text. Returns the /// remainder to insert and clears the suggestion. pub fn accept(&mut self, text: &str) -> Option { let rest = self.ghost_for(text)?.to_owned(); self.clear(); Some(rest) } /// Dismiss the current suggestion (Esc) until a new one loads. pub fn dismiss(&mut self) { self.dismissed = true; } /// Drop the suggestion and invalidate any in-flight fetch (turn started, /// prompt sent, session switched...). pub fn clear(&mut self) { self.full_text.clear(); self.generation = self.generation.wrapping_add(1); } /// Whether a (non-dismissed) suggestion is loaded, regardless of the /// current prompt text. pub fn has_suggestion(&self) -> bool { self.enabled && !self.dismissed && !self.full_text.is_empty() } #[cfg(test)] pub(crate) fn set_suggestion_for_test(&mut self, text: &str) { self.enabled = true; self.dismissed = false; self.full_text = text.to_owned(); } } /// Resolve the enabled state: env override wins, then the persisted /// `prompt_suggestions` setting (default on). The env var is read once per /// process; the setting is a thread-local cache, so this is cheap enough for /// per-frame calls. pub fn resolve_enabled() -> bool { static ENV_OVERRIDE: std::sync::OnceLock> = std::sync::OnceLock::new(); ENV_OVERRIDE .get_or_init(|| kigi_config::env_bool(PROMPT_SUGGESTIONS_ENV)) .unwrap_or_else(crate::appearance::cache::load_prompt_suggestions) } /// Content-free size metadata for acceptance-rate telemetry: `(chars, words)` /// of the full suggestion text. Never log the text itself. pub fn suggestion_size(text: &str) -> (usize, usize) { (text.chars().count(), text.split_whitespace().count()) } /// Resolve the client-side model hint sent with the suggestion request: /// env override > `kigi-0.1` when the catalog offers it > `None`. /// /// The hint is one tier of the shell-side resolution (env > config.toml > /// remote settings > this hint > `kigi-0.1` default): the shell /// catalog-guards the effective model and skips the request entirely when /// it is not sampleable — the session model is never used for suggestion /// calls. pub fn resolve_model(models: &crate::acp::model_state::ModelState) -> Option { if let Ok(model) = std::env::var(PROMPT_SUGGESTIONS_MODEL_ENV) && !model.trim().is_empty() { return Some(model); } let preferred = agent_client_protocol::ModelId::new(std::sync::Arc::from(preferred_suggestion_model())); models .available .contains_key(&preferred) .then(|| preferred_suggestion_model().to_owned()) } #[cfg(test)] mod tests { use super::*; fn loaded_controller(text: &str) -> PromptSuggestionController { let mut c = PromptSuggestionController { enabled: true, ..Default::default() }; let generation = c.begin_fetch(); assert!(c.on_loaded(Some(text.to_owned()), generation)); c } #[test] fn ghost_shows_full_suggestion_on_empty_prompt() { let c = loaded_controller("run the tests"); assert_eq!(c.ghost_for(""), Some("run the tests")); } #[test] fn ghost_shrinks_as_matching_prefix_is_typed() { let c = loaded_controller("run the tests"); assert_eq!(c.ghost_for("r"), Some("un the tests")); assert_eq!(c.ghost_for("run the"), Some(" tests")); } #[test] fn ghost_disappears_when_typed_out_fully() { let c = loaded_controller("run the tests"); assert_eq!(c.ghost_for("run the tests"), None); } #[test] fn ghost_hides_on_divergent_text_and_returns_on_clear() { let c = loaded_controller("run the tests"); assert_eq!(c.ghost_for("x"), None); assert_eq!(c.ghost_for("rux"), None); // Clearing the input brings the suggestion back. assert_eq!(c.ghost_for(""), Some("run the tests")); } #[test] fn accept_returns_remainder_and_clears() { let mut c = loaded_controller("run the tests"); assert_eq!(c.accept("run ").as_deref(), Some("the tests")); assert!(!c.has_suggestion()); assert_eq!(c.ghost_for(""), None); } #[test] fn accept_on_divergent_text_returns_none() { let mut c = loaded_controller("run the tests"); assert_eq!(c.accept("xyz"), None); // Suggestion intact for when the input is cleared. assert!(c.has_suggestion()); } #[test] fn dismiss_hides_until_next_load() { let mut c = loaded_controller("run the tests"); c.dismiss(); assert_eq!(c.ghost_for(""), None); assert!(!c.has_suggestion()); let generation = c.begin_fetch(); assert!(c.on_loaded(Some("commit this".to_owned()), generation)); assert_eq!(c.ghost_for(""), Some("commit this")); } #[test] fn stale_generation_is_discarded() { let mut c = PromptSuggestionController { enabled: true, ..Default::default() }; let stale = c.begin_fetch(); let _newer = c.begin_fetch(); assert!(!c.on_loaded(Some("old".to_owned()), stale)); assert_eq!(c.ghost_for(""), None); } #[test] fn clear_invalidates_in_flight_fetch() { let mut c = PromptSuggestionController { enabled: true, ..Default::default() }; let generation = c.begin_fetch(); c.clear(); assert!(!c.on_loaded(Some("late".to_owned()), generation)); assert!(!c.has_suggestion()); } #[test] fn empty_or_multiline_suggestions_are_rejected() { let mut c = PromptSuggestionController { enabled: true, ..Default::default() }; let generation = c.begin_fetch(); assert!(!c.on_loaded(Some(" ".to_owned()), generation)); let generation = c.begin_fetch(); assert!(!c.on_loaded(Some("a\nb".to_owned()), generation)); let generation = c.begin_fetch(); assert!(!c.on_loaded(None, generation)); } #[test] fn disabled_controller_shows_nothing() { let mut c = loaded_controller("run the tests"); c.enabled = false; assert_eq!(c.ghost_for(""), None); assert!(!c.has_suggestion()); } }