Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/prompt_suggestion.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

293 lines
11 KiB
Rust

//! 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=<model-id>`.
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<String>, 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<String> {
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<Option<bool>> = 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<String> {
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());
}
}